From 8d8eff16db5014b1c37d16bfe50e7414c56f48a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 02:39:05 +0000 Subject: [PATCH 1/9] docs: add text editor behaviors reference Start a living doc of expected text editor behaviors. First entry: Enter on a blank list item outdents one level, and at the top level converts the item to a normal non-list block while preserving its text type (heading, blockquote, or plain text). https://claude.ai/code/session_01V4wW9QQpVzduvU5K3BzNGq --- docs/editor-behaviors.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 docs/editor-behaviors.md diff --git a/docs/editor-behaviors.md b/docs/editor-behaviors.md new file mode 100644 index 00000000..d809e49f --- /dev/null +++ b/docs/editor-behaviors.md @@ -0,0 +1,33 @@ +# Text Editor Behaviors + +A living reference for how Leaflet's text editor is expected to behave. Add a +new section for each behavior as it is defined. Each entry describes the +user-facing behavior first, then (where it exists) points to the code that +implements it. + +## Enter on a blank list item + +When the cursor is in a **blank** list item (the item contains no text) and the +user presses **Enter**: + +1. **If the item is nested** — there are outer list layers above it — it + **outdents by one level**: the item moves up to its parent's level. No new + list item is created. +2. **If the item is already at the top level** — there are no more layers to + outdent — it is **converted into a normal, non-list text block**. The block + keeps its text type: a heading stays a heading (at the same heading level), a + blockquote stays a blockquote, and plain text stays plain text. Only the list + treatment (bullet / number / checkbox) is removed. + +This applies to every kind of list item — unordered, ordered, and checkbox +(to-do). + +**Not affected:** pressing Enter in a list item that has content behaves +normally — it splits the item / creates a new sibling list item at the same +depth. Only blank items outdent. + +Implementation: `components/Blocks/TextBlock/keymap.ts` — the `enter` command +detects an empty list item and delegates to outdent. `src/utils/list-operations.ts` +— `outdent` moves the item up one level, or, at the top level (`depth === 1`), +removes the `block/is-list` attribute while leaving `block/type` (text / +heading / blockquote) untouched. From 1974bb00be98a1027ac576af5b6a3297e86b8913 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 02:40:24 +0000 Subject: [PATCH 2/9] docs: drop implementation pointers from editor behaviors Keep the doc a purely human-readable specification of behavior. https://claude.ai/code/session_01V4wW9QQpVzduvU5K3BzNGq --- docs/editor-behaviors.md | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/docs/editor-behaviors.md b/docs/editor-behaviors.md index d809e49f..b63e743c 100644 --- a/docs/editor-behaviors.md +++ b/docs/editor-behaviors.md @@ -1,9 +1,8 @@ # Text Editor Behaviors A living reference for how Leaflet's text editor is expected to behave. Add a -new section for each behavior as it is defined. Each entry describes the -user-facing behavior first, then (where it exists) points to the code that -implements it. +new section for each behavior as it is defined. Each entry specifies the +behavior from the user's point of view, independent of how it is implemented. ## Enter on a blank list item @@ -25,9 +24,3 @@ This applies to every kind of list item — unordered, ordered, and checkbox **Not affected:** pressing Enter in a list item that has content behaves normally — it splits the item / creates a new sibling list item at the same depth. Only blank items outdent. - -Implementation: `components/Blocks/TextBlock/keymap.ts` — the `enter` command -detects an empty list item and delegates to outdent. `src/utils/list-operations.ts` -— `outdent` moves the item up one level, or, at the top level (`depth === 1`), -removes the `block/is-list` attribute while leaving `block/type` (text / -heading / blockquote) untouched. From 1ffac5411dc828660bfe6b12df18c996a0b3f384 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 02:30:57 +0000 Subject: [PATCH 3/9] Fix list Enter races in creation and outdent Both the create and outdent paths drove structural mutations off React-prop snapshots (which lag the Replicache store) and a fixed focus timer, so fast "type, Enter, type, Enter" or repeated Enter-on-blank produced lost focus, colliding positions, transiently non-list items, and dropped blocks. Create path (keymap `enter`): - Position the new sibling from the freshly-read sibling rather than the stale `propsRef.current.position`, so it can't collide with or sort before the item just created. - Create the list item atomically: read inherited list-style/check-list up front and write type + is-list + style + checklist in a single `addBlock` mutation, closing the window where the new block wasn't yet a list item and a follow-up Enter inserted a plain paragraph. - Focus when the new block's editor actually registers (retry) instead of a fixed 10ms timeout that silently dropped focus, sending keystrokes to the previous block. Outdent path (`outdentBlock`): - Validate the destination anchor before any retraction. Previously a stale/missing `after` returned after the block was already retracted and its following siblings re-parented under it, orphaning the block and everything below it. https://claude.ai/code/session_01V4wW9QQpVzduvU5K3BzNGq --- components/Blocks/TextBlock/keymap.ts | 106 +++++++++++++------------- src/replicache/mutations.ts | 39 +++++++++- 2 files changed, 91 insertions(+), 54 deletions(-) diff --git a/components/Blocks/TextBlock/keymap.ts b/components/Blocks/TextBlock/keymap.ts index 3f3d1fc6..eb3654f7 100644 --- a/components/Blocks/TextBlock/keymap.ts +++ b/components/Blocks/TextBlock/keymap.ts @@ -509,8 +509,12 @@ const enter = let index = siblings.findIndex( (sib) => sib.data.value === propsRef.current.entityID, ); + // Lower bound comes from the freshly-read sibling, not + // propsRef.current.position (a render-lagged snapshot). Mixing a + // stale lower bound with a fresh upper bound could collide with or + // sort before the just-created item when Entering quickly. position = generateKeyBetween( - propsRef.current.position, + siblings[index]?.data.position || propsRef.current.position, siblings[index + 1]?.data.position || null, ); } else { @@ -525,6 +529,19 @@ const enter = children[0]?.data.position || null, ); } + // Read the list facts to inherit from the source block up front, then + // create the new item with all of them in a single mutation. Splitting + // is-list / list-style / check-list into follow-up assertFacts left a + // window where the new block existed but wasn't yet a list item, so a + // fast follow-up Enter saw no listData and inserted a plain paragraph. + let [listStyle] = + (await repRef.current?.query((tx) => + scanIndex(tx).eav(propsRef.current.entityID, "block/list-style"), + )) || []; + let [checked] = + (await repRef.current?.query((tx) => + scanIndex(tx).eav(propsRef.current.entityID, "block/check-list"), + )) || []; await repRef.current?.mutate.addBlock({ newEntityID, factID: v7(), @@ -534,6 +551,14 @@ const enter = : propsRef.current.listData.parent, type: blockType, position, + list: { + listStyle: listStyle?.data.value, + checklist: checked + ? state.selection.anchor === 1 + ? checked.data.value + : false + : undefined, + }, }); if ( !createChild && @@ -548,38 +573,6 @@ const enter = after: null, }); } - await repRef.current?.mutate.assertFact({ - entity: newEntityID, - attribute: "block/is-list", - data: { type: "boolean", value: true }, - }); - // Copy list style (ordered/unordered) to new list item - let listStyle = await repRef.current?.query((tx) => - scanIndex(tx).eav(propsRef.current.entityID, "block/list-style"), - ); - if (listStyle?.[0]) { - await repRef.current?.mutate.assertFact({ - entity: newEntityID, - attribute: "block/list-style", - data: { - type: "list-style-union", - value: listStyle[0].data.value, - }, - }); - } - let checked = await repRef.current?.query((tx) => - scanIndex(tx).eav(propsRef.current.entityID, "block/check-list"), - ); - if (checked?.[0]) - await repRef.current?.mutate.assertFact({ - entity: newEntityID, - attribute: "block/check-list", - data: { - type: "boolean", - value: - state.selection.anchor === 1 ? checked?.[0].data.value : false, - }, - }); } // if the block is not a list, add a new text block after it if (!propsRef.current.listData) { @@ -644,28 +637,37 @@ const enter = parent: propsRef.current.parent, }); - setTimeout(() => { + // The new block is created by an async mutation and only becomes + // focusable once its editor mounts and registers. A single fixed timeout + // raced that mount: if the editor wasn't ready, focus was silently + // dropped (focusBlock no-ops without a view) and the following keystrokes + // landed in the previous block. Retry until the editor registers so the + // caret reliably follows the new item. + let attempts = 0; + let placeContentAndFocus = () => { let block = useEditorStates.getState().editorStates[newEntityID]; - if (block) { + if (!block) { + if (attempts++ < 50) setTimeout(placeContentAndFocus, 10); + return; + } + if (newContent.content.size > 2) { let tr = block.editor.tr; - if (newContent.content.size > 2) { - tr.replaceWith(0, tr.doc.content.size, newContent.content); - tr.setSelection(TextSelection.create(tr.doc, 0)); - let newState = block.editor.apply(tr); - setEditorState(newEntityID, { - editor: newState, - }); - } - focusBlock( - { - value: newEntityID, - parent: propsRef.current.parent, - type: "text", - }, - { type: "start" }, - ); + tr.replaceWith(0, tr.doc.content.size, newContent.content); + tr.setSelection(TextSelection.create(tr.doc, 0)); + setEditorState(newEntityID, { + editor: block.editor.apply(tr), + }); } - }, 10); + focusBlock( + { + value: newEntityID, + parent: propsRef.current.parent, + type: "text", + }, + { type: "start" }, + ); + }; + placeContentAndFocus(); }); // if you are in the middle of a text block, split the block diff --git a/src/replicache/mutations.ts b/src/replicache/mutations.ts index 682fec30..915fed6f 100644 --- a/src/replicache/mutations.ts +++ b/src/replicache/mutations.ts @@ -75,6 +75,14 @@ const addBlock: Mutation<{ type: Fact<"block/type">["data"]["value"]; newEntityID: string; position: string; + // When set, the new block is created as a list item in the same mutation as + // its type, so it is never momentarily a non-list block. Asserting these as + // separate follow-up mutations left a window where a racing Enter saw no list + // data and inserted a plain paragraph, breaking the list. + list?: { + listStyle?: Fact<"block/list-style">["data"]["value"]; + checklist?: boolean; + }; }> = async (args, ctx) => { await ctx.createEntity({ entityID: args.newEntityID, @@ -95,6 +103,27 @@ const addBlock: Mutation<{ data: { type: "block-type-union", value: args.type }, attribute: "block/type", }); + if (args.list) { + await ctx.assertFact({ + entity: args.newEntityID, + attribute: "block/is-list", + data: { type: "boolean", value: true }, + }); + if (args.list.listStyle) { + await ctx.assertFact({ + entity: args.newEntityID, + attribute: "block/list-style", + data: { type: "list-style-union", value: args.list.listStyle }, + }); + } + if (args.list.checklist !== undefined) { + await ctx.assertFact({ + entity: args.newEntityID, + attribute: "block/check-list", + data: { type: "boolean", value: args.list.checklist }, + }); + } + } }; const addLastBlock: Mutation<{ @@ -226,6 +255,14 @@ const outdentBlock: Mutation<{ (f) => f.data.value === args.block, ); if (currentFactIndex === -1) return; + // Validate the destination anchor BEFORE mutating anything. `after` and + // `newParent` are computed by the caller from React props that can lag the + // store; if the anchor isn't present in the (fresh) new parent, bailing here + // is a clean no-op. Doing this check after the retraction below would leave + // the block — and every sibling re-parented under it — orphaned and dropped + // from the document. + let index = newSiblings.findIndex((f) => f.data.value === args.after); + if (index === -1) return; // Filter out blocks that are being processed separately (e.g., in multi-select outdent) let excludeSet = new Set(args.excludeFromSiblings || []); let currentSiblingsAfter = currentSiblings @@ -252,8 +289,6 @@ const outdentBlock: Mutation<{ }); } - let index = newSiblings.findIndex((f) => f.data.value === args.after); - if (index === -1) return; let newPosition = generateKeyBetween( newSiblings[index]?.data.position, newSiblings[index + 1]?.data.position || null, From af1002b36a9d3a0c6388f1c7f527d09e5499f278 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 02:36:00 +0000 Subject: [PATCH 4/9] Trim list-Enter fix comments to rationale only Drop comments that just narrate what the code shows; keep the non-obvious why (atomic creation avoids a non-list window, validate-before-retract avoids orphaning, focusBlock no-ops until mount). https://claude.ai/code/session_01V4wW9QQpVzduvU5K3BzNGq --- components/Blocks/TextBlock/keymap.ts | 20 +++++--------------- src/replicache/mutations.ts | 16 ++++++---------- 2 files changed, 11 insertions(+), 25 deletions(-) diff --git a/components/Blocks/TextBlock/keymap.ts b/components/Blocks/TextBlock/keymap.ts index eb3654f7..ec6f1aaa 100644 --- a/components/Blocks/TextBlock/keymap.ts +++ b/components/Blocks/TextBlock/keymap.ts @@ -509,10 +509,8 @@ const enter = let index = siblings.findIndex( (sib) => sib.data.value === propsRef.current.entityID, ); - // Lower bound comes from the freshly-read sibling, not - // propsRef.current.position (a render-lagged snapshot). Mixing a - // stale lower bound with a fresh upper bound could collide with or - // sort before the just-created item when Entering quickly. + // Use the freshly-read sibling position, not the render-lagged + // propsRef snapshot, which can collide with the just-created item. position = generateKeyBetween( siblings[index]?.data.position || propsRef.current.position, siblings[index + 1]?.data.position || null, @@ -529,11 +527,6 @@ const enter = children[0]?.data.position || null, ); } - // Read the list facts to inherit from the source block up front, then - // create the new item with all of them in a single mutation. Splitting - // is-list / list-style / check-list into follow-up assertFacts left a - // window where the new block existed but wasn't yet a list item, so a - // fast follow-up Enter saw no listData and inserted a plain paragraph. let [listStyle] = (await repRef.current?.query((tx) => scanIndex(tx).eav(propsRef.current.entityID, "block/list-style"), @@ -637,12 +630,9 @@ const enter = parent: propsRef.current.parent, }); - // The new block is created by an async mutation and only becomes - // focusable once its editor mounts and registers. A single fixed timeout - // raced that mount: if the editor wasn't ready, focus was silently - // dropped (focusBlock no-ops without a view) and the following keystrokes - // landed in the previous block. Retry until the editor registers so the - // caret reliably follows the new item. + // focusBlock silently no-ops until the new block's editor mounts, so a + // fixed timeout could drop focus and send the next keystrokes to the old + // block. Retry until it registers. let attempts = 0; let placeContentAndFocus = () => { let block = useEditorStates.getState().editorStates[newEntityID]; diff --git a/src/replicache/mutations.ts b/src/replicache/mutations.ts index 915fed6f..8d5eb40a 100644 --- a/src/replicache/mutations.ts +++ b/src/replicache/mutations.ts @@ -75,10 +75,9 @@ const addBlock: Mutation<{ type: Fact<"block/type">["data"]["value"]; newEntityID: string; position: string; - // When set, the new block is created as a list item in the same mutation as - // its type, so it is never momentarily a non-list block. Asserting these as - // separate follow-up mutations left a window where a racing Enter saw no list - // data and inserted a plain paragraph, breaking the list. + // Set in the same mutation as the type so the block is never briefly a + // non-list block — otherwise a racing Enter sees no list data and inserts a + // plain paragraph, breaking the list. list?: { listStyle?: Fact<"block/list-style">["data"]["value"]; checklist?: boolean; @@ -255,12 +254,9 @@ const outdentBlock: Mutation<{ (f) => f.data.value === args.block, ); if (currentFactIndex === -1) return; - // Validate the destination anchor BEFORE mutating anything. `after` and - // `newParent` are computed by the caller from React props that can lag the - // store; if the anchor isn't present in the (fresh) new parent, bailing here - // is a clean no-op. Doing this check after the retraction below would leave - // the block — and every sibling re-parented under it — orphaned and dropped - // from the document. + // Bail before the retraction below: a missing anchor here is a clean no-op, + // but checking after retracting would orphan the block and the siblings + // re-parented under it, dropping them from the document. let index = newSiblings.findIndex((f) => f.data.value === args.after); if (index === -1) return; // Filter out blocks that are being processed separately (e.g., in multi-select outdent) From e09097b13119de6991bddc5b239ec9c37b3b2f19 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 03:32:25 +0000 Subject: [PATCH 5/9] Fix undo losing the block after indent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit indent fired retractFact + addLastBlock without awaiting them. main's undo refactor closes the undo group when the command promise settles (replacing the old 100ms endGroup timeout), so the un-awaited mutators registered their undo ops after the group closed — as two separate entries. One undo then reversed only addLastBlock (retracting the new card/block fact) and orphaned the block; a second undo restored it. Await both mutations (as outdent already does) so their undo ops land inside the group and a single undo restores the block to its prior position. https://claude.ai/code/session_01V4wW9QQpVzduvU5K3BzNGq --- src/utils/list-operations.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/utils/list-operations.ts b/src/utils/list-operations.ts index 8b46dd8a..7d8bc98e 100644 --- a/src/utils/list-operations.ts +++ b/src/utils/list-operations.ts @@ -45,8 +45,12 @@ export async function indent( if (!newParent) return { success: false }; if (foldState && foldState.foldedBlocks.includes(newParent.entity)) foldState.toggleFold(newParent.entity); - rep?.mutate.retractFact({ factID: block.factID }); - rep?.mutate.addLastBlock({ + // Await both mutations so their undo ops register before the caller's promise + // settles; withUndoGroup closes the group on settle, and if these run after + // that the retract/add land as separate undo entries — one undo then orphans + // the block instead of restoring it to its old position. + await rep?.mutate.retractFact({ factID: block.factID }); + await rep?.mutate.addLastBlock({ parent: newParent.entity, factID: v7(), entity: block.value, From d6a35b049762aec0089dd45727ca42e3959bfeac Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 04:29:01 +0000 Subject: [PATCH 6/9] Fix undo/redo data loss and position swaps in list ops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent defects surfaced by the subagent investigation: - retractFact's redo closure omitted ignoreUndo, so redoing any indent/outdent re-entered the undo manager — truncating the redo stack and pushing a dangling entry that double-referenced then lost the block. Add ignoreUndo (every other closure already passes it). - indent did retract-old-fact + add-new-factID. A mis-grouped undo could apply only the add and orphan the block. Reparent the existing card/block fact in a single mutation (reuse its factID) so it's one symmetric undo entry that can't be half-applied. - the nested sibling sort in getBlocks had no id tiebreak (the top-level sorts do), so equal/colliding positions rendered in unstable order. Add the tiebreak. https://claude.ai/code/session_01V4wW9QQpVzduvU5K3BzNGq --- src/replicache/clientMutationContext.ts | 2 +- src/replicache/getBlocks.ts | 12 ++++++++++-- src/utils/list-operations.ts | 12 +++++------- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/replicache/clientMutationContext.ts b/src/replicache/clientMutationContext.ts index edc87fed..198e907e 100644 --- a/src/replicache/clientMutationContext.ts +++ b/src/replicache/clientMutationContext.ts @@ -113,7 +113,7 @@ export function clientMutationContext( } }, redo: () => { - rep.mutate.retractFact({ factID: id }); + rep.mutate.retractFact({ ignoreUndo: true, factID: id }); }, }); await tx.del(id); diff --git a/src/replicache/getBlocks.ts b/src/replicache/getBlocks.ts index fa3b27d4..a8cf20d0 100644 --- a/src/replicache/getBlocks.ts +++ b/src/replicache/getBlocks.ts @@ -52,7 +52,11 @@ export const getBlocksWithType = async ( ): Promise => { let children = ( await scan.eav(root.data.value, "card/block") - ).sort((a, b) => (a.data.position > b.data.position ? 1 : -1)); + ).sort((a, b) => { + if (a.data.position === b.data.position) + return a.id > b.id ? 1 : -1; + return a.data.position > b.data.position ? 1 : -1; + }); let type = (await scan.eav(root.data.value, "block/type"))[0]; let checklist = await scan.eav( root.data.value, @@ -131,7 +135,11 @@ export const getBlocksWithTypeLocal = ( ): Block[] => { let children = scan .eav(root.data.value, "card/block") - .sort((a, b) => (a.data.position > b.data.position ? 1 : -1)); + .sort((a, b) => { + if (a.data.position === b.data.position) + return a.id > b.id ? 1 : -1; + return a.data.position > b.data.position ? 1 : -1; + }); let type = scan.eav(root.data.value, "block/type")[0]; let listStyle = scan.eav(root.data.value, "block/list-style")[0]; let listNumber = scan.eav(root.data.value, "block/list-number")[0]; diff --git a/src/utils/list-operations.ts b/src/utils/list-operations.ts index 7d8bc98e..b2ee5f01 100644 --- a/src/utils/list-operations.ts +++ b/src/utils/list-operations.ts @@ -1,7 +1,6 @@ import { Block } from "components/Blocks/Block"; import { Replicache } from "replicache"; import type { ReplicacheMutators } from "src/replicache"; -import { v7 } from "uuid"; export function orderListItems( block: Block, @@ -45,14 +44,13 @@ export async function indent( if (!newParent) return { success: false }; if (foldState && foldState.foldedBlocks.includes(newParent.entity)) foldState.toggleFold(newParent.entity); - // Await both mutations so their undo ops register before the caller's promise - // settles; withUndoGroup closes the group on settle, and if these run after - // that the retract/add land as separate undo entries — one undo then orphans - // the block instead of restoring it to its old position. - await rep?.mutate.retractFact({ factID: block.factID }); + // Reparent the block's existing card/block fact in one mutation (reusing its + // factID) rather than retract-old + add-new-id. assertFact moves the fact and + // captures the old (parent, position) as the single undo entry, so a split or + // mis-grouped undo can't half-apply it and orphan the block. await rep?.mutate.addLastBlock({ parent: newParent.entity, - factID: v7(), + factID: block.factID, entity: block.value, }); From 0532d817fd1ad1a11caeb1451f02bea50cc0b1e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 04:29:14 +0000 Subject: [PATCH 7/9] Rework undo grouping and keep focus across reparenting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grouping: a single shared isGrouping flag was driven by three producers — Tab's withUndoGroup, the keymap Enter/Backspace groups, and a 200ms text-edit timeout group in trackUndoRedo — that closed each other's groups and split a command's mutations across boundaries (orphaning blocks on undo) or merged a Tab into adjacent typing. Centralize all grouping in the undo manager: - command groups are depth-counted (nesting-safe) and flush any open text-edit group first, so a command never merges with typing; - text edits coalesce through addGrouped/flushGroup (the manager owns the 200ms window), so trackUndoRedo no longer races a per-component timer; - undo/redo are serialized so rapid keypresses don't re-enter @rocicorp/undo's recursive group walk and corrupt the stack. Focus: indent/outdent change a block's parent but keep its entityID. parent was a dependency of the ProseMirror mount effect, so every structural edit (and its undo/redo) destroyed and recreated the EditorView, dropping the caret to . Drop parent from the dependency array (it's read via propsRef) so the view survives reparenting and the caret follows the block. https://claude.ai/code/session_01V4wW9QQpVzduvU5K3BzNGq --- components/Blocks/TextBlock/index.tsx | 10 +- .../Blocks/TextBlock/mountProsemirror.ts | 38 +++--- components/Footnotes/FootnoteEditor.tsx | 2 - src/undoManager.ts | 116 ++++++++++++++---- 4 files changed, 108 insertions(+), 58 deletions(-) diff --git a/components/Blocks/TextBlock/index.tsx b/components/Blocks/TextBlock/index.tsx index f785e091..a0f5f71e 100644 --- a/components/Blocks/TextBlock/index.tsx +++ b/components/Blocks/TextBlock/index.tsx @@ -251,7 +251,7 @@ function BaseTextBlock(props: BlockProps & { className?: string }) { handleMentionOpenChange, } = useMentionState(props.entityID, props); - let { mountRef, actionTimeout, overlay } = useMountProsemirror({ + let { mountRef, overlay } = useMountProsemirror({ props, openMentionAutocomplete, }); @@ -284,11 +284,9 @@ function BaseTextBlock(props: BlockProps & { className?: string }) { data: { type: "block-type-union", value: "horizontal-rule" }, }); } - if (actionTimeout.current) { - rep.undoManager.endGroup(); - window.clearTimeout(actionTimeout.current); - actionTimeout.current = null; - } + // Finalize any open coalescing text-edit group so the next action + // (or undo) starts from a clean boundary. + rep.undoManager.flushGroup(); }} onFocus={() => { handleMentionOpenChange(false); diff --git a/components/Blocks/TextBlock/mountProsemirror.ts b/components/Blocks/TextBlock/mountProsemirror.ts index 3a3e2462..b6b4c0bd 100644 --- a/components/Blocks/TextBlock/mountProsemirror.ts +++ b/components/Blocks/TextBlock/mountProsemirror.ts @@ -55,7 +55,7 @@ export function useMountProsemirror({ props: BlockProps; openMentionAutocomplete: () => void; }) { - let { entityID, parent } = props; + let { entityID } = props; let rep = useReplicache(); let mountRef = useRef(null); const repRef = useRef | null>(null); @@ -66,8 +66,6 @@ export function useMountProsemirror({ let propsRef = useRef({ ...props, entity_set, alignment }); let handlePaste = useHandlePaste(entityID, propsRef); - const actionTimeout = useRef(null); - propsRef.current = { ...props, entity_set, alignment }; repRef.current = rep.rep; @@ -348,7 +346,6 @@ export function useMountProsemirror({ trackUndoRedo( tr, rep.undoManager, - actionTimeout, setState(oldEditorState), setState(newState), ); @@ -366,38 +363,33 @@ export function useMountProsemirror({ }; }); } - }, [entityID, parent, value, cursorPlugin, handlePaste, rep]); - return { mountRef, actionTimeout, overlay }; + // `parent` is intentionally NOT a dependency: indent/outdent change a + // block's parent but keep the same entityID, and rebuilding the EditorView + // on reparent destroyed the caret (dropping focus to on every + // structural edit and its undo/redo). The view reads parent via propsRef. + }, [entityID, value, cursorPlugin, handlePaste, rep]); + return { mountRef, overlay }; } export function trackUndoRedo( tr: Transaction, undoManager: UndoManager, - actionTimeout: { current: number | null }, undo: () => void, redo: () => void, ) { let addToHistory = tr.getMeta("addToHistory"); let isBulkOp = tr.getMeta("bulkOp"); - // externalUndoGroup: the caller already has an undo group open and is keeping - // it open across async mutations (e.g. the Enter handler grouping a block - // split with the new block's creation), so skip the timeout-based group - // management here and just add the entry. Unlike bulkOp this does NOT suppress - // focus on undo (see setState above), so the cursor returns to the block. + // externalUndoGroup: the caller already holds a command group open across its + // async mutations (e.g. the Enter handler grouping a block split with the new + // block's creation), so just add to it. bulkOp likewise manages its own + // grouping. Otherwise coalesce consecutive text edits into one undo step + // (the undo manager owns the coalescing window, so a command can flush it and + // never merge with adjacent typing). let skipGroupManagement = isBulkOp || tr.getMeta("externalUndoGroup"); let docHasChanges = tr.steps.length !== 0 || tr.docChanged; if (addToHistory !== false && docHasChanges) { - if (actionTimeout.current) window.clearTimeout(actionTimeout.current); - else if (!skipGroupManagement) undoManager.startGroup(); - - if (!skipGroupManagement) { - actionTimeout.current = window.setTimeout(() => { - undoManager.endGroup(); - actionTimeout.current = null; - }, 200); - } - - undoManager.add({ undo, redo }); + if (skipGroupManagement) undoManager.add({ undo, redo }); + else undoManager.addGrouped({ undo, redo }); } } diff --git a/components/Footnotes/FootnoteEditor.tsx b/components/Footnotes/FootnoteEditor.tsx index d5a850a7..c7d091e7 100644 --- a/components/Footnotes/FootnoteEditor.tsx +++ b/components/Footnotes/FootnoteEditor.tsx @@ -71,7 +71,6 @@ function EditableFootnote(props: { cursorPlugin, overlay, } = useCollabText(props.footnoteEntityID); - let actionTimeout = useRef(null); let { pageID } = useFootnoteContext(); useLayoutEffect(() => { @@ -165,7 +164,6 @@ function EditableFootnote(props: { trackUndoRedo( tr, rep.undoManager, - actionTimeout, () => { this.focus(); this.updateState(oldState); diff --git a/src/undoManager.ts b/src/undoManager.ts index fc782632..1bebdeb2 100644 --- a/src/undoManager.ts +++ b/src/undoManager.ts @@ -3,54 +3,116 @@ import { create } from "zustand"; export type UndoManager = ReturnType; export const useUndoState = create(() => ({ canUndo: false, canRedo: false })); + +// Consecutive text edits within this window coalesce into one undo step. +const COALESCE_MS = 200; + +type Op = { undo: () => Promise | void; redo: () => Promise | void }; + export const createUndoManager = () => { - let isGrouping = false; let undoManager = new RociUndoManager({ onChange: (state) => { useUndoState.setState(state); }, }); + + // @rocicorp/undo allows only one group open at a time. Three producers share + // this single instance — explicit command groups (Tab/Enter/Backspace via + // startGroup/withUndoGroup) and coalescing text-edit groups (addGrouped) — + // so all grouping state is owned here to keep them from closing each other's + // groups (which previously split a multi-mutation op across boundaries and + // orphaned blocks on undo). + let groupDepth = 0; // open explicit command groups (counted so nesting is safe) + let coalesceOpen = false; // a text-edit coalescing group is open + let coalesceTimer: ReturnType | null = null; + // Serialize undo/redo so rapid keypresses don't re-enter @rocicorp/undo's + // recursive group walk (it mutates a shared index) and corrupt the stack. + let opChain: Promise = Promise.resolve(); + + let clearCoalesceTimer = () => { + if (coalesceTimer !== null) { + clearTimeout(coalesceTimer); + coalesceTimer = null; + } + }; + // Finalize the open coalescing text group, if any. + let flushCoalesce = () => { + clearCoalesceTimer(); + if (coalesceOpen) { + coalesceOpen = false; + undoManager.endGroup(); + } + }; + let um = { - add: (args: { - undo: () => Promise | void; - redo: () => Promise | void; - }) => { + add: (args: Op) => { undoManager.add(args); }, - startGroup: (groupName?: string) => { - if (isGrouping) { - undoManager.endGroup(); - } - isGrouping = true; - undoManager.startGroup(); + // Open an atomic command group. Finalizes any pending text-edit group first + // so a command never merges with adjacent typing, and opens only one raw + // group regardless of nesting depth. + startGroup: () => { + flushCoalesce(); + if (groupDepth === 0) undoManager.startGroup(); + groupDepth++; }, endGroup: () => { - isGrouping = false; - undoManager.endGroup(); + if (groupDepth === 0) return; + groupDepth--; + if (groupDepth === 0) undoManager.endGroup(); }, - undo: () => undoManager.undo(), - redo: () => undoManager.redo(), withUndoGroup: (cb: () => T): T => { - const wasGrouping = isGrouping; - if (!wasGrouping) um.startGroup(); - const endIfOwner = () => { - if (!wasGrouping) um.endGroup(); - }; + um.startGroup(); + let end = () => um.endGroup(); let r: T; try { r = cb(); } catch (e) { - endIfOwner(); + end(); throw e; } - // Replicache mutator bodies — and the undoManager.add calls they make — - // run asynchronously, after the callback's synchronous portion returns. - // If the callback is async, hold the group open until it settles so those - // mutations land in this group instead of as separate undo entries. - if (r instanceof Promise) return r.finally(endIfOwner) as T; - endIfOwner(); + // Hold the group open until an async command settles so all its mutations + // (whose undoManager.add calls run as the awaited mutators execute) land + // in this group. + if (r instanceof Promise) return r.finally(end) as T; + end(); return r; }, + // Add a text edit, coalescing consecutive edits within COALESCE_MS. If a + // command group is open the edit just joins it. + addGrouped: (args: Op) => { + if (groupDepth > 0) { + undoManager.add(args); + return; + } + if (!coalesceOpen) { + undoManager.startGroup(); + coalesceOpen = true; + } + undoManager.add(args); + clearCoalesceTimer(); + coalesceTimer = setTimeout(flushCoalesce, COALESCE_MS); + }, + // Finalize the open coalescing group immediately (e.g. on blur). + flushGroup: () => { + flushCoalesce(); + }, + undo: () => { + flushCoalesce(); + opChain = opChain.then(() => undoManager.undo()).then( + () => {}, + () => {}, + ); + return opChain; + }, + redo: () => { + flushCoalesce(); + opChain = opChain.then(() => undoManager.redo()).then( + () => {}, + () => {}, + ); + return opChain; + }, }; return um; }; From d7d311d7d25855311ed798808e26b97988d9d7fc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 04:40:22 +0000 Subject: [PATCH 8/9] Coalesce text-edit undo by span key, not just time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit addGrouped coalesced every text edit within the 200ms window regardless of source, so edits to two different blocks within the window merged into one undo step (only the blur flush saved it). Take a span key (the block/footnote entityID): consecutive same-key edits coalesce within the window, and a different key finalizes the open group immediately so distinct spans never merge — independent of timing or blur. trackUndoRedo threads the editor's entityID through as the key. https://claude.ai/code/session_01V4wW9QQpVzduvU5K3BzNGq --- components/Blocks/TextBlock/mountProsemirror.ts | 4 +++- components/Footnotes/FootnoteEditor.tsx | 1 + src/undoManager.ts | 14 +++++++++++--- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/components/Blocks/TextBlock/mountProsemirror.ts b/components/Blocks/TextBlock/mountProsemirror.ts index b6b4c0bd..2e2fe659 100644 --- a/components/Blocks/TextBlock/mountProsemirror.ts +++ b/components/Blocks/TextBlock/mountProsemirror.ts @@ -346,6 +346,7 @@ export function useMountProsemirror({ trackUndoRedo( tr, rep.undoManager, + entityID, setState(oldEditorState), setState(newState), ); @@ -374,6 +375,7 @@ export function useMountProsemirror({ export function trackUndoRedo( tr: Transaction, undoManager: UndoManager, + coalesceKey: string, undo: () => void, redo: () => void, ) { @@ -390,6 +392,6 @@ export function trackUndoRedo( if (addToHistory !== false && docHasChanges) { if (skipGroupManagement) undoManager.add({ undo, redo }); - else undoManager.addGrouped({ undo, redo }); + else undoManager.addGrouped({ undo, redo }, coalesceKey); } } diff --git a/components/Footnotes/FootnoteEditor.tsx b/components/Footnotes/FootnoteEditor.tsx index c7d091e7..5d877e00 100644 --- a/components/Footnotes/FootnoteEditor.tsx +++ b/components/Footnotes/FootnoteEditor.tsx @@ -164,6 +164,7 @@ function EditableFootnote(props: { trackUndoRedo( tr, rep.undoManager, + props.footnoteEntityID, () => { this.focus(); this.updateState(oldState); diff --git a/src/undoManager.ts b/src/undoManager.ts index 1bebdeb2..2451601a 100644 --- a/src/undoManager.ts +++ b/src/undoManager.ts @@ -24,6 +24,7 @@ export const createUndoManager = () => { // orphaned blocks on undo). let groupDepth = 0; // open explicit command groups (counted so nesting is safe) let coalesceOpen = false; // a text-edit coalescing group is open + let currentCoalesceKey: string | undefined; // span key of the open group let coalesceTimer: ReturnType | null = null; // Serialize undo/redo so rapid keypresses don't re-enter @rocicorp/undo's // recursive group walk (it mutates a shared index) and corrupt the stack. @@ -40,6 +41,7 @@ export const createUndoManager = () => { clearCoalesceTimer(); if (coalesceOpen) { coalesceOpen = false; + currentCoalesceKey = undefined; undoManager.endGroup(); } }; @@ -78,16 +80,22 @@ export const createUndoManager = () => { end(); return r; }, - // Add a text edit, coalescing consecutive edits within COALESCE_MS. If a - // command group is open the edit just joins it. - addGrouped: (args: Op) => { + // Add a text edit that coalesces with adjacent edits sharing the same span + // key (e.g. the block/footnote entityID) within COALESCE_MS. A different + // key — or a lapsed window — starts a fresh group; an open command group + // (groupDepth > 0) just absorbs the edit. + addGrouped: (args: Op, coalesceKey?: string) => { if (groupDepth > 0) { undoManager.add(args); return; } + // A different span finalizes the open group now, independent of the + // timer, so edits to two different spans never merge into one undo step. + if (coalesceOpen && coalesceKey !== currentCoalesceKey) flushCoalesce(); if (!coalesceOpen) { undoManager.startGroup(); coalesceOpen = true; + currentCoalesceKey = coalesceKey; } undoManager.add(args); clearCoalesceTimer(); From 58aa679d67ff3cda19ac26cf1c4ef79486c35577 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 05:10:39 +0000 Subject: [PATCH 9/9] Stop splitting typing runs in undo coalescing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text-edit coalescing was correct, but the group got finalized between keystrokes, so each character became its own undo step: - The
 onBlur handler called undoManager.flushGroup() on every blur, and
  focus churn during the deferred-updateState re-render fired it mid-run.
  Remove the blur flush (and the now-unused flushGroup); the idle timer, command
  groups, span-key changes, and undo/redo already provide every real boundary.
- COALESCE_MS was 200ms — shorter than a normal inter-keystroke gap (typing
  below ~5 cps), so the timer flushed between keystrokes. Raise to 500ms; the
  timer resets each keystroke, so a run now coalesces until an actual pause.

https://claude.ai/code/session_01V4wW9QQpVzduvU5K3BzNGq
---
 components/Blocks/TextBlock/index.tsx |  3 ---
 src/undoManager.ts                    | 11 +++++------
 2 files changed, 5 insertions(+), 9 deletions(-)

diff --git a/components/Blocks/TextBlock/index.tsx b/components/Blocks/TextBlock/index.tsx
index a0f5f71e..64f56758 100644
--- a/components/Blocks/TextBlock/index.tsx
+++ b/components/Blocks/TextBlock/index.tsx
@@ -284,9 +284,6 @@ function BaseTextBlock(props: BlockProps & { className?: string }) {
                 data: { type: "block-type-union", value: "horizontal-rule" },
               });
             }
-            // Finalize any open coalescing text-edit group so the next action
-            // (or undo) starts from a clean boundary.
-            rep.undoManager.flushGroup();
           }}
           onFocus={() => {
             handleMentionOpenChange(false);
diff --git a/src/undoManager.ts b/src/undoManager.ts
index 2451601a..7285d9d0 100644
--- a/src/undoManager.ts
+++ b/src/undoManager.ts
@@ -4,8 +4,11 @@ import { create } from "zustand";
 export type UndoManager = ReturnType;
 export const useUndoState = create(() => ({ canUndo: false, canRedo: false }));
 
-// Consecutive text edits within this window coalesce into one undo step.
-const COALESCE_MS = 200;
+// A run of typing coalesces into one undo step until the user pauses for this
+// long (the timer resets on every keystroke, so only an actual gap between
+// keystroke ends a run). 200ms was short enough that any normal-paced gap
+// (typing slower than ~5 cps) flushed between keystrokes, undoing char by char.
+const COALESCE_MS = 500;
 
 type Op = { undo: () => Promise | void; redo: () => Promise | void };
 
@@ -101,10 +104,6 @@ export const createUndoManager = () => {
       clearCoalesceTimer();
       coalesceTimer = setTimeout(flushCoalesce, COALESCE_MS);
     },
-    // Finalize the open coalescing group immediately (e.g. on blur).
-    flushGroup: () => {
-      flushCoalesce();
-    },
     undo: () => {
       flushCoalesce();
       opChain = opChain.then(() => undoManager.undo()).then(