Operand

do no harem.

gram: docs

> ./packages/react/src/components/InlineHeaderFooterEditor.tsx

/**
* InlineHeaderFooterEditor — UI chrome for header/footer editing.
*
* Phase 5 of HF editing unification (openspec/changes/unify-hf-editing/):
* this component no longer creates a ProseMirror EditorView. The painter
* is the visible HF renderer (phase 2), and the persistent hidden HF PM
* mounted by `HiddenHeaderFooterPMs` is the sole editor. This wrapper
* floats the separator bar / options menu over the painted HF region and
* exposes the persistent view via its imperative ref so toolbar commands
* (bold, font, undo/redo) and save-on-close find the right PM.
*/
import React, {
useRef,
useEffect,
useCallback,
useState,
useImperativeHandle,
useLayoutEffect,
forwardRef,
} from 'react';
import type { CSSProperties } from 'react';
import { useTranslation } from '../i18n';
import { EditorView } from 'prosemirror-view';
import { undo, redo } from 'prosemirror-history';
import { schema } from '@eigenpal/docx-editor-core/prosemirror';
import { proseDocToBlocks } from '@eigenpal/docx-editor-core/prosemirror/conversion';
import { Z_INDEX } from '../styles/zIndex';
import type { HeaderFooter, BlockContent } from '@eigenpal/docx-editor-core/types/document';
// ============================================================================
// TYPES
// ============================================================================
export interface InlineHeaderFooterEditorProps {
/** The header or footer being edited */
headerFooter: HeaderFooter;
/** Whether editing header or footer */
position: 'header' | 'footer';
/**
* The persistent hidden HF EditorView for this slot. Phase 5 of the HF
* editing unification (openspec/changes/unify-hf-editing/) — this
* component no longer creates its own EditorView; the painter and this
* overlay both read from the same persistent PM mounted by
* `HiddenHeaderFooterPMs`. The component is now UI chrome only:
* separator bar, options menu, save-and-close on Escape.
*/
view: EditorView | null;
/** The DOM element to overlay (the .layout-page-header / .layout-page-footer) */
targetElement: HTMLElement;
/** The positioning parent element (the div wrapping PagedEditor) */
parentElement: HTMLElement;
/** Callback when editing is complete — receives updated content blocks */
onSave: (content: BlockContent[]) => void;
/** Callback when editing is cancelled */
onClose: () => void;
/** Callback to remove the header/footer entirely */
onRemove?: () => void;
}
export interface InlineHeaderFooterEditorRef {
/** Get the ProseMirror EditorView */
getView(): EditorView | null;
/** Focus the editor */
focus(): void;
/** Undo */
undo(): boolean;
/** Redo */
redo(): boolean;
}
// ============================================================================
// STYLES
// ============================================================================
const separatorBarStyle: CSSProperties = {
position: 'absolute',
left: 0,
right: 0,
// Sit ABOVE the painted header content so the bar doesn't intercept clicks
// landing on the first row of header text. Pre-unification the bar floated
// above an empty box; the painter now shows through so we have to hoist.
bottom: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '2px 0',
fontSize: 11,
color: 'var(--doc-primary)',
userSelect: 'none',
// Container is `pointer-events: none`; restore on the chrome so the
// label + options button stay clickable.
pointerEvents: 'auto',
};
const labelStyle: CSSProperties = {
fontWeight: 500,
letterSpacing: 0.3,
};
const optionsButtonStyle: CSSProperties = {
background: 'none',
border: 'none',
color: 'var(--doc-primary)',
cursor: 'pointer',
fontSize: 11,
padding: '2px 6px',
borderRadius: 3,
};
const dropdownStyle: CSSProperties = {
position: 'absolute',
right: 0,
top: '100%',
background: 'var(--doc-surface)',
border: '1px solid var(--doc-border-light)',
borderRadius: 4,
boxShadow: '0 2px 6px var(--doc-shadow)',
zIndex: Z_INDEX.dropdown,
minWidth: 160,
padding: '4px 0',
};
const dropdownItemStyle: CSSProperties = {
display: 'block',
width: '100%',
padding: '6px 12px',
border: 'none',
background: 'none',
textAlign: 'left',
cursor: 'pointer',
fontSize: 12,
color: 'var(--doc-text)',
};
// ============================================================================
// COMPONENT
// ============================================================================
export const InlineHeaderFooterEditor = forwardRef<
InlineHeaderFooterEditorRef,
InlineHeaderFooterEditorProps
>(function InlineHeaderFooterEditor(
{
headerFooter: _headerFooter,
position,
view,
targetElement,
parentElement,
onSave,
onClose,
onRemove,
},
ref
) {
const viewRef = useRef<EditorView | null>(view);
viewRef.current = view;
const [showOptions, setShowOptions] = useState(false);
const optionsRef = useRef<HTMLDivElement>(null);
// Compute overlay position relative to the parent element so the
// separator-bar / options-menu chrome floats over the painted HF region.
// No PM is mounted here any more (phase 5) — the persistent hidden HF PM
// mounted by `HiddenHeaderFooterPMs` is the sole HF editor.
const [overlayPos, setOverlayPos] = useState<{
top: number;
left: number;
width: number;
height: number;
} | null>(null);
useLayoutEffect(() => {
const computePosition = () => {
const parentRect = parentElement.getBoundingClientRect();
const targetRect = targetElement.getBoundingClientRect();
setOverlayPos({
top: targetRect.top - parentRect.top + parentElement.scrollTop,
left: targetRect.left - parentRect.left + parentElement.scrollLeft,
width: targetRect.width,
height: targetRect.height,
});
};
computePosition();
// Recompute on scroll/resize so the bar follows the painter.
const scrollParent = parentElement.closest('[style*="overflow"]') || parentElement;
scrollParent.addEventListener('scroll', computePosition);
window.addEventListener('resize', computePosition);
return () => {
scrollParent.removeEventListener('scroll', computePosition);
window.removeEventListener('resize', computePosition);
};
}, [targetElement, parentElement]);
// Auto-focus the persistent PM when the overlay first mounts — this is
// what makes typing land in HF content after a double-click. Clicks
// inside the painter route through `usePagesPointer.onHfPagesMouseDown`
// which also calls `view.focus()`, but mount-time focus is needed for
// the initial entry. Guarded by a ref so a later view-identity change
// (e.g. HF EditorView re-mount on doc reload) doesn't yank focus away
// from the user's current selection mid-session.
const didAutoFocusRef = useRef(false);
useEffect(() => {
if (!view || didAutoFocusRef.current) return;
didAutoFocusRef.current = true;
requestAnimationFrame(() => view.focus());
}, [view]);
// Save current content from the persistent PM.
const handleSave = useCallback(() => {
if (!viewRef.current) return;
const blocks = proseDocToBlocks(viewRef.current.state.doc);
onSave(blocks);
}, [onSave]);
// Save-and-close: always save (the persistent PM may have edits that
// never went through this overlay, e.g. via the agent bridge).
const handleSaveAndClose = useCallback(() => {
handleSave();
onClose();
}, [handleSave, onClose]);
// Handle Escape key — save + close
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
handleSaveAndClose();
}
}
document.addEventListener('keydown', handleKeyDown, true);
return () => document.removeEventListener('keydown', handleKeyDown, true);
}, [handleSaveAndClose]);
// Close options dropdown when clicking outside
useEffect(() => {
if (!showOptions) return;
function handleClick(e: MouseEvent) {
if (optionsRef.current && !optionsRef.current.contains(e.target as Node)) {
setShowOptions(false);
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [showOptions]);
// Expose ref
useImperativeHandle(ref, () => ({
getView: () => viewRef.current,
focus: () => viewRef.current?.focus(),
undo: () => {
const view = viewRef.current;
if (!view) return false;
return undo(view.state, view.dispatch);
},
redo: () => {
const view = viewRef.current;
if (!view) return false;
return redo(view.state, view.dispatch);
},
}));
const { t } = useTranslation();
const label = position === 'header' ? t('headerFooter.header') : t('headerFooter.footer');
if (!overlayPos) return null;
const containerStyle: CSSProperties = {
position: 'absolute',
top: overlayPos.top,
left: overlayPos.left,
width: overlayPos.width,
// Match the painted HF rect height so the chrome bar (positioned via
// `top: 100%` / `bottom: 100%`) sits flush against the painter.
height: overlayPos.height,
zIndex: Z_INDEX.hfInlineEditor,
// Post-unification (openspec/changes/unify-hf-editing): the painter shows
// through this overlay — only the chrome (separator bar, options menu)
// should swallow clicks. Painter clicks must reach the pages container so
// `usePagesPointer.handlePagesMouseDown` can route them via
// `onHfPagesMouseDown` to the persistent HF EditorView. Without this,
// clicking anywhere inside the painted HF region was a no-op (#468).
pointerEvents: 'none',
};
// Footer bar sits BELOW the painted footer; header bar ABOVE the painted
// header. Both anchored so they never overlap the cells underneath.
const footerBarStyle: CSSProperties = { ...separatorBarStyle, bottom: 'auto', top: '100%' };
return (
<div className="hf-inline-editor" style={containerStyle}>
{position === 'footer' && (
<div className="hf-separator-bar" style={footerBarStyle}>
<span style={labelStyle}>{label}</span>
<OptionsMenu
label={label}
showOptions={showOptions}
setShowOptions={setShowOptions}
optionsRef={optionsRef}
onRemove={onRemove}
onClose={handleSaveAndClose}
viewRef={viewRef}
/>
</div>
)}
{position === 'header' && (
<div className="hf-separator-bar" style={separatorBarStyle}>
<span style={labelStyle}>{label}</span>
<OptionsMenu
label={label}
showOptions={showOptions}
setShowOptions={setShowOptions}
optionsRef={optionsRef}
onRemove={onRemove}
onClose={handleSaveAndClose}
viewRef={viewRef}
/>
</div>
)}
</div>
);
});
// ============================================================================
// OPTIONS MENU SUB-COMPONENT
// ============================================================================
function OptionsMenu({
label,
showOptions,
setShowOptions,
optionsRef,
onRemove,
onClose,
viewRef,
}: {
label: string;
showOptions: boolean;
setShowOptions: (v: boolean | ((prev: boolean) => boolean)) => void;
optionsRef: React.RefObject<HTMLDivElement | null>;
onRemove?: () => void;
onClose: () => void;
viewRef: React.RefObject<EditorView | null>;
}) {
const { t } = useTranslation();
const insertField = (fieldType: 'PAGE' | 'NUMPAGES') => {
const view = viewRef.current;
if (!view) return;
// Get marks at the current cursor position so the field inherits surrounding styling
const { $from, from } = view.state.selection;
const marks = view.state.storedMarks || $from.marks();
const node = schema.nodes.field.create({
fieldType,
instruction: ` ${fieldType} \\* MERGEFORMAT `,
fieldKind: 'simple',
dirty: true,
});
const tr = view.state.tr.insert(from, node.mark(marks));
view.dispatch(tr);
// PM's `view.focus()` (NOT `view.dom.focus()`) — the former dispatches
// a no-op transaction internally to refresh the selection display,
// which the painter pipeline observes and re-paints with the freshly
// inserted field resolved. `view.dom.focus()` skips that step and
// leaves the painter showing pre-insert content until the next
// keystroke triggers a transaction. The hidden host is at
// `position: fixed; left:-9999px` so PM's focus doesn't actually
// scroll the viewport, despite the earlier comment to the contrary.
view.focus();
};
return (
<div style={{ position: 'relative' }} ref={optionsRef}>
<button
type="button"
style={optionsButtonStyle}
onClick={(e) => {
e.stopPropagation();
setShowOptions((prev) => !prev);
}}
onMouseDown={(e) => e.stopPropagation()}
>
{t('headerFooter.options')}
</button>
{showOptions && (
<div style={dropdownStyle} onMouseDown={(e) => e.stopPropagation()}>
{/*
`onMouseDown={(e) => e.stopPropagation()}` on the dropdown is
CRITICAL — without it, the button's mousedown bubbles to the
pages container, where `handlePagesMouseDown` treats the click
as "in HF area but not on a span" and snaps the HF cursor to
end-of-doc. The button's onClick then runs insertField with
`from = end-of-doc` and the field lands far away from where
the user was typing. Stopping propagation keeps the HF
selection where the user left it.
*/}
<button
type="button"
style={dropdownItemStyle}
onClick={() => {
setShowOptions(false);
insertField('PAGE');
}}
onMouseOver={(e) => {
(e.target as HTMLElement).style.backgroundColor = 'var(--doc-bg-hover)';
}}
onMouseOut={(e) => {
(e.target as HTMLElement).style.backgroundColor = 'transparent';
}}
>
{t('headerFooter.insertPageNumber')}
</button>
<button
type="button"
style={dropdownItemStyle}
onClick={() => {
setShowOptions(false);
insertField('NUMPAGES');
}}
onMouseOver={(e) => {
(e.target as HTMLElement).style.backgroundColor = 'var(--doc-bg-hover)';
}}
onMouseOut={(e) => {
(e.target as HTMLElement).style.backgroundColor = 'transparent';
}}
>
{t('headerFooter.insertTotalPages')}
</button>
<div style={{ borderTop: '1px solid var(--doc-border-light)', margin: '4px 0' }} />
{onRemove && (
<button
type="button"
style={dropdownItemStyle}
onClick={() => {
setShowOptions(false);
onRemove();
}}
onMouseOver={(e) => {
(e.target as HTMLElement).style.backgroundColor = 'var(--doc-bg-hover)';
}}
onMouseOut={(e) => {
(e.target as HTMLElement).style.backgroundColor = 'transparent';
}}
>
{t('headerFooter.remove', { label: label.toLowerCase() })}
</button>
)}
<button
type="button"
style={dropdownItemStyle}
onClick={() => {
setShowOptions(false);
onClose();
}}
onMouseOver={(e) => {
(e.target as HTMLElement).style.backgroundColor = 'var(--doc-bg-hover)';
}}
onMouseOut={(e) => {
(e.target as HTMLElement).style.backgroundColor = 'transparent';
}}
>
{t('headerFooter.closeEditing', { label: label.toLowerCase() })}
</button>
</div>
)}
</div>
);
}