This commit is contained in:
Hamid
2026-05-16 23:06:23 -07:00
parent f51c7c302c
commit b3e0378af5
3 changed files with 221 additions and 18 deletions
@@ -3,6 +3,11 @@ import { Text, Group } from "react-konva";
import Konva from "konva"; import Konva from "konva";
import type { TextShapeProps } from "./types"; import type { TextShapeProps } from "./types";
import { getFontFamily } from "@/pages/editor/utils/fontFamily"; import { getFontFamily } from "@/pages/editor/utils/fontFamily";
import {
getEffectiveTextWidth,
measureTextBlock,
usesWrappedLayout,
} from "@/pages/editor/utils/textStyle";
const getFontWeight = (fontWeight?: string): string => { const getFontWeight = (fontWeight?: string): string => {
if (!fontWeight) return "normal"; if (!fontWeight) return "normal";
@@ -53,6 +58,13 @@ const TextShape = ({
const shapeRef = useRef<Konva.Text>(null); const shapeRef = useRef<Konva.Text>(null);
const groupRef = useRef<Konva.Group>(null); const groupRef = useRef<Konva.Group>(null);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const fontSize = obj.fontSize || 24;
const allowWrap = usesWrappedLayout(
obj.text,
obj.height,
fontSize,
obj.lineHeight ?? 1.2,
);
// Transformer is managed in EditorCanvas for multi-select support // Transformer is managed in EditorCanvas for multi-select support
// Set offset for text node // Set offset for text node
@@ -89,8 +101,32 @@ const TextShape = ({
}); });
measureNode.destroy(); measureNode.destroy();
const nextWidth = Math.max(1, Math.ceil(rw)); const fontSize = obj.fontSize || 24;
const nextHeight = Math.max(1, Math.ceil(rh)); const cssMeasured = measureTextBlock(obj.text || "", {
fontSize,
fontFamily: obj.fontFamily,
fontWeight: obj.fontWeight,
letterSpacing: obj.letterSpacing ?? 0,
lineHeight: obj.lineHeight ?? 1.2,
});
const konvaWidth = Math.max(1, Math.ceil(rw));
const nextWidth = getEffectiveTextWidth(
konvaWidth,
obj.text,
{
fontSize,
fontFamily: obj.fontFamily,
fontWeight: obj.fontWeight,
letterSpacing: obj.letterSpacing ?? 0,
lineHeight: obj.lineHeight ?? 1.2,
},
!allowWrap,
) ?? konvaWidth;
const nextHeight = Math.max(
1,
Math.ceil(rh),
cssMeasured.height,
);
const widthChanged = Math.abs((obj.width || 0) - nextWidth) > 1; const widthChanged = Math.abs((obj.width || 0) - nextWidth) > 1;
const heightChanged = Math.abs((obj.height || 0) - nextHeight) > 1; const heightChanged = Math.abs((obj.height || 0) - nextHeight) > 1;
@@ -155,6 +191,7 @@ const TextShape = ({
obj.lineHeight, obj.lineHeight,
obj.width, obj.width,
obj.height, obj.height,
allowWrap,
onUpdate, onUpdate,
]); ]);
@@ -321,8 +358,9 @@ const TextShape = ({
fontStyle={konvaFontStyle} fontStyle={konvaFontStyle}
letterSpacing={obj.letterSpacing ?? 0} letterSpacing={obj.letterSpacing ?? 0}
lineHeight={obj.lineHeight ?? 1.2} lineHeight={obj.lineHeight ?? 1.2}
width={obj.width || undefined} width={allowWrap ? obj.width || undefined : undefined}
align={textAlign} align={textAlign}
wrap={allowWrap ? "word" : "none"}
/> />
</Group> </Group>
); );
+132 -5
View File
@@ -1,3 +1,5 @@
import { getFontFamily } from "@/pages/editor/utils/fontFamily";
/** CSS font-weight values that match irancell @font-face declarations. */ /** CSS font-weight values that match irancell @font-face declarations. */
export const getCssFontWeight = (fontWeight?: string): number => { export const getCssFontWeight = (fontWeight?: string): number => {
if (!fontWeight || fontWeight === "normal") return 300; if (!fontWeight || fontWeight === "normal") return 300;
@@ -13,15 +15,140 @@ export const getCssFontWeight = (fontWeight?: string): number => {
return 300; return 300;
}; };
export const isSingleLineText = (text?: string): boolean => !text?.includes("\n"); /** Konva.Text uses `fontStyle: bold` (canvas keyword), not numeric weight. */
export const usesKonvaBoldStyle = (fontWeight?: string): boolean => {
if (!fontWeight || fontWeight === "normal") return false;
if (fontWeight === "bold" || fontWeight === "bolder") return true;
const n = parseInt(fontWeight, 10);
return !Number.isNaN(n) && n >= 600;
};
/** Scaled width for viewer text; adds slack so CSS does not wrap tighter than Konva. */ /** Canvas/CSS `font` string shared by editor measurement and viewer layout. */
export const buildCanvasFont = (
fontSize: number,
fontFamily?: string,
fontWeight?: string,
): string => {
const weight = getCssFontWeight(fontWeight);
const family = getFontFamily(fontFamily);
return `${weight} ${fontSize}px ${family}`;
};
/** Canvas font string that matches Konva.Text (`fontStyle: bold`). */
export const buildKonvaCanvasFont = (
fontSize: number,
fontFamily?: string,
fontWeight?: string,
): string => {
const family = getFontFamily(fontFamily);
const style = usesKonvaBoldStyle(fontWeight) ? "bold" : "normal";
return `${style} ${fontSize}px ${family}`;
};
const LINE_BREAK_RE = /[\r\n\u2028\u2029]/;
export const isSingleLineText = (text?: string): boolean =>
!text || !LINE_BREAK_RE.test(text);
/**
* Konva can wrap text inside a fixed width without inserting `\n`.
* Use stored height to detect multi-line layout.
*/
export const usesWrappedLayout = (
text: string | undefined,
height: number | undefined,
fontSize: number,
lineHeight = 1.2,
): boolean => {
if (!isSingleLineText(text)) return true;
const linePx = fontSize * lineHeight;
return (height ?? 0) > linePx * 1.35;
};
/** Extra px so HTML layout does not wrap tighter than Konva/canvas metrics. */
export const SINGLE_LINE_WIDTH_SLACK_PX = 12;
export type TextMeasureOptions = {
fontSize: number;
fontFamily?: string;
fontWeight?: string;
letterSpacing?: number;
lineHeight?: number;
};
const measureLineWidth = (
ctx: CanvasRenderingContext2D,
line: string,
letterSpacing: number,
): number => {
let lineWidth = ctx.measureText(line).width;
if (letterSpacing && line.length > 1) {
lineWidth += letterSpacing * (line.length - 1);
}
return lineWidth;
};
/** Measure text using CSS and Konva font strings; returns the wider result. */
export const measureTextBlock = (
text: string,
options: TextMeasureOptions,
): { width: number; height: number; lineCount: number } => {
if (!text) return { width: 0, height: 0, lineCount: 0 };
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) return { width: 0, height: 0, lineCount: 1 };
const {
fontSize,
fontFamily,
fontWeight,
letterSpacing = 0,
lineHeight = 1.2,
} = options;
const lines = text.split(LINE_BREAK_RE);
const fonts = [
buildCanvasFont(fontSize, fontFamily, fontWeight),
buildKonvaCanvasFont(fontSize, fontFamily, fontWeight),
];
let maxWidth = 0;
for (const font of fonts) {
ctx.font = font;
for (const line of lines) {
maxWidth = Math.max(maxWidth, measureLineWidth(ctx, line, letterSpacing));
}
}
return {
width: Math.ceil(maxWidth),
height: Math.ceil(lines.length * fontSize * lineHeight),
lineCount: lines.length,
};
};
/** Width used for layout: at least stored Konva width and canvas measurement. */
export const getEffectiveTextWidth = (
storedWidth: number | undefined,
text: string | undefined,
options: TextMeasureOptions,
singleLine: boolean,
): number | undefined => {
if (!text && !storedWidth) return undefined;
const measured = measureTextBlock(text || "", options).width;
const base = Math.max(storedWidth || 0, measured);
if (!singleLine) return base || undefined;
return Math.max(1, base + SINGLE_LINE_WIDTH_SLACK_PX);
};
/** Scaled width for viewer text (wrapped / multi-line layout only). */
export const getScaledTextWidth = ( export const getScaledTextWidth = (
width: number | undefined, width: number | undefined,
scale: number, scale: number,
singleLine: boolean,
): number | undefined => { ): number | undefined => {
if (!width) return undefined; if (!width) return undefined;
const scaled = Math.ceil(width * scale); return Math.ceil(width * scale) + Math.ceil(SINGLE_LINE_WIDTH_SLACK_PX * scale);
return singleLine ? scaled + 2 : scaled;
}; };
+48 -10
View File
@@ -3,7 +3,13 @@ import { type PageData } from '../types';
import type { EditorObject } from '@/pages/editor/store/editorStore'; import type { EditorObject } from '@/pages/editor/store/editorStore';
import { toCssLinearGradient } from '@/pages/editor/utils/gradient'; import { toCssLinearGradient } from '@/pages/editor/utils/gradient';
import { getFontFamily } from '@/pages/editor/utils/fontFamily'; import { getFontFamily } from '@/pages/editor/utils/fontFamily';
import { getCssFontWeight, getScaledTextWidth, isSingleLineText } from '@/pages/editor/utils/textStyle'; import {
getCssFontWeight,
getEffectiveTextWidth,
getScaledTextWidth,
usesWrappedLayout,
SINGLE_LINE_WIDTH_SLACK_PX,
} from '@/pages/editor/utils/textStyle';
import '@/pages/viewer/styles/entranceAnimations.css'; import '@/pages/viewer/styles/entranceAnimations.css';
import { mergeEntranceAnimationStyle } from '@/pages/viewer/utils/entranceAnimationStyle'; import { mergeEntranceAnimationStyle } from '@/pages/viewer/utils/entranceAnimationStyle';
import { useBookPageEntranceSequence } from '@/pages/viewer/hooks/useBookPageEntranceSequence'; import { useBookPageEntranceSequence } from '@/pages/viewer/hooks/useBookPageEntranceSequence';
@@ -123,10 +129,35 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
case 'text': { case 'text': {
// برای text، opacity در رنگ اعمال می‌شود، پس باید opacity را از baseStyle حذف کنیم // برای text، opacity در رنگ اعمال می‌شود، پس باید opacity را از baseStyle حذف کنیم
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { opacity, ...textBaseStyle } = baseStyle; const { opacity, transform: _baseTransform, ...textBaseStyle } = baseStyle;
const singleLine = isSingleLineText(obj.text); const lineHeight = obj.lineHeight ?? 1.2;
const textWidth = getScaledTextWidth(obj.width, scale, singleLine); const wrapped = usesWrappedLayout(obj.text, obj.height, obj.fontSize || 24, lineHeight);
const textLeft = Math.round(((obj.x || 0) - (obj.width || 0)) * scale); const rotation = obj.rotation || 0;
const fontSize = obj.fontSize || 24;
const measureOpts = {
fontSize,
fontFamily: obj.fontFamily,
fontWeight: obj.fontWeight,
letterSpacing: obj.letterSpacing ?? 0,
lineHeight,
};
const layoutWidth = wrapped
? getEffectiveTextWidth(obj.width, obj.text, measureOpts, false)
: undefined;
const textWidth = wrapped ? getScaledTextWidth(layoutWidth, scale) : undefined;
// getScaledTextWidth adds SINGLE_LINE_WIDTH_SLACK_PX to prevent unwanted CSS wrapping.
// Compensate by shifting textLeft left by the same amount so the right edge stays at obj.x * scale.
const textSlackPx = textWidth !== undefined ? Math.ceil(SINGLE_LINE_WIDTH_SLACK_PX * scale) : 0;
const textLeft = wrapped
? Math.round(((obj.x || 0) - (layoutWidth || obj.width || 0)) * scale) - textSlackPx
: Math.round((obj.x || 0) * scale);
const textTransform = wrapped
? rotation
? `rotate(${rotation}deg)`
: undefined
: rotation
? `rotate(${rotation}deg) translateX(-100%)`
: 'translateX(-100%)';
return ( return (
<div <div
key={obj.id || index} key={obj.id || index}
@@ -134,20 +165,27 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
{ {
...textBaseStyle, ...textBaseStyle,
left: `${textLeft}px`, left: `${textLeft}px`,
width: textWidth ? `${textWidth}px` : undefined, width: wrapped ? (textWidth ? `${textWidth}px` : undefined) : 'max-content',
fontSize: `${(obj.fontSize || 16) * scale}px`, maxWidth: wrapped ? undefined : 'none',
fontSize: `${fontSize * scale}px`,
fontFamily: getFontFamily(obj.fontFamily), fontFamily: getFontFamily(obj.fontFamily),
fontWeight: getCssFontWeight(obj.fontWeight), fontWeight: getCssFontWeight(obj.fontWeight),
lineHeight: obj.lineHeight ?? 1.2, lineHeight,
textAlign: obj.textAlign ?? 'right', textAlign: obj.textAlign ?? 'right',
direction: 'rtl', direction: 'rtl',
color: getColorWithOpacity(obj.fill, obj.opacity), color: getColorWithOpacity(obj.fill, obj.opacity),
whiteSpace: singleLine ? 'nowrap' : 'pre-wrap', whiteSpace: wrapped ? 'pre-wrap' : 'nowrap',
overflowWrap: wrapped ? 'break-word' : 'normal',
letterSpacing: obj.letterSpacing ? `${obj.letterSpacing * scale}px` : undefined, letterSpacing: obj.letterSpacing ? `${obj.letterSpacing * scale}px` : undefined,
boxSizing: 'content-box', boxSizing: 'content-box',
transform: textTransform,
transformOrigin: wrapped ? 'top left' : 'top right',
}, },
obj, obj,
index, index,
wrapped
? { rotationDeg: rotation, transformOrigin: 'top left' }
: { rotationDeg: rotation, transformOrigin: 'top right' },
)} )}
> >
{obj.text || ''} {obj.text || ''}
@@ -212,7 +250,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
width: obj.width ? `${obj.width * scale}px` : 'auto', width: obj.width ? `${obj.width * scale}px` : 'auto',
height: obj.height ? `${obj.height * scale}px` : 'auto', height: obj.height ? `${obj.height * scale}px` : 'auto',
color: obj.fill || '#3b82f6', color: obj.fill || '#3b82f6',
fontSize: `${(obj.fontSize || 16) * scale}px`, fontSize: `${(obj.fontSize || 24) * scale}px`,
fontFamily: getFontFamily(obj.fontFamily), fontFamily: getFontFamily(obj.fontFamily),
fontWeight: getCssFontWeight(obj.fontWeight), fontWeight: getCssFontWeight(obj.fontWeight),
textDecoration: 'underline', textDecoration: 'underline',