feat(footnote): word-like footnote interactions (SD-3400)#3696
Draft
tupizz wants to merge 13 commits into
Draft
Conversation
…-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.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.docxnow renders all 6 notes.Interactions delivered
FootnoteInsertInput.atis now optional (omitted = insert at the caret), andeditor.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 plaininsertFootnoteAtCursor()for non-PM callers.Endnotes share the marker/navigation plumbing (
endnoteReferenceis 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
check:public:docapi).Deferred (per spike, each gated on confirmation)