diff --git a/src/pages/editor/components/canvas/useDrawingHandlers.ts b/src/pages/editor/components/canvas/useDrawingHandlers.ts index 54d8862..2c87916 100644 --- a/src/pages/editor/components/canvas/useDrawingHandlers.ts +++ b/src/pages/editor/components/canvas/useDrawingHandlers.ts @@ -3,6 +3,7 @@ import Konva from "konva"; import { useEditorStore, type EditorObject } from "@/pages/editor/store/editorStore"; import { useTextDefaultsStore } from "@/pages/editor/store/textDefaultsStore"; import { createDrawingObject } from "./drawingUtils"; +import { DEFAULT_TEXT_MAX_WIDTH_PX } from "@/pages/editor/utils/textStyle"; export const useDrawingHandlers = () => { const [isDrawing, setIsDrawing] = useState(false); @@ -82,6 +83,7 @@ export const useDrawingHandlers = () => { letterSpacing: defaults.letterSpacing, wordSpacing: defaults.wordSpacing, height: defaults.fontSize || 24, + textMaxWidth: DEFAULT_TEXT_MAX_WIDTH_PX, }; useEditorStore.getState().commitObjectHistoryBeforeChange(); addObject(newText); diff --git a/src/pages/editor/components/sidebar/settings/TextSettings.tsx b/src/pages/editor/components/sidebar/settings/TextSettings.tsx index cc7ad66..fb22457 100644 --- a/src/pages/editor/components/sidebar/settings/TextSettings.tsx +++ b/src/pages/editor/components/sidebar/settings/TextSettings.tsx @@ -1,6 +1,7 @@ import type { EditorObject } from "../../../store/editorStore"; import Input from "@/components/Input"; import ColorPicker from "@/components/ColorPicker"; +import { DEFAULT_TEXT_MAX_WIDTH_PX } from "@/pages/editor/utils/textStyle"; type TextSettingsProps = { selectedObject: EditorObject; @@ -60,6 +61,18 @@ const TextSettings = ({ selectedObject, onUpdate }: TextSettingsProps) => { }) } /> + + onUpdate(selectedObject.id, { + textMaxWidth: Math.max(80, parseNumber(e.target.value) || DEFAULT_TEXT_MAX_WIDTH_PX), + }) + } + />

چینش متن

diff --git a/src/pages/editor/components/tools/TextShape.tsx b/src/pages/editor/components/tools/TextShape.tsx index b659a9f..8e11a9f 100644 --- a/src/pages/editor/components/tools/TextShape.tsx +++ b/src/pages/editor/components/tools/TextShape.tsx @@ -4,8 +4,10 @@ import Konva from "konva"; import type { TextShapeProps } from "./types"; import { getFontFamily } from "@/pages/editor/utils/fontFamily"; import { + DEFAULT_TEXT_MAX_WIDTH_PX, getEffectiveTextWidth, measureTextBlock, + resolveTextMaxWidth, usesWrappedLayout, } from "@/pages/editor/utils/textStyle"; @@ -58,6 +60,7 @@ const TextShape = ({ const shapeRef = useRef(null); const groupRef = useRef(null); const [isEditing, setIsEditing] = useState(false); + const [maxTextWidth, setMaxTextWidth] = useState(DEFAULT_TEXT_MAX_WIDTH_PX); const fontSize = obj.fontSize || 24; const allowWrap = usesWrappedLayout( obj.text, @@ -86,6 +89,7 @@ const TextShape = ({ const groupNode = groupRef.current; if (!textNode || !groupNode) return; if (groupNode.scaleX() !== 1 || groupNode.scaleY() !== 1) return; + const stage = textNode.getStage(); textNode._setTextData(); @@ -110,6 +114,14 @@ const TextShape = ({ lineHeight: obj.lineHeight ?? 1.2, }); const konvaWidth = Math.max(1, Math.ceil(rw)); + const resolvedMaxWidth = resolveTextMaxWidth({ + anchorX: obj.x || 0, + pageWidth: stage?.width(), + defaultMaxWidth: obj.textMaxWidth ?? DEFAULT_TEXT_MAX_WIDTH_PX, + }); + if (Math.abs(resolvedMaxWidth - maxTextWidth) > 1) { + setMaxTextWidth(resolvedMaxWidth); + } const nextWidth = getEffectiveTextWidth( konvaWidth, obj.text, @@ -122,16 +134,33 @@ const TextShape = ({ }, !allowWrap, ) ?? konvaWidth; + const clampedWidth = Math.min(nextWidth, resolvedMaxWidth); + const forcedWrapByWidth = clampedWidth < nextWidth - 1; + let measuredHeight = rh; + if (forcedWrapByWidth) { + const wrappedMeasureNode = textNode.clone({ + width: clampedWidth, + wrap: "word", + x: 0, + y: 0, + }); + wrappedMeasureNode._setTextData(); + const { height: wrappedHeight } = wrappedMeasureNode.getClientRect({ + skipTransform: true, + }); + wrappedMeasureNode.destroy(); + measuredHeight = Math.max(measuredHeight, wrappedHeight); + } const nextHeight = Math.max( 1, - Math.ceil(rh), + Math.ceil(measuredHeight), cssMeasured.height, ); - const widthChanged = Math.abs((obj.width || 0) - nextWidth) > 1; + const widthChanged = Math.abs((obj.width || 0) - clampedWidth) > 1; const heightChanged = Math.abs((obj.height || 0) - nextHeight) > 1; if (widthChanged || heightChanged) { - onUpdate(obj.id, { width: nextWidth, height: nextHeight }); + onUpdate(obj.id, { width: clampedWidth, height: nextHeight }); } textNode.getLayer()?.batchDraw(); }; @@ -191,7 +220,10 @@ const TextShape = ({ obj.lineHeight, obj.width, obj.height, + obj.x, + obj.textMaxWidth, allowWrap, + maxTextWidth, onUpdate, ]); @@ -199,6 +231,8 @@ const TextShape = ({ const konvaFontStyle = useMemo(() => getKonvaFontStyle(obj.fontWeight), [obj.fontWeight]); const fillColor = useMemo(() => getColorWithOpacity(obj.fill, obj.opacity), [obj.fill, obj.opacity]); const textAlign = obj.textAlign ?? "right"; + const wrapByMaxWidth = (obj.width ?? 0) >= maxTextWidth - 1; + const shouldWrap = allowWrap || wrapByMaxWidth; const handleDblClick = () => { if (!shapeRef.current || !groupRef.current) return; @@ -238,7 +272,8 @@ const TextShape = ({ position: "absolute", top: `${areaPosition.y}px`, left: `${areaPosition.x}px`, - width: `${box.width}px`, + width: `${Math.min(box.width, maxTextWidth * stageScale)}px`, + maxWidth: `${maxTextWidth * stageScale}px`, minHeight: `${box.height}px`, fontSize: `${(obj.fontSize || 24) * stageScale}px`, fontFamily: fontFamily, @@ -358,9 +393,9 @@ const TextShape = ({ fontStyle={konvaFontStyle} letterSpacing={obj.letterSpacing ?? 0} lineHeight={obj.lineHeight ?? 1.2} - width={allowWrap ? obj.width || undefined : undefined} + width={shouldWrap ? obj.width || maxTextWidth : undefined} align={textAlign} - wrap={allowWrap ? "word" : "none"} + wrap={shouldWrap ? "word" : "none"} /> ); diff --git a/src/pages/editor/store/editorStore.types.ts b/src/pages/editor/store/editorStore.types.ts index d9cde94..19bf24b 100644 --- a/src/pages/editor/store/editorStore.types.ts +++ b/src/pages/editor/store/editorStore.types.ts @@ -61,6 +61,8 @@ export type EditorObject = { stroke?: string; strokeWidth?: number; text?: string; + /** حداکثر عرض باکس متن (px) برای wrap پیش‌فرض متن‌های بلند */ + textMaxWidth?: number; fontSize?: number; lineHeight?: number; textAlign?: "left" | "center" | "right"; diff --git a/src/pages/editor/utils/textStyle.ts b/src/pages/editor/utils/textStyle.ts index 1bd552e..93462d7 100644 --- a/src/pages/editor/utils/textStyle.ts +++ b/src/pages/editor/utils/textStyle.ts @@ -63,6 +63,39 @@ export const usesWrappedLayout = ( /** Extra px so HTML layout does not wrap tighter than Konva/canvas metrics. */ export const SINGLE_LINE_WIDTH_SLACK_PX = 12; +export const DEFAULT_TEXT_MAX_WIDTH_PX = 520; +const MIN_TEXT_MAX_WIDTH_PX = 80; + +/** + * Maximum width for text boxes. + * - never larger than page/view bounds + * - has a sane default for long single-line text + */ +export const resolveTextMaxWidth = (args: { + anchorX: number; + pageWidth?: number; + defaultMaxWidth?: number; + edgePadding?: number; +}): number => { + const { + anchorX, + pageWidth, + defaultMaxWidth = DEFAULT_TEXT_MAX_WIDTH_PX, + edgePadding = 8, + } = args; + + const fromAnchor = Math.floor(anchorX - edgePadding); + const fromPage = + pageWidth !== undefined + ? Math.floor(Math.min(anchorX - edgePadding, pageWidth - edgePadding)) + : fromAnchor; + + const hardLimit = Math.max(MIN_TEXT_MAX_WIDTH_PX, fromPage); + return Math.max( + MIN_TEXT_MAX_WIDTH_PX, + Math.min(defaultMaxWidth, hardLimit), + ); +}; export type TextMeasureOptions = { fontSize: number; diff --git a/src/pages/viewer/components/BookPage.tsx b/src/pages/viewer/components/BookPage.tsx index 1c8c2f1..3938aba 100644 --- a/src/pages/viewer/components/BookPage.tsx +++ b/src/pages/viewer/components/BookPage.tsx @@ -5,8 +5,10 @@ import { toCssLinearGradient, getSvgGradientEndpoints } from '@/pages/editor/uti import { getCustomShapePointBounds } from '@/pages/editor/utils/customShape'; import { getFontFamily } from '@/pages/editor/utils/fontFamily'; import { + DEFAULT_TEXT_MAX_WIDTH_PX, getCssFontWeight, getViewerTextLayout, + resolveTextMaxWidth, usesWrappedLayout, } from '@/pages/editor/utils/textStyle'; import '@/pages/viewer/styles/entranceAnimations.css'; @@ -199,14 +201,39 @@ const BookPage = forwardRef( measureOpts, wrapped, }); + const maxWidthPx = Math.ceil( + resolveTextMaxWidth({ + anchorX: (obj.x || 0) * scale, + pageWidth: pageWidth ?? 794 * scale, + defaultMaxWidth: (obj.textMaxWidth ?? DEFAULT_TEXT_MAX_WIDTH_PX) * scale, + }), + ); + const wrappedWidthPx = + obj.width !== undefined ? Math.ceil(obj.width * scale) : undefined; + const wrapByMaxWidth = + (wrapped ? wrappedWidthPx : textLayout.widthPx) !== undefined && + (wrapped ? wrappedWidthPx : textLayout.widthPx)! > maxWidthPx; + const finalWrapped = wrapped || wrapByMaxWidth; + const resolvedWidthPx = finalWrapped + ? (wrappedWidthPx !== undefined + ? Math.min(wrappedWidthPx, maxWidthPx) + : (textLayout.widthPx !== undefined + ? Math.min(textLayout.widthPx, maxWidthPx) + : undefined)) + : textLayout.widthPx; + const anchorRightPx = Math.round((obj.x || 0) * scale); + const resolvedLeftPx = + resolvedWidthPx !== undefined + ? anchorRightPx - resolvedWidthPx + : textLayout.leftPx; return (
( textAlign: textLayout.textAlign, direction: 'rtl', color: getColorWithOpacity(obj.fill, obj.opacity), - whiteSpace: wrapped ? 'pre-wrap' : 'nowrap', - overflowWrap: wrapped ? 'break-word' : 'normal', + whiteSpace: finalWrapped ? 'pre-wrap' : 'nowrap', + overflowWrap: finalWrapped ? 'break-word' : 'normal', letterSpacing: obj.letterSpacing ? `${obj.letterSpacing * scale}px` : undefined, boxSizing: 'content-box', + zIndex: index, transform: textLayout.transform, transformOrigin: textLayout.transformOrigin, },