From 08c13dabbd34b2e20cfd64792dc0302f31d9be7b Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 6 Oct 2026 12:33:49 +0000 Subject: [PATCH] fix: show the caret in new, empty text (#932) CanvasKit lays out no line for an empty paragraph, so the text editor found no caret until the first character was typed. A one-space line now gives an empty text's caret its height, and its alignment places it. --- CHANGELOG.md | 1 + packages/core/src/text/editor.ts | 33 +++++++++++++++++++++++++------- tests/engine/text/editor.test.ts | 30 +++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 431fecea7..f60522718 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,7 @@ ### Fixed +- Show the blinking caret in a new, empty text layer before the first character is typed. - Text saved to `.fig` or copied to Figma keeps its layout in Figma: it wraps at the layer's width with its alignment and line height, and keeps ligatures and contextual forms such as Inter's arrows, where Figma previously drew every saved OpenPencil text layer on one unwrapped line (#914). - Release a document's memory when its tab closes. Every closed tab kept its scene, canvas, and editor panels alive until reload, so memory grew with each document opened and closed. Menus and shortcuts now also follow the active document, so Undo and Redo are offered according to its history rather than the first document opened. - Start Pi chats with OpenPencil's MCP tools when the Harness companion runs on Node 22.15 or later; the companion now installs the dependency Pi's MCP adapter needs and loads its TypeScript sources. A reopened Pi session starts fresh instead of failing to resume its in-memory sandbox, and npm output from Pi no longer mixes into the companion's protocol. diff --git a/packages/core/src/text/editor.ts b/packages/core/src/text/editor.ts index 3f7361cc0..67cb70c48 100644 --- a/packages/core/src/text/editor.ts +++ b/packages/core/src/text/editor.ts @@ -340,6 +340,31 @@ export class TextEditor { this.moveWord(extend, 'right') } + /** + * The caret of empty text. CanvasKit lays out no line for an empty paragraph, so a line + * holding one space gives the caret's height, and the alignment its place. + */ + private emptyCaret(paragraph: Paragraph): TextCaret | null { + const node = this.paragraphNode + const line = paragraph.getLineMetrics().at(0) + if (line) { + const offsetY = this.paragraphVerticalOffset() + return { x: line.left, y0: offsetY, y1: offsetY + line.height } + } + if (!node || !this.renderer) return null + const probe = this.renderer.buildParagraph({ ...node, text: ' ' }) + const height = probe.getLineMetrics()[0]?.height ?? probe.getHeight() + probe.delete() + const available = Math.max(0, node.height - height) + let offsetY = 0 + if (node.textAlignVertical === 'CENTER') offsetY = available / 2 + if (node.textAlignVertical === 'BOTTOM') offsetY = available + let x = 0 + if (node.textAlignHorizontal === 'CENTER') x = node.width / 2 + if (node.textAlignHorizontal === 'RIGHT') x = node.width + return { x, y0: offsetY, y1: offsetY + height } + } + getCaretRect(): TextCaret | null { const s = this._state if (!s?.paragraph) return null @@ -347,13 +372,7 @@ export class TextEditor { const text = s.text const cursor = s.cursor - if (text.length === 0) { - const metrics = s.paragraph.getLineMetrics() - if (metrics.length === 0) return null - const line = metrics[0] - const offsetY = this.paragraphVerticalOffset() - return { x: line.left, y0: offsetY, y1: offsetY + line.height } - } + if (text.length === 0) return this.emptyCaret(s.paragraph) let lo: number let hi: number diff --git a/tests/engine/text/editor.test.ts b/tests/engine/text/editor.test.ts index fb6977889..eb1329459 100644 --- a/tests/engine/text/editor.test.ts +++ b/tests/engine/text/editor.test.ts @@ -189,6 +189,36 @@ describe('TextEditor', () => { } }) + test('places the caret of empty text, which CanvasKit lays out no line for', () => { + const empty = asDouble({ + delete: () => undefined, + getHeight: () => 0, + getLineMetrics: () => [] + }) + const space = asDouble({ + delete: () => undefined, + getHeight: () => 17, + getLineMetrics: () => [{ height: 17, left: 0 }] + }) + const editor = new TextEditor(mockCk) + editor.setRenderer( + asDouble[0]>({ + buildParagraph: (node: SceneNode) => (node.text === '' ? empty : space), + fontGeneration: 1 + }) + ) + editor.start( + createDefaultNode(() => 'new-text', 'TEXT', { + text: '', + width: 120, + height: 57, + textAlignHorizontal: 'CENTER', + textAlignVertical: 'BOTTOM' + }) + ) + expect(editor.getCaretRect()).toEqual({ x: 60, y0: 40, y1: 57 }) + }) + test('offsets the empty-text caret for vertical alignment', () => { const { editor } = createParagraphEditor('BOTTOM') editorState(editor).text = ''