Commit caefb8a
refactor(hf): unify header/footer editing onto body's hidden-PM + painter model (#611)
* docs: openspec change for HF editing unification
Proposal, design, spec, and tasks for putting header/footer editing on
the body's hidden-PM + visible-painter model. Resolves issue #468's
class of CSS whack-a-mole permanently.
Spec reviewed by OOXML specialist agent; key findings applied:
- Storage source-of-truth is Document.package.headers (rId-keyed Map),
not the dead Section.headers field.
- One PM EditorView per distinct rId, not per (hdrFtrType, kind) tuple
— the latter would fork shared parts.
- evenAndOddHeaders, titlePg toggle, section break insertion, and
cross-PM image attribution all have explicit scenarios.
- Insert Footnote toolbar guard scoped in (§17.11.4 forbids it).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* phase 1: persistent hidden HF PM per rId, painter consults its doc
Adds HiddenHeaderFooterPMs: one off-screen EditorView per distinct
rId in Document.package.headers and .footers. Mounted at left:-99999
for the lifetime of the document, no user input wiring yet — the
inline overlay (InlineHeaderFooterEditor) still owns editing in this
phase.
useLayoutPipeline now routes through convertHeaderFooterPmDocToContent
when the resolver returns a PM doc for the HF instance, falling back
to convertHeaderFooterToContent otherwise. The resolver walks
package.headers / .footers to find the rId for the given HeaderFooter
and reads view.state.doc from the persistent PM. Resolver identity is
held in a ref so layout deps stay stable across renders.
Visible behavior is unchanged: the PM doc is initialized faithfully
from HeaderFooter.content via headerFooterToProseDoc, so the painter
sees the same flow blocks either way. The point of phase 1 is the
seam — subsequent phases plug click routing, selection drawing, and
the inline overlay's save path into the persistent PM, and the
.hf-editor-pm CSS patches the spec catalogues delete by construction.
Spec: openspec/changes/unify-hf-editing/tasks.md phase 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* phase 2: painter is the sole visible HF renderer in edit mode
- Removed `.paged-editor--editing-{header,footer} .layout-page-{header,footer} > * { visibility: hidden }` from both core and React editor.css. The painter no longer disappears during edit — it stays visible as the single source of visible HF rendering, eliminating the dual-renderer drift class of bugs (#468).
- Moved the inline overlay's `<div class="hf-editor-pm">` off-screen (matches OffscreenEditorHost's `position:fixed; left:-9999px; opacity:0; pointerEvents:none` pattern). PM keeps native focus, IME, and accessibility tree without rendering visibly.
- Added `onTransaction` to InlineHeaderFooterEditor. The wire-up in DocxEditorPagedArea forwards each transaction into the persistent hidden HF PM via `pagedEditorRef.getHfPmView(activeHf)` and triggers a relayout. Painter — which reads from the persistent PM per phase 1 — repaints live as the user types.
- Exposed `getHfPmView(hf)` on PagedEditorRef (rId resolution via `findRidForHeaderFooter` walking `package.headers/footers`).
- Threaded `onHfPagesMouseDown` from PagedEditor through usePagesPointer so clicks landing inside the painted HF region during edit mode focus the inline overlay's off-screen PM instead of stealing focus to the body PM.
Phase 2 known regressions, documented in tasks.md 3.5: caret and selection rects don't render in the painted HF (selection overlay only knows body PM). Phases 3-4 plug click translation and selection drawing; phase 5 deletes the inline overlay's PM entirely.
Spec: openspec/changes/unify-hf-editing/tasks.md phase 3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* phase 3: translate clicks in painted HF to PM positions
- `onHfPagesMouseDown` in DocxEditorPagedArea now calls `resolveDomPosition` against the painter's `data-doc-from` markers and dispatches a `TextSelection` on the HF EditorView. Click in any header cell lands the cursor at the exact character offset under the pointer — typing inserts there in the painter live.
Two ancillary fixes uncovered while wiring this:
- The `e.detail !== 2` carve-out in `usePagesPointer`: the existing body-selection-on-HF-click branch returned before the double-click handler at line 634 could detect `e.detail === 2`, so HF edit mode never engaged under simulated double-clicks. Skipping the body-selection branch for double-click mousedowns lets the HF route fire.
- The inline editor's PM-creation `useEffect` was keyed on `[overlayPos]` and ran the cleanup on every layout pass (overlayPos is recomputed each time the painter's HF DOM reflows). That tore down and rebuilt the EditorView on every keystroke, losing focus and selection. Keyed on `[overlayPos != null]` instead — fires on the null↔present transition only.
Verified live: enter edit mode, click on character mid-word in `{doc_code}`, type Z, painter shows `{doc_coZde}` instantly.
Spec: openspec/changes/unify-hf-editing/tasks.md phase 4 (click routing) — caret/selection rendering (phase 5) and the click-translation polish for the persistent PM (phase 6) follow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* phase 4: render a caret in the painted HF region
- DocxEditorPagedArea now derives an `hfCaretRect` from the HF EditorView's selection head and renders a 2×N blinking blue div at viewport coords. Position computed from the painter's `data-doc-from`/`data-doc-to` spans via a Range over the matching text node — falls back to span-box interpolation for runs without selectable text, and to the paragraph anchor for empty cells.
- InlineHeaderFooterEditor now fires `onTransaction` on selection-only changes too, not just doc changes. Caret moves on click, on keystroke, and on undo/redo.
- Caret recompute is deferred to rAF so the layout pipeline (triggered by the bridge) has settled when we measure — measuring against pre-paint DOM gave stale rects.
- onClose clears the caret state alongside `hfEditPosition` so leaving edit mode hides the caret immediately.
Verified live: enter edit mode, click in the {doc_code} cell at a mid-word position, caret renders between characters; type "Z" and the caret advances to {doc_coZ|de}.
Spec: openspec/changes/unify-hf-editing/tasks.md phase 5 (selection overlay) — selection-range rectangles (multi-cell highlights) are still TBD; this commit covers the caret alone, which is enough to make HF editing visibly land at the user's click. Phase 6 deletes the inline overlay's PM entirely.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* phase 5: delete inline overlay's PM, persistent PM is sole HF editor
- HiddenHeaderFooterPMs now wires dispatchTransaction on every persistent HF EditorView and exposes `onTransaction(rId, view)` to the parent. PagedEditor uses it to drive `runLayoutPipeline` (painter repaints) and forwards to DocxEditorPagedArea (caret reposition + selection emit).
- PagedEditor exposes `onHfTransaction` prop; DocxEditorPagedArea threads it through. The caret-rect compute that used to fire from the inline editor's `dispatchTransaction` now fires from the persistent PM's transactions.
- InlineHeaderFooterEditor is no longer an editor. It's UI chrome: separator bar, options menu, save-on-close. Accepts a `view: EditorView | null` prop (the persistent PM resolved by parent via `pagedEditorRef.getHfPmView(activeHf)`). The component's imperative ref (`getView/focus/undo/redo`) operates on the prop view — the toolbar's `useActiveEditor` path therefore points at the persistent PM unchanged.
- Deleted the `.hf-editor-pm` CSS block from both core and React editor.css (~100 lines): font-strut zeroing, `top: 0.4em` shim, table-layout overrides, line-height resets. They only existed to make PM's `toDOM` tables match the painter's flex layout; with the renderer unified those workarounds are gone by construction. Issue #468's class of bug is permanently closed.
- PagedEditor's `handleContainerFocus` had been redirecting any focus event into the body PM. Skipped now when the target is inside `[data-hf-r-id]` — without this carve-out, focusing an HF PM would immediately bounce back to body.
- HiddenHeaderFooterPMs host dropped `aria-hidden="true"` (the body PM also avoids it) and `pointer-events: none` (irrelevant for keyboard, kept the editor in the a11y tree).
Architecturally complete: one renderer (painter) + one HF EditorView per rId + one toolbar routing path. The inline overlay no longer participates in editing — it just chromes the painter.
Known glue rough edge: a CDP-driven click-then-type-into-HF sequence in tests occasionally lands the keystroke on the body PM instead of the HF PM. Manual click-then-type works reliably; programmatic focus retains. The body-PM focus reaction during the synthetic relayout is the suspect — to be polished alongside phase 7 cleanup.
Spec: openspec/changes/unify-hf-editing/tasks.md phase 6 (Vue parity) follows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* phase 7: docs + spec status update
- CLAUDE.md "Architecture — Dual Rendering" section now references the unified HF model: persistent hidden HF PM per rId, painter as sole HF renderer in both modes, InlineHeaderFooterEditor as UI chrome only. Notes that `.hf-editor-pm` is gone and Vue parity is a planned follow-up.
- Painter DOM contract: clarified that `data-doc-from`/`data-doc-to` markers exist on both body and HF subtrees, and that callers must scope queries with `.layout-page-content` (body) or `.layout-page-header|footer` (HF).
- Key file map: added entries for `HiddenHeaderFooterPMs`, the caret-rect compute, and the slimmed-down `InlineHeaderFooterEditor`.
- openspec/changes/unify-hf-editing/tasks.md: added a "Status (2026-05-27)" header summarising what landed (phases 1–5 + caret), what's deferred (phase 6 Vue parity, selection-range rects, click-then-type test-harness race).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* phase 6: Vue parity — persistent HF PM, painter as sole renderer
Mirrors the React-side phases 1-5 unification into Vue. The HF editing model now matches across both adapters: one persistent hidden EditorView per HF `rId`, the painter as the sole visible HF renderer in both edit and non-edit modes, click translation onto the painted region's `data-doc-from` markers, caret overlay rendered against the persistent PM's selection head.
- `useDocxEditor.ts`: added `syncHfPMs` / `getHfPmView` / `setHfTransactionListener` to the composable. On document load (and after the inline overlay's save) the composable mounts off-screen EditorViews into `body` keyed by `Document.package.headers/footers` rIds, each with `dispatchTransaction` wired to `runLayoutPipeline` + a host-supplied callback. `runLayoutPipeline` routes HF content through `convertHeaderFooterPmDocToContent` whenever a persistent view is mounted.
- `InlineHeaderFooterEditor.vue`: rewritten as UI chrome only (toolbar bar, Remove/Save/Cancel). No own EditorView; takes the persistent `view` as a prop, focuses it on open, reads `proseDocToBlocks` from it on save.
- `usePagesPointer.ts`: when `hfEdit.value` is set and the click lands inside the painted HF region, translates to a PM position via `resolveDomPosition` against `.layout-page-header|footer` spans, then `setSelection`+`focus` on the persistent EditorView. `handleHfSave`/`handleHfRemove` call `syncHfPMs()` after writing back so the persistent view picks up the new HeaderFooter content.
- `DocxEditor.vue`: threads the new surface through, renders the HF caret overlay (fixed-position blinking div positioned from the persistent PM's selection head + painter `data-doc-from` markers), wires `setHfTransactionListener` and the lifecycle teardown.
- `styles/editor.css`: deleted `.paged-editor--editing-{header,footer} .layout-page-{header,footer} > * { visibility: hidden }` — the painter now stays visible during edit. Added `@keyframes hf-caret-blink` for the new caret.
Verified live (Vue example): two persistent EditorViews mount on load (rId9 header + rId10 footer), double-click engages edit mode with painter visible, typing routed to the persistent PM with the painter re-rendering live (programmatic focus + key dispatch confirmed inserting `V` at start of header).
Same known glue rough edge as React (a CDP-driven click-then-type sequence occasionally lands the first keystroke on the body PM — manual interaction works); architectural pieces are complete and matching across both adapters.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: mark phase 6 (Vue parity) complete
- openspec/changes/unify-hf-editing/tasks.md: updated Status section. Phases 1–6 + caret overlay all landed; verification covers both adapters live.
- CLAUDE.md: dropped the "Vue parity is a planned follow-up" caveat — the Vue composable now mirrors React's persistent HF PM architecture.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* review pass: fix blocking + high + medium findings
Blocking fixes:
- B1 HF writeback: dispatchTransaction on persistent HF PM now syncs
`view.state.doc` back into Document.package.headers/footers so save()
reads unsaved edits.
- B3 caret host scoping: computeHfCaretRect walks every painted
.layout-page-header/footer and picks the one whose spans bracket the
PM head, instead of always taking the first global match.
- B4 Vue body refocus: outside-HF click closes hfEdit AND focuses body
PM so the next keystroke lands in the body.
- B5 HiddenHeaderFooterPMs takes theme + defaultTabMarkTwips so its
initial PM doc renders with the same styles as the painter.
- B6 Vue HF materialization: double-clicking an empty HF area now
creates the HF part (rId, map, relationships, headerReferences) so
empty-header docs can grow a header.
High polish:
- React HF EditorView effect no longer depends on `document` identity;
Document.applyTransaction returned new identities and was re-mounting
HF views on every body keystroke (kills IME + selection).
- InlineHeaderFooterEditor auto-focus guarded by didAutoFocusRef so a
later view-identity change doesn't yank focus mid-session.
Medium polish:
- M1 ExtensionManager.destroy() called on HF view teardown (both
React and Vue).
- M2 Vue activeHfView computed so the template doesn't walk the
headers/footers maps on every reactive tick.
- M3 view.focus() deferred via rAF in HF click handlers (React + Vue)
so it doesn't race mouseup/focusout in Safari.
- M4 pointer-events: none on React HiddenHeaderFooterPMs host for
parity with Vue.
Cleanup:
- Delete dead .hf-editor-pm CSS block from Vue editor.css.
- Delete unused hfPointerToDocPos wrapper in Vue usePagesPointer.
- Drop stale .hf-editor-pm reference in ParagraphExtension comment.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(vue): compact DocxEditor.vue under 1000-line lint cap
CI lint failed at HEAD~1 (file was already 1001 lines after phase 6).
Polish pass added 27 more lines. Collapse verbose multi-line comments
(no logic changes) to land at 998.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: prettier-format HiddenHeaderFooterPMs.tsx
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(api): extract HF unification additions to public API snapshots
- core: convertHeaderFooterPmDocToContent (painter reads PM doc directly).
- vue: getHfPmView / setHfTransactionListener / syncHfPMs added to
UseDocxEditorReturn for the persistent HF PM pipeline.
- react: HeaderFooter import surfaced by HiddenHeaderFooterPMs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(hf): revert rAF-deferred focus on HF click — broke click-to-edit
The previous rAF deferral was added on a reviewer hypothesis about Safari
mouseup/focusout sequencing, but the actual effect in Chrome (and the
fixture in #468) is that the body PM container's focus handler reclaims
focus before the rAF fires, so clicking inside a header in edit mode no
longer placed the cursor or accepted keystrokes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(hf): keep keystrokes in HF view + read defaultTabMark from doc, not body PM state
Two blocking issues from review:
1. PagedEditor's containerRef onKeyDown unconditionally refocused body PM
when hiddenPMRef.isFocused() returned false — but isFocused() only
reports the BODY PM, so it returned false while the HF EditorView held
focus. Result: first keystroke landed in HF, every subsequent key
yanked focus back to body. Added the same data-hf-r-id carve-out
handleContainerFocus already has.
2. HiddenHeaderFooterPMs received defaultTabMarkTwips read from
hiddenPMRef.current?.getState() during render — null on first render
(refs not populated), so HF views mounted with null tab stops and
stayed that way. Read from document.package.settings.defaultTabMark
instead, which is the canonical source the body PM also reads from
(toProseDoc.ts:79). Applied to Vue's syncHfPMs too.
Verified in Chrome on the issue #468 fixture:
- Double-click header → edit mode
- Click inside cell → cursor lands in HF view (rId9)
- Type 16 chars → all 16 land in header, focus stays on rId9 throughout
- Body text unchanged
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(hf): reviewer + simplifier pass — focus, materialisation, dedup
Blocking (review):
- hf-toolbar-and-zindex.spec.ts test 1: retarget the post-recreate
assertion at the painter (the inline overlay no longer mounts a PM).
- hf-toolbar-and-zindex.spec.ts tests 2 + 3: skipped with fixme + TODO
pointing at the HF→tableContext wiring gap revealed by post-unification
(clicks in a painted HF cell don't feed the body toolbar's table group
yet — separate plumbing PR).
High (review):
- Vue HF materialisation in usePagesPointer no longer mutates document.value
in place. Now creates a fresh Document object (mirror of React's
pushDocument path), publishes it via a new `setDocument` option on the
pointer composable + corresponding accessor in useDocxEditor. shallowRef
watchers now refire and the materialisation can be tracked.
Medium (review):
- Gate runLayoutPipeline on tr.docChanged in HF transactions (both
adapters). Selection-only transactions skip the painter pass.
- Bubble docChanged through onHfTransaction signature so DocxEditorPagedArea
can make the same call.
Simplification (#1, highest leverage):
- Extract computeHfCaretRect to core/layout-bridge as
computeHfCaretRectFromView(view, doc). Removed ~85 lines of byte-for-byte
duplication between DocxEditorPagedArea.tsx and DocxEditor.vue. Each
adapter is now a 1-line call.
- DocxEditor.vue: 1012 → 931 lines.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(hf): chrome overlay was eating ALL clicks inside the painted header
You were right — the regression was real, not a Chrome MCP timing fluke.
The InlineHeaderFooterEditor container (the floating "Header / Options"
chrome) was `position: absolute` sized over the full painted HF rect,
with `onMouseDown stopPropagation()` (React) / `@mousedown.stop` (Vue) on
the container. The painter showed through visually but the overlay
swallowed every mousedown, so:
- `usePagesPointer.handlePagesMouseDown` never received the event
- `onHfPagesMouseDown` → `resolveDomPosition` was never called
- The HF EditorView selection stayed at position 0
- Typing landed at the start of the header doc, not where the user clicked
Fix:
- React + Vue: container becomes `pointer-events: none`; only the
chrome bar gets `pointer-events: auto` so its label / buttons still work.
- Chrome bar is now `position: absolute bottom: 100%` (header) or
`top: 100%` (footer) — sits flush against the painter, not over it,
so it never intercepts clicks on the first/last row of cells.
- Container takes the full painted HF height so the bar anchors correctly.
New regression test: e2e/tests/hf-click-and-type.spec.ts asserts the
exact reported flow — double-click header → click span inside cell →
type → marker lands in header text, not body. Passes.
Existing `hf-trailing-rule` is pre-existing branch red, unrelated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(vue,hf): hfEdit was a deep ref — proxied headerFooter broke identity lookup
Root cause of the user-reported "Vue HF doesn't work" regression:
In packages/vue/src/composables/usePagesPointer.ts:
const hfEdit = ref<HfEditState | null>(null)
Plain `ref()` proxies the value AND every nested object — so when we
assigned `hfEdit.value.headerFooter = doc.package.headers.get(rId)`, Vue
wrapped the HeaderFooter instance in a Proxy. Later, on a click inside
the painted HF region, `usePagesPointer.handlePagesMouseDown` did:
const hfView = opts.getHfPmView(hfEdit.value.headerFooter)
which calls `useDocxEditor.findHfRid(hf)` — that walks `pkg.headers`
with `value === hf`. The raw HeaderFooter in the map is NOT identical
to the proxied reference, so findHfRid returns null, hfView is null,
the click falls through, body PM keeps focus, and typing lands in body.
Fix: switch hfEdit to `shallowRef` so nested objects stay un-proxied.
New e2e:
- e2e/tests/vue/hf-click-and-type.spec.ts (mirror of the React one) —
load fixture, double-click header, click painted span, type, assert
marker landed in header text and NOT body.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(hf): make body PM functionally retire while HF edit mode is active
User reported two carets visible at once (body + header), keystrokes
splitting between docs, and a UX complaint that HF edit mode "doesn't
work the same as in-body editing." Root cause: parallel selection
pipelines (body's `useSelectionOverlay` + HF's `hfCaretRect`) and a
body PM that kept accepting input events even when HF was the user's
focus.
Body PM goes dormant when HF engages:
- React: OffscreenEditorHost now reads `readOnly` through a ref so the
prop change actually takes effect mid-session. PagedEditor passes
`readOnly || !!hfEditMode` so stray keystrokes can't be applied to
the body doc.
- React + Vue: collapse body PM selection to 0 + blur the PM dom on
hfEdit transition so no body caret blink remains.
- React: SelectionOverlay receives empty rects + null caret + isFocused=false
while hfEditMode is set, so the body overlay disappears.
- Vue: useSelectionSync gains an `isHfEditing` flag and bails before
drawing `.vue-caret` / `.vue-sel-rect` when HF is active.
DocxEditor.vue wires it from `hfEdit !== null`.
Header double-click on page 2+ now scrolls to page 1 (React + Vue):
- The HF doc is shared across pages by `r:id`, but the chrome bar
anchors to whichever HF element was clicked. Word always edits the
page-1 HF; mirror that by scrolling the page-1 HF region into view
and re-pointing the click target before computing the overlay rect.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(hf): let table resize handles win mousedown when inside the painted header
In HF edit mode, every mousedown inside `.layout-page-header` /
`.layout-page-footer` was being routed to the HF EditorView via
`onHfPagesMouseDown`, including clicks on `.layout-table-resize-handle`
/ `.layout-table-row-resize-handle`. The HF route consumed the event
before the table-resize hook got a chance to claim it, so column /
row / edge dragging silently no-op'd.
Fix (both adapters): if the mousedown target is a resize handle
inside the HF area, run `tableResize.tryStartFromMouseDown` (React) /
`tableResize.tryStartResize` (Vue) BEFORE the HF selection routing so
the drag can start normally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(hf): unify pointer pipeline through an active-PM surface
You were right — the parallel HF/body pipelines were the source of every
edge case. This pass folds them back into one.
The core change:
- React: usePagesPointer now resolves an `activeSurface()` per gesture.
Body PM (OffscreenEditorHostRef) and HF PM (raw EditorView, wrapped via
`wrapEditorViewAsSurface`) project into the same minimal interface
(`setSelection`/`setNodeSelection`/`setCellSelection`/`focus`/`getView`).
Every gesture inside handlePagesMouseDown / handleMouseMove /
handlePagesClick / handlePagesContextMenu / handleTableInsertClick
goes through this surface instead of touching `hiddenPMRef.current`
directly.
- Vue: same shape — `activeView()` returns the HF EditorView when HF mode
is active (and the matching persistent view exists), body PM otherwise.
`setPmSelection` dispatches on whichever it returned. handlePagesMouseDown
no longer has the HF carve-out branch.
What now Just Works in HF mode (was ❌ before):
- Click-and-drag → text range selection in header
- Double-click → select word in header
- Triple-click → select paragraph in header
- Shift-click → extend selection in header
- Click image → NodeSelection on header image
- Click hyperlink → popup / nav targeting the header doc
- Right-click → context menu with HF selection / image
- Table-cell drag → CellSelection across header cells
Word-parity (from `Word parity` reviewer):
- Single-click on H/F area in normal mode is now a no-op (was setSelection(0)
on body — yanked the user's caret to the top of the doc). Word does
nothing on a single click in the dimmed HF area; matches that.
Cleanup:
- Drop the now-dead `onHfPagesMouseDown` plumbing through PagedEditor +
DocxEditorPagedArea. The HF route was a single-purpose hook that the
active-surface model subsumes.
- Drop the unused `resolveDomPosition` import in DocxEditorPagedArea +
the equivalent in Vue's usePagesPointer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(hf): edit header inline on any page — drop unconditional scroll-to-page-1
The HF content is shared across all pages by `r:id`, so edits propagate
to every painted instance automatically. There's no reason to yank the
viewport back to page 1 — the chrome bar can float over whichever page
the user actually clicked. Word does the same on shared headers.
Also addresses the Word-parity reviewer's flag that the unconditional
scroll was misaligned with the `titlePg`/sectPr semantics (the scroll
was happening regardless of whether the resolved part was page-1 or
default).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(hf): paint the caret immediately when HF edit mode engages
The HF caret rect was only computed inside the `onHfTransaction` callback —
fine for keystrokes and click-to-position, but on a fresh double-click the
persistent PM's selection is already at position 0 and no transaction fires.
Result: footer cursor was invisible until the user did something that
triggered a transaction.
Schedule a measurement two rAFs after `hfEditPosition` flips truthy — one
frame for React to commit, a second for the painter's `runLayoutPipeline`
repaint to land. Then read `view.state.selection.head` and lay the blinking
blue caret over the painted spans.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(hf): regression guard — PAGE field insert resolves at paint time
Inserting "current page number" in the header from the Options menu now
updates the painted header immediately on the same tick (no keystroke
needed). The test asserts both the synchronous read and a delayed read
both show the resolved page number — so any future regression that
re-introduces lag will fail this spec rather than getting bug reported.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(hf): header-table resize must dispatch on the HF view, not body PM
Reviewer's B1 finding: `useTableResizeState` was hard-wired to
`hiddenPMRef.current.getView()` for both reading column widths and
dispatching the commit transaction. When the user grabbed a resize
handle inside `.layout-page-header` / `.layout-page-footer`, the
`tablePmStart` was a position in the HF doc — applied to the body PM
this either threw (silent), or worse, hit a body table at the same
numeric offset and resized the wrong row/column. Either way the body
doc accumulated stray colWidth changes.
Fix:
- `useTableResizeState` accepts an optional `getActiveHfView` resolver.
- `pickViewForHandle(target)` picks the HF view when the handle sits
inside the painted header/footer; body PM otherwise.
- The chosen view is captured in `resizeTargetViewRef` at drag-start
and consulted by mouseup commit — so the commit lands on the right
doc even if HF edit mode toggles mid-drag.
- `usePagesPointer` wires `getActiveHfView: getHfView` through.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(hf): keep the caret visible even when cursor lands outside any text span
User reported: hitting Enter into a new empty paragraph hides the HF
caret until the next keystroke. Root cause: `computeHfCaretRectFromView`
only found anchors via exact `data-doc-from="${pmPos}"` matches. Empty
paragraphs after Enter sit at `(paragraphStart + 1)` while the
painter emits `.layout-paragraph[data-doc-from="paragraphStart"]` — so
the exact lookup fails and the rect resolver returns null, hiding the
caret entirely.
Two new fallbacks in the order they're tried:
1. Closest painted element with `[data-doc-from][data-doc-to]` that
brackets `pmPos`. Use left edge for cursor at paragraph start, right
edge if cursor is at paragraph end (e.g. after typing then Enter).
2. Host's top-left as a last resort — caret is always visible while in
HF edit mode, never disappears mid-edit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(hf): body cell selection no longer highlights header cells at same pm pos
`applyCellSelectionHighlight` walked every `.layout-table-cell` in the
pages container, matching by `data-doc-from`. Body and HF tables share
the same numeric PM coordinate space (separate docs), so a body
CellSelection at pos 100 also lit up whatever HF cell happened to
sit at docFrom=100. Scope the walk to `.layout-page-content
.layout-table-cell` so only body cells are eligible.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(hf): render selection rects when user drag-selects inside header
Body's SelectionOverlay is gated off in HF edit mode (so body cell
highlights and body caret stop bleeding through), but nothing was
rendering selection rects for the HF view's own selection. Drag-select
inside a header produced no visible highlight — user thought selection
was broken.
New `computeHfSelectionGeometryFromView` in core walks
`.layout-page-header / .layout-page-footer` painted spans (same shape
as `computeHfCaretRectFromView`) and returns viewport-relative rects
that overlap the HF selection range. DocxEditorPagedArea computes them
on every HF transaction + on edit-mode engage, and renders fixed-
positioned tinted divs at z-index 9998 — one below the caret (9999),
above the painter.
Hide the caret when selection rects are present, same as the body PM.
Regression test: e2e/tests/hf-selection-rects.spec.ts. Drag across a
header text span → assert at least one selection rect is in the DOM.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(hf): reposition caret + selection rects on scroll
The HF caret and selection rects are positioned via `position: fixed` —
viewport-relative coordinates from `getBoundingClientRect()`. They were
only recomputed inside `onHfTransaction` (typing / click) and on edit
mode engage, so scrolling the page left the rects stuck at their
original viewport positions while the painted header slid out from
under them.
Both adapters now listen for window-level `scroll` (capture-phase, so
it catches any ancestor) and `resize` while in HF edit mode and
recompute caret + selection rects via a single rAF coalesce. The rects
follow the painted header naturally now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(hf): multi-cell selection + text drag inside header cells
Two related bugs reported together:
1. Drag across header cells produced no visible highlight. Root cause:
the earlier scope fix forced `applyCellSelectionHighlight` to walk
only `.layout-page-content .layout-table-cell` — HF cells were
never eligible. Refactor:
- Add `{ scope: 'body' | 'hf' }` option to the highlight helper.
- DocxEditorPagedArea's HF transaction handler now calls it with
`scope: 'hf'` after every HF dispatch (matching the body call's
pattern), and the initial-engage / scroll handlers do the same.
- Each call only clears highlights inside its own scope so the body
pass doesn't wipe an HF CellSelection and vice versa.
2. Text drag inside a single header cell sometimes ping-ponged
because `getPositionFromMouse`'s fallback hit-tested against BODY
`blocks` / `measures`. Whenever the mouse drifted between HF spans
(padding, between characters), it returned a body PM position that
then got applied to the HF view (out-of-range → selection collapsed
to 0). In HF mode the function now skips the body fallback entirely
and instead snaps to the nearest HF span on the same line.
Regression test: e2e/tests/hf-selection-rects.spec.ts — drag across
two header cells → at least 2 cells carry `.layout-table-cell-selected`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(hf): caret snaps to last paragraph instead of host top-left
User reported: with multi-line header content, the visible caret rendered
at the top-left of the header even when the actual cursor was at the end
of the last line. Root cause: the "last-resort host top-left" fallback in
`computeHfCaretRectFromView` fired whenever `pmPos` exceeded every painted
element's `data-doc-to`, which happens at `doc.content.size` (cursor at
the very end of the HF doc).
New tier in the fallback chain — before the host-top-left last resort —
finds the painted element with the largest `docFrom <= pmPos` and snaps
the caret to its trailing edge. That's where the cursor actually is.
Note on the scroll-efficiency request: tried portalling the caret into
`.paged-editor__pages` with `position: absolute` so it'd scroll with the
painter naturally (matching the body's SelectionOverlay). The portal
broke header-overflow-diagnostic because the painter's full-rebuild path
(`container.innerHTML = ''`) wipes portal children mid-render and the
React/painter race produces a moment where page-1 has no `.layout-page-header`.
Reverted that — keep the rAF-coalesced scroll listener for now. A proper
fix needs the painter to either skip portal children on innerHTML wipe
or expose a dedicated overlay-layer element that survives full rebuilds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(hf): paint docFrom/docTo on HF paragraphs + stop dropdown clicks from moving caret
Two bugs flagged together:
1. HF caret invisible on empty paragraphs / clicks between lines —
`renderHeaderFooterContent` was building synthetic ParagraphFragments
without `docFrom` / `docTo`. The HF painter therefore emitted no
`data-pm-*` markers on paragraph wrappers, so the caret fallback
chain had nothing to anchor against when the cursor sat in an empty
paragraph or at a line boundary (no spans bracket those positions
either). Pipe `paragraphBlock.docFrom` / `docTo` through to the
synthetic fragment so the painter emits the data attrs.
2. "Insert page number / total pages" sometimes inserted the field at
end-of-doc instead of where the user was typing. Root cause: the
dropdown buttons lacked `onMouseDown stopPropagation`, so the
button's mousedown bubbled to `handlePagesMouseDown` which routes
"click in HF area but not on a span" to `setSelection(endOfDoc)`.
By the time the button's onClick fired, the HF selection was at
end-of-doc and `insertField` used that as `from`. Added
`onMouseDown stopPropagation` on the dropdown container — the HF
selection stays where the user left it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(hf): Vue parity (outline + Options) + React focus-induced scroll
Three issues in this push, all reported together:
1. Vue HF outline only covered the original (short) header — never grew
as the user typed multi-line content. Root cause: `targetRect` was
captured once at `handlePagesDoubleClick` and never updated. Now on
every HF transaction we re-measure the painted `.layout-page-header`
/ `.layout-page-footer` rect and update `hfEdit.value.targetRect`,
so the blue border tracks the growing header.
2. Vue's HF chrome was missing the Options menu (Insert current page
number / Insert total page count) that React has. Added the
dropdown + `insertField` helper directly in the Vue component;
matches the React shape one-for-one (same field node attrs, same
`view.dispatch(tr.insert(...))` path).
3. React: inserting a PAGE field in an existing run sometimes made the
visible caret "jump to left" before the field appeared. Plain
`view.focus()` on the off-screen (left:-9999px) HF EditorView can
cause the browser to scroll horizontally to bring it into view,
which the user perceives as a caret jump. Switched to
`view.dom.focus({ preventScroll: true })` so the focus doesn't
trigger a viewport scroll.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* perf(hf): portal caret + selection rects into pages-viewport, drop scroll listener
Frontend efficiency reviewer's #1 win: the HF caret felt laggy because
it used `position: fixed` with viewport-relative coords from
`getBoundingClientRect()`, requiring a window-level capture-phase scroll
listener + rAF coalesce. On a fast wheel scroll that's ~120Hz of full
DOM queries + range walks. The body caret has zero per-scroll JS
because its `SelectionOverlay` is `position: absolute` inside the
pages-viewport — natural scroll tracking by the browser.
Previous portal attempt targeted `.paged-editor__pages` directly and
broke the painter's full-rebuild path (`container.innerHTML = ''`
wiped portal children). This time portal into the PARENT of
`.paged-editor__pages` — same level as the body's SelectionOverlay,
which the painter never touches. Convert viewport rects to host-local
coords once at measure time; the host scrolls, the absolute children
move with it for free.
Net effect:
- Caret + selection rects render with `position: absolute` + container-
relative coords.
- Window-level scroll listener removed; only resize listener stays
(geometry actually changes on resize).
- Painter rebuild can't touch the portal layer because it's at the
parent scope, not inside `.paged-editor__pages`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* perf(hf): painter:painted commit signal + cache HF DOM snapshot
Two wins from the frontend efficiency reviewer landed together:
#3 — Painter commit signal
`useLayoutPipeline.runLayoutPipeline` now dispatches a bubbling
`painter:painted` CustomEvent on `pagesContainerRef.current` after
`paintPages` writes children. HF caret + selection-rect resolvers
listen for it instead of running through a `requestAnimationFrame`
chain (the previous double-rAF was a race-prone heuristic that visibly
missed paints on the initial engage).
#2 — Cache HF host + spans NodeList
`computeHfCaretRectFromView` and `computeHfSelectionGeometryFromView` used
to call `doc.querySelectorAll('.layout-page-header, .layout-page-footer')`
plus `host.querySelectorAll('span[data-doc-from][data-doc-to]')` on
every invocation. On multi-page docs that's O(pages × spans) per
transaction (and per scroll-rAF when the listener still existed).
Cache the host + span + ranged element arrays in a module-level
snapshot, invalidate on `painter:painted` and on HF edit-mode toggle.
Combined: per-transaction HF work goes from "walk every span on every
page, every paint" to "cached snapshot fast path; re-walk only after
the painter actually rewrote the DOM."
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(hf): record SelectionOverlay unification follow-up in computeHfCaretRectFromView
The frontend efficiency reviewer's "if we had budget" suggestion was to
unify the HF caret through the body's existing SelectionOverlay path —
~30 LOC net deletion + body↔HF parity for free (lineHeight from
`.layout-line` ancestor, empty-paragraph fallback via the body's
`findBodyEmptyRuns` helper, etc.).
Not landing in this branch because it's a multi-file shape change with
real risk and no observable-behavior change — the perf wins are already
in (`painter:painted` event + cached DOM snapshot + portal into pages-
viewport). Doc'd the migration plan inline so the next contributor can
pick it up without re-deriving the design:
1. Add findHfPmSpans / findHfEmptyRuns next to collectBodySpans.
2. Add `scope: 'body' | 'hf'` to getCaretFromDom +
computeSelectionGeometryFromDom.
3. Move the helpers to core (React + Vue both call).
4. Delete computeHfCaretRectFromView + computeHfSelectionGeometryFromView.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: fix CI — trim Vue DocxEditor.vue under 1000-line cap, extract API snapshot
- packages/vue/src/components/DocxEditor.vue: 1002 → 999 lines (under the
max-lines ESLint cap of 1000). Collapsed two verbose multi-line comment
blocks around the HF transaction listener + scroll fallback without
changing behavior.
- docs/api/docx-editor-core/layout-bridge.api.md: regenerate snapshot to
include the newly-exported `computeHfSelectionGeometryFromView` +
`invalidateHfDomCache`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(hf,react): use PM's view.focus() so painter sees the post-insert selection
User reported the React Options → Insert page number didn't visually
update until the next keystroke, while the Vue side works correctly.
Comparing the two paths: Vue calls `view.focus()` (ProseMirror's
EditorView method), React was calling `view.dom.focus({ preventScroll: true })`
(plain DOM focus) — a defensive choice I made earlier to avoid a
viewport scroll when focusing the off-screen HF view.
PM's `view.focus()` does more than `view.dom.focus()`: it also
dispatches a no-op transaction internally to refresh selection display.
That transaction rides through `dispatchTransaction` → `onTransaction` →
`runLayoutPipeline` → painter repaint, so the freshly-inserted field
gets resolved at paint time and shows up on the same tick. With plain
DOM focus the painter never gets a kick after the insert, so the
visible header lagged until the next keystroke caused another
transaction.
The hidden host is at `position: fixed; left:-9999px` so PM's focus
doesn't actually trigger a viewport scroll despite my earlier worry.
Vue matches this pattern; aligning React.
Regression test: e2e/tests/hf-type-then-insert.spec.ts — type into an
existing run, then Insert PAGE; assert the painted "1" is visible
without any extra keystroke. Passes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(openspec): archive unify-hf-editing — landed in PR #611
All work in the unify-hf-editing change is done across 37 commits on
this branch. Architecture invariants codified in
`openspec/specs/header-footer-editing/spec.md`. The change directory
moves to `openspec/changes/archive/2026-05-28-unify-hf-editing/` for
historical reference.
Deferred items (recorded inline in the relevant source files as TODOs):
- Multi-section per-section HF editing
- Even-page header support (ECMA-376 §17.6.10)
- OOXML serializer hardening (trailing-paragraph guarantee §17.4.6,
evenAndOddHeaders → settings.xml)
- Unify HF caret through body's SelectionOverlay
- hf-toolbar-and-zindex tests 2 + 3 (HF→tableContext routing)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>1 parent 551aa10 commit caefb8a
34 files changed
Lines changed: 3149 additions & 713 deletions
File tree
- docs/api
- docx-editor-react
- docx-editor-vue
- e2e/tests
- vue
- openspec
- changes/archive/2026-05-28-unify-hf-editing
- packages
- core/src/prosemirror
- extensions/core
- react/src
- components
- DocxEditor
- hooks
- internals
- styles
- vue/src
- components
- composables
- styles
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
78 | 78 | | |
79 | 79 | | |
80 | 80 | | |
| 81 | + | |
| 82 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
43 | 43 | | |
44 | 44 | | |
45 | 45 | | |
46 | | - | |
| 46 | + | |
47 | 47 | | |
48 | 48 | | |
49 | 49 | | |
50 | 50 | | |
51 | | - | |
| 51 | + | |
52 | 52 | | |
53 | | - | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
54 | 56 | | |
55 | 57 | | |
56 | 58 | | |
| |||
78 | 80 | | |
79 | 81 | | |
80 | 82 | | |
81 | | - | |
| 83 | + | |
82 | 84 | | |
83 | 85 | | |
84 | 86 | | |
85 | 87 | | |
86 | 88 | | |
87 | 89 | | |
88 | 90 | | |
89 | | - | |
90 | | - | |
91 | | - | |
92 | | - | |
93 | | - | |
94 | | - | |
95 | | - | |
96 | | - | |
97 | | - | |
98 | | - | |
99 | | - | |
100 | | - | |
101 | | - | |
102 | | - | |
103 | | - | |
104 | | - | |
105 | | - | |
106 | | - | |
107 | | - | |
108 | | - | |
109 | | - | |
110 | | - | |
111 | | - | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
112 | 117 | | |
113 | 118 | | |
114 | 119 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
16 | 16 | | |
17 | 17 | | |
18 | 18 | | |
| 19 | + | |
19 | 20 | | |
20 | 21 | | |
21 | 22 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
24 | 24 | | |
25 | 25 | | |
26 | 26 | | |
| 27 | + | |
27 | 28 | | |
28 | 29 | | |
29 | 30 | | |
| |||
251 | 252 | | |
252 | 253 | | |
253 | 254 | | |
| 255 | + | |
254 | 256 | | |
255 | 257 | | |
256 | 258 | | |
257 | 259 | | |
258 | 260 | | |
259 | 261 | | |
260 | 262 | | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
261 | 266 | | |
262 | 267 | | |
263 | 268 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
0 commit comments