fix text
This commit is contained in:
@@ -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<Konva.Text>(null);
|
||||
const groupRef = useRef<Konva.Group>(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"}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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<HTMLDivElement, BookPageProps>(
|
||||
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 (
|
||||
<div
|
||||
key={obj.id || index}
|
||||
@@ -134,20 +165,27 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
{
|
||||
...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<HTMLDivElement, BookPageProps>(
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user