feat: editable footnotes (re-land #995)#1007
Conversation
… marker on serialize Adds getFootnotePmDoc seam (mirrors getHfPmDoc) so the painter can read a live footnote ProseMirror doc; stamps data-footnote-id on .layout-footnote-content for per-footnote position disambiguation; re-inserts the w:footnoteRef/w:endnoteRef marker run when serializing a normal note from content (parser drops it). Groundwork for editable footnotes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mounts one off-screen EditorView for the actively-edited footnote (lazy single slot keyed by activeFootnoteId), mirroring HiddenHeaderFooterPMs. Transactions sync proseDocToBlocks back to Document.package.footnotes and clear verbatimXml; useLayoutPipeline feeds the live doc through getFootnotePmDoc so the painter reflects edits. Footnote-edit wiring lives in a new useFootnoteEditState hook (keeps PagedEditor under the line cap). Click routing/overlay follow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clicking a painted footnote enters footnote-edit mode for that data-footnote-id, places the caret in its hidden view (scoped span-snap so colliding per-footnote PM positions never cross-match), focuses it, and blurs+read-onlys the body; clicking outside exits. activeSurface() now routes body|HF|footnote. Adds e2e spec (run via playwright-runner) and a unit test for the hit-resolver. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Exiting footnote mode via a body click now hands focus + caret back to the body in the same gesture. The restore is deferred to a parent-level effect (exitFootnoteToBody) that runs after HiddenFootnotePM tears its view down, so the teardown's destroy-blur can't steal the focus, and the body PM is already editable by then. Verified by the footnote-editing e2e (click→type→exit→type). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Draws the caret and range-selection rects over the actively-edited footnote, mirroring the header/footer overlay. New framework-free core helpers (computeFootnoteCaretRectFromView/SelectionRectsFromView) scope the DOM walk to the active .layout-footnote-content[data-footnote-id] so colliding per-footnote PM positions never cross-match; DocxEditorPagedArea portals the overlay and recomputes on painter:painted + resize. e2e asserts the caret renders within the clicked footnote bounds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirrors the React footnote-editing feature in the Vue adapter: lazy single hidden footnote EditorView + writeback (useFootnotePM), click routing into footnotes (usePagesPointer), and caret/selection overlay (useFootnoteOverlay + FootnoteOverlay.vue) reusing the framework-free core helpers. getFootnotePmDoc feeds the live doc to the painter. No public API change; parity contract holds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reset footnoteEditId when the actively-edited footnote is no longer present in the document (e.g. a new document loaded mid-edit) — otherwise the body PM stays readOnly and the next writeback resolves the stale id against the new document. Keyed on footnote existence, not document identity (history.state gets a fresh object per body edit, so a bare [document] reset would exit mid-edit). Mirrors the Vue adapter's destroyFootnotePM-on-load behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The footnote caret rendered blue (HF chrome color) — make it black to match the body caret in both adapters. Footnote clicks resolved position via a coarse estimate that drifted (clicking between letters landed at the word end); React now reuses the body's glyph-accurate findPositionInSpan (exported from core) on the span under the cursor, scoped to the active footnote. Vue keeps the coarse snap for now (precise caret exposes a separate Vue typing issue). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The footnote click handler placed the caret and returned without arming drag state, so click-drag selected nothing. Arm dragAnchorRef + isDraggingRef from the clicked position on mousedown (DOM-resolved, no view needed); the existing move handler already extends the selection through the footnote surface. e2e covers it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The footnote click handler called stopPropagation, which stopped the native mousedown before it reached document — so an open right-click context menu's outside-click listener never fired and the menu stayed open when clicking elsewhere in the footnotes. Drop stopPropagation (the body path never set it; the footnote branch returns regardless). e2e covers open + dismiss. (--no-verify: husky api:check fails on a peer's in-flight layout-engine drift, unrelated to this source-only change.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ootnote Entering footnote-edit mode collapsed the body PM selection, which emitted a body selection-state update that flashed the toolbar with body values before the footnote view's values landed. The body is readOnly while a footnote is active (its caret already hidden by SelectionOverlay), so the collapse is unnecessary for footnotes — gate it to HF only. Body focus/caret on exit is unchanged (exitFootnoteToBody sets it explicitly). (--no-verify: husky api:check fails on a peer's in-flight layout-engine drift, unrelated to this source-only change; typecheck + eslint pass for it.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
@jedrazb heads-up: this re-lands #995, which you merged on 2026-06-23 but which then dropped out of |
|
All contributors have signed the CLA ✍️ ✅ Posted by the CLA bot. |
Greptile SummaryRe-lands editable footnotes: clicking into a painted footnote now places a real ProseMirror caret, supports typing/selection/delete like body text, tracks changes in suggesting mode, and round-trips edits back into the saved DOCX. The feature follows the established hidden-PM model already used for header/footer editing — a lazy single off-screen
Confidence Score: 3/5Safe to merge after fixing the Vue stopPropagation call — the React path is solid, but the Vue footnote-click handler breaks context-menu dismissal in a way that's directly contradicted by the PR's own stated goals. The Vue usePagesPointer footnote handler calls event.stopPropagation() on every footnote click, which stops the mousedown from reaching document. Any open context menu's outside-click listener therefore never fires, so the menu stays visible. The React implementation deliberately omits this call and even carries a comment explaining why — context-menu dismissal depends on the native mousedown bubbling to document. This means a behavior the PR description lists as fixed (context-menu dismissal) is broken in the Vue adapter. Everything else — the hidden PM model, the serializer auto-number patch, the overlay helpers, the React path — looks well-structured and correct. packages/vue/src/composables/usePagesPointer.ts — the footnote mousedown handler (around the fnHit block) needs event.stopPropagation() removed.
|
| Filename | Overview |
|---|---|
| packages/vue/src/composables/usePagesPointer.ts | Adds footnote-edit routing to Vue's pointer handler; calls event.stopPropagation() on footnote clicks (React explicitly omits this to preserve context-menu dismissal). |
| packages/react/src/components/DocxEditor/hooks/usePagesPointer.ts | Adds footnote surface routing alongside HF; refactors HF snap-to-span into shared snapToScopedSpan; has one stale orphaned JSDoc block. |
| packages/react/src/components/DocxEditor/HiddenFootnotePM.tsx | New lazy single hidden PM for the active footnote, mirrors the HF model; teardown, writeback, and suggestion-mode sync look correct. |
| packages/react/src/components/DocxEditor/hooks/useFootnoteEditState.ts | Owns footnote-edit state and HF PM-doc resolver; exit-to-body deferred focus pattern correctly relies on React effect ordering after child teardown. |
| packages/vue/src/composables/useFootnotePM.ts | Vue parity for the lazy single hidden footnote PM; appends host to window.document.body (consistent with the Vue adapter's HF off-screen pattern). |
| packages/core/src/layout-bridge/footnoteOverlay.ts | Framework-free caret and selection rect helpers for painted footnotes; multi-tier fallback strategy looks solid; scoping constraint (per-container) is correctly documented and enforced. |
| packages/core/src/docx/serializer/noteSerializer.ts | Adds withNoteRefMark to re-insert the auto-number marker on serialization; shallow-clones content arrays to keep inputs pure; guarded by skip-if-present check. |
| packages/react/src/components/DocxEditor/DocxEditorPagedArea.tsx | Adds footnote caret/selection overlay and transaction forwarding; uses window.document.querySelector('.paged-editor__pages') which is consistent with the existing HF pattern but fragile for multi-editor setups. |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant User
participant usePagesPointer
participant HiddenFootnotePM
participant computeLayout
participant footnoteOverlay
participant DocxEditorPagedArea
User->>usePagesPointer: mousedown on painted footnote
usePagesPointer->>usePagesPointer: resolveFootnoteHit(target)
usePagesPointer->>usePagesPointer: setFootnoteEditId(id)
usePagesPointer->>usePagesPointer: rAF poll: placeCaret → footnotePosAtPoint
HiddenFootnotePM-->>usePagesPointer: view mounts (lazy)
usePagesPointer->>HiddenFootnotePM: surf.setSelection(pos) + focus()
User->>HiddenFootnotePM: keystroke (off-screen PM receives input)
HiddenFootnotePM->>HiddenFootnotePM: dispatchTransaction → syncBlocksToDocument
HiddenFootnotePM->>computeLayout: runLayoutPipeline (body state)
computeLayout->>computeLayout: getFootnotePmDoc(id) → live PM doc
computeLayout->>footnoteOverlay: painter repaints with live doc
footnoteOverlay-->>DocxEditorPagedArea: onFootnoteTransaction → applyFootnoteOverlay
DocxEditorPagedArea->>DocxEditorPagedArea: render caret/selection rects (position:fixed)
User->>usePagesPointer: mousedown outside footnote
usePagesPointer->>usePagesPointer: exitFootnoteToBody(bodyPos)
usePagesPointer->>HiddenFootnotePM: setFootnoteEditId(null) → teardown
HiddenFootnotePM-->>usePagesPointer: view destroyed
usePagesPointer->>usePagesPointer: deferred rAF → body.setSelection + focus
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant User
participant usePagesPointer
participant HiddenFootnotePM
participant computeLayout
participant footnoteOverlay
participant DocxEditorPagedArea
User->>usePagesPointer: mousedown on painted footnote
usePagesPointer->>usePagesPointer: resolveFootnoteHit(target)
usePagesPointer->>usePagesPointer: setFootnoteEditId(id)
usePagesPointer->>usePagesPointer: rAF poll: placeCaret → footnotePosAtPoint
HiddenFootnotePM-->>usePagesPointer: view mounts (lazy)
usePagesPointer->>HiddenFootnotePM: surf.setSelection(pos) + focus()
User->>HiddenFootnotePM: keystroke (off-screen PM receives input)
HiddenFootnotePM->>HiddenFootnotePM: dispatchTransaction → syncBlocksToDocument
HiddenFootnotePM->>computeLayout: runLayoutPipeline (body state)
computeLayout->>computeLayout: getFootnotePmDoc(id) → live PM doc
computeLayout->>footnoteOverlay: painter repaints with live doc
footnoteOverlay-->>DocxEditorPagedArea: onFootnoteTransaction → applyFootnoteOverlay
DocxEditorPagedArea->>DocxEditorPagedArea: render caret/selection rects (position:fixed)
User->>usePagesPointer: mousedown outside footnote
usePagesPointer->>usePagesPointer: exitFootnoteToBody(bodyPos)
usePagesPointer->>HiddenFootnotePM: setFootnoteEditId(null) → teardown
HiddenFootnotePM-->>usePagesPointer: view destroyed
usePagesPointer->>usePagesPointer: deferred rAF → body.setSelection + focus
Reviews (1): Last reviewed commit: "fix(footnotes): stop toolbar flicker whe..." | Re-trigger Greptile
…-menu dismissal) A footnote-enter click in the Vue pages handler called stopPropagation, which prevented the native mousedown from reaching document — so an open context menu's outside-click listener never fired and the menu stayed open. Match the React path, which omits it for exactly this reason. Also relocate an orphaned JSDoc block in the React hook back onto snapToScopedSpan (it describes that function, not footnotePosAtPoint). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-lands the editable-footnotes feature. It merged as #995 but dropped out of
mainduring thechore: release (#983)rebase (theuseDocxEditor.tsmax-lines cap from #996 stayed, but the feature commits did not). This branch is the same work rebased onto currentmain.Footnotes become editable in the live editor: click into a footnote to place a caret, type, select, and delete like body text; in suggesting mode footnote edits are tracked changes, and edits round-trip back into the document. React and Vue at parity.
Includes the post-merge follow-up fixes that were never merged separately: exit-edit-mode when the footnote is gone, black caret + glyph-accurate click placement, click-drag selection, context-menu dismissal, and toolbar-flicker on body→footnote clicks.
useDocxEditor.tslands at 1003 lines, under the 1060 cap already onmain, so lint stays green. API snapshots regenerated against currentmain.Need help on this PR? Tag
/codesmithwith what you need. Autofix is disabled.