feat:优化多行输入取消聚焦会换行的问题

This commit is contained in:
liangyufeng committed 2026-03-05 11:06:02 +08:00
1 parent 1630ecbfdb
commit 4ab7912007
5 files changed
+273 -40

No files matched your search

+3 -2
View File
@@ -9,7 +9,8 @@
"Bash(pnpm nx typecheck:*)",
"Bash(pnpm run build)",
"Bash(git commit:*)",
"Bash(grep:*)"
"Bash(grep:*)",
"Bash(pnpm typecheck:*)"
]
}
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ import {
export class GifExecutor {
private config: Required<ExecutorConfig>;
private startTime: number = 0;
private startTime = 0;
private segments: GifTimeSegment[] = [];
constructor(config: ExecutorConfig = {}) {
+1 -1
View File
@@ -1018,7 +1018,7 @@ const DrawnixContent: React.FC<DrawnixContentProps> = ({
setInlineTextInput(null);
return;
}
const text = inlineTextRef.current.textContent || '';
const text = inlineTextRef.current.innerText || '';
if (text.trim()) {
DrawTransforms.insertText(board, inlineTextInput.worldPoint, text);
}
@@ -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<string, any> = {
const props: Record<string, unknown> = {
points: normalizeShapePoints(newPoints),
textHeight: nh,
autoSize: false,
@@ -200,7 +227,8 @@ export const withTextResize: PlaitPlugin = (board: PlaitBoard) => {
afterResize: (resizeRef: ResizeRef<PlaitText, ResizeHandle>) => {
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<PlaitText>,
[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<PlaitText>,
[i]
);
}
}
} finally {
@@ -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;
}