Operand

do no harem.

gram: docs

> ./packages/react/src/components/DocxEditor/PagedEditor.tsx

/**
* PagedEditor Component
*
* Main paginated editing component that integrates:
* - HiddenProseMirror: off-screen editor for keyboard input
* - Layout engine: computes page layout from PM state
* - DOM painter: renders pages to visible DOM
* - Selection overlay: renders caret and selection highlights
*
* Architecture:
* 1. User clicks on visible pages → hit test → update PM selection
* 2. User types → hidden PM receives input → PM transaction
* 3. PM transaction → convert to blocks → measure → layout → paint
* 4. Selection changes → compute rects → update overlay
*/
import React, { useEffect, useRef, useState, useCallback, useMemo, forwardRef, memo } from 'react';
import type { CSSProperties } from 'react';
import { TextSelection, type EditorState, type Transaction, type Plugin } from 'prosemirror-state';
import type { EditorView } from 'prosemirror-view';
import type { Node as PMNode } from 'prosemirror-model';
// Internal components
import { HiddenProseMirror, type HiddenProseMirrorRef } from './HiddenProseMirror';
import { HiddenHeaderFooterPMs, type HiddenHeaderFooterPMsRef } from './HiddenHeaderFooterPMs';
import { SelectionOverlay } from './overlays/SelectionOverlay';
import { ImageSelectionOverlay } from './overlays/ImageSelectionOverlay';
import { DecorationLayer } from './overlays/DecorationLayer';
// Layout engine
import type { Layout } from '@eigenpal/docx-editor-core/layout-engine';
// Layout bridge
import { DEFAULT_PAGE_HEIGHT_PX } from '@eigenpal/docx-editor-core/layout-bridge';
// Selection sync
import { LayoutSelectionGate } from './internals/LayoutSelectionGate';
// Visual line navigation hook
import { useVisualLineNavigation } from '../../hooks/useVisualLineNavigation';
// Sidebar constants
import { SIDEBAR_DOCUMENT_SHIFT } from '../sidebar/constants';
// Types
import type {
Document,
Theme,
StyleDefinitions,
SectionProperties,
HeaderFooter,
} from '@eigenpal/docx-editor-core/types/document';
import type { WrapType } from '@eigenpal/docx-editor-core/docx/wrapTypes';
import type { RenderedDomContext } from '../../plugin-api/types';
import {
DEFAULT_PAGE_WIDTH,
DEFAULT_PAGE_GAP,
EMPTY_PLUGINS,
VIEWPORT_PADDING_BOTTOM,
VIEWPORT_PADDING_TOP,
containerStyles,
viewportStyles,
pagesContainerStyles,
pluginOverlaysStyles,
} from './internals/styles';
import { viewportMinHeightPx } from './internals/scrollUtils';
import { useLayoutPipeline } from './hooks/useLayoutPipeline';
import { useSelectionOverlay } from './hooks/useSelectionOverlay';
import { useImageInteractions } from './hooks/useImageInteractions';
import { usePagedScrollApi } from './hooks/usePagedScrollApi';
import { usePagesPointer } from './hooks/usePagesPointer';
import { usePagedEditorRefApi } from './hooks/usePagedEditorRefApi';
import { useLayoutTriggers } from './hooks/useLayoutTriggers';
import { TableInsertButton } from './overlays/TableInsertButton';
import { HyperlinkPopup, type HyperlinkPopupData } from '../ui/HyperlinkPopup';
export { DEFAULT_PAGE_WIDTH };
// =============================================================================
// TYPES
// =============================================================================
export interface PagedEditorProps {
/** The document to edit. */
document: Document | null;
/** Document styles for style resolution. */
styles?: StyleDefinitions | null;
/** Theme for styling. */
theme?: Theme | null;
/** Section properties (page size, margins). */
sectionProperties?: SectionProperties | null;
/** Body-level final section properties, used after the last explicit section break. */
finalSectionProperties?: SectionProperties | null;
/** Header content for all pages (or pages 2+ when titlePg is set). */
headerContent?: HeaderFooter | null;
/** Footer content for all pages (or pages 2+ when titlePg is set). */
footerContent?: HeaderFooter | null;
/** Header content for first page only (when titlePg is set). */
firstPageHeaderContent?: HeaderFooter | null;
/** Footer content for first page only (when titlePg is set). */
firstPageFooterContent?: HeaderFooter | null;
/** Whether the editor is read-only. */
readOnly?: boolean;
/** Gap between pages in pixels. */
pageGap?: number;
/** Zoom level (1 = 100%). */
zoom?: number;
/** Callback when document changes. */
onDocumentChange?: (document: Document) => void;
/** Callback when selection changes. */
onSelectionChange?: (from: number, to: number) => void;
/** External ProseMirror plugins. */
externalPlugins?: Plugin[];
/** Extension manager for plugins/schema/commands (optional — falls back to default) */
extensionManager?: import('@eigenpal/docx-editor-core/prosemirror/extensions').ExtensionManager;
/** Callback when editor is ready. */
onReady?: (ref: PagedEditorRef) => void;
/** Callback when rendered DOM context is ready. */
onRenderedDomContextReady?: (context: RenderedDomContext) => void;
/** Plugin overlays to render inside the viewport. */
pluginOverlays?: React.ReactNode;
/** Callback when header or footer is double-clicked for editing. */
onHeaderFooterDoubleClick?: (position: 'header' | 'footer', pageNumber?: number) => void;
/** Active header/footer editing mode (dims body, intercepts body clicks). */
hfEditMode?: 'header' | 'footer' | null;
/** Called when user clicks the body area while in HF editing mode. */
onBodyClick?: () => void;
/**
* Called after every transaction lands on the persistent hidden HF PM
* for any `rId`. Phase 5 of HF unification — the persistent PM is the
* sole HF editor and its transactions must trigger relayout (so the
* painter repaints) plus whatever caret / save state the parent owns.
* Returns nothing.
*/
onHfTransaction?: (rId: string, view: EditorView, docChanged: boolean) => void;
/** Custom class name. */
className?: string;
/** Custom styles. */
style?: CSSProperties;
/** Whether comments sidebar is open (shifts document left). */
commentsSidebarOpen?: boolean;
/** Sidebar overlay rendered inside the scroll container (scrolls with document). */
sidebarOverlay?: React.ReactNode;
/** Ref callback for the scroll container element. */
scrollContainerRef?: React.Ref<HTMLDivElement>;
/** Callback when a hyperlink is clicked (for showing popup). */
onHyperlinkClick?: (data: {
href: string;
displayText: string;
tooltip?: string;
position: { top: number; left: number };
}) => void;
/** Hyperlink popup state (null = hidden). */
hyperlinkPopupData?: HyperlinkPopupData | null;
/** Called when user wants to navigate to the link. */
onHyperlinkPopupNavigate?: (href: string) => void;
/** Called when user wants to copy the URL. */
onHyperlinkPopupCopy?: (href: string) => void;
/** Called when user saves hyperlink edits. */
onHyperlinkPopupEdit?: (displayText: string, href: string) => void;
/** Called when user removes the hyperlink. */
onHyperlinkPopupRemove?: () => void;
/** Called when the popup should close. */
onHyperlinkPopupClose?: () => void;
/** Callback when user right-clicks on the pages (for context menu).
* When the right-click target resolves to an image node, `image` carries
* the image's PM doc position, current wrap type, current cssFloat (lets
* the menu disambiguate Square Left vs Square Right), and — for inline
* images only — the rendered EMU offset of the image relative to the
* page content origin. The host promotes that offset into the new
* anchor's `wp:positionH/V` if the user converts inline → anchor. */
onContextMenu?: (data: {
x: number;
y: number;
hasSelection: boolean;
image?: {
pos: number;
wrapType: WrapType;
cssFloat?: 'left' | 'right' | 'none' | null;
inlinePositionEmu?: { horizontalEmu: number; verticalEmu: number };
} | null;
}) => void;
/** Callback with pre-computed Y positions for comment/tracked-change anchors (for sidebar positioning without DOM queries). */
onAnchorPositionsChange?: (positions: Map<string, number>) => void;
/**
* Callback fired when the page count changes after a layout pass.
* Parents use this to keep their own page counters (e.g. scroll indicator,
* `getTotalPages()` ref method) in sync without having to poll `getLayout()`.
*/
onTotalPagesChange?: (totalPages: number) => void;
/** Set of resolved comment IDs — hides highlight for these comments */
resolvedCommentIds?: Set<number>;
}
export interface PagedEditorRef {
/** Get the current document. */
getDocument(): Document | null;
/** Get the ProseMirror EditorState. */
getState(): EditorState | null;
/** Get the ProseMirror EditorView. */
getView(): EditorView | null;
/** Focus the editor. */
focus(): void;
/** Blur the editor. */
blur(): void;
/** Check if focused. */
isFocused(): boolean;
/** Dispatch a transaction. */
dispatch(tr: Transaction): void;
/** Undo. */
undo(): boolean;
/** Redo. */
redo(): boolean;
/** Set selection by PM position. */
setSelection(anchor: number, head?: number): void;
/** Get current layout. */
getLayout(): Layout | null;
/** Force re-layout. */
relayout(): void;
/** Scroll the visible pages to bring a PM position into view. */
scrollToPosition(pmPos: number): void;
/**
* Scroll to the paragraph identified by Word `w14:paraId` / PM `paraId`.
* @returns whether a matching paragraph was found
*/
scrollToParaId(paraId: string): boolean;
/**
* Scroll the paginated view so `pageNumber` (1-indexed) is in view.
* No-op if the layout isn't ready yet or pageNumber is out of range.
*/
scrollToPage(pageNumber: number): void;
/**
* Scroll to the comment identified by `commentId` and select its range so
* the selection overlay highlights it. Resolves the id → PM range via the
* live comment marks; returns `false` (not a throw, not a silent no-op)
* when the id no longer resolves so the caller can surface a "location no
* longer exists" affordance.
*/
scrollToCommentId(commentId: number): boolean;
/**
* Scroll to the tracked change identified by `revisionId` and select its
* range so the selection overlay highlights it. Resolves the id → PM range
* via the live tracked-change marks; returns `false` when the id no longer
* resolves (the change was accepted/rejected/deleted).
*/
scrollToChangeId(revisionId: number): boolean;
/**
* Select the PM position range `[from, to]` so the selection overlay
* highlights it, and scroll its start into view. No-op for a malformed
* range or a `from` past the document end; `to` is clamped to the document
* size (raw caller positions, so out-of-range must not throw).
*/
highlightRange(from: number, to: number): void;
/**
* Look up the persistent hidden HF PM EditorView for a given HeaderFooter
* instance. Returns null when none is mounted (no document, or `hf` is not
* present in `Document.package.headers/footers`). Phase 2 of the HF
* unification: the inline overlay uses this to replicate edits into the
* persistent PM so the painter — which reads from the persistent PM per
* phase 1 — re-renders live during typing. Phase 5 deletes the inline
* overlay's PM and this method's only remaining caller is the click /
* focus router (phase 3).
*/
getHfPmView(hf: HeaderFooter): EditorView | null;
}
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
// Module-scope helpers extracted to per-domain files — see top of file
// for the import block.
// =============================================================================
// COMPONENT
// =============================================================================
/**
* PagedEditor - Main paginated editing component.
*/
const PagedEditorComponent = forwardRef<PagedEditorRef, PagedEditorProps>(
function PagedEditor(props, ref) {
const {
document,
styles,
theme: _theme,
sectionProperties,
finalSectionProperties,
headerContent,
footerContent,
firstPageHeaderContent,
firstPageFooterContent,
readOnly = false,
pageGap = DEFAULT_PAGE_GAP,
zoom = 1,
onDocumentChange,
onSelectionChange,
externalPlugins = EMPTY_PLUGINS,
extensionManager,
onReady,
onRenderedDomContextReady,
pluginOverlays,
onHeaderFooterDoubleClick,
hfEditMode,
onBodyClick,
onHfTransaction,
className,
style,
commentsSidebarOpen = false,
sidebarOverlay,
scrollContainerRef: scrollContainerRefProp,
onHyperlinkClick,
onContextMenu,
onAnchorPositionsChange,
onTotalPagesChange,
resolvedCommentIds,
hyperlinkPopupData,
onHyperlinkPopupNavigate,
onHyperlinkPopupCopy,
onHyperlinkPopupEdit,
onHyperlinkPopupRemove,
onHyperlinkPopupClose,
} = props;
// Resolve the scroll container: prefer parent-provided ref, fallback to own container
const getScrollContainer = useCallback((): HTMLDivElement | null => {
if (scrollContainerRefProp && typeof scrollContainerRefProp === 'object') {
return (scrollContainerRefProp as React.RefObject<HTMLDivElement | null>).current;
}
return containerRef.current;
}, [scrollContainerRefProp]);
// Refs
const containerRef = useRef<HTMLDivElement>(null);
const pagesContainerRef = useRef<HTMLDivElement>(null);
/** Viewport wrapper: sync minHeight/marginBottom in layout pipeline before scroll restore. */
const viewportLayoutRef = useRef<HTMLDivElement>(null);
const hiddenPMRef = useRef<HiddenProseMirrorRef>(null);
/**
* Persistent hidden PM EditorViews for every distinct HF `rId` — phase 1
* of the HF editing unification (see openspec/changes/unify-hf-editing/).
* Phase 1 mounts them off-screen with no user input wiring; the painter
* pipeline and selection overlay learn to consult them in later phases.
*/
const hiddenHfPMsRef = useRef<HiddenHeaderFooterPMsRef>(null);
/**
* Latest `document` prop in a ref — read by HF PM lookup (`getHfPmView`
* on the PagedEditorRef). Refs are needed because the imperative-handle
* API rebuilds on `[layout, runLayoutPipeline, …]` not on `document`, so
* a closure over `document` directly would go stale between rebuilds.
*/
const documentRef = useRef(document);
documentRef.current = document;
// Visual line navigation (ArrowUp/ArrowDown with sticky X)
const { handlePMKeyDown } = useVisualLineNavigation({ pagesContainerRef });
// Store callbacks in refs to avoid infinite re-render loops
// when parent passes unstable callback references
const onSelectionChangeRef = useRef(onSelectionChange);
const onDocumentChangeRef = useRef(onDocumentChange);
const onReadyRef = useRef(onReady);
const onRenderedDomContextReadyRef = useRef(onRenderedDomContextReady);
// Keep refs in sync with latest props
onSelectionChangeRef.current = onSelectionChange;
onDocumentChangeRef.current = onDocumentChange;
onReadyRef.current = onReady;
onRenderedDomContextReadyRef.current = onRenderedDomContextReady;
// State
const [isFocused, setIsFocused] = useState(false);
// When HF edit mode engages, the body PM must visually retire — collapse
// its selection to a non-rendered cursor, drop `isFocused`, and blur the
// PM. Otherwise the user sees TWO carets (one in the painted header, one
// in the body) and keystrokes that briefly land on the body before the
// HF view reclaims focus get inserted into the body doc. Symmetric cleanup
// when HF edit mode exits — restore body focus so typing flows back.
useEffect(() => {
if (hfEditMode) {
const view = hiddenPMRef.current?.getView();
if (view) {
try {
const tr = view.state.tr.setSelection(TextSelection.create(view.state.doc, 0));
view.dispatch(tr);
} catch {
// Position may be invalid mid-transaction; SelectionOverlay
// also gates on hfEditMode so the caret stays hidden anyway.
}
(view.dom as HTMLElement).blur?.();
}
setIsFocused(false);
}
}, [hfEditMode]);
// Image selection state — `isImageInteractingRef` lives at the parent so
// useSelectionOverlay can read it (to gate the deferred image-info clear)
// while useImageInteractions writes it (during drag / resize).
const isImageInteractingRef = useRef(false);
// Selection gate - ensures selection renders only when layout is current
const syncCoordinator = useMemo(() => new LayoutSelectionGate(), []);
// Persistent hidden HF PM lookup — phase 1 of HF editing unification.
// Walks `document.package.headers`/`footers` to find the rId for this
// HeaderFooter instance, then asks the HiddenHeaderFooterPMs ref for
// its current PM doc. Returns null when no PM is mounted (cold boot
// before the effect runs) so the pipeline falls back to the Document
// model path. Stable identity per `document` so the layout pipeline
// doesn't re-run on every render.
const getHfPmDoc = useCallback(
(hf: HeaderFooter): PMNode | null => {
const ref = hiddenHfPMsRef.current;
if (!ref) return null;
const pkg = document?.package;
if (!pkg) return null;
const findRid = (bag?: Map<string, HeaderFooter>): string | null => {
if (!bag) return null;
for (const [rId, value] of bag) {
if (value === hf) return rId;
}
return null;
};
const rId = findRid(pkg.headers) ?? findRid(pkg.footers);
if (!rId) return null;
return ref.getView(rId)?.state.doc ?? null;
},
[document]
);
// Layout pipeline — owns layout/blocks/measures state, the rAF-coalesced
// scheduler, scroll-restore plumbing, the painter, and the page-count
// notifier. Returns `notifyDecorationLayer` for the DecorationLayer
// resync that handleTransaction triggers on every PM transaction.
const {
layout,
blocks,
measures,
decorationSyncToken,
notifyDecorationLayer,
contentWidth,
runLayoutPipeline,
scheduleLayout,
} = useLayoutPipeline({
document,
styles,
theme: _theme,
sectionProperties,
finalSectionProperties,
headerContent,
footerContent,
firstPageHeaderContent,
firstPageFooterContent,
getHfPmDoc,
pageGap,
zoom,
resolvedCommentIds,
pagesContainerRef,
viewportLayoutRef,
hiddenPMRef,
syncCoordinator,
getScrollContainer,
onTotalPagesChange,
onAnchorPositionsChange,
onRenderedDomContextReady,
});
// Selection overlay — caret, range rects, image overlay info, plus the
// ResizeObserver + post-layout recompute that keep geometry fresh.
const {
selectionRects,
caretPosition,
selectedImageInfo,
setSelectionRects,
setCaretPosition,
setSelectedImageInfo,
buildImageSelectionInfo,
updateSelectionOverlay,
handleSelectionChange,
} = useSelectionOverlay({
layout,
blocks,
measures,
zoom,
containerRef,
pagesContainerRef,
hiddenPMRef,
syncCoordinator,
isImageInteractingRef,
onSelectionChangeRef,
});
// =========================================================================
// Event Handlers
// =========================================================================
/**
* Handle PM transaction - re-layout on content/selection change.
*/
const handleTransaction = useCallback(
(transaction: Transaction, newState: EditorState) => {
// Bump on every transaction (including selection-only and meta-only
// ones) so DecorationLayer re-syncs — yCursorPlugin awareness updates
// arrive as meta transactions with no doc change.
notifyDecorationLayer();
if (transaction.docChanged) {
// Increment state sequence to signal document changed
syncCoordinator.incrementStateSeq();
// Content changed - schedule layout (coalesced via rAF)
scheduleLayout(newState);
// Notify document change - use ref to avoid infinite loops
const newDoc = hiddenPMRef.current?.getDocument();
if (newDoc) {
onDocumentChangeRef.current?.(newDoc);
}
}
// Request selection update (will only execute when layout is current)
syncCoordinator.requestRender();
// Only update selection overlay immediately for non-doc-changing transactions
// (e.g. arrow keys, clicks). For doc changes, the overlay will be updated
// after layout completes via the useEffect([layout]) hook, avoiding cursor
// flicker from stale DOM positions.
if (!transaction.docChanged) {
updateSelectionOverlay(newState);
}
},
[scheduleLayout, updateSelectionOverlay, syncCoordinator, notifyDecorationLayer]
// NOTE: onDocumentChange removed from dependencies - accessed via ref to prevent infinite loops
);
// Scroll API exposed via the PagedEditorRef. Owns the AbortController
// chain that lets a fresh scroll supersede an in-flight paint-settle.
const { scrollToPositionImpl, scrollToPageImpl, scrollToParaIdImpl } = usePagedScrollApi({
layout,
blocks,
measures,
pagesContainerRef,
hiddenPMRef,
getScrollContainer,
});
// Pointer routing — every mouse path on the visible pages: cursor
// placement, drag-to-select (with cell-selection promotion), table
// resize handles, the floating "+" insert button, hyperlink clicks,
// header/footer double-clicks, word/paragraph multi-click, and
// right-click → host context-menu.
const {
handlePagesMouseDown,
handlePagesMouseMove,
handlePagesClick,
handlePagesContextMenu,
handleTableInsertClick,
tableInsertButton,
clearTableInsertTimer,
hideTableInsertButton,
getPositionFromMouse,
} = usePagesPointer({
pagesContainerRef,
hiddenPMRef,
// Resolve the active HF EditorView for the current `hfEditMode` slot
// so usePagesPointer can route every gesture (single-click, drag,
// multi-click, image-select, hyperlink, context menu) through the
// HF PM instead of the body PM.
getHfView: useCallback(() => {
const hfRef = hiddenHfPMsRef.current;
if (!hfRef) return null;
const sp = document?.package?.document?.finalSectionProperties;
const refs = hfEditMode === 'header' ? sp?.headerReferences : sp?.footerReferences;
const refEntry =
refs?.find((r) => r.type === 'default') ?? refs?.find((r) => r.type === 'first') ?? null;
if (!refEntry?.rId) return null;
return hfRef.getView(refEntry.rId);
}, [hfEditMode, document]),
layout,
blocks,
measures,
zoom,
readOnly,
hfEditMode,
onBodyClick,
onContextMenu,
onHyperlinkClick,
onHeaderFooterDoubleClick,
setSelectedImageInfo,
setSelectionRects,
setCaretPosition,
buildImageSelectionInfo,
setIsFocused,
scrollToPositionImpl,
});
/**
* Handle focus on container - redirect to hidden PM.
*/
const handleContainerFocus = useCallback(
(e: React.FocusEvent) => {
if (readOnly) return;
// Don't steal focus from sidebar inputs (textareas, inputs, buttons)
const target = e.target as HTMLElement;
if (target.closest('.docx-comments-sidebar') || target.closest('.docx-unified-sidebar'))
return;
// Don't steal focus from the hyperlink popup's text/URL inputs —
// the focus event bubbles up here and would bounce focus back to
// the body PM, making the inputs impossible to edit.
if (target.closest('.ep-hyperlink-popup')) return;
// Phase 5 of HF editing unification: when focus lands on one of
// the persistent hidden HF PMs (mounted off-screen as siblings of
// `.layout-page-content`), don't redirect to the body PM — that
// would steal focus from the HF editor the user just opened.
// `data-hf-r-id` is set on the host div by HiddenHeaderFooterPMs.
if (target.closest('[data-hf-r-id]')) return;
hiddenPMRef.current?.focus();
setIsFocused(true);
},
[readOnly]
);
/**
* Handle blur from container.
*/
const handleContainerBlur = useCallback((e: React.FocusEvent) => {
// Check if focus is moving to hidden PM or staying within container
const relatedTarget = e.relatedTarget as HTMLElement | null;
if (relatedTarget && containerRef.current?.contains(relatedTarget)) {
return; // Focus staying within editor
}
// Keep selection visible when focus moves to toolbar or dropdown portals
if (
relatedTarget?.closest(
'[role="toolbar"], [data-radix-popper-content-wrapper], [data-radix-select-content], .docx-table-options-dropdown'
)
) {
return;
}
setIsFocused(false);
}, []);
// Image overlay interactions — resize + drag-to-move. Owns the writes
// to `isImageInteractingRef` that gate the selection hook's deferred
// image-info clear during drag/resize gestures.
const {
handleImageResize,
handleImageResizeStart,
handleImageResizeEnd,
handleImageDragMove,
handleImageDragStart,
handleImageDragEnd,
} = useImageInteractions({
pagesContainerRef,
hiddenPMRef,
zoom,
isImageInteractingRef,
getPositionFromMouse,
});
/**
* Handle keyboard events on container.
* Most keyboard handling is done by ProseMirror, but we intercept
* specific keys for navigation and ensure focus stays on hidden PM.
*/
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (readOnly) return;
// Don't steal focus from a persistent HF EditorView — body's
// `isFocused()` check would return false while HF is focused,
// causing the body PM to grab every keystroke after the first.
const target = e.target as HTMLElement | null;
if (target?.closest('[data-hf-r-id]')) return;
// Don't hijack keystrokes typed into the hyperlink popup's inputs —
// refocusing the body PM here would steal focus mid-type and route
// keys (e.g. space) into the document instead of the input.
if (target?.closest('.ep-hyperlink-popup')) return;
// Ensure hidden PM is focused if user types
if (!hiddenPMRef.current?.isFocused()) {
hiddenPMRef.current?.focus();
setIsFocused(true);
}
// Prevent space from scrolling the container - let PM handle it as text input.
// During IME composition, let the browser handle space natively to avoid
// duplicating the final composed character (e.g., Korean Hangul).
if (e.key === ' ' && !e.ctrlKey && !e.metaKey && !e.nativeEvent.isComposing) {
e.preventDefault();
const view = hiddenPMRef.current?.getView();
if (view) {
// Route through handleTextInput so plugins (suggestion mode) can intercept
const { from, to } = view.state.selection;
const handled = view.someProp('handleTextInput', (f: Function) =>
f(view, from, to, ' ')
);
if (!handled) {
view.dispatch(view.state.tr.insertText(' '));
}
}
return;
}
// PageUp/PageDown - let container handle scrolling
if (['PageUp', 'PageDown'].includes(e.key) && !e.metaKey && !e.ctrlKey) {
// Let PM handle the cursor movement first
// If PM doesn't handle it (at bounds), the container will scroll
}
// Cmd/Ctrl+Home - scroll to top and move cursor to start
if (e.key === 'Home' && (e.metaKey || e.ctrlKey)) {
const sc = getScrollContainer();
if (sc) sc.scrollTop = 0;
}
// Cmd/Ctrl+End - scroll to bottom and move cursor to end
if (e.key === 'End' && (e.metaKey || e.ctrlKey)) {
const sc = getScrollContainer();
if (sc) sc.scrollTop = sc.scrollHeight;
}
},
[readOnly, getScrollContainer]
);
/**
* Handle mousedown on container (outside pages).
*/
const handleContainerMouseDown = useCallback(
(e: React.MouseEvent) => {
if (readOnly) return;
// Don't steal focus from sidebar inputs
if (
(e.target as HTMLElement).closest('.docx-comments-sidebar') ||
(e.target as HTMLElement).closest('.docx-unified-sidebar')
)
return;
// Focus hidden PM if clicking outside pages area
if (!hiddenPMRef.current?.isFocused()) {
hiddenPMRef.current?.focus();
setIsFocused(true);
}
},
[readOnly]
);
// =========================================================================
// Initial Layout
// =========================================================================
/**
* Run initial layout when document or view changes.
*/
const handleEditorViewReady = useCallback(
(view: EditorView) => {
runLayoutPipeline(view.state);
updateSelectionOverlay(view.state);
// Auto-focus the editor so the user can start typing immediately
if (!readOnly) {
// Use requestAnimationFrame to ensure DOM is ready
requestAnimationFrame(() => {
view.focus();
setIsFocused(true);
});
}
},
[runLayoutPipeline, updateSelectionOverlay, readOnly]
);
// Re-layout triggers: web-font load complete + header/footer content changes.
useLayoutTriggers({
hiddenPMRef,
runLayoutPipeline,
updateSelectionOverlay,
headerContent,
footerContent,
firstPageHeaderContent,
firstPageFooterContent,
});
// Imperative-handle setup — exposes PagedEditorRef + mirrors via onReady.
usePagedEditorRefApi({
ref,
hiddenPMRef,
hiddenHfPMsRef,
documentRef,
layout,
runLayoutPipeline,
scrollToPositionImpl,
scrollToParaIdImpl,
scrollToPageImpl,
setIsFocused,
onReadyRef,
});
// =========================================================================
// Render
// =========================================================================
// Min-height of the viewport wrapper. Delegates to `viewportMinHeightPx`
// so the same math runs in both the JSX commit and the imperative write
// the layout pipeline does mid-pipeline (needed for scroll-restore math
// before React commits).
const totalHeight = useMemo(() => {
if (!layout) return DEFAULT_PAGE_HEIGHT_PX + VIEWPORT_PADDING_TOP + VIEWPORT_PADDING_BOTTOM;
return viewportMinHeightPx(layout, pageGap);
}, [layout, pageGap]);
return (
<div
ref={containerRef}
className={`ep-root paged-editor ${className ?? ''}`}
style={{ ...containerStyles, ...style }}
tabIndex={0}
onFocus={handleContainerFocus}
onBlur={handleContainerBlur}
onKeyDown={handleKeyDown}
onMouseDown={handleContainerMouseDown}
>
{/* Hidden ProseMirror for keyboard input */}
<HiddenProseMirror
ref={hiddenPMRef}
document={document}
styles={styles}
widthPx={contentWidth}
// When HF mode is active, the body PM is functionally retired —
// marking it readOnly stops keystrokes / input events from being
// applied to the body doc even if focus briefly slips to it.
readOnly={readOnly || !!hfEditMode}
onTransaction={handleTransaction}
onSelectionChange={handleSelectionChange}
externalPlugins={externalPlugins}
extensionManager={extensionManager}
onEditorViewReady={handleEditorViewReady}
onKeyDown={handlePMKeyDown}
/>
{/* Persistent hidden HF PMs — one per distinct rId */}
<HiddenHeaderFooterPMs
ref={hiddenHfPMsRef}
document={document}
styles={styles}
theme={_theme}
defaultTabStopTwips={document?.package?.settings?.defaultTabStop ?? null}
onTransaction={(rId, view, docChanged) => {
// Only re-layout when the HF doc actually changed — selection-only
// transactions (arrow keys, click-to-position) don't move text, so
// the painter has nothing new to paint.
if (docChanged) {
const bodyState = hiddenPMRef.current?.getState();
if (bodyState) runLayoutPipeline(bodyState);
}
onHfTransaction?.(rId, view, docChanged);
}}
/>
{/* Viewport for visible pages */}
<div
ref={viewportLayoutRef}
style={{
...viewportStyles,
minHeight: totalHeight,
// Negative margin at zoom<1 shrinks scroll area to match visual height;
// positive margin at zoom>1 grows it so content isn't clipped.
marginBottom: zoom !== 1 ? totalHeight * (zoom - 1) : undefined,
transform: (() => {
const parts: string[] = [];
if (commentsSidebarOpen) {
// Center page + sidebar as a unit within the container
parts.push(`translateX(-${SIDEBAR_DOCUMENT_SHIFT}px)`);
}
if (zoom !== 1) parts.push(`scale(${zoom})`);
return parts.length > 0 ? parts.join(' ') : undefined;
})(),
transformOrigin: 'top center',
transition: 'transform 0.2s ease',
}}
>
{/* Pages container */}
<div
ref={pagesContainerRef}
className={`paged-editor__pages${readOnly ? ' paged-editor--readonly' : ''}${hfEditMode ? ` paged-editor--hf-editing paged-editor--editing-${hfEditMode}` : ''}`}
style={pagesContainerStyles}
onMouseDown={handlePagesMouseDown}
onMouseMove={handlePagesMouseMove}
onClick={handlePagesClick}
onContextMenu={handlePagesContextMenu}
aria-hidden="true" // Visual only, PM provides semantic content
/>
{/* Selection overlay — hidden while HF edit mode is active so the
body PM caret + selection rects don't render alongside the
header's. The HF view owns its own caret via DocxEditorPagedArea
`hfCaretRect`. */}
<SelectionOverlay
selectionRects={hfEditMode ? [] : selectionRects}
caretPosition={hfEditMode ? null : caretPosition}
isFocused={isFocused && !hfEditMode}
pageGap={pageGap}
readOnly={readOnly}
/>
{/* Image selection overlay */}
<ImageSelectionOverlay
imageInfo={selectedImageInfo}
zoom={zoom}
isFocused={isFocused}
onResize={handleImageResize}
onResizeStart={handleImageResizeStart}
onResizeEnd={handleImageResizeEnd}
onDragMove={handleImageDragMove}
onDragStart={handleImageDragStart}
onDragEnd={handleImageDragEnd}
onContextMenu={handlePagesContextMenu}
/>
{/* Table quick action insert button */}
{tableInsertButton && (
<TableInsertButton
type={tableInsertButton.type}
x={tableInsertButton.x}
y={tableInsertButton.y}
onMouseDown={handleTableInsertClick}
onMouseEnter={clearTableInsertTimer}
onMouseLeave={hideTableInsertButton}
/>
)}
{/* Plugin overlays (highlights, annotations) */}
{pluginOverlays && (
<div className="paged-editor__plugin-overlays" style={pluginOverlaysStyles}>
{pluginOverlays}
</div>
)}
{/* Generic PM decoration forwarder — surfaces yCursorPlugin remote
cursors, search-highlight plugins, etc. on the visible pages.
No-op when no plugin emits decorations. */}
<DecorationLayer
getView={() => hiddenPMRef.current?.getView() ?? null}
getPagesContainer={() => pagesContainerRef.current}
zoom={zoom}
decorationSyncToken={decorationSyncToken}
syncCoordinator={syncCoordinator}
/>
</div>
{/* Sidebar overlay — positioned to match visual document height, visible overflow for sidebar items */}
{sidebarOverlay && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: totalHeight * zoom,
pointerEvents: 'none',
overflow: 'visible',
}}
>
<div style={{ pointerEvents: 'auto' }}>{sidebarOverlay}</div>
</div>
)}
{/* Hyperlink popup — rendered inside containerRef so it shares a
scroll context with the link. position: absolute + coords in
container space mean the browser repositions on scroll for free. */}
{hyperlinkPopupData &&
onHyperlinkPopupNavigate &&
onHyperlinkPopupCopy &&
onHyperlinkPopupEdit &&
onHyperlinkPopupRemove &&
onHyperlinkPopupClose && (
<HyperlinkPopup
data={hyperlinkPopupData}
onNavigate={onHyperlinkPopupNavigate}
onCopy={onHyperlinkPopupCopy}
onEdit={onHyperlinkPopupEdit}
onRemove={onHyperlinkPopupRemove}
onClose={onHyperlinkPopupClose}
readOnly={readOnly}
/>
)}
</div>
);
}
);
export const PagedEditor = memo(PagedEditorComponent);
export default PagedEditor;