Skip to content

feat(footnote): word-like footnote interactions (SD-3400)#3696

Draft
tupizz wants to merge 13 commits into
mainfrom
tadeu/sd-3400-feature-footnote-interactions
Draft

feat(footnote): word-like footnote interactions (SD-3400)#3696
tupizz wants to merge 13 commits into
mainfrom
tadeu/sd-3400-feature-footnote-interactions

Conversation

@tupizz

@tupizz tupizz commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Implements SD-3400 Feature: Footnote Interactions: create, navigate, select, edit, and delete footnotes with Word-like behavior from both the body and the footnote area.

Prerequisite fix

Footnotes were silently dropped when the body filled a terminal page: the SD-2656 bodyMaxY-anchored reserve collapses to ~0 on the last page (no continuation target), the planner places nothing, and the body never yields. Reproduced on the ticket's own fixture (0 of 6 footnotes rendered). Fixed with a terminal-page reserve bump mirroring the existing carry-forward bump, guarded so already-placed footnotes are untouched. Footnote tests.docx now renders all 6 notes.

Interactions delivered

  • Staged delete from the body: first Backspace selects the footnote marker, second Backspace deletes it; note removal and renumbering cascade through the existing pipeline. Forward Delete mirrors it. Handles the marker-wrapped-in-its-own-run caret boundaries.
  • Delete from the note area: clearing all note content removes the footnote on both sides (body reference + note element, last-reference gated) and commits immediately when the note is emptied, with no extra click. Freshly inserted empty notes are exempt until they have held content.
  • Insert footnote: FootnoteInsertInput.at is now optional (omitted = insert at the caret), and editor.commands.insertFootnote() inserts an empty note (creating the footnotes part with separators when absent) and moves focus into it. Built for custom toolbars and intentionally not registered in the default toolbar; the dev app header has a demo button. Exposed as plain insertFootnoteAtCursor() for non-PM callers.
  • Double-click navigation: double-clicking a body reference opens its note for editing. Resolves through the elementsFromPoint hit chain because real pointer events land on the selection overlay.
  • Visible affordance and focus feedback: reference markers get a pointer cursor, hover pill, and an enlarged invisible hit halo (the painted digit is ~6x11px). The active note is highlighted at the page bottom (tint + accent bar + activation pulse) while its session is open.
  • Smart scroll: opening a note session scrolls the note into view (no-op when already visible; inserted notes scroll once they paint).
  • Text cursor: note content shows an I-beam instead of the default arrow.

Endnotes share the marker/navigation plumbing (endnoteReference is handled by the same resolvers and CSS), but full endnote interaction coverage stays a stretch item per the ticket.

Architecture

Logic lives outside the ProseMirror extension: the extension keeps schema, NodeView, and a 3-line command shim. New modules:

  • presentation-editor/notes/note-target.ts: single source of truth for note-target parsing (removes duplicated definitions in EditorInputManager and PresentationEditor).
  • presentation-editor/notes/NoteSessionCoordinator.ts: highlight + smart scroll + emptied-note commit, extracted from PresentationEditor and unit-tested in jsdom.
  • pointer-events/note-reference-hit.ts: pure pointer-to-note-target resolver.
  • extensions/footnote/insert-footnote.js: insert orchestration as a plain function.

Tests

  • New unit coverage: dense-page render regression (layout-bridge), staged-delete commands, keymap chain order, double-click resolution incl. overlay path, NoteSessionCoordinator (7 tests), insert wrapper selection fallback, area-delete commit wiring, reference data attributes, footnote CSS.
  • Full suites green: 111 super-editor tests across touched areas, 16 layout-bridge footnote tests, 23 painter style tests. Document API contract gates pass (check:public:docapi).
  • Verified end-to-end in the dev server with real pointer input on the ticket's fixtures.

Deferred (per spike, each gated on confirmation)

  • Cross-reference (REF field) to footnote navigation: needs bookmark-to-note indexing; rare in the corpus.
  • Right-to-left drag selection in notes: suspected, not yet reproduced.
  • Resize/font-change re-measure smoothing: pending a perf trace showing real jank.

tupizz added 13 commits June 9, 2026 14:47
…-3400)

On the last page there is no continuation target, so the SD-2656
bodyMaxY-anchored maxReserve collapses to ~0 once the body fills the page:
the planner can place nothing, reserves[pageIndex] stays 0, the body never
yields, and every anchored footnote is silently dropped (no error). Reproduced
on Footnote tests.docx: 0 of 6 footnotes rendered.

Add a terminal-page reserve bump mirroring the existing carry-forward bump:
when a footnote is anchored on the last page and the placed reserve is short of
its demand, reserve that demand (capped at the physical band) so the next
relayout pass shrinks the body and the footnote renders on its anchor page,
matching Word. Guarded on reserves[pageIndex] < clusterDemand so pages whose
footnote already placed fully are untouched (no gap regression on non-dense
pages or multi-page splits).

Footnote tests.docx now renders all 6 footnotes (grows 1 to 2 pages).
Note content is painted as generic .superdoc-fragment elements marked
contenteditable=false, so the browser showed a default arrow over editable
note text instead of an I-beam. Add a dedicated ensureFootnoteStyles injector
(mirroring the other per-concern ensure*Styles) that sets cursor: text on
fragments whose block-id starts with footnote-/endnote-/__sd_semantic_footnote-
/__sd_semantic_endnote-. Wired into the renderer's one-time style injection.
… (SD-3400)

Double-clicking a footnote/endnote reference marker in the body now opens the
corresponding note. The painted reference is a superscript run carrying
data-pm-start but no note id, so #handleDoubleClick resolves the PM node at that
position; when it is a footnoteReference/endnoteReference it builds the note
target and calls the existing activateRenderedNoteSession, which focuses the
note session and scrolls it into view. Single-click behavior is unchanged.
…-3400)

Add PresentationEditor.activateNoteSession(target): opens a footnote/endnote
note session without a pointer, focusing the note and scrolling it into view
with the caret at the note's start. Makes #activateRenderedNoteSession's click
coords optional so the no-coords path skips hit-testing and lands at note start.

This closes the last gap in the insert-footnote flow: the existing
document.footnotes.insert() API creates the body marker + note entry (and the
notes part if absent) and returns the new noteId; a custom toolbar action then
calls activateNoteSession({ storyType, noteId }) so focus moves into the new
note and the user can type immediately. Insert stays in the document API and
focus stays in the presentation layer; the toolbar action composes the two
(kept off the default toolbar).
Word-like two-step delete from the body. The first Backspace with a collapsed
caret immediately after a footnote/endnote reference selects the marker (a
TextSelection spanning the atom, since footnoteReference is selectable:false);
the second Backspace sees a non-empty selection, so the new command returns
false and the chain falls through to deleteSelection, which removes the marker.
Removal/renumber then cascade through the existing pipeline (the renderer only
paints notes that still have a body reference).

New selectFootnoteMarkerBefore / selectFootnoteMarkerAfter commands wired into
the Backspace chain (after selectInlineSdtBeforeRunStart, before
backspaceAtomBefore) and Delete chain (after selectInlineSdtAfterRunEnd).
footnoteReference is intentionally NOT added to the backspaceAtomBefore
allowlist — staged selection + deleteSelection mirrors the SDT precedent.

Verified end-to-end: 1st press selects marker, 2nd press deletes it; note drops
from the area and remaining notes renumber (6 to 5; {2:1,3:2,4:3,5:4,6:5}).
Clearing all content of a footnote/endnote in the note area now deletes the
whole footnote: commitNoteRuntime detects empty content and calls
footnotesRemoveWrapper, which deletes the body reference node AND removes the
OOXML note element (when no other reference remains). The document then
renumbers through the existing pipeline. This is symmetric with the body-side
staged delete — deleting from either side removes both the marker and the note
(per product decision: remove on both sides), and it avoids the orphaned-ref
state that previously left numbering inconsistent.

Guarded on the body reference still existing so a stale/duplicate commit is a
no-op. Wiring covered by unit tests (mocking the removal boundary, which is
itself covered by footnote-wrappers.test.ts).
…hain (SD-3400)

Two manual-testing regressions:

Staged delete: each reference is wrapped in its own run, so a caret at the
start of the following run (the common position after clicking past the
superscript) saw nodeBefore as the run wrapper, failed the marker check, and
fell through to normal backspace — deleting the previous letter. The boundary
branches now unwrap a neighboring run whose trailing/leading child is a note
reference. Delete-key mirror gets the same treatment.

Double-click navigation: real pointer events land on the selection overlay
above the pages, so closest('[data-pm-start]') on the event target missed the
painted reference and the dblclick did nothing. The resolver now walks the
elementsFromPoint hit chain, mirroring the rendered-note resolver.
…SD-3400)

Make FootnoteInsertInput.at optional: omitting it inserts the reference at the
current selection head, which is what a toolbar action needs (place a marker at
the current cursor location). docapi contract gates pass.

Add editor.commands.insertFootnote(): inserts an empty footnote at the caret
(creating the footnotes part with separators when the document has none) and
activates the new note session so the user can immediately type the note text.
Sets preventDispatch on the chain transaction because the document API
dispatches its own compound transactions. Intentionally not registered in the
default toolbar (per SD-3400) — any custom toolbar action can call it.
…delete (SD-3400)

Three UX gaps from manual testing:

Clickability: painted body reference markers now carry data-note-reference /
data-note-id (stamped in buildReferenceMarkerRun, covers endnotes too) and get
a pointer cursor plus a hover pill, signalling that the number is interactive.

Focus feedback: while a note session is open, the note's fragments at the page
bottom get the sd-note-session-active highlight (tint + accent bar + one-time
pulse). Applied on activation, re-applied after every paint (fragments are
rebuilt), removed on exit, and self-healing when the session ends through any
path. Paint-only - no layout impact.

Instant area-delete: clearing all content of a note that previously had
content now auto-exits the session, which commits the both-sides removal
immediately - no click back into the document required. Freshly inserted
empty notes are exempt until they have held content, so insert-and-type is
unaffected.
Lighter tint (0.12 to 0.07 alpha), thinner accent bar (2px to 1px) pushed 3px
away from the note line via a masked box-shadow pair, gentler activation pulse.
Feedback from manual review: the previous bar read too heavy next to the text.
…n (SD-3400)

The painted reference digit is ~6x11px, which made the hover affordance and
double-click navigation nearly impossible to acquire with a real mouse (the
handler itself is robust — verified with realistic per-event sequences). An
invisible ::after halo expands the interactive target to roughly 16x19px:
hover, pointer cursor, and double-click all hit the marker span, with no text
movement (pseudo-element is absolutely positioned off a position:relative
span).

Also wire an Insert footnote button into the dev app header as the demo of the
custom-toolbar action: it calls editor.commands.insertFootnote(), which inserts
at the caret and focuses the new note. The default product toolbar remains
untouched per SD-3400.
…-3400)

Opening a note session now brings the note into view. The scroll is smart:
no-op when the note's fragment is already fully visible, otherwise it
smooth-centers the fragment in the scroll container. Double-clicked notes are
already painted and scroll immediately; freshly inserted notes only paint after
the post-insert relayout, so the request stays pending and completes from the
layoutUpdated hook once the fragment exists. Cleared on session exit.

Verified live: double-click with the note band off-screen scrolls 0 to 490 with
the note fully visible; toolbar insert scrolls 0 to 751 onto the new note.
Behavior-preserving modularization of the SD-3400 footnote interaction work,
gated by the existing suites (111 super-editor tests + 16 layout-bridge
footnote tests) and a browser smoke pass.

- notes/note-target.ts: single source of truth for RenderedNoteTarget,
  parseRenderedNoteTarget, isSameRenderedNoteTarget, and the block-id prefix
  mapping. Removes the duplicated definitions in EditorInputManager and
  PresentationEditor.
- notes/NoteSessionCoordinator.ts: extracts the active-note UX (highlight,
  smart scroll, emptied-note commit) out of PresentationEditor into a small
  collaborator with injected deps, following the dom/ coordinator precedent.
  PresentationEditor delegates via onActivated/onPaint/onExit; the logic is
  now unit-tested in jsdom (7 tests) instead of browser-only.
- pointer-events/note-reference-hit.ts: pure resolver for double-clicked body
  reference markers (closest + elementsFromPoint walk); EditorInputManager
  keeps a thin delegating method.
- extensions/footnote/insert-footnote.js: insertFootnoteAtCursor as a plain
  importable function; the PM command is now a 3-line shim, keeping the
  extension as adapter (schema, NodeView, command registration) with logic in
  modules. Covered by its own tests.
- incrementalLayout.ts: dedupe the cluster-demand and band-cap computations
  shared by the carry-forward and terminal-page reserve bumps
  (clusterDemandFor/maxBandFor).
- note-story-runtime.ts: split commitNoteRuntime into removeEmptiedNote /
  commitRichNoteContent / commitPlainTextNoteContent.
@linear-code

linear-code Bot commented Jun 9, 2026

Copy link
Copy Markdown

SD-3400

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants