From 4ab791200701d2d711bc8decf9db0fbe5522fde9 Mon Sep 17 00:00:00 2001 From: liangyufeng Date: Thu, 5 Mar 2026 11:06:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E4=BC=98=E5=8C=96=E5=A4=9A=E8=A1=8C?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E5=8F=96=E6=B6=88=E8=81=9A=E7=84=A6=E4=BC=9A?= =?UTF-8?q?=E6=8D=A2=E8=A1=8C=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 5 +- apps/web-e2e/src/manual-gen/gif-executor.ts | 2 +- packages/drawnix/src/drawnix.tsx | 2 +- .../drawnix/src/plugins/with-text-resize.ts | 115 +++++++---- .../drawnix/src/utils/text-measurement.ts | 189 ++++++++++++++++++ 5 files changed, 273 insertions(+), 40 deletions(-) create mode 100644 packages/drawnix/src/utils/text-measurement.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f00c6663..c890e200 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,7 +9,8 @@ "Bash(pnpm nx typecheck:*)", "Bash(pnpm run build)", "Bash(git commit:*)", - "Bash(grep:*)" + "Bash(grep:*)", + "Bash(pnpm typecheck:*)" ] } -} +} \ No newline at end of file diff --git a/apps/web-e2e/src/manual-gen/gif-executor.ts b/apps/web-e2e/src/manual-gen/gif-executor.ts index 1f089e26..875516a6 100644 --- a/apps/web-e2e/src/manual-gen/gif-executor.ts +++ b/apps/web-e2e/src/manual-gen/gif-executor.ts @@ -16,7 +16,7 @@ import { export class GifExecutor { private config: Required; - private startTime: number = 0; + private startTime = 0; private segments: GifTimeSegment[] = []; constructor(config: ExecutorConfig = {}) { diff --git a/packages/drawnix/src/drawnix.tsx b/packages/drawnix/src/drawnix.tsx index b7f457c2..55ffd553 100644 --- a/packages/drawnix/src/drawnix.tsx +++ b/packages/drawnix/src/drawnix.tsx @@ -1018,7 +1018,7 @@ const DrawnixContent: React.FC = ({ setInlineTextInput(null); return; } - const text = inlineTextRef.current.textContent || ''; + const text = inlineTextRef.current.innerText || ''; if (text.trim()) { DrawTransforms.insertText(board, inlineTextInput.worldPoint, text); } diff --git a/packages/drawnix/src/plugins/with-text-resize.ts b/packages/drawnix/src/plugins/with-text-resize.ts index 9ee878e5..2c11597e 100644 --- a/packages/drawnix/src/plugins/with-text-resize.ts +++ b/packages/drawnix/src/plugins/with-text-resize.ts @@ -8,6 +8,8 @@ import { PlaitBoard, + PlaitElement, + PlaitOperation, PlaitPlugin, Point, RectangleClient, @@ -27,10 +29,11 @@ import { import { PlaitDrawElement, PlaitText, DrawTransforms } from '@plait/draw'; import { DEFAULT_FONT_SIZE } from '@plait/text-plugins'; import { ResizeHandle } from '../utils/resize-utils'; +import { + calculateScaledTextHeight, + getMinTextContentSize, +} from '../utils/text-measurement'; -const MIN_BOX_SIZE = 10; -const LINE_HEIGHT_FACTOR = 1.4; -const MIN_TEXT_BOX_HEIGHT = DEFAULT_FONT_SIZE * LINE_HEIGHT_FACTOR; const EDGE_CURSOR_CLASSES = ['ns-resize', 'ew-resize']; function rotatePoint(point: Point, center: Point, angle: number): Point { @@ -45,10 +48,17 @@ function rotatePoint(point: Point, center: Point, angle: number): Point { ]; } +/** Slate 文本节点结构:含 text/font-size 或 children */ +interface SlateTextNode { + text?: string; + 'font-size'?: string; + children?: SlateTextNode[]; +} + function hitTestAllHandles( rectangle: RectangleClient, point: Point, - angle: number = 0 + angle = 0 ) { const refs = getRectangleResizeHandleRefs(rectangle, RESIZE_HANDLE_DIAMETER); const testPoint = angle @@ -79,7 +89,10 @@ function hitTestAllHandles( /** * 递归缩放文本节点的 font-size */ -function scaleTextContent(node: any, scaleFactor: number): any { +function scaleTextContent( + node: SlateTextNode | null | undefined, + scaleFactor: number +): SlateTextNode | null | undefined { if (!node) return node; if ('text' in node && typeof node.text === 'string') { const cur = node['font-size'] @@ -91,7 +104,9 @@ function scaleTextContent(node: any, scaleFactor: number): any { if ('children' in node && Array.isArray(node.children)) { return { ...node, - children: node.children.map((c: any) => scaleTextContent(c, scaleFactor)), + children: node.children + .map((c) => scaleTextContent(c, scaleFactor)) + .filter((x): x is SlateTextNode => x != null), }; } return node; @@ -108,7 +123,7 @@ function getSelectedTextElement(board: PlaitBoard): PlaitText | null { interface TextSnapshot { width: number; height: number; - text: any; + text: SlateTextNode | undefined; } export const withTextResize: PlaitPlugin = (board: PlaitBoard) => { @@ -122,7 +137,7 @@ export const withTextResize: PlaitPlugin = (board: PlaitBoard) => { const el = getSelectedTextElement(board); if (!el) return null; const rect = getRectangleByElements(board, [el], false); - const angle = (el as any).angle || 0; + const angle = (el as PlaitText & { angle?: number }).angle ?? 0; const hit = hitTestAllHandles(rect, point, angle); if (hit) { return { @@ -161,9 +176,19 @@ export const withTextResize: PlaitPlugin = (board: PlaitBoard) => { } const proj = (projDx * w + projDy * h) / diag; - // 最小缩放:确保元素不会小于 CSS 行框 strut 高度,防止文本溢出裁切 - const minScale = Math.min(1.0, Math.max(MIN_BOX_SIZE / w, MIN_TEXT_BOX_HEIGHT / h)); - const scale = Math.max(minScale, (diag + proj) / diag); + + // 计算缩放后文本内容的实际高度 + const targetScale = (diag + proj) / diag; + const scaledWidth = w * targetScale; + const requiredHeight = calculateScaledTextHeight(element.text, scaledWidth, targetScale); + + // 最小缩放:确保容器不小于能容纳文字内容的最小尺寸(取内容最小宽高的最大值) + const minScaleForContent = requiredHeight / h; + const contentMin = getMinTextContentSize(element.text); + const minScaleForBox = Math.max(contentMin.width / w, contentMin.height / h); + const minScale = Math.max(minScaleForContent, minScaleForBox); + + const scale = Math.max(minScale, targetScale); const nw = w * scale; const nh = h * scale; @@ -177,7 +202,9 @@ export const withTextResize: PlaitPlugin = (board: PlaitBoard) => { } const newPoints: [Point, Point] = [[nx, ny], [nx + nw, ny + nh]]; - const path = board.children.findIndex((c: any) => c.id === element.id); + const path = board.children.findIndex( + (c: PlaitElement) => (c as PlaitDrawElement).id === element.id + ); if (path < 0) return; // 必须在同一次 setNode 中同时设置 text,否则 onContextChanged @@ -186,7 +213,7 @@ export const withTextResize: PlaitPlugin = (board: PlaitBoard) => { ? scaleTextContent(element.text, scale) : undefined; - const props: Record = { + const props: Record = { points: normalizeShapePoints(newPoints), textHeight: nh, autoSize: false, @@ -200,7 +227,8 @@ export const withTextResize: PlaitPlugin = (board: PlaitBoard) => { afterResize: (resizeRef: ResizeRef) => { if (!resizeRef) return; const path = board.children.findIndex( - (c: any) => c.id === (resizeRef.element as PlaitText).id + (c: PlaitElement) => + (c as PlaitDrawElement).id === (resizeRef.element as PlaitText).id ); if (path < 0) return; const cur = board.children[path] as PlaitText; @@ -232,32 +260,37 @@ export const withTextResize: PlaitPlugin = (board: PlaitBoard) => { // apply: 拦截框架多选缩放对 text 元素 set_node(points) 的操作, // 同步注入字体缩放,确保 points 和 text 在同一操作中更新 const { apply } = board; - board.apply = (operation: any) => { + board.apply = (operation: PlaitOperation) => { if ( operation.type === 'set_node' && textSnapshots.size > 0 && !handledBySingleResize && - operation.newProperties?.points && - !operation.newProperties?.text + 'newProperties' in operation && + operation.newProperties && + typeof operation.newProperties === 'object' && + 'points' in operation.newProperties && + operation.newProperties.points && + !('text' in operation.newProperties && operation.newProperties.text) ) { const pathIndex = operation.path?.[0]; const node = pathIndex != null ? board.children[pathIndex] : null; if (node && PlaitDrawElement.isText(node)) { - const snapshot = textSnapshots.get((node as any).id); + const textNode = node as PlaitText; + const snapshot = textSnapshots.get(textNode.id); if (snapshot) { - const newRect = RectangleClient.getRectangleByPoints(operation.newProperties.points); + const newRect = RectangleClient.getRectangleByPoints( + operation.newProperties.points as [Point, Point] + ); const scale = newRect.width / snapshot.width; if (Math.abs(scale - 1) > 0.001 && snapshot.text) { const scaledText = scaleTextContent(snapshot.text, scale); - operation.newProperties = { - ...operation.newProperties, - text: scaledText, + Object.assign(operation.newProperties, { + text: scaledText ?? undefined, textHeight: newRect.height, - }; - operation.properties = { - ...operation.properties, - text: (node as any).text, - }; + }); + if (operation.properties && typeof operation.properties === 'object') { + Object.assign(operation.properties, { text: textNode.text }); + } } } } @@ -265,25 +298,31 @@ export const withTextResize: PlaitPlugin = (board: PlaitBoard) => { apply(operation); }; - // globalPointerUp: 多选缩放完成后,修正过小元素到最小尺寸(与单选 minScale 一致:宽度不小于 MIN_BOX_SIZE,高度不小于 MIN_TEXT_BOX_HEIGHT) + // globalPointerUp: 多选缩放完成后,修正过小元素到最小尺寸 const { globalPointerUp } = board; board.globalPointerUp = (event: PointerEvent) => { globalPointerUp(event); if (!handledBySingleResize && textSnapshots.size > 0) { textSnapshots.forEach((snapshot, elementId) => { - const path = board.children.findIndex((c: any) => c.id === elementId); + const path = board.children.findIndex( + (c: PlaitElement) => (c as PlaitDrawElement).id === elementId + ); if (path < 0) return; const cur = board.children[path] as PlaitText; const curRect = RectangleClient.getRectangleByPoints(cur.points); + // 计算当前文本内容实际需要的高度 + const currentScale = curRect.width / snapshot.width; + const requiredHeight = calculateScaledTextHeight(cur.text, curRect.width, currentScale); + const contentMin = getMinTextContentSize(cur.text); + // 修正系数:确保宽高不小于能容纳文字内容的最小尺寸,且高度不小于内容实际需要 const scaleW = - curRect.width >= MIN_BOX_SIZE ? 1 : MIN_BOX_SIZE / curRect.width; + curRect.width >= contentMin.width ? 1 : contentMin.width / curRect.width; const scaleH = - curRect.height >= MIN_TEXT_BOX_HEIGHT - ? 1 - : MIN_TEXT_BOX_HEIGHT / curRect.height; - const correction = Math.max(1, scaleW, scaleH); + curRect.height >= contentMin.height ? 1 : contentMin.height / curRect.height; + const scaleContent = curRect.height >= requiredHeight ? 1 : requiredHeight / curRect.height; + const correction = Math.max(1, scaleW, scaleH, scaleContent); if (correction > 1) { const newW = curRect.width * correction; @@ -303,7 +342,7 @@ export const withTextResize: PlaitPlugin = (board: PlaitBoard) => { DrawTransforms.resizeGeometry(board, normalizedPoints, newH, [path]); Transforms.setNode( board, - { text: scaledText, autoSize: false } as any, + { text: scaledText, autoSize: false } as Partial, [path] ); } @@ -346,7 +385,11 @@ export const withTextResize: PlaitPlugin = (board: PlaitBoard) => { if (textEl.autoSize) continue; const rect = RectangleClient.getRectangleByPoints(textEl.points); if (textEl.textHeight !== undefined && Math.abs(textEl.textHeight - rect.height) > 0.01) { - Transforms.setNode(board, { textHeight: rect.height } as any, [i]); + Transforms.setNode( + board, + { textHeight: rect.height } as Partial, + [i] + ); } } } finally { diff --git a/packages/drawnix/src/utils/text-measurement.ts b/packages/drawnix/src/utils/text-measurement.ts new file mode 100644 index 00000000..6d2b3357 --- /dev/null +++ b/packages/drawnix/src/utils/text-measurement.ts @@ -0,0 +1,189 @@ +/** + * 文本测量工具函数 + * Text Measurement Utilities + */ + +import { DEFAULT_FONT_SIZE } from '@plait/text-plugins'; + +const LINE_HEIGHT_FACTOR = 1.4; +const DEFAULT_PADDING = 8; + +interface TextNode { + text?: string; + 'font-size'?: string; + children?: TextNode[]; +} + +/** + * 递归提取文本节点中的所有文本段落及其字体大小 + */ +function extractTextSegments(node: TextNode): Array<{ text: string; fontSize: number }> { + const segments: Array<{ text: string; fontSize: number }> = []; + + if (!node) return segments; + + if ('text' in node && typeof node.text === 'string') { + const fontSize = node['font-size'] + ? parseFloat(node['font-size']) + : DEFAULT_FONT_SIZE; + segments.push({ text: node.text, fontSize }); + } + + if ('children' in node && Array.isArray(node.children)) { + for (const child of node.children) { + segments.push(...extractTextSegments(child)); + } + } + + return segments; +} + +/** + * 使用 Canvas API 测量文本宽度 + */ +function measureTextWidth(text: string, fontSize: number): number { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) return text.length * fontSize * 0.6; + + ctx.font = `${fontSize}px sans-serif`; + return ctx.measureText(text).width; +} + +/** + * 计算文本内容在给定宽度下的实际渲染高度 + * @param textNode 文本节点树 + * @param containerWidth 容器宽度 + * @returns 文本内容的实际高度(包含 padding) + */ +export function calculateTextContentHeight( + textNode: TextNode | undefined, + containerWidth: number +): number { + if (!textNode) return DEFAULT_FONT_SIZE * LINE_HEIGHT_FACTOR + DEFAULT_PADDING * 2; + + const segments = extractTextSegments(textNode); + if (segments.length === 0) { + return DEFAULT_FONT_SIZE * LINE_HEIGHT_FACTOR + DEFAULT_PADDING * 2; + } + + const availableWidth = Math.max(10, containerWidth - DEFAULT_PADDING * 2); + let totalHeight = 0; + let currentLineWidth = 0; + let currentLineMaxFontSize = DEFAULT_FONT_SIZE; + + for (const segment of segments) { + const { text, fontSize } = segment; + + // 处理换行符 + const lines = text.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // 如果不是第一行,说明有换行符,需要结算当前行 + if (i > 0) { + totalHeight += currentLineMaxFontSize * LINE_HEIGHT_FACTOR; + currentLineWidth = 0; + currentLineMaxFontSize = fontSize; + } + + if (line.length === 0) continue; + + // 测量当前行的文本宽度 + const textWidth = measureTextWidth(line, fontSize); + + // 如果当前行加上这段文本会超出容器宽度,需要换行 + if (currentLineWidth + textWidth > availableWidth && currentLineWidth > 0) { + totalHeight += currentLineMaxFontSize * LINE_HEIGHT_FACTOR; + currentLineWidth = textWidth; + currentLineMaxFontSize = fontSize; + } else { + currentLineWidth += textWidth; + currentLineMaxFontSize = Math.max(currentLineMaxFontSize, fontSize); + } + } + } + + // 加上最后一行的高度 + if (currentLineWidth > 0 || segments.length > 0) { + totalHeight += currentLineMaxFontSize * LINE_HEIGHT_FACTOR; + } + + // 加上上下 padding + return totalHeight + DEFAULT_PADDING * 2; +} + +/** + * 计算能完整容纳文字内容的最小矩形尺寸(宽、高取内容所需最小值的最大值) + * @param textNode 文本节点树 + * @returns { width, height } 含 padding,单位与测量一致 + */ +export function getMinTextContentSize( + textNode: TextNode | undefined +): { width: number; height: number } { + if (!textNode) { + const h = + DEFAULT_FONT_SIZE * LINE_HEIGHT_FACTOR + DEFAULT_PADDING * 2; + return { width: DEFAULT_PADDING * 2, height: h }; + } + const segments = extractTextSegments(textNode); + if (segments.length === 0) { + const h = + DEFAULT_FONT_SIZE * LINE_HEIGHT_FACTOR + DEFAULT_PADDING * 2; + return { width: DEFAULT_PADDING * 2, height: h }; + } + let maxLineWidth = 0; + for (const segment of segments) { + const lines = segment.text.split('\n'); + for (const line of lines) { + const w = measureTextWidth(line, segment.fontSize); + if (w > maxLineWidth) maxLineWidth = w; + } + } + const minWidth = maxLineWidth + DEFAULT_PADDING * 2; + const minHeight = calculateTextContentHeight(textNode, minWidth); + return { width: minWidth, height: minHeight }; +} + +/** + * 计算缩放后文本内容的高度 + * @param textNode 原始文本节点树 + * @param containerWidth 缩放后的容器宽度 + * @param scaleFactor 缩放系数 + * @returns 缩放后文本内容的实际高度 + */ +export function calculateScaledTextHeight( + textNode: TextNode | undefined, + containerWidth: number, + scaleFactor: number +): number { + if (!textNode) return DEFAULT_FONT_SIZE * LINE_HEIGHT_FACTOR * scaleFactor + DEFAULT_PADDING * 2; + + // 创建缩放后的文本节点副本 + const scaledNode = scaleTextNodeFontSize(textNode, scaleFactor); + + // 计算缩放后的高度 + return calculateTextContentHeight(scaledNode, containerWidth); +} + +/** + * 递归缩放文本节点的字体大小 + */ +function scaleTextNodeFontSize(node: TextNode, scaleFactor: number): TextNode { + if (!node) return node; + + const result: TextNode = { ...node }; + + if ('font-size' in node && node['font-size']) { + const currentSize = parseFloat(node['font-size']); + const newSize = Math.max(1, currentSize * scaleFactor); + result['font-size'] = `${newSize}`; + } + + if ('children' in node && Array.isArray(node.children)) { + result.children = node.children.map(child => scaleTextNodeFontSize(child, scaleFactor)); + } + + return result; +}