diff --git a/src/pages/editor/components/tools/TextShape.tsx b/src/pages/editor/components/tools/TextShape.tsx index 22f0f7b..b659a9f 100644 --- a/src/pages/editor/components/tools/TextShape.tsx +++ b/src/pages/editor/components/tools/TextShape.tsx @@ -3,6 +3,11 @@ import { Text, Group } from "react-konva"; import Konva from "konva"; import type { TextShapeProps } from "./types"; import { getFontFamily } from "@/pages/editor/utils/fontFamily"; +import { + getEffectiveTextWidth, + measureTextBlock, + usesWrappedLayout, +} from "@/pages/editor/utils/textStyle"; const getFontWeight = (fontWeight?: string): string => { if (!fontWeight) return "normal"; @@ -53,6 +58,13 @@ const TextShape = ({ const shapeRef = useRef(null); const groupRef = useRef(null); 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 // Set offset for text node @@ -89,8 +101,32 @@ const TextShape = ({ }); measureNode.destroy(); - const nextWidth = Math.max(1, Math.ceil(rw)); - const nextHeight = Math.max(1, Math.ceil(rh)); + const fontSize = obj.fontSize || 24; + 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 heightChanged = Math.abs((obj.height || 0) - nextHeight) > 1; @@ -155,6 +191,7 @@ const TextShape = ({ obj.lineHeight, obj.width, obj.height, + allowWrap, onUpdate, ]); @@ -321,8 +358,9 @@ const TextShape = ({ fontStyle={konvaFontStyle} letterSpacing={obj.letterSpacing ?? 0} lineHeight={obj.lineHeight ?? 1.2} - width={obj.width || undefined} + width={allowWrap ? obj.width || undefined : undefined} align={textAlign} + wrap={allowWrap ? "word" : "none"} /> ); diff --git a/src/pages/editor/utils/textStyle.ts b/src/pages/editor/utils/textStyle.ts index 514d569..10f6225 100644 --- a/src/pages/editor/utils/textStyle.ts +++ b/src/pages/editor/utils/textStyle.ts @@ -1,3 +1,5 @@ +import { getFontFamily } from "@/pages/editor/utils/fontFamily"; + /** CSS font-weight values that match irancell @font-face declarations. */ export const getCssFontWeight = (fontWeight?: string): number => { if (!fontWeight || fontWeight === "normal") return 300; @@ -13,15 +15,140 @@ export const getCssFontWeight = (fontWeight?: string): number => { 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 = ( width: number | undefined, scale: number, - singleLine: boolean, ): number | undefined => { if (!width) return undefined; - const scaled = Math.ceil(width * scale); - return singleLine ? scaled + 2 : scaled; + return Math.ceil(width * scale) + Math.ceil(SINGLE_LINE_WIDTH_SLACK_PX * scale); }; diff --git a/src/pages/viewer/components/BookPage.tsx b/src/pages/viewer/components/BookPage.tsx index a010334..bd90429 100644 --- a/src/pages/viewer/components/BookPage.tsx +++ b/src/pages/viewer/components/BookPage.tsx @@ -3,7 +3,13 @@ import { type PageData } from '../types'; import type { EditorObject } from '@/pages/editor/store/editorStore'; import { toCssLinearGradient } from '@/pages/editor/utils/gradient'; 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 { mergeEntranceAnimationStyle } from '@/pages/viewer/utils/entranceAnimationStyle'; import { useBookPageEntranceSequence } from '@/pages/viewer/hooks/useBookPageEntranceSequence'; @@ -123,10 +129,35 @@ const BookPage = forwardRef( case 'text': { // برای text، opacity در رنگ اعمال می‌شود، پس باید opacity را از baseStyle حذف کنیم // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { opacity, ...textBaseStyle } = baseStyle; - const singleLine = isSingleLineText(obj.text); - const textWidth = getScaledTextWidth(obj.width, scale, singleLine); - const textLeft = Math.round(((obj.x || 0) - (obj.width || 0)) * scale); + const { opacity, transform: _baseTransform, ...textBaseStyle } = baseStyle; + const lineHeight = obj.lineHeight ?? 1.2; + const wrapped = usesWrappedLayout(obj.text, obj.height, obj.fontSize || 24, lineHeight); + 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 (
( { ...textBaseStyle, left: `${textLeft}px`, - width: textWidth ? `${textWidth}px` : undefined, - fontSize: `${(obj.fontSize || 16) * scale}px`, + width: wrapped ? (textWidth ? `${textWidth}px` : undefined) : 'max-content', + maxWidth: wrapped ? undefined : 'none', + fontSize: `${fontSize * scale}px`, fontFamily: getFontFamily(obj.fontFamily), fontWeight: getCssFontWeight(obj.fontWeight), - lineHeight: obj.lineHeight ?? 1.2, + lineHeight, textAlign: obj.textAlign ?? 'right', direction: 'rtl', 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, boxSizing: 'content-box', + transform: textTransform, + transformOrigin: wrapped ? 'top left' : 'top right', }, obj, index, + wrapped + ? { rotationDeg: rotation, transformOrigin: 'top left' } + : { rotationDeg: rotation, transformOrigin: 'top right' }, )} > {obj.text || ''} @@ -212,7 +250,7 @@ const BookPage = forwardRef( width: obj.width ? `${obj.width * scale}px` : 'auto', height: obj.height ? `${obj.height * scale}px` : 'auto', color: obj.fill || '#3b82f6', - fontSize: `${(obj.fontSize || 16) * scale}px`, + fontSize: `${(obj.fontSize || 24) * scale}px`, fontFamily: getFontFamily(obj.fontFamily), fontWeight: getCssFontWeight(obj.fontWeight), textDecoration: 'underline',