fix text
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user