Add Canvas-based document editor package with Korean IME support#58
Conversation
New @wafflebase/document package providing a minimal Canvas-based rich text editor (like Google Docs) with the same architecture as the sheet package: model/store/view layers, Vite build, Vitest. Features: - Document model: Document → Block[] → Inline[] hierarchy - Inline formatting: bold, italic, underline, font size/family, color - Block styles: alignment (left/center/right), line height, margins - Canvas rendering with DPR-aware scaling and word-wrap layout - Cursor with blink animation and pixel-accurate positioning - Text selection with keyboard (Shift+Arrow) and mouse drag - Undo/redo via snapshot-based MemDocStore - IME composition support (compositionstart/end events) - Software Hangul assembler for Mobile Safari (which sends raw jamo without composition events to hidden textareas) - Playwright cross-browser IME test infrastructure (Chromium + WebKit) - Demo page with toolbar and event logger for debugging Co-Authored-By: Claude Opus 4.6 <[email protected]>
Use full paths from repo root so the entropy checker can resolve them. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Flagged by knip dead-code detection. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Verification: verify:selfResult: ✅ PASS in 98.1s
Verification: verify:integrationResult: ✅ PASS |
📝 WalkthroughWalkthroughAdds a new Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant TextEditor
participant IME as IME/Composition
participant Doc
participant Store as DocStore
participant Layout as LayoutEngine
participant Canvas
User->>TextEditor: Type / Key / Pointer
TextEditor->>IME: compositionstart / input events
IME-->>TextEditor: composing data
TextEditor->>Doc: insertText / deleteText (compose preview)
Doc->>Store: updateBlock
TextEditor->>Layout: computeLayout(blocks, ctx, width)
Layout->>Canvas: DocumentLayout
Canvas->>Canvas: render(layout, cursor, selection)
IME->>TextEditor: compositionend
TextEditor->>Doc: deleteText(prev composed range) / insertText(final)
Doc->>Store: updateBlock
TextEditor->>Layout: computeLayout(...)
Layout->>Canvas: DocumentLayout
Canvas->>User: Visual update
sequenceDiagram
participant HangulAssembler
participant Input as jamo char
participant State as CompositionState
participant Output as HangulResult
Input->>HangulAssembler: feed(ch)
HangulAssembler->>State: classify (consonant/vowel/other)
alt jamo consonant
State->>State: onConsonant() update lead/tail
else jamo vowel
State->>State: onVowel() combine with lead or split tail
else non-jamo
State->>State: flush() commit previous
end
State->>Output: return {commit?, composing?}
Output-->>HangulAssembler: caller applies commit/composing
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment Tip You can disable poems in the walkthrough.Disable the |
The CI frozen-lockfile check failed because playwright was added to packages/document/package.json but the lockfile was not updated in the same commit. Co-Authored-By: Claude Opus 4.6 <[email protected]>
There was a problem hiding this comment.
Actionable comments posted: 17
🧹 Nitpick comments (4)
docs/design/README.md (1)
26-29: Pre-existing duplicate entry fordocs-site.md.Lines 26 and 29 both reference
docs-site.md. Consider removing the duplicate in a follow-up cleanup.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@docs/design/README.md` around lines 26 - 29, The docs index contains a duplicated row for docs-site.md; remove the redundant entry so the table only references docs-site.md once (update the table rows that list [docs-site.md](docs-site.md) to eliminate the duplicate at the second occurrence), ensuring any descriptive text remains correct and spacing/alignment of the Markdown table is preserved.packages/document/index.html (1)
23-27: Consider responsive handling for the editor container.The fixed
800pxwidth won't adapt to smaller viewports. For a demo page this may be acceptable, but if this will be used for mobile testing (mentioned in PR for Mobile Safari), consider adding a responsive fallback.📝 Responsive suggestion
`#editor-container` { - width: 800px; margin: 24px auto; background: `#fff`; + width: min(800px, 100vw - 32px); margin: 24px auto; background: `#fff`; border: 1px solid `#ddd`; border-radius: 4px; height: calc(100vh - 300px); position: relative; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/document/index.html` around lines 23 - 27, The CSS for `#editor-container` uses a fixed width (800px) which breaks on small viewports; update the selector to use responsive sizing—replace the fixed width with a fluid width (e.g., width as a percentage or calc(100% - some padding) plus max-width: 800px) and add a simple media query fallback for very small screens to adjust height or padding if needed; target the `#editor-container` rule and ensure border, margin and position behavior remain unchanged while swapping the fixed width for responsive properties.packages/document/vite.config.ts (1)
1-3: Consider adding a clarifying comment.This empty config file alongside
vite.build.tsmay confuse future maintainers. A brief comment explaining its purpose (dev server defaults vs library build) would help.📝 Suggested clarification
import { defineConfig } from 'vite'; +// Development server config (uses Vite defaults). +// Library build config is in vite.build.ts. export default defineConfig({});🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/document/vite.config.ts` around lines 1 - 3, Add a brief clarifying comment at the top of this file explaining why an empty Vite config is exported (e.g., to preserve dev-server/default behavior while library-specific build config lives in vite.build.ts), so future maintainers understand the presence of export default defineConfig({}) and the relationship to vite.build.ts.packages/document/test/view/hangul.test.ts (1)
71-73: Test description typo: expected word and title are inconsistent.Line 71 says
"한아"but Line 73 asserts"하나". Please rename the test title/comment to match the expected output for clearer intent.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/document/test/view/hangul.test.ts` around lines 71 - 73, The test title/comment is inconsistent: it says "한아" but the assertion expects "하나"; update the test description to reflect the expected output by renaming the it(...) title from '"한아" → ㅎㅏㄴㅏ' (or similar) to '"하나" → ㅎㅏㄴㅏ' so the human-readable description matches the assemble(['ㅎ','ㅏ','ㄴ','ㅏ']) expectation and intent around the assemble function's behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@docs/design/docs.md`:
- Around line 37-39: Update the design doc's IME scope to reflect the
implemented behavior: change the "IME composition handling — deferred (basic
`beforeinput` only)" non-goal to state that Korean IME composition (including
the Mobile Safari workaround added in this PR) is supported; mention the
specific implemented coverage (Korean IME on desktop and mobile Safari
workaround) and, if relevant, note any remaining limitations instead of marking
IME handling as deferred. Reference the "IME composition handling" phrase in the
docs so reviewers can verify the update matches the PR changes.
- Around line 10-30: The doc mentions the old package identity `packages/docs`
and `@wafflebase/docs`; update every occurrence to the new package identity
`packages/document` and `@wafflebase/document` (including the Goals section and
the Structure section where package names and import paths are listed) so
references, examples, and any path strings match the implemented package; search
for the symbols `packages/docs`, `@wafflebase/docs`, and any path examples in
this file and replace them with `packages/document` and `@wafflebase/document`
ensuring consistency across the entire document.
In `@docs/tasks/active/20260320-docs-package-todo.md`:
- Line 5: The doc entry currently shows "Status: COMPLETE" while the manual
validation item "manual smoke test" remains unchecked; update the task so status
reflects reality by either (a) completing the unchecked "manual smoke test" item
at the bottom of the file and adding any required verification notes, or (b)
reverting "Status: COMPLETE" back to an in-progress state and adding a TODO
comment indicating the pending "manual smoke test" and owner; locate the
"Status: COMPLETE" header and the unchecked "manual smoke test" checklist item
to make the change.
In `@packages/document/demo.ts`:
- Around line 23-28: The logEvent function uses innerHTML to build a line which
injects the user-influenced detail and is XSS-prone; replace the innerHTML
construction in logEvent with safe DOM creation: create a container div, create
three spans (one for the padded eventSeq number with style, one for the type
with class cls and textContent set to type.padEnd(20), and one for the detail
with textContent set to detail), append them to the container, then append the
container to logEl—ensuring you reference logEvent, eventSeq and logEl when
making the change.
- Around line 71-77: The clipboard copy handler on the 'btn-copy-log' element
uses a .then() chain without error handling; change the click listener callback
to an async function that awaits navigator.clipboard.writeText(logEl.innerText)
inside a try/catch, update the button text to 'Copied!' on success and to an
error state (e.g., 'Copy failed') on catch, and restore the original 'Copy' text
after a timeout in both paths; ensure you reference the same DOM element
(document.getElementById('btn-copy-log') / logEl) and handle the case where the
element or navigator.clipboard may be unavailable.
In `@packages/document/scripts/verify-ime-browser.mjs`:
- Around line 371-430: The script currently treats missing browser executables
as warnings and still prints "[verify:ime] All IME browser tests passed." even
if no scenarios ran; add a sentinel (e.g., ranAnyScenario = false) and set it
true inside each successful runAllScenarios call (for Chromium and WebKit), then
after both engine blocks check the flag and if still false call
printInstallHelp() and exit non‑zero (or throw) so the script fails when both
browsers are missing; reference chromiumBrowser, webkitBrowser, runAllScenarios
and the final success log to locate where to set the flag and where to fail.
In `@packages/document/src/model/document.ts`:
- Around line 61-84: deleteText can hang if pos.offset is at the end of the
block or length exceeds remaining text because resolveOffset may return
available === 0 and the loop doesn't advance; update deleteText to first compute
the block's remaining characters from pos (e.g., sum of inline lengths after pos
offset via getBlock and resolveOffset or by summing block.inlines), clamp the
requested length to that remaining amount and return early if clamped length is
0, then proceed with the existing loop and calls to
resolveOffset/normalizeInlines to perform deletions.
In `@packages/document/src/store/memory.ts`:
- Around line 23-35: getDocument and getBlock currently return live references
allowing external mutation that bypasses pushUndo; change them to return
deep-cloned copies instead (use cloneDocument for getDocument and
cloneBlock/deep clone for getBlock) so callers cannot modify store internals
directly, and ensure initialize/Doc construction uses the cloned document rather
than the original; leave setDocument as-is (it already clones and calls
pushUndo) and keep pushUndo usage unchanged.
In `@packages/document/src/view/editor.ts`:
- Around line 77-90: render() always paints from y=0 and resizes the canvas to
the container height so layout.totalHeight and container.scrollTop are ignored;
fix by making the canvas height match layout.totalHeight (or at least
max(container height, layout.totalHeight)) and pass the current vertical offset
(container.scrollTop) into docCanvas.render instead of 0, and adjust calls that
compute pixel positions (cursor.getPixelPosition and
selection.getSelectionRects) to accept/compensate for container.scrollTop so
hit-testing and rendering align; update recomputeLayout usage if needed to
ensure layout.totalHeight is computed before resizing/rendering.
- Around line 47-56: Edits are mutating a plain Doc instance created from
storeDoc (via new Doc(storeDoc)) so changes bypass the DocStore undo stack;
refactor so all mutations go through DocStore mutation APIs and record undo
snapshots: instantiate or obtain the live Doc via DocStore (use
docStore.getDocument()/docStore.setDocument patterns or add a
docStore.createLiveDoc helper) and ensure TextEditor, applyStyle, and
applyBlockStyle call the store’s mutation methods that internally call
pushUndo() (or explicitly call docStore.pushUndo() before applying changes) so
every edit is recorded; update the code paths referenced (the Doc instantiation
at the top plus the code areas around TextEditor, applyStyle, applyBlockStyle
and the regions noted at 93-101 and 120-148) to use the store-backed mutation
methods instead of mutating the raw Doc object.
In `@packages/document/src/view/layout.ts`:
- Around line 76-85: computeLayout() currently computes a single lineHeight
using getBlockFontSize() which only inspects block.inlines[0], causing later
larger inlines to overlap; change the calculation to base lineHeight on the
maximum font size in scope instead: either update getBlockFontSize() to scan all
inlines in the block (not just block.inlines[0]) or add a helper like
getLineMaxFontSize(line) and compute lineHeight per line as (lineMaxFontSize *
(inline.style.lineHeight ?? block.style.lineHeight ?? 1.5)), then use that
per-line height when setting line.height and updating blockHeight in the loops
inside computeLayout() (and apply the same fix at the other occurrence around
lines 255-260).
- Around line 129-152: layoutBlock currently only wraps at word-level segments
from measureSegments, so oversized segments (seg.width > maxWidth) never break
and overflow; modify the loop that iterates over segments in layoutBlock to
detect when seg.width > maxWidth and perform a character-level fallback: iterate
the characters of the offending inline (use seg.charStart/charEnd and
block.inlines[seg.inlineIndex] to access text), measure sub-segments per
character (or small slices) and emit runs that fill remaining space then wrap,
updating currentRuns, lineWidth, and lines just like the normal path; apply the
same character-level splitting logic to the second wrapping loop referenced (the
block around lines 171-229) so both wrapping places handle oversized tokens.
- Around line 369-394: The hit-test offset is wrong because run.charStart is
stored per-inline (via measureSegments) so treating it as a line-global index
miscomputes offsets for later runs; fix the calculation in the loop over
targetLine.runs (the block referencing run.charStart and targetLine.runs[0]) by
computing the offset within the line as the sum of the lengths of all previous
runs plus bestOffset (e.g. sum targetLine.runs[0..runIndex-1].text.length) and
then return blockId with offset = charsBeforeLine + that sum + bestOffset
instead of using run.charStart.
In `@packages/document/src/view/selection.ts`:
- Around line 96-123: The multi-line selection branch currently builds rects
using the full line block bounds (lb.x and lb.width), causing
centered/right-aligned lines to highlight into margins; update the three rect
constructions to use the actual run/line bounds instead: for the first-line rect
use startPixel.x and startPixel.width (or the computed run width) instead of
lb.width, for the middle-line loop use each line's actual run x and width rather
than lb.x/lb.width, and for the last-line rect use endPixel.x - runX (or
endPixel.width) anchored at the run's x; locate and change the code that pushes
into rects in the multi-line branch (references: rects array, startPixel,
endPixel, lb, startPixel.height) so each rect is constrained to the line run
bounds rather than the full block.
In `@packages/document/src/view/text-editor.ts`:
- Around line 59-67: The key handler in TextEditor is preventing default for
Cmd/Ctrl+Z but not performing undo/redo; update the branch in the TextEditor
keydown handler (the code path inside the TextEditor class that checks for
Meta/Ctrl+Z and Shift/Meta+Z or Ctrl+Y) to call the document undo/redo APIs and
then re-render: invoke this.doc.undo() for plain Meta/Ctrl+Z and invoke
this.doc.redo() for Meta/Ctrl+Shift+Z and Ctrl+Y (or, if your Doc API uses
different names, call the appropriate undo()/redo() methods), keep
event.preventDefault(), and call this.requestRender() after the change so the
editor updates.
- Around line 351-382: The handler computes newPos from this.cursor.position
even when a selection exists, causing arrows without shift to move from the
wrong spot; change handleArrow so when shiftKey is false and
this.selection.range exists you first collapse the position to the appropriate
selection boundary (for 'left' and 'up' use the earlier/minimum of range.anchor
and range.focus; for 'right' and 'down' use the later/maximum), then call
moveLeft/moveRight/moveVertical using that collapsed base position, then clear
the selection via this.selection.setRange(null) and move the cursor to newPos
and requestRender; reference handleArrow, this.selection.range, range.anchor,
range.focus, moveLeft, moveRight, moveVertical, selection.setRange, and
cursor.moveTo.
- Around line 539-576: The software-assembler path in applyHangulResult doesn't
clear the active selection before inserting the first composing jamo, so
previews get appended instead of replacing selections; update applyHangulResult
so that in the composing branch where (result.composing &&
this.hangulComposingLength === 0 && !result.commit) you call deleteSelection()
before setting this.hangulStartPos (and then proceed to insert result.composing
and set hangulComposingLength), ensuring behavior matches the browser-IME path
that clears selection in handleCompositionStart().
---
Nitpick comments:
In `@docs/design/README.md`:
- Around line 26-29: The docs index contains a duplicated row for docs-site.md;
remove the redundant entry so the table only references docs-site.md once
(update the table rows that list [docs-site.md](docs-site.md) to eliminate the
duplicate at the second occurrence), ensuring any descriptive text remains
correct and spacing/alignment of the Markdown table is preserved.
In `@packages/document/index.html`:
- Around line 23-27: The CSS for `#editor-container` uses a fixed width (800px)
which breaks on small viewports; update the selector to use responsive
sizing—replace the fixed width with a fluid width (e.g., width as a percentage
or calc(100% - some padding) plus max-width: 800px) and add a simple media query
fallback for very small screens to adjust height or padding if needed; target
the `#editor-container` rule and ensure border, margin and position behavior
remain unchanged while swapping the fixed width for responsive properties.
In `@packages/document/test/view/hangul.test.ts`:
- Around line 71-73: The test title/comment is inconsistent: it says "한아" but
the assertion expects "하나"; update the test description to reflect the expected
output by renaming the it(...) title from '"한아" → ㅎㅏㄴㅏ' (or similar) to '"하나" →
ㅎㅏㄴㅏ' so the human-readable description matches the assemble(['ㅎ','ㅏ','ㄴ','ㅏ'])
expectation and intent around the assemble function's behavior.
In `@packages/document/vite.config.ts`:
- Around line 1-3: Add a brief clarifying comment at the top of this file
explaining why an empty Vite config is exported (e.g., to preserve
dev-server/default behavior while library-specific build config lives in
vite.build.ts), so future maintainers understand the presence of export default
defineConfig({}) and the relationship to vite.build.ts.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: a421ebc0-2d16-4242-bd51-4dc11846a68d
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (29)
.mcp.jsondocs/design/README.mddocs/design/docs.mddocs/tasks/active/20260320-docs-package-lessons.mddocs/tasks/active/20260320-docs-package-todo.mdpackage.jsonpackages/document/demo.tspackages/document/index.htmlpackages/document/package.jsonpackages/document/scripts/verify-ime-browser.mjspackages/document/src/index.tspackages/document/src/model/document.tspackages/document/src/model/types.tspackages/document/src/store/memory.tspackages/document/src/store/store.tspackages/document/src/view/cursor.tspackages/document/src/view/doc-canvas.tspackages/document/src/view/editor.tspackages/document/src/view/hangul.tspackages/document/src/view/layout.tspackages/document/src/view/selection.tspackages/document/src/view/text-editor.tspackages/document/src/view/theme.tspackages/document/test/model/document.test.tspackages/document/test/store/memory.test.tspackages/document/test/view/hangul.test.tspackages/document/tsconfig.jsonpackages/document/vite.build.tspackages/document/vite.config.ts
Disambiguate the VitePress documentation site package from the Canvas-based document editor package (packages/document). Also rename the nav bar label from "Docs" to "Documentation". Co-Authored-By: Claude Opus 4.6 <[email protected]>
Align the root package.json script alias with the package name for consistency with other aliases (frontend, backend, sheet, etc.). Co-Authored-By: Claude Opus 4.6 <[email protected]>
- packages/sheet → packages/sheets (@wafflebase/sheets) - packages/document → packages/docs (@wafflebase/docs) - Update all imports, configs, scripts, design docs, and READMEs - Archive task files left unchanged (historical records) Co-Authored-By: Claude Opus 4.6 <[email protected]>
Update verify-self.mjs lane command and guard-generated-files.sh hint message that were missed in the bulk rename. Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Add DocStore.snapshot() for undo stack before mutations
- Wire Ctrl+Z/Shift+Z/Ctrl+Y to undo/redo in TextEditor
- Fix innerHTML XSS in demo.ts logEvent, add clipboard error handling
- Fix deleteText infinite loop when offset at block end
- Fix arrow keys to collapse selection before moving
- Fix hangul assembler to clear selection on first composing jamo
- Update README.md project structure (sheets, docs, cli, documentation)
- Update docs/design/docs.md: IME is implemented, not deferred
- Remove duplicate docs-site.md entry in design README
- Fix hangul test description typo ("한아" → "하나")
Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Layout: compute line height per-line using max font size across runs - Layout: character-level fallback for words wider than max width - Layout: fix hit-test offset using cumulative run lengths - Selection: use actual line run bounds for highlight rects - Editor: pass scrollTop to canvas render, resize canvas to layout height - Editor: add scroll event listener for re-rendering on scroll - IME verify: fail when no browsers are available instead of false pass Co-Authored-By: Claude Opus 4.6 <[email protected]>
Incorporate conditional-format multi-range changes from main. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Update paths from packages/sheet/ to packages/sheets/ in the conditional-format-multi-range design doc merged from main. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Fix stale package references in design doc (packages/docs → packages/document), correct task status, add responsive width for demo page, return deep clone from MemDocStore.getBlock(), and add clarifying comment to vite.config.ts. Co-Authored-By: Claude Opus 4.6 <[email protected]>
The verify:entropy doc-staleness check found broken refs because the design doc referenced packages/document/ but the actual directory is packages/docs/. Keep @wafflebase/document as the npm package name while using the real directory path in file refs. Co-Authored-By: Claude Opus 4.6 <[email protected]>
getDocument() and getBlock() now return deep clones so external callers cannot mutate store internals without going through the undo-tracked APIs. Added replaceDocument() for the editor to sync its working Doc back to the store after snapshot'd mutations. Co-Authored-By: Claude Opus 4.6 <[email protected]>
New @wafflebase/document package providing a minimal Canvas-based rich text editor (like Google Docs) with the same architecture as the sheet package: model/store/view layers, Vite build, Vitest. Features: - Document model: Document → Block[] → Inline[] hierarchy - Inline formatting: bold, italic, underline, font size/family, color - Block styles: alignment (left/center/right), line height, margins - Canvas rendering with DPR-aware scaling and word-wrap layout - Cursor with blink animation and pixel-accurate positioning - Text selection with keyboard (Shift+Arrow) and mouse drag - Undo/redo via snapshot-based MemDocStore - IME composition support (compositionstart/end events) - Software Hangul assembler for Mobile Safari (which sends raw jamo without composition events to hidden textareas) - Playwright cross-browser IME test infrastructure (Chromium + WebKit) - Demo page with toolbar and event logger for debugging Co-Authored-By: Claude Opus 4.6 <[email protected]>
Summary
packages/documentpackage (@wafflebase/docs) — a Canvas-based rich-text document editor following the same architecture aspackages/sheetdocs/design/docs.mdArchitecture
Document → Block → Inlinehierarchy withDocclass for manipulationDocStoreinterface +MemDocStore(snapshot-based undo/redo)Test plan
Docmodel (insert, delete, split, merge, formatting) — 18 testsMemDocStore(CRUD, undo/redo) — 8 testsHangulAssembler(syllable assembly, compound tails/vowels, splitting) — 18 testspnpm verify:fastpasses🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Documentation
Tests
Chores