@hecto/editor
v0.2.0
Published
contenteditable projection of @hecto/richtext. The model is the truth; the DOM is a view.
Readme
@hecto/editor
contenteditable projection of @hecto/richtext. The model is the truth
and the DOM is a view — document content is never read back from the DOM
except during IME composition.
pnpm add @hecto/editorimport { createDocumentReplica } from '@hecto/richtext'
import { commandTable } from '@hecto/richtext/commands'
import { bindEditor } from '@hecto/editor'
const replica = createDocumentReplica('alice')
const caret = {
blockId: 'missing',
charId: null,
side: 'before' as const,
}
const seeded = commandTable.insertText(
replica,
{ anchor: caret, head: caret },
{ text: 'hello' },
)
replica.apply(seeded.ops)
const host = document.createElement('div')
const editor = bindEditor(host, { document: replica, label: 'Note' })
editor.focus()
replica.snapshot().blocks[0]?.text // 'hello'
editor.destroy()What's in the box
.—bindEditor,bindCollab,mountEditor,attachInput,attachIme,attachClipboard,attachA11y,attachChrome,sanitizeHtml./react—Editoronly.bindEditoris not on this barrel.
react is an optional peer (>=18). SSR of Editor renders one
div[data-hecto-react-editor] and must not touch document.
The styled @hecto/ui Editor wraps @hecto/editor/react in Field /
hairline / crimson-ring. It does not set outline: none.
Collaboration
bindCollab syncs one paragraph through a TextDocument
(@hecto/crdt/merge) and a DocClient (@hecto/crdt/client). Presence
carets use text.idAt / text.indexOfId. Empty text has no id.
IME
During composition the browser edits the composing text node. On
compositionend, attachIme prefix/suffix-trims UTF-16 against the
model (surrogate-safe) and commits through insertText /
deleteBackward. compositionend.data is not the source of truth: a
cancelled composition that restores the DOM does not commit. Nested
blocks and data-hecto-decoration nodes are skipped when reading DOM
text.
bindEditor already attaches IME. Compose mountEditor + attachInput
attachImeyourself only when you are not usingbindEditor.
Manual Android Gboard checklist
Host: mountEditor + attachInput + attachIme on a paragraph
"Hello". Use a physical device or emulator with Gboard, Japanese (or
Korean / Chinese) and English. Compare replica.snapshot() to visible
text — do not verify by reading innerHTML.
| # | Action | Pass when |
| --- | --- | --- |
| 1 | Tap the block, switch Gboard to Japanese, type a reading, leave the candidate underlined | Replica is still "Hello". No preventDefault on composing beforeinput. Caret / underline stay in the composing node. |
| 2 | Confirm the candidate (Enter / candidate tap) | One splice. Snapshot and DOM are "Hello" plus the committed word. No doubled characters. |
| 3 | Compose in the middle of "Hello" (caret after l) and confirm | Prefix Hel / suffix lo survive. One command. |
| 4 | Backspace during composition, then confirm | Model matches what is on screen. No leftover composing text. |
| 5 | Dismiss composition (back / hide keyboard) without confirming | Model unchanged. DOM matches the model after the browser reverts. |
| 6 | English autocorrect / slide-to-type a word | compositionend (or Gboard's commit) yields one splice. The corrected word is in the replica once. |
| 7 | Gboard suggestion strip replaces the last word | Same as 6: one prefix/suffix splice, not a second insert on top of the uncorrected word. |
| 8 | Newline via Gboard Enter after a committed composition | insertParagraph / splitBlock runs after IME commit, not during isComposing. |
| 9 | Two compositions in a row (日本 then 語) | Result "Hello日本語". Each commit is its own splice. |
| 10 | Compose inside a list item | Sibling items and the list parent do not change. |
Some Gboard builds fire insertCompositionText / insertReplacementText
without a reliable compositionstart. compositionend is still the
commit. If a build never fires compositionend and only sends a
non-composing insertReplacementText / insertText, that event is
ordinary input, not IME.
