Skip to content

Commit caefb8a

Browse files
jedrazbclaude
andauthored
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

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,5 @@ packages/*/temp/
7878
# Not committed — consumer (e.g. docs-editor-page) clones the repo and
7979
# runs the script itself. See CLAUDE.md → Docs JSON.
8080
docs/json/
81+
examples/vite/public/DC_Template_Descricao_Cargo_Controlado_Enterprise.docx
82+
examples/vue/public/DC_Template_Descricao_Cargo_Controlado_Enterprise.docx

CLAUDE.md

Lines changed: 32 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,16 @@ Empty-doc specs (`formatting`, `text-editing`) use `editor.gotoEmpty()`. Demo-as
4343

4444
**Two renderers. Know which one owns your bug.**
4545

46-
- **HIDDEN ProseMirror** (`left: -9999px`) — editing state, undo/redo, keyboard. `components/DocxEditor/OffscreenEditorHost.tsx`.
46+
- **HIDDEN ProseMirror** (`left: -9999px`) — editing state, undo/redo, keyboard. `components/DocxEditor/OffscreenEditorHost.tsx` (body) + `HiddenHeaderFooterPMs.tsx` (one EditorView per HF `rId`).
4747
- **VISIBLE pages** — what user sees. Static DOM rebuilt from PM state. **NOT `toDOM`**`src/layout-painter/paintPage.ts`. Fixing `toDOM` for a visual bug → user sees nothing.
4848

4949
Data flow: DOCX → `unzip``parser``Document``toProseDoc` → PM → painter → pages. Save: PM → `fromProseDoc``Document``serializer``rezip`.
5050

51-
Click flow: `usePagesPointer.handlePagesMouseDown``getPositionFromMouse` → PM setSelection → `PagedEditor.handleTransaction` → painter re-render.
51+
Click flow: `usePagesPointer.handlePagesMouseDown``getPositionFromMouse` (body) or `resolveDomPosition` scoped to `.layout-page-header`/`.layout-page-footer` (HF) → PM setSelection → `PagedEditor.handleTransaction` → painter re-render.
5252

53-
Vue host: `useDocxEditor()` in `packages/vue/src/composables/useDocxEditor.ts`. Dual-rendering rule applies to Vue too.
53+
Header/footer editing follows the same model as the body: the persistent hidden HF PM is the sole editor; the painter is the sole visible renderer in both edit and non-edit modes. The `InlineHeaderFooterEditor` overlay is UI chrome only (separator bar, options menu, save-on-close) — it does NOT mount its own EditorView. There is no `.hf-editor-pm` CSS — those workarounds existed to make PM's `toDOM` tables match the painter's flex layout and are gone now that the painter is the sole renderer. See `openspec/changes/unify-hf-editing/` for the design.
54+
55+
Vue host: `useDocxEditor()` in `packages/vue/src/composables/useDocxEditor.ts`. Dual-rendering rule applies to Vue too — the composable mounts the same per-`rId` persistent HF EditorView pattern (via `syncHfPMs` / `getHfPmView` / `setHfTransactionListener`) and routes HF rendering through `convertHeaderFooterPmDocToContent` in lockstep with React.
5456

5557
### React/Vue parity
5658

@@ -78,37 +80,40 @@ Stable dataset attrs on painted DOM (CSS, queries, selection map depend on these
7880

7981
- `data-block-id` — block index
8082
- `data-from-line`/`data-to-line` — measured line range
81-
- `data-doc-from`/`data-doc-to` — PM positions for selection mapping
83+
- `data-doc-from`/`data-doc-to` — PM positions for selection mapping (body AND HF — different PM docs, scope queries with `.layout-page-content` for body / `.layout-page-header|footer` for HF; see `collectBodySpans.ts` for the pattern)
8284
- `data-comment-id` — comment-range spans
8385
- `data-change-author`/`data-change-date`/`data-revision-id` — tracked changes
8486
- `data-continues-from-prev`/`data-continues-on-next` — split paragraphs
8587
- `data-flex-line` — flex-promoted lines (image-aligned, right-tab); `paintParagraphFragment` suppresses `text-indent` on these (would apply per-flex-item)
8688

8789
### Key file map
8890

89-
| Debugging | File |
90-
| --------------------------- | ----------------------------------------------------------- |
91-
| Text/paragraph rendering | `layout-painter/renderParagraph.ts` |
92-
| Image rendering | `layout-painter/renderImage.ts` |
93-
| Table rendering | `layout-painter/renderTable.ts` |
94-
| Page composition | `layout-painter/paintPage.ts` |
95-
| Formatting commands | `prosemirror/extensions/marks/`, `nodes/` |
96-
| Keyboard shortcuts | `prosemirror/extensions/features/BaseKeymapExtension.ts` |
97-
| Toolbar ↔ selection | `prosemirror/plugins/selectionTracker.ts` |
98-
| DOCX XML parsers | `docx/paragraphParser.ts`, `docx/tableParser.ts` |
99-
| Document → PM | `prosemirror/conversion/toProseDoc.ts` |
100-
| Click → PM position | `components/DocxEditor/hooks/usePagesPointer.ts` |
101-
| Selection rects / caret | `components/DocxEditor/hooks/useSelectionOverlay.ts` |
102-
| Layout pipeline | `components/DocxEditor/hooks/useLayoutPipeline.ts` |
103-
| Scroll API | `components/DocxEditor/hooks/usePagedScrollApi.ts` |
104-
| Image resize/drag | `components/DocxEditor/hooks/useImageInteractions.ts` |
105-
| Font/HF reflow triggers | `components/DocxEditor/hooks/useLayoutTriggers.ts` |
106-
| Table resize | `components/DocxEditor/hooks/useTableResizeState.ts` |
107-
| Measure-block cache | `components/DocxEditor/internals/measureBlock.ts` |
108-
| Sidebar comment Y positions | `components/DocxEditor/internals/sidebarAnchorPositions.ts` |
109-
| PM position → DOM | `components/DocxEditor/internals/pmAnchors.ts` |
110-
| Main toolbar | `components/Toolbar.tsx` |
111-
| Editor CSS | `prosemirror/editor.css` |
91+
| Debugging | File |
92+
| --------------------------- | --------------------------------------------------------------- |
93+
| Text/paragraph rendering | `layout-painter/renderParagraph.ts` |
94+
| Image rendering | `layout-painter/renderImage.ts` |
95+
| Table rendering | `layout-painter/renderTable.ts` |
96+
| Page composition | `layout-painter/paintPage.ts` |
97+
| Formatting commands | `prosemirror/extensions/marks/`, `nodes/` |
98+
| Keyboard shortcuts | `prosemirror/extensions/features/BaseKeymapExtension.ts` |
99+
| Toolbar ↔ selection | `prosemirror/plugins/selectionTracker.ts` |
100+
| DOCX XML parsers | `docx/paragraphParser.ts`, `docx/tableParser.ts` |
101+
| Document → PM | `prosemirror/conversion/toProseDoc.ts` |
102+
| Click → PM position | `components/DocxEditor/hooks/usePagesPointer.ts` |
103+
| Selection rects / caret | `components/DocxEditor/hooks/useSelectionOverlay.ts` |
104+
| HF persistent PMs | `components/DocxEditor/HiddenHeaderFooterPMs.tsx` |
105+
| HF caret in painter | `components/DocxEditor/DocxEditorPagedArea.tsx` (`hfCaretRect`) |
106+
| HF inline chrome | `components/InlineHeaderFooterEditor.tsx` |
107+
| Layout pipeline | `components/DocxEditor/hooks/useLayoutPipeline.ts` |
108+
| Scroll API | `components/DocxEditor/hooks/usePagedScrollApi.ts` |
109+
| Image resize/drag | `components/DocxEditor/hooks/useImageInteractions.ts` |
110+
| Font/HF reflow triggers | `components/DocxEditor/hooks/useLayoutTriggers.ts` |
111+
| Table resize | `components/DocxEditor/hooks/useTableResizeState.ts` |
112+
| Measure-block cache | `components/DocxEditor/internals/measureBlock.ts` |
113+
| Sidebar comment Y positions | `components/DocxEditor/internals/sidebarAnchorPositions.ts` |
114+
| PM position → DOM | `components/DocxEditor/internals/pmAnchors.ts` |
115+
| Main toolbar | `components/Toolbar.tsx` |
116+
| Editor CSS | `prosemirror/editor.css` |
112117

113118
### Extensions
114119

docs/api/docx-editor-react/index.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { EditorHandle } from '@eigenpal/docx-editor-core';
1616
import { EditorState } from 'prosemirror-state';
1717
import { EditorView } from 'prosemirror-view';
1818
import { FontOption } from '@eigenpal/docx-editor-core/utils/fontOptions';
19+
import { HeaderFooter } from '@eigenpal/docx-editor-core/types/document';
1920
import { Layout } from '@eigenpal/docx-editor-core/layout-engine';
2021
import * as prosemirror_state from 'prosemirror-state';
2122
import * as prosemirror_view from 'prosemirror-view';

docs/api/docx-editor-vue/composables.api.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { formatStorageSize } from '@eigenpal/docx-editor-core';
2424
import { getAutoSaveStatusLabel } from '@eigenpal/docx-editor-core';
2525
import { getAutoSaveStorageSize } from '@eigenpal/docx-editor-core';
2626
import { getSelectionRuns } from '@eigenpal/docx-editor-core';
27+
import { HeaderFooter } from '@eigenpal/docx-editor-core/types/document';
2728
import { HighlightRect } from '@eigenpal/docx-editor-core/utils';
2829
import { isAutoSaveSupported } from '@eigenpal/docx-editor-core';
2930
import { Layout } from '@eigenpal/docx-editor-core/layout-engine/types';
@@ -251,13 +252,17 @@ export interface UseDocxEditorReturn {
251252
focus: () => void;
252253
getCommands: () => CommandMap;
253254
getDocument: () => Document_2 | null;
255+
getHfPmView: (hf: HeaderFooter) => EditorView | null;
254256
isReady: Ref<boolean>;
255257
layout: ShallowRef<Layout | null>;
256258
loadBuffer: (buffer: ArrayBuffer | Uint8Array | Blob | File) => Promise<void>;
257259
loadDocument: (doc: Document_2) => void;
258260
parseError: Ref<string | null>;
259261
reLayout: () => void;
260262
save: () => Promise<Blob | null>;
263+
setDocument: (doc: Document_2) => void;
264+
setHfTransactionListener: (cb: ((rId: string, view: EditorView, docChanged: boolean) => void) | null) => void;
265+
syncHfPMs: () => void;
261266
}
262267

263268
// @public (undocumented)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { test, expect } from '@playwright/test';
2+
import { EditorPage } from '../helpers/editor-page';
3+
4+
// Smoke test for the user-reported #468 regression: double-click the header,
5+
// then a single click inside a cell should place the cursor there, and
6+
// keystrokes should land in the header (not the body).
7+
test('HF click → place cursor → type lands in header', async ({ page }) => {
8+
const editor = new EditorPage(page);
9+
await editor.goto();
10+
await editor.waitForReady();
11+
12+
await page
13+
.locator('input[type="file"][accept=".docx"]')
14+
.setInputFiles('e2e/fixtures/header-with-table.docx');
15+
await page.waitForSelector('.paged-editor__pages');
16+
await page.waitForSelector('[data-page-number]');
17+
await expect(page.locator('.layout-page-header [data-from-row]')).toHaveCount(1, {
18+
timeout: 15000,
19+
});
20+
21+
// Double-click the header to engage edit mode.
22+
const header = page.locator('.layout-page-header').first();
23+
await header.dblclick();
24+
await expect(page.locator('.hf-inline-editor')).toHaveCount(1);
25+
26+
// Find a painted span inside a table cell so we're not clicking on
27+
// the separator chrome bar at the top of the overlay.
28+
const span = page.locator('.layout-page-header .layout-table-cell span[data-doc-from]').first();
29+
await span.click();
30+
31+
// Now type a marker string. It must land in the header.
32+
await page.keyboard.type('ZZZMARK');
33+
34+
const headerText = await page.locator('.layout-page-header').first().textContent();
35+
expect(headerText).toContain('ZZZMARK');
36+
37+
// And the body must NOT contain the marker.
38+
const bodyText = await page.locator('.layout-page-content').first().textContent();
39+
expect(bodyText).not.toContain('ZZZMARK');
40+
});

e2e/tests/hf-field-insert.spec.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { test, expect } from '@playwright/test';
2+
import { EditorPage } from '../helpers/editor-page';
3+
4+
test('HF: inserting PAGE field updates header immediately (no space keystroke needed)', async ({
5+
page,
6+
}) => {
7+
const editor = new EditorPage(page);
8+
await editor.goto();
9+
await editor.waitForReady();
10+
11+
await page
12+
.locator('input[type="file"][accept=".docx"]')
13+
.setInputFiles('e2e/fixtures/header-with-table.docx');
14+
await page.waitForSelector('.paged-editor__pages');
15+
await page.waitForSelector('[data-page-number]');
16+
await expect(page.locator('.layout-page-header [data-from-row]')).toHaveCount(1, {
17+
timeout: 15000,
18+
});
19+
20+
// Engage HF edit mode and place the cursor in a span so the insert lands
21+
// inside the header rather than at position 0.
22+
await page.locator('.layout-page-header').first().dblclick();
23+
await expect(page.locator('.hf-inline-editor')).toHaveCount(1);
24+
await page.locator('.layout-page-header .layout-table-cell span[data-doc-from]').first().click();
25+
26+
const headerBefore = await page.locator('.layout-page-header').first().textContent();
27+
28+
// Click Options → "Insert current page number"
29+
await page
30+
.locator('.hf-inline-editor')
31+
.getByRole('button', { name: /options/i })
32+
.click();
33+
await page
34+
.locator('.hf-inline-editor')
35+
.getByRole('button', { name: /current page number/i })
36+
.click();
37+
38+
// Immediately read the painted header — should reflect the inserted field
39+
// without any extra keystroke.
40+
const headerImmediate = await page.locator('.layout-page-header').first().textContent();
41+
// And after a beat for any deferred repaint.
42+
await page.waitForTimeout(200);
43+
const headerAfter = await page.locator('.layout-page-header').first().textContent();
44+
45+
console.log('BEFORE:', JSON.stringify(headerBefore));
46+
console.log('IMMEDIATE:', JSON.stringify(headerImmediate));
47+
console.log('AFTER:', JSON.stringify(headerAfter));
48+
49+
// Both immediate and delayed reads should differ from the pre-insert text.
50+
expect(headerImmediate).not.toEqual(headerBefore);
51+
expect(headerAfter).not.toEqual(headerBefore);
52+
});
53+
54+
test('HF: PAGE field inserted without clicking inside cell first', async ({ page }) => {
55+
// User's reported flow: double-click header → immediately open Options →
56+
// Insert page number. No intervening click to place the cursor. The field
57+
// should still appear in the painted header right away.
58+
const editor = new EditorPage(page);
59+
await editor.goto();
60+
await editor.waitForReady();
61+
62+
await page
63+
.locator('input[type="file"][accept=".docx"]')
64+
.setInputFiles('e2e/fixtures/header-with-table.docx');
65+
await page.waitForSelector('.layout-page-header [data-from-row]', { timeout: 15000 });
66+
67+
await page.locator('.layout-page-header').first().dblclick();
68+
await expect(page.locator('.hf-inline-editor')).toHaveCount(1);
69+
70+
const headerBefore = await page.locator('.layout-page-header').first().textContent();
71+
72+
await page
73+
.locator('.hf-inline-editor')
74+
.getByRole('button', { name: /options/i })
75+
.click();
76+
await page
77+
.locator('.hf-inline-editor')
78+
.getByRole('button', { name: /current page number/i })
79+
.click();
80+
81+
// No keystroke, no extra click. Read the painted header.
82+
const headerAfter = await page.locator('.layout-page-header').first().textContent();
83+
console.log('NO-CELL-CLICK BEFORE:', JSON.stringify(headerBefore));
84+
console.log('NO-CELL-CLICK AFTER :', JSON.stringify(headerAfter));
85+
expect(headerAfter).not.toEqual(headerBefore);
86+
});
87+
88+
test('HF: NUMPAGES field renders total page count immediately', async ({ page }) => {
89+
const editor = new EditorPage(page);
90+
await editor.goto();
91+
await editor.waitForReady();
92+
93+
await page
94+
.locator('input[type="file"][accept=".docx"]')
95+
.setInputFiles('e2e/fixtures/header-with-table.docx');
96+
await page.waitForSelector('.layout-page-header [data-from-row]', { timeout: 15000 });
97+
98+
const totalPages = await page.locator('[data-page-number]').count();
99+
100+
await page.locator('.layout-page-header').first().dblclick();
101+
await expect(page.locator('.hf-inline-editor')).toHaveCount(1);
102+
await page.locator('.layout-page-header .layout-table-cell span[data-doc-from]').first().click();
103+
104+
await page
105+
.locator('.hf-inline-editor')
106+
.getByRole('button', { name: /options/i })
107+
.click();
108+
await page
109+
.locator('.hf-inline-editor')
110+
.getByRole('button', { name: /total page count/i })
111+
.click();
112+
113+
const headerAfter = await page.locator('.layout-page-header').first().textContent();
114+
console.log('NUMPAGES AFTER:', JSON.stringify(headerAfter), 'totalPages=', totalPages);
115+
// The resolved value should appear somewhere in the painted header.
116+
expect(headerAfter).toContain(String(totalPages));
117+
});

0 commit comments

Comments
 (0)