Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
aad2703
docs: make the editor capability docs agree with the code
bartekplus Oct 5, 2026
6a25d2a
fix(trix): refuse Review and typing writes during IME composition
bartekplus Oct 5, 2026
4bab691
fix(slate): refuse Slate reads and writes during IME composition
bartekplus Oct 5, 2026
c22fb26
fix(review): refuse content-script Review writes when Review opens du…
bartekplus Oct 5, 2026
c1c3dc2
feat(bridge): guard CKEditor 5 typing, CodeMirror 5 and DOM-editor wr…
bartekplus Oct 5, 2026
4c0a60d
fix(bridge): see a composition that starts while the bridge is off
bartekplus Oct 5, 2026
d26e5a5
test: add direct unit tests for the model and DOM Review writers
bartekplus Oct 5, 2026
50a933d
fix(suggestions): drop predictions for a caret place that the page mo…
bartekplus Oct 5, 2026
a069dd7
test(e2e): extend the rich-editor typing matrix
bartekplus Oct 5, 2026
6bc78bc
fix(review): let a model editor get its writer when its model catches…
bartekplus Oct 5, 2026
9f4f156
fix(quill): make an accepted prediction its own undo step
bartekplus Oct 5, 2026
5e1019c
fix(ckeditor5): type next to an inline object in one model batch
bartekplus Oct 5, 2026
956278e
feat(e2e): cover Tiptap through the ProseMirror path
bartekplus Oct 5, 2026
0f44f18
fix(review): keep the "Apply fixes individually" note in Google Docs
bartekplus Oct 5, 2026
9a130ed
test: restore a space in a Trix section comment
bartekplus Oct 5, 2026
f4fbcc8
fix(suggestions): consume Escape only when it closes a visible Fluent…
bartekplus Oct 5, 2026
e1feddd
feat(outlook): add a RoosterJS snapshot writer for Outlook on the web
bartekplus Oct 5, 2026
1ef6f3d
feat(notion): type and apply Review fixes in one Notion block leaf
bartekplus Oct 5, 2026
e0e052d
docs: report on Gmail, Outlook and Notion editor support
bartekplus Oct 5, 2026
c8b03b3
fix(outlook): keep the user's caret in the undo step before a write
bartekplus Oct 5, 2026
7ab1ebe
fix(notion): keep the suggestion state of a block leaf on the page root
bartekplus Oct 5, 2026
a600e26
docs: record the Outlook and Notion live check results
bartekplus Oct 5, 2026
82dc2b3
test(e2e): reset prefix-only mode for the rich contenteditable test
bartekplus Oct 5, 2026
ead9df8
test(e2e): place the caret by selection in the typing acceptance test
bartekplus Oct 5, 2026
fb8a2e3
docs: record that the RoosterJS and Notion-like fixtures pass on Firefox
bartekplus Oct 5, 2026
7ecb7a3
fix(inline): give Escape to the page when no inline ghost shows
bartekplus Oct 5, 2026
c3b235b
fix(review): end a Notion composition where its events fire
bartekplus Oct 5, 2026
3929889
docs: record that the Notion live check of Review passed
bartekplus Oct 5, 2026
1809af5
fix(editors): keep the undo caret in the block of the user's caret
bartekplus Oct 5, 2026
9e7e7e6
fix(notion): keep the shared root state when one leaf detaches
bartekplus Oct 5, 2026
57a609f
fix(review): find the Slate or ProseMirror writer after a composition
bartekplus Oct 5, 2026
aa48f2e
fix(notion): write no Review fix after the review closes in the focus…
bartekplus Oct 5, 2026
b76781e
refactor: simplify code from the editor-support review
bartekplus Oct 5, 2026
3645c8c
test: simplify and isolate the new editor tests
bartekplus Oct 5, 2026
1d66cb9
docs: write the Notion and CKEditor 5 notes in short sentences
bartekplus Oct 5, 2026
ef966d5
fix(suggestions): drop late prediction answers after Escape
bartekplus Oct 5, 2026
a3e4282
test(e2e): reset the settings that inline mode locks
bartekplus Oct 5, 2026
95b9ccd
Merge remote-tracking branch 'origin/master' into editor-support-gaps
bartekplus Oct 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 99 additions & 1 deletion bun.lock

Large diffs are not rendered by default.

91 changes: 75 additions & 16 deletions docs/editor-capabilities.md

Large diffs are not rendered by default.

121 changes: 121 additions & 0 deletions docs/editor-surfaces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Large editor surfaces: Gmail, Outlook and Notion

[FluentTyper](../README.md) / [Editor capability detection](editor-capabilities.md) / Large editor surfaces

This report covers three high-traffic editors that had no verified writer: Gmail compose, Outlook on the web (RoosterJS) and Notion.
The evidence comes from public sources, open-source code, synthetic fixtures and one live probe in a test session (October 2026).
The live probe used only a new, empty Outlook draft and one new Notion test block. It read no mail and no other page text.
A live check of the writers followed in the same way, in Chrome with a development build (see [Live check results](#live-check-results)).
**[V]** marks a verified fact. **[I]** marks an inference.

## Summary

| Surface | Editor model | Generic path safe? | Writer now | Status |
| ------------------- | -------------------------------------------------- | ------------------------------------------- | -------------------------------------------------- | ------------------------------------- |
| Gmail compose | DOM is the model [I] | Yes, with limits [I] | Generic contenteditable path | No change |
| Outlook (RoosterJS) | DOM plus a cached Content Model, snapshot Undo [V] | **No**: Undo and Redo can lose the edit [V] | RoosterJS snapshot transaction | Supported; live check passed (Chrome) |
| Notion | Block tree with server sync, own Undo [V] | Only when the caret is in the block [V] | Native edit in one block leaf, with a revert watch | Supported; live check passed (Chrome) |

## Gmail compose

**Editor model.** The body is one `contenteditable` element. No public source shows a separate document model [I].
InboxSDK, an open-source Gmail SDK, edits the body with plain DOM ranges, and Gmail keeps these edits [V, source].
Undo is the browser's native Undo for text edits [I].

**Generic path.** It is safe for text edits. `execCommand("insertText")` sends `beforeinput` and `input` and goes into the native Undo stack [I].
A DOM edit without a key press can start the draft save late. Gmail reads the DOM when it sends, so the edit is not lost [I].

**Verified writer.** Not necessary. Gmail has no public editor API. A writer would depend on minified internals.

**Risks.**

- Smart Compose also accepts its ghost text with Tab. The native popup detector does not see that ghost text [I].
- Quoted text (`.gmail_quote`) and signatures are in the same editable root. Review treats them as normal text [I].
- Mail is private data. A live check must use a test account and a new, empty draft.

**Decision.** The maintainer chose to keep the generic path.

## Outlook on the web (RoosterJS)

**Editor model.** RoosterJS keeps the DOM live and caches a Content Model. Undo uses HTML snapshots. Rooster takes Cmd+Z and Ctrl+Z itself, and the browser's Undo stack is not used [V].
The compose body is a `div` with `role="textbox"`, `contenteditable="true"` and `data-ms-editor="true"`. It is in the top document [V].

**Generic path: not safe.** A native `insertText` edit does not tell Rooster that the content changed. After a click, one Undo removed two steps, and Redo could not restore the edit [V, live and with roosterjs 9.60.0].

**Verified writer.** A `Transaction` in `ReviewDomEditors.ts` uses the pattern of Rooster's own find and replace: `takeSnapshot()`, the validated native edit, then `takeSnapshot()` and `triggerEvent("contentChanged")` [V, live].
The MAIN-world bridge finds the editor in `window.__ROOSTERJS_DEVTOOLS_EDITORS__` (roosterjs 9.59 and later). It refuses during IME, during shadow edit, without focus and without a range selection.
An editor that is identified without that list gets Review and Copy only. Typing acceptance and Review Apply are each one Rooster Undo step.
The e2e fixture uses the real `roosterjs` package as a dev dependency.

**Bug found.** After Undo of an accepted word, the typed prefix stayed selected, and the next key replaced it [V, live].
The first snapshot recorded the replaced range, and Rooster's Undo restores the selection of that snapshot.
Now the bridge puts the user's caret back before the first snapshot and selects the range again for the edit. TinyMCE, CKEditor 4, Froala and Summernote had the same problem and get the same fix. TinyMCE also records the caret with `beforeChange()`.

**Risks.**

- The devtools list is marked internal. If Microsoft removes it, Outlook falls back to Review only.
- Outlook runs extra plugins (legacy bridge, autocorrect, autoformat). They can change text near an edit. The write is then reported as unverified.
- Text in an empty editor has no Rooster mark yet. Without the list, such a field is not identified.

## Notion

**Editor model.** One root `contenteditable` holds the page. Each block has its own leaf `[data-content-editable-leaf="true"]` [V].
Notion keeps a block tree, sends changes to its server, and has its own Undo. It reads the leaf DOM back into its model on `input` [V].
There is no in-page editor API. Notion ignores synthetic `beforeinput` events [V].

**Generic path.** A native `insertText` edit persists, with one Undo step, when the caret is already in the leaf [V].
Directly after a click, Notion reverted the same edit within one second, with no signal [V]. FluentTyper attached to the whole page root, not to the block.

**Verified writer.** Each block leaf is a field, as each Gutenberg block is. A write needs the caret already in that leaf, no composition, and a range inside the leaf's text.
After the write, FluentTyper reads the leaf back and watches it for one second. A revert makes a Review fix "unverified". Each Review fix is its own Notion Undo step, so Fix all is not offered.
The e2e fixture is a synthetic page with Notion's DOM shape. It contains no Notion code.

**Bug found.** Escape closed the FluentTyper popup but also reached the page. In Notion, Escape selects the block, and the next keys typed nothing. Now Escape is consumed only when it closes a visible FluentTyper popup or inline suggestion.

**Bug found (live check).** Notion's DOM lock removes each foreign attribute of a block leaf at once, also each FluentTyper `data-ft-*` attribute. It logs a "Reverting mutation of attribute" warning for each [V].
Thus the MAIN-world early Tab bridge did not see FluentTyper's state, and Tab used the slower path. FluentTyper does not write again when an attribute is removed: it writes only on its own events (attach, menu render). This is not a loop.
Now the state of a leaf is on the page root, which Notion does not lock [V]. The root names the entry of the leaf that shows the menu.

**Risks.**

- Verification reads the DOM, not Notion's model. A revert later than one second is not seen.
- The 100 ms and one-second limits come from one live probe.
- Each block gets its own session and a hidden popup host.

## Live check results

Chrome, development build, October 2026. All text was synthetic. The test blocks were removed after the check. The test draft was cleared and closed, and it was not sent.

| # | Check | Result |
| --- | ------------------------------------------------------------------------------ | ------ |
| 1 | Outlook: the developer tools list holds the compose editor | Pass |
| 2 | Outlook: Tab acceptance, then one Undo and one Redo | Pass |
| 3 | Outlook: the same after a click at the line end and after a space | Pass |
| 4 | Outlook: Review has no "Review only" note; one fix and Fix all, each one Undo | Pass |
| 5 | Outlook: the "To" field keeps the generic path | Pass |
| 6 | Outlook: Outlook's autocorrect does not change the accepted word | Pass |
| 7 | Notion: Tab acceptance in a block, one Undo, the word stays after a reload | Pass |
| 8 | Notion: acceptance in a second block stays after a reload | Pass |
| 9 | Notion: Escape with the popup open closes only the popup | Pass |
| 10 | Notion: Review note, no Fix all, one fix, one Undo, the fix stays after reload | Pass |
| 11 | Notion: a fix with the caret in another block is refused | Pass |

The check found two bugs. Both have a fix and a regression test:

- Outlook: after Undo of an accepted word, the typed prefix stayed selected (see [Outlook](#outlook-on-the-web-roosterjs)).
- Notion: the DOM lock removed FluentTyper's attributes from the leaves (see [Notion](#notion)).

Limits that stay:

- Notion: a one-line block shows no Review button. The launcher needs a field of at least 36 px height (`ReviewLauncher.ts`), and a one-line leaf is 28 px high. Open Review with the shortcut or the popup.
- Notion: one Undo can remove the user's typing and a FluentTyper autocorrect together. Notion puts them into one undo step.
- Outlook: the "To" field gets the capitalization of the first word ("hello" becomes "Hello"), as a prose field does.

## Live checks that are still open

1. A real IME composition in Outlook and Notion: the writers must refuse.
2. Firefox on the live sites. The synthetic RoosterJS and Notion-like fixtures pass the full e2e suite on Firefox and Chrome.

---

[Editor capability detection](editor-capabilities.md) · [Review reference](review-reference.md)
67 changes: 62 additions & 5 deletions docs/review-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -852,11 +852,14 @@ signature and invalidate pending fixes.
| `<textarea>`, text `<input>` | overlay measured through a hidden mirror in FluentTyper's shadow root | yes | yes | one native undo step for the whole batch |
| `contenteditable` | CSS Custom Highlights (overlay fallback, e.g. inside shadow DOM) | yes, validated native transaction | within one Text node | one native Undo step per supported batch |
| Quill | CSS Custom Highlights | yes | with verified model bridge | one Quill history event per batch |
| ProseMirror (verified host bridge) | yes | yes | yes | one host undo step for the batch |
| ProseMirror, also Tiptap (verified host bridge) | yes | yes | yes | one host undo step for the batch |
| Word for the web | overlay where the rendered text matches the model | yes, native Word transaction | yes | one Word Undo step per transaction |
| Slate (verified host bridge) | yes | yes | yes | one slate-history step for the batch |
| Lexical, Draft.js, CKEditor 5, Trix (verified host bridge) | yes | yes | yes | one host undo step for the batch |
| TinyMCE, CKEditor 4, Froala, Summernote (verified host bridge) | yes | yes, validated native transaction | yes | one host undo step for the batch |
| RoosterJS, Outlook on the web (editor in the developer tools list) | yes | yes, validated native transaction | yes | one Rooster snapshot step for the batch |
| RoosterJS identified without its editor | yes | no: review-only; Copy for the suggestion | no | — |
| Notion (block leaf that holds the caret) | CSS Custom Highlights | yes: one verified native edit at a time | no | one Notion undo step per fix |
| Other model-editor fingerprints, or one of the above without a bridge | yes | no: review-only; Copy for the suggestion | no | — |
| Google Docs | overlay over the text Docs shows; list only where Docs has not drawn it | yes: one verified replacement at a time | no | Docs history |
| Code editors, sensitive and ineligible fields | refused with an explanation | — | — | — |
Expand All @@ -883,7 +886,8 @@ Findings are located by offsets into that snapshot, never by searching for the t

ProseMirror writes go through its owning view's document transactions, with one
history event per correction or Fix-all batch. The bridge discovers the view during
normal focus, selection and document updates, without test globals. If its private
normal focus, selection and document updates, without test globals. Tiptap renders
a ProseMirror view, so it uses this path. If its private
DOM descriptor is unavailable or the owning view has not been verified, it stays
review-only. Edits are prepared before dispatch and the complete resulting model
is checked, including marks, links, attributes, structure and protected nodes.
Expand All @@ -905,14 +909,25 @@ duration of the write, so the edit never merges into the user's previous typing.

Lexical, Draft.js, CKEditor 5 and Trix keep their own document model. A read is
valid only while every mapped DOM text node holds the model's own text at the
same place; otherwise the editor stays review-only. Each batch is one model
same place; otherwise the editor stays review-only. A difference when Review
opens can be temporary, for example a DOM that is ahead of a pending model
render. The open Review reads the model again on each read and once a second.
When the model reads, Apply becomes available. A difference that stays keeps
the editor review-only. Review does not read only the matching blocks: a model
write makes the host render the changed block again from its model, and text
that only the DOM has can disappear before the read-back finds it. Each batch is one model
transaction, last edit first, that keeps the marks of the replaced text:

- Lexical: the editor and node keys that Lexical stores on its DOM; `spliceText`
in one discrete update tagged `history-push`, so the batch never merges into typing.
- Draft.js: the editor component, found through React's fiber; the page's own
`EditorState.push` with the `spellcheck-change` type, which is always its own
undo step. Review waits for React to render the new text.
`EditorState.push` with the `insert-fragment` type. Draft.js merges a push into
the previous undo step only for `insert-characters`, `backspace-character` and
`delete-character`, so each `insert-fragment` push is its own undo step, and
Draft.js undoes it in its model. The `spellcheck-change` type is not used:
Draft.js gives its undo to the browser's native undo, which has no entry for a
model write. An editor with `allowUndo` off records no undo step. Review waits
for React to render the new text.
- CKEditor 5: the public DOM converter and mapper give the model ranges; one
`model.change` batch.
- Trix: the public `editor` API in one recorded undo entry, while the document
Expand All @@ -924,6 +939,48 @@ the batch in one host undo step: TinyMCE undo levels, CKEditor 4 snapshots,
Froala steps and Summernote history. Typing that the host has not recorded yet
becomes its own step first.

RoosterJS (Outlook on the web) also keeps its content in the DOM, but its Undo
restores HTML snapshots and records only its own input. A native edit that Rooster
did not record is lost: one Undo removes two steps, and Redo cannot restore the
edit. Rooster has no fingerprint of its own. The Outlook compose body is a
`[contenteditable="true"][data-ms-editor="true"]` element, and other pages
(for example with Microsoft Editor) can have the same attributes. Thus the
MAIN-world bridge identifies the editor:

- roosterjs 9.59 and later add each editor to `window.__ROOSTERJS_DEVTOOLS_EDITORS__`.
The writer uses the editor whose content div is the field and that is not disposed.
- Without that editor, the text nodes that Rooster rendered from its model
(`__roosterjsContentModel`) identify it. Then the field is review-only, and
typing writes are refused.
- A field with the attributes and no Rooster evidence keeps the generic path.

The writer uses the steps of Rooster's own find and replace: `takeSnapshot()`,
the validated native edits, `takeSnapshot()` and a `contentChanged` event. It
refuses when Rooster is in an IME composition (`isInIME()`) or in shadow edit,
does not have focus, or has no range selection. After the write, Rooster must
have an undo step (`canMove(-1)`); otherwise the result is unverified.

Notion has no in-page editor API. Its page root is one contenteditable.
Each text block is a nested leaf. Review reviews the leaf that holds the caret.
It never reviews the full page. Notion reads the leaf into its model on input.
A live probe saw Notion revert a write that came right after the caret moved into the leaf.
Thus each fix is one native edit in that leaf, with these checks:

1. Review gives focus back to the page root and waits 100 ms.
If the user closes Review in this wait, Review writes nothing.
2. The caret must already be in the reviewed leaf. Review never moves the caret there.
3. No IME composition may run.
4. After the write, the leaf must hold the expected text.
5. The leaf must still hold that text 1 s after the write.
If Notion reverts the write in this time, the result is "unverified".
Review does not write again.

Each fix is one Notion undo step. Thus Review does not offer Fix all.
These checks read the DOM, not the model of Notion.
The tests use a synthetic Notion-like page.
A live check in Chrome passed ([details](editor-capabilities.md#notion),
[live check results](editor-surfaces.md#live-check-results)).

Plain contenteditable snapshots also capture formatting wrappers and attributes.
A native correction must retain existing elements. Changes across text nodes are
refused before writing. Verified model transactions can retain split formatting.
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@
"@lexical/history": "^0.51.0",
"@lexical/link": "^0.51.0",
"@lexical/rich-text": "^0.51.0",
"@tiptap/core": "3.31.4",
"@tiptap/pm": "3.31.4",
"@tiptap/starter-kit": "3.31.4",
"@types/bun": "^1.4.2",
"@types/chrome": "^0.3.0",
"@types/luxon": "^3.7.5",
Expand Down Expand Up @@ -87,6 +90,7 @@
"react-dom": "19.2.0",
"react-dom18": "npm:react-dom@18.3.1",
"react18": "npm:react@18.3.1",
"roosterjs": "9.60.0",
"slate": "^0.126.2",
"slate-dom": "^0.126.0",
"slate-history": "^0.113.1",
Expand Down
Loading
Loading