openpencil/openspec/specs/text-editing/spec.md
Anton A S 5fbad7b092 Sync specs & docs: style runs, JSX renderer, CLI expansion, tests
- Merge from master: 22 commits (rich text, JSX renderer, CLI, dedup, tests)
- Update specs: text-editing (style runs, ⌘B/I/U, .fig roundtrip,
  double/triple-click, selectLine), canvas-rendering (mixed-style
  ParagraphBuilder), editor-ui (B/I/U/S buttons), cli (analyze, node,
  pages, variables), tooling (jscpd, kiwi-serialize, test:coverage),
  testing (.fig roundtrip, import perf, JSX tests), scene-graph
  (StyleRun model, JSX renderer)
- Update docs: features (rich text formatting, JSX renderer, expanded
  CLI, code quality), figma-comparison (Text styles 🔲→🟡, 80/150),
  roadmap (Phase 4+5 delivered items)
- Archive sync-style-runs-jsx-cli-tests change
2026-03-01 12:09:41 +03:00

7.9 KiB

text-editing Specification

Purpose

Text creation and canvas-native inline editing. Text tool (T), TextEditor class in core (cursor, selection, word boundaries, line navigation via CanvasKit Paragraph API), phantom textarea for input capture, font loading (Inter default + system fonts via font-kit in Tauri / Local Font Access API in browser), font picker with virtual scroll and CSS preview.

Requirements

Requirement: Text tool creates text nodes

Pressing T SHALL activate the text tool. Clicking on canvas SHALL create a new empty text node with cursor blinking.

Scenario: Create text node

  • WHEN user presses T and clicks on the canvas
  • THEN an empty text node is created at the click position with a visible cursor

Requirement: Inline text editing

Double-clicking a text node SHALL enter canvas-native inline editing mode. A hidden phantom textarea (positioned off-screen, opacity 0) SHALL capture keyboard input, IME composition, and clipboard events. The canvas SHALL render the text directly using CanvasKit Paragraph API — no visible textarea overlay.

Scenario: Enter edit mode

  • WHEN user double-clicks a text node
  • THEN a phantom textarea is created off-screen and focused, the canvas renders text with a blinking caret

Scenario: IME composition

  • WHEN user enters IME composition during text editing
  • THEN the phantom textarea handles compositionstart/compositionend events and the composed text is inserted into the TextEditor

Requirement: TextEditor class in core

A TextEditor class SHALL live in packages/core/src/text-editor.ts providing cursor positioning, text selection, word boundary detection, and line navigation using CanvasKit Paragraph API (getGlyphPositionAtCoordinate, getRectsForRange, getLineMetrics). It SHALL support: insert, backspace, delete, select all, select word, selectLine, selectLineAt, move left/right/up/down, move to line start/end, move word left/right, and extend selection with Shift.

Scenario: Cursor positioning

  • WHEN user clicks inside a text node during editing
  • THEN the cursor moves to the glyph position nearest to the click coordinates

Scenario: Word selection

  • WHEN user double-clicks a word during text editing
  • THEN the word is selected (from word boundary to word boundary)

Scenario: Line selection

  • WHEN selectLineAt is called at a position within line 2
  • THEN the entire second line is selected

Scenario: Line navigation

  • WHEN user presses ⌘← during text editing
  • THEN the cursor moves to the start of the current line

Requirement: Text editing keyboard navigation

The phantom textarea SHALL handle keyboard navigation with modifier support: ⌥← / ⌥→ (word movement), ⌘← / ⌘→ (line start/end), ⌘⌫ (delete to line start), ⌥⌫ (delete word), Shift extends selection for all movement keys. Cut (⌘X), Copy (⌘C), Paste (⌘V) SHALL use navigator.clipboard API.

Scenario: Word-level backspace

  • WHEN user presses ⌥⌫ during text editing
  • THEN the word before the cursor is deleted

Scenario: Copy selection

  • WHEN user selects text and presses ⌘C
  • THEN the selected text is copied to the clipboard

Requirement: Text selection via mouse drag

Clicking inside a text node during editing SHALL position the cursor. Dragging SHALL extend the selection. Double-click SHALL select the word at click position. Triple-click SHALL select all text. Clicking outside the text node bounds SHALL commit the edit and exit editing mode.

Scenario: Drag to select

  • WHEN user clicks and drags inside a text node during editing
  • THEN the text between click start and current position is selected

Scenario: Double-click selects word

  • WHEN user double-clicks on "world" in "Hello world" during editing
  • THEN "world" is selected

Scenario: Triple-click selects all

  • WHEN user triple-clicks inside a text node during editing
  • THEN all text is selected

Scenario: Click outside commits

  • WHEN user clicks outside the editing text node
  • THEN the text edit is committed and editing mode exits

Requirement: CanvasKit Paragraph API rendering

Text nodes SHALL be rendered using CanvasKit's Paragraph API for proper text shaping, line breaking, and font metrics.

Scenario: Multi-line text wrapping

  • WHEN a text node with a fixed width contains text longer than the width
  • THEN the text wraps to multiple lines using Paragraph API layout

Requirement: Font loading

The editor SHALL load Inter as the default font. In Tauri, system fonts SHALL be enumerated and loaded via Rust commands (list_system_fonts, load_system_font) using the font-kit crate. In browser, the Local Font Access API SHALL be used when available. Font list SHALL be preloaded on app startup (cached via OnceLock in Rust) so the picker is ready instantly.

Scenario: Default font

  • WHEN a text node is created without specifying a font
  • THEN the Inter font is used for rendering

Scenario: System fonts via Tauri

  • WHEN the app runs as a Tauri desktop app
  • THEN system fonts are enumerated via the font-kit Rust command and loaded via invoke()

Scenario: System fonts via browser

  • WHEN the app runs in a browser with Local Font Access API
  • THEN system fonts are enumerated via queryLocalFonts

Scenario: Font preloading

  • WHEN the app starts
  • THEN the font list is preloaded so the font picker opens without delay

Requirement: Font picker component

A FontPicker component SHALL provide font family selection with: reka-ui Listbox with virtual scroll (ListboxVirtualizer), search filter (ListboxFilter), CSS font preview (each row rendered in the font itself), and scroll-to-current behavior. Font faces are registered for CSS preview via registerFontFaces.

  • WHEN user types "Mono" in the font picker search
  • THEN only font families containing "Mono" are shown

Scenario: Font preview

  • WHEN user opens the font picker
  • THEN each font family name is displayed in its own font face

Requirement: Font weight support

Font weight SHALL be applied to the CanvasKit text style during rendering.

Scenario: Bold text rendering

  • WHEN a text node has fontWeight 700
  • THEN the canvas rendering displays bold text

Requirement: Blue editing outline

A blue outline SHALL appear around a text node during editing to indicate edit mode.

Scenario: Edit mode indicator

  • WHEN user is editing a text node inline
  • THEN a blue outline is visible around the text node bounds

Requirement: Rich text style runs

Text nodes SHALL support per-character formatting via StyleRun arrays: {start, length, style} where style includes fontWeight, italic (boolean), and textDecoration (UNDERLINE, STRIKETHROUGH). ⌘B toggles bold, ⌘I toggles italic, ⌘U toggles underline on the current selection. With no selection, the shortcut toggles the whole-node style. Style runs adjust on insert/delete to preserve formatting.

Scenario: Bold selection

  • WHEN user selects "world" in "Hello world" and presses ⌘B
  • THEN the word "world" renders bold while "Hello " stays regular

Scenario: Toggle italic on whole node

  • WHEN user presses ⌘I with no text selected in a text node
  • THEN the entire text node toggles italic

Scenario: Style preservation on insert

  • WHEN user types "X" between a bold and regular segment
  • THEN the inserted character inherits the style of the preceding segment and run boundaries adjust

Requirement: .fig roundtrip for style runs

The .fig import SHALL parse characterStyleIDs and styleOverrideTable from TextData into StyleRun arrays. The .fig export SHALL write back with a deduped style table.

Scenario: Import rich text from .fig

  • WHEN a .fig file with mixed bold/italic text is imported
  • THEN the style runs are correctly reconstructed