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.
This commit is contained in:
Danila Poyarkov 2026-10-06 12:33:49 +00:00 committed by GitHub
parent b0e0b321e9
commit 08c13dabbd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 57 additions and 7 deletions

View file

@ -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.

View file

@ -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

View file

@ -189,6 +189,36 @@ describe('TextEditor', () => {
}
})
test('places the caret of empty text, which CanvasKit lays out no line for', () => {
const empty = asDouble<Paragraph>({
delete: () => undefined,
getHeight: () => 0,
getLineMetrics: () => []
})
const space = asDouble<Paragraph>({
delete: () => undefined,
getHeight: () => 17,
getLineMetrics: () => [{ height: 17, left: 0 }]
})
const editor = new TextEditor(mockCk)
editor.setRenderer(
asDouble<Parameters<TextEditor['setRenderer']>[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 = ''