Skip to content

feat: editable footnotes (re-land #995)#1007

Open
jacobjove wants to merge 14 commits into
eigenpal:mainfrom
jacobjove:reland/editable-footnotes
Open

feat: editable footnotes (re-land #995)#1007
jacobjove wants to merge 14 commits into
eigenpal:mainfrom
jacobjove:reland/editable-footnotes

Conversation

@jacobjove

@jacobjove jacobjove commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Re-lands the editable-footnotes feature. It merged as #995 but dropped out of main during the chore: release (#983) rebase (the useDocxEditor.ts max-lines cap from #996 stayed, but the feature commits did not). This branch is the same work rebased onto current main.

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.ts lands at 1003 lines, under the 1060 cap already on main, so lint stays green. API snapshots regenerated against current main.


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.

Jacob Jove and others added 13 commits June 23, 2026 12:26
… 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>
@vercel

vercel Bot commented Jun 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docx-editor Ready Ready Preview, Comment Jun 24, 2026 3:32pm

Request Review

@jacobjove

Copy link
Copy Markdown
Contributor Author

@jedrazb heads-up: this re-lands #995, which you merged on 2026-06-23 but which then dropped out of main during the chore: release (#983) rebase. The useDocxEditor.ts max-lines bump you added in #996 to keep it survived, but the feature commits themselves did not — there's no revert or follow-up commit, so it looks like an accidental rebase/force-push casualty rather than an intentional removal. This branch is the identical work rebased cleanly onto current main (no conflicts, snapshots regenerated). Flagging in case your release reflog shows what happened.

@eigenpal-release-pal

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅

Posted by the CLA bot.

@greptile-apps

greptile-apps Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Re-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 EditorView per active footnote, with the painter reading the live PM doc via a getFootnotePmDoc seam on computeLayout.

  • Core changes: footnoteOverlay.ts (caret + selection rect helpers scoped per-container to avoid footnote PM position collisions), noteSerializer.ts (re-inserts the auto-number ref mark that the body parser doesn't model), footnoteLayout.ts + computeLayout.ts (getFootnotePmDoc live-doc seam).
  • React: HiddenFootnotePM, useFootnoteEditState, updated usePagesPointer (footnote surface routing alongside HF), DocxEditorPagedArea overlay wiring, PagedEditor integration.
  • Vue parity: useFootnotePM, useFootnoteOverlay, FootnoteOverlay.vue, usePagesPointer routing; the Vue caret placement intentionally stays at coarse span-snap (a precise mid-span variant exposed a multi-char typing scatter and is deferred).

Confidence Score: 3/5

Safe 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.

Important Files Changed

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
Loading
%%{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
Loading

Reviews (1): Last reviewed commit: "fix(footnotes): stop toolbar flicker whe..." | Re-trigger Greptile

Comment thread packages/vue/src/composables/usePagesPointer.ts
Comment thread packages/react/src/components/DocxEditor/hooks/usePagesPointer.ts Outdated
…-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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant