- {/* Main content area */}
-
- {/* Wrapper for toolbar + scroll container + outline overlay */}
-
- {/* Toolbar - above the scroll container so scrollbar doesn't extend behind it */}
- {/* Hide toolbar only when readOnly prop is explicitly set (not from viewing mode) */}
- {showToolbar && !readOnlyProp && (
-
-
+
+ {/* Main content area */}
+
+ {/* Wrapper for scroll container + outline overlay */}
+
+ {/* Ribbon/toolbar header - fixed above scroll container */}
+ {shouldShowToolbar && (
+
-
- {renderLogo && {renderLogo()}}
- {documentName !== undefined && (
-
- )}
- {renderTitleBarRight && (
-
- {renderTitleBarRight()}
-
- )}
-
-
-
{toolbarChildren}
-
-
- {/* Horizontal Ruler - sticky with toolbar */}
- {showRuler && (
-
-
-
- )}
-
- )}
+ ) : (
+ <>
+ {useEditorToolbar ? (
+
+
+ {renderLogo && (
+ {renderLogo()}
+ )}
+ {documentName !== undefined && (
+
+ )}
+ {renderTitleBarRight && (
+
+ {renderTitleBarRight()}
+
+ )}
+
+
+
+ {toolbarChildren}
+
+
+ ) : (
+
+ {toolbarChildren}
+
+ )}
+ >
+ )}
- {/* Editor container - this is the scroll container (toolbar is above, not inside) */}
-
- {/* Editor content wrapper */}
-
- {/* Editor content area */}
-
{
- // Focus editor when clicking on the background area (not the editor itself)
- // Using mouseDown for immediate response before focus can be lost
- if (e.target === e.currentTarget) {
- e.preventDefault();
- pagedEditorRef.current?.focus();
- }
- }}
- onContextMenu={handleEditorContextMenu}
- >
- {/* Vertical Ruler - fixed on left edge (hidden when readOnly prop is set) */}
- {showRuler && !readOnlyProp && (
+ {/* Horizontal Ruler - fixed with ribbon header */}
+ {effectiveShowRuler && (
-
)}
- {/* Brighten highlight for the focused/expanded sidebar item */}
- {expandedSidebarItem && expandedSidebarItem.startsWith('comment-') && (
-
- )}
- {expandedSidebarItem?.startsWith('tc-') && (
-
+ )}
+ {expandedSidebarItem?.startsWith('tc-') && (
+
- )}
-
{
- // Extract full selection state from PM and use the standard handler
- const view = pagedEditorRef.current?.getView();
- if (view) {
- const selectionState = extractSelectionState(view.state);
- handleSelectionChange(selectionState);
- } else {
- handleSelectionChange(null);
- }
- }}
- externalPlugins={allExternalPlugins}
- onReady={(ref) => {
- onEditorViewReady?.(ref.getView()!);
- }}
- onRenderedDomContextReady={onRenderedDomContextReady}
- pluginOverlays={pluginOverlays}
- onHyperlinkClick={handleHyperlinkClick}
- onContextMenu={handleContextMenu}
- commentsSidebarOpen={sidebarOpen}
- onAnchorPositionsChange={setAnchorPositions}
- resolvedCommentIds={resolvedIdsForRender}
- scrollContainerRef={scrollContainerRef}
- sidebarOverlay={
- <>
- {allSidebarItems.length > 0 && (
- {
+ // Extract full selection state from PM and use the standard handler
+ const view = pagedEditorRef.current?.getView();
+ if (view) {
+ const selectionState = extractSelectionState(view.state);
+ handleSelectionChange(selectionState);
+ } else {
+ handleSelectionChange(null);
+ }
+ }}
+ externalPlugins={allExternalPlugins}
+ onReady={(ref) => {
+ onEditorViewReady?.(ref.getView()!);
+ }}
+ onRenderedDomContextReady={onRenderedDomContextReady}
+ pluginOverlays={pluginOverlays}
+ onHyperlinkClick={handleHyperlinkClick}
+ onContextMenu={handleContextMenu}
+ commentsSidebarOpen={sidebarOpen}
+ onAnchorPositionsChange={setAnchorPositions}
+ resolvedCommentIds={resolvedIdsForRender}
+ scrollContainerRef={scrollContainerRef}
+ sidebarOverlay={
+ <>
+ {allSidebarItems.length > 0 && (
+ {
+ const sp =
+ history.state?.package?.document?.finalSectionProperties;
+ return sp?.pageWidth ? Math.round(sp.pageWidth / 15) : 816;
+ })()}
+ zoom={state.zoom}
+ editorContainerRef={scrollContainerRef}
+ onExpandedItemChange={setExpandedSidebarItem}
+ />
+ )}
+ {
const sp = history.state?.package?.document?.finalSectionProperties;
return sp?.pageWidth ? Math.round(sp.pageWidth / 15) : 816;
})()}
- zoom={state.zoom}
- editorContainerRef={scrollContainerRef}
- onExpandedItemChange={setExpandedSidebarItem}
+ sidebarOpen={sidebarOpen}
+ resolvedCommentIds={resolvedCommentIds}
+ onMarkerClick={() => {
+ setShowCommentsSidebar(true);
+ }}
/>
- )}
- {
- const sp = history.state?.package?.document?.finalSectionProperties;
- return sp?.pageWidth ? Math.round(sp.pageWidth / 15) : 816;
- })()}
- sidebarOpen={sidebarOpen}
- resolvedCommentIds={resolvedCommentIds}
- onMarkerClick={() => {
- setShowCommentsSidebar(true);
- }}
- />
- >
- }
- />
+ >
+ }
+ />
- {/* Floating "add comment" button — appears on right edge of page at selection */}
- {floatingCommentBtn != null && !isAddingComment && !readOnly && (
-
-
+
+ )}
- {/* Right-click context menu */}
- setContextMenu((prev) => ({ ...prev, isOpen: false }))}
- />
+ {/* Right-click context menu */}
+ setContextMenu((prev) => ({ ...prev, isOpen: false }))}
+ />
- {/* Inline Header/Footer Editor — positioned over the target area */}
- {hfEditPosition &&
- (() => {
- const activeHf = hfEditIsFirstPage
- ? hfEditPosition === 'header'
- ? firstPageHeaderContent
- : firstPageFooterContent
- : hfEditPosition === 'header'
- ? headerContent
- : footerContent;
- if (!activeHf) return null;
- const targetEl = getHfTargetElement(hfEditPosition);
- const parentEl = editorContentRef.current;
- if (!targetEl || !parentEl) return null;
- return (
- setHfEditPosition(null)}
- onSelectionChange={handleSelectionChange}
- onRemove={handleRemoveHeaderFooter}
+ {/* Page navigation / indicator */}
+ {showPageNumbers &&
+ state.totalPages > 0 &&
+ (enablePageNavigation ? (
+
- );
- })()}
+ ) : (
+
+ ))}
+
+ {/* Inline Header/Footer Editor — positioned over the target area */}
+ {hfEditPosition &&
+ (() => {
+ const activeHf = hfEditIsFirstPage
+ ? hfEditPosition === 'header'
+ ? firstPageHeaderContent
+ : firstPageFooterContent
+ : hfEditPosition === 'header'
+ ? headerContent
+ : footerContent;
+ if (!activeHf) return null;
+ const targetEl = getHfTargetElement(hfEditPosition);
+ const parentEl = editorContentRef.current;
+ if (!targetEl || !parentEl) return null;
+ return (
+ setHfEditPosition(null)}
+ onSelectionChange={handleSelectionChange}
+ onRemove={handleRemoveHeaderFooter}
+ />
+ );
+ })()}
+
+ {/* end editor flex wrapper */}
- {/* end editor flex wrapper */}
+ {/* end scroll container */}
+
+ {/* Document outline sidebar — absolutely positioned, doesn't scroll */}
+ {showOutline && (
+
setShowOutline(false)}
+ topOffset={toolbarHeight}
+ />
+ )}
+
+ {/* Unified sidebar (comments + plugin items) rendered inside PagedEditor via sidebarOverlay prop */}
+
+ {/* Outline toggle button — absolutely positioned below toolbar */}
+ {showOutlineButton && !showOutline && (
+ e.stopPropagation()}
+ title="Show document outline"
+ style={{
+ position: 'absolute',
+ left: 48,
+ top: toolbarHeight + 12,
+ zIndex: 20,
+ background: 'transparent',
+ border: 'none',
+ borderRadius: '50%',
+ padding: 6,
+ cursor: 'pointer',
+ display: 'flex',
+ alignItems: 'center',
+ }}
+ >
+
+
+ )}
- {/* end scroll container */}
+ {/* end wrapper for scroll container + outline */}
+
- {/* Page indicator — Google Docs style, next to scrollbar while scrolling */}
- {scrollPageInfo.totalPages > 1 && (
-
- {scrollPageInfo.currentPage} of {scrollPageInfo.totalPages}
-
+ {/* Hyperlink popup (Google Docs-style) */}
+
+
+ {/* Right-click context menu */}
+
+
+ {/* Toast notifications */}
+
+
+ {/* Lazy-loaded dialogs — only fetched when first opened */}
+
+ {findReplace.state.isOpen && (
+
)}
-
- {/* Document outline sidebar — absolutely positioned, doesn't scroll */}
- {showOutline && (
- setShowOutline(false)}
- topOffset={toolbarHeight}
+ {hyperlinkDialog.state.isOpen && (
+
)}
-
- {/* Unified sidebar (comments + plugin items) rendered inside PagedEditor via sidebarOverlay prop */}
-
- {/* Outline toggle button — absolutely positioned below toolbar */}
- {showOutlineButton && !showOutline && (
- e.stopPropagation()}
- title="Show document outline"
- style={{
- position: 'absolute',
- left: 48,
- top: toolbarHeight + 12,
- zIndex: 20,
- background: 'transparent',
- border: 'none',
- borderRadius: '50%',
- padding: 6,
- cursor: 'pointer',
- display: 'flex',
- alignItems: 'center',
+ {tablePropsOpen && (
+ setTablePropsOpen(false)}
+ onApply={(props) => {
+ const view = getActiveEditorView();
+ if (view) {
+ setTableProperties(props)(view.state, view.dispatch);
+ }
}}
- >
-
-
+ currentProps={
+ state.pmTableContext?.table?.attrs as Record | undefined
+ }
+ />
)}
-
- {/* end wrapper for scroll container + outline */}
-
-
- {/* Hyperlink popup (Google Docs-style) */}
-
-
- {/* Right-click context menu */}
-
-
- {/* Toast notifications */}
-
-
- {/* Lazy-loaded dialogs — only fetched when first opened */}
-
- {findReplace.state.isOpen && (
-
- )}
- {hyperlinkDialog.state.isOpen && (
-
- )}
- {tablePropsOpen && (
- setTablePropsOpen(false)}
- onApply={(props) => {
- const view = getActiveEditorView();
- if (view) {
- setTableProperties(props)(view.state, view.dispatch);
+ {imagePositionOpen && (
+ setImagePositionOpen(false)}
+ onApply={handleApplyImagePosition}
+ />
+ )}
+ {imagePropsOpen && (
+ setImagePropsOpen(false)}
+ onApply={handleApplyImageProperties}
+ currentData={
+ state.pmImageContext
+ ? {
+ alt: state.pmImageContext.alt ?? undefined,
+ borderWidth: state.pmImageContext.borderWidth ?? undefined,
+ borderColor: state.pmImageContext.borderColor ?? undefined,
+ borderStyle: state.pmImageContext.borderStyle ?? undefined,
+ }
+ : undefined
}
- }}
- currentProps={
- state.pmTableContext?.table?.attrs as Record | undefined
- }
- />
- )}
- {imagePositionOpen && (
- setImagePositionOpen(false)}
- onApply={handleApplyImagePosition}
- />
- )}
- {imagePropsOpen && (
- setImagePropsOpen(false)}
- onApply={handleApplyImageProperties}
- currentData={
- state.pmImageContext
- ? {
- alt: state.pmImageContext.alt ?? undefined,
- borderWidth: state.pmImageContext.borderWidth ?? undefined,
- borderColor: state.pmImageContext.borderColor ?? undefined,
- borderStyle: state.pmImageContext.borderStyle ?? undefined,
- }
- : undefined
- }
- />
- )}
- {showPageSetup && (
- setShowPageSetup(false)}
- onApply={handlePageSetupApply}
- currentProps={history.state?.package.document?.finalSectionProperties}
- />
- )}
- {footnotePropsOpen && (
- setFootnotePropsOpen(false)}
- onApply={handleApplyFootnoteProperties}
- footnotePr={history.state?.package.document?.finalSectionProperties?.footnotePr}
- endnotePr={history.state?.package.document?.finalSectionProperties?.endnotePr}
- />
- )}
-
- {/* InlineHeaderFooterEditor is rendered inside the editor content area (position:relative div) */}
- {/* Hidden file input for image insertion */}
-
-
+ />
+ )}
+ {imageSizeOpen && (
+
+ )}
+ {showPageSetup && (
+
setShowPageSetup(false)}
+ onApply={handlePageSetupApply}
+ currentProps={history.state?.package.document?.finalSectionProperties}
+ />
+ )}
+ {footnotePropsOpen && (
+ setFootnotePropsOpen(false)}
+ onApply={handleApplyFootnoteProperties}
+ footnotePr={history.state?.package.document?.finalSectionProperties?.footnotePr}
+ endnotePr={history.state?.package.document?.finalSectionProperties?.endnotePr}
+ />
+ )}
+
+ {/* InlineHeaderFooterEditor is rendered inside the editor content area (position:relative div) */}
+ {/* Hidden file input for image insertion */}
+
+
+
);
diff --git a/packages/react/src/components/DocxEditor.tsx.orig b/packages/react/src/components/DocxEditor.tsx.orig
new file mode 100644
index 00000000..91ba2a35
--- /dev/null
+++ b/packages/react/src/components/DocxEditor.tsx.orig
@@ -0,0 +1,3457 @@
+/**
+ * DocxEditor Component
+ *
+ * Main component integrating all editor features:
+ * - Toolbar for formatting
+ * - ProseMirror-based editor for content editing
+ * - Zoom control
+ * - Error boundary
+ * - Loading states
+ */
+
+import {
+ useRef,
+ useCallback,
+ useState,
+ useEffect,
+ useMemo,
+ forwardRef,
+ useImperativeHandle,
+ lazy,
+ Suspense,
+} from 'react';
+import type { CSSProperties, ReactNode } from 'react';
+import type {
+ Document,
+ Theme,
+ HeaderFooter,
+ SectionProperties,
+} from '@eigenpal/docx-core/types/document';
+
+import { Toolbar, ToolbarButton, ToolbarSeparator } from './Toolbar';
+import type { SelectionFormatting, FormattingAction } from './toolbarTypes';
+import { RibbonToolbar } from './Ribbon';
+import { pointsToHalfPoints } from './ui/FontSizePicker';
+import { DocumentOutline } from './DocumentOutline';
+import { CommentsSidebar, type TrackedChangeEntry } from './CommentsSidebar';
+import type { HeadingInfo } from '@eigenpal/docx-core/utils/headingCollector';
+import type { Comment } from '@eigenpal/docx-core/types/content';
+import { ErrorBoundary, ErrorProvider } from './ErrorBoundary';
+import type { TableAction } from './ui/TableToolbar';
+import { mapHexToHighlightName } from './toolbarUtils';
+import {
+ PageNumberIndicator,
+ type PageIndicatorPosition,
+ type PageIndicatorVariant,
+} from './ui/PageNumberIndicator';
+import {
+ PageNavigator,
+ type PageNavigatorPosition,
+ type PageNavigatorVariant,
+} from './ui/PageNavigator';
+import { HorizontalRuler } from './ui/HorizontalRuler';
+import { EditingModeDropdown, type EditorMode } from './ui/EditingModeDropdown';
+import { VerticalRuler } from './ui/VerticalRuler';
+import { type PrintOptions } from './ui/PrintPreview';
+// Dialog hooks and utilities (static imports — lightweight, no UI)
+import {
+ useFindReplace,
+ findInDocument,
+ scrollToMatch,
+ type FindMatch,
+ type FindOptions,
+ type FindResult,
+} from './dialogs/FindReplaceDialog';
+import { useHyperlinkDialog, type HyperlinkData } from './dialogs/HyperlinkDialog';
+import type { ImagePositionData } from './dialogs/ImagePositionDialog';
+import type { ImagePropertiesData } from './dialogs/ImagePropertiesDialog';
+import {
+ InlineHeaderFooterEditor,
+ type InlineHeaderFooterEditorRef,
+} from './InlineHeaderFooterEditor';
+
+// Dialog components (lazy-loaded — only fetched when first opened)
+const FindReplaceDialog = lazy(() => import('./dialogs/FindReplaceDialog'));
+const HyperlinkDialog = lazy(() => import('./dialogs/HyperlinkDialog'));
+const TablePropertiesDialog = lazy(() =>
+ import('./dialogs/TablePropertiesDialog').then((m) => ({ default: m.TablePropertiesDialog }))
+);
+const ImagePositionDialog = lazy(() =>
+ import('./dialogs/ImagePositionDialog').then((m) => ({ default: m.ImagePositionDialog }))
+);
+const ImagePropertiesDialog = lazy(() =>
+ import('./dialogs/ImagePropertiesDialog').then((m) => ({ default: m.ImagePropertiesDialog }))
+);
+const FootnotePropertiesDialog = lazy(() =>
+ import('./dialogs/FootnotePropertiesDialog').then((m) => ({
+ default: m.FootnotePropertiesDialog,
+ }))
+);
+const PageSetupDialog = lazy(() =>
+ import('./dialogs/PageSetupDialog').then((m) => ({ default: m.PageSetupDialog }))
+);
+import { MaterialSymbol } from './ui/Icons';
+import { Tooltip } from './ui/Tooltip';
+import { HyperlinkPopup, type HyperlinkPopupData } from './ui/HyperlinkPopup';
+import { Toaster, toast } from 'sonner';
+import { getBuiltinTableStyle, type TableStylePreset } from './ui/TableStyleGallery';
+import { DocumentAgent } from '@eigenpal/docx-core/agent/DocumentAgent';
+import { DefaultLoadingIndicator, DefaultPlaceholder, ParseError } from './DocxEditorHelpers';
+import { parseDocx } from '@eigenpal/docx-core/docx/parser';
+import { type DocxInput } from '@eigenpal/docx-core/utils/docxInput';
+import { onFontsLoaded, loadDocumentFonts } from '@eigenpal/docx-core/utils/fontLoader';
+import { resolveColor } from '@eigenpal/docx-core/utils/colorResolver';
+import { twipsToPixels } from '@eigenpal/docx-core/utils/units';
+import { executeCommand } from '@eigenpal/docx-core/agent/executor';
+import { useTableSelection } from '../hooks/useTableSelection';
+import { useDocumentHistory } from '../hooks/useHistory';
+import { clampZoom } from '../hooks/useWheelZoom';
+
+// Extension system
+import { createStarterKit } from '@eigenpal/docx-core/prosemirror/extensions/StarterKit';
+import { ExtensionManager } from '@eigenpal/docx-core/prosemirror/extensions/ExtensionManager';
+import {
+ createSuggestionModePlugin,
+ setSuggestionMode,
+} from '@eigenpal/docx-core/prosemirror/plugins/suggestionMode';
+
+// Conversion (for HF inline editor save)
+import { proseDocToBlocks } from '@eigenpal/docx-core/prosemirror/conversion/fromProseDoc';
+
+// ProseMirror editor
+import {
+ type SelectionState,
+ TextSelection,
+ extractSelectionState,
+ toggleBold,
+ toggleItalic,
+ toggleUnderline,
+ toggleStrike,
+ toggleSuperscript,
+ toggleSubscript,
+ setTextColor,
+ clearTextColor,
+ setHighlight,
+ setFontSize,
+ setFontFamily,
+ setAlignment,
+ setLineSpacing,
+ toggleBulletList,
+ toggleNumberedList,
+ increaseIndent,
+ decreaseIndent,
+ setIndentLeft,
+ setIndentRight,
+ setIndentFirstLine,
+ toggleParagraphBottomBorder,
+ removeTabStop,
+ increaseListLevel,
+ decreaseListLevel,
+ clearFormatting,
+ applyStyle,
+ createStyleResolver,
+ // Hyperlink commands
+ getHyperlinkAttrs,
+ getSelectedText,
+ setHyperlink,
+ removeHyperlink,
+ insertHyperlink,
+ // Text direction commands
+ setRtl,
+ setLtr,
+ // Page break command
+ insertPageBreak,
+ // Table of Contents command
+ generateTOC,
+ // Table commands
+ getTableContext,
+ insertTable,
+ addRowAbove,
+ addRowBelow,
+ deleteRow as pmDeleteRow,
+ addColumnLeft,
+ addColumnRight,
+ deleteColumn as pmDeleteColumn,
+ deleteTable as pmDeleteTable,
+ selectTable as pmSelectTable,
+ selectRow as pmSelectRow,
+ selectColumn as pmSelectColumn,
+ mergeCells as pmMergeCells,
+ splitCell as pmSplitCell,
+ setCellBorder,
+ setCellVerticalAlign,
+ setCellMargins,
+ setCellTextDirection,
+ toggleNoWrap,
+ setRowHeight,
+ toggleHeaderRow,
+ distributeColumns,
+ autoFitContents,
+ setTableProperties,
+ applyTableStyle,
+ removeTableBorders,
+ setAllTableBorders,
+ setOutsideTableBorders,
+ setInsideTableBorders,
+ setCellFillColor,
+ setTableBorderColor,
+ setTableBorderWidth,
+ type TableContextInfo,
+} from '@eigenpal/docx-core/prosemirror';
+import { acceptChange, rejectChange } from '@eigenpal/docx-core/prosemirror/commands/comments';
+import { collectHeadings } from '@eigenpal/docx-core/utils/headingCollector';
+import {
+ getChangedParagraphIds,
+ hasStructuralChanges,
+ hasUntrackedChanges,
+ clearTrackedChanges,
+} from '@eigenpal/docx-core/prosemirror/extensions/features/ParagraphChangeTrackerExtension';
+
+// Paginated editor
+import { PagedEditor, type PagedEditorRef } from '../paged-editor/PagedEditor';
+
+// Plugin API types
+import type { RenderedDomContext } from '../plugin-api/types';
+
+// ============================================================================
+// TYPES
+// ============================================================================
+
+/**
+ * DocxEditor props
+ */
+export interface DocxEditorProps {
+ /** Document data — ArrayBuffer, Uint8Array, Blob, or File */
+ documentBuffer?: DocxInput | null;
+ /** Pre-parsed document (alternative to documentBuffer) */
+ document?: Document | null;
+ /** Callback when document is saved */
+ onSave?: (buffer: ArrayBuffer) => void;
+ /** Author name used for comments and track changes */
+ author?: string;
+ /** Callback when document changes */
+ onChange?: (document: Document) => void;
+ /** Callback when selection changes */
+ onSelectionChange?: (state: SelectionState | null) => void;
+ /** Callback on error */
+ onError?: (error: Error) => void;
+ /** Callback when fonts are loaded */
+ onFontsLoaded?: () => void;
+ /** External ProseMirror plugins (from PluginHost) */
+ externalPlugins?: import('prosemirror-state').Plugin[];
+ /** Callback when editor view is ready (for PluginHost) */
+ onEditorViewReady?: (view: import('prosemirror-view').EditorView) => void;
+ /** Theme for styling */
+ theme?: Theme | null;
+ /** Toolbar mode (default: 'compact'). */
+ toolbar?: 'compact' | 'ribbon';
+ /** Whether to show toolbar in read-only mode (default: false for compact, true for ribbon) */
+ showToolbarWhenReadOnly?: boolean;
+ /** Whether to show zoom control (default: true) */
+ showZoomControl?: boolean;
+ /** Whether to show page number indicator (default: true) */
+ showPageNumbers?: boolean;
+ /** Whether to enable interactive page navigation (default: true) */
+ enablePageNavigation?: boolean;
+ /** Position of page number indicator (default: 'bottom-center') */
+ pageNumberPosition?: PageIndicatorPosition | PageNavigatorPosition;
+ /** Variant of page number indicator (default: 'default') */
+ pageNumberVariant?: PageIndicatorVariant | PageNavigatorVariant;
+ /** Whether to show page margin guides/boundaries (default: false) */
+ showMarginGuides?: boolean;
+ /** Color for margin guides (default: '#c0c0c0') */
+ marginGuideColor?: string;
+ /** Whether to show horizontal ruler (default: false) */
+ showRuler?: boolean;
+ /** Callback when ruler visibility changes */
+ onShowRulerChange?: (visible: boolean) => void;
+ /** Unit for ruler display (default: 'inch') */
+ rulerUnit?: 'inch' | 'cm';
+ /** Initial zoom level (default: 1.0) */
+ initialZoom?: number;
+ /** Whether the editor is read-only */
+ readOnly?: boolean;
+ /** Custom toolbar actions */
+ toolbarExtra?: ReactNode;
+ /** Additional CSS class name */
+ className?: string;
+ /** Additional inline styles */
+ style?: CSSProperties;
+ /** Placeholder when no document */
+ placeholder?: ReactNode;
+ /** Loading indicator */
+ loadingIndicator?: ReactNode;
+ /** Whether to show the document outline sidebar (default: false) */
+ showOutline?: boolean;
+ /** Whether to show print button in toolbar (default: true) */
+ showPrintButton?: boolean;
+ /** Print options for print preview */
+ printOptions?: PrintOptions;
+ /** Callback when print is triggered */
+ onPrint?: () => void;
+ /** Callback when content is copied */
+ onCopy?: () => void;
+ /** Callback when content is cut */
+ onCut?: () => void;
+ /** Callback when content is pasted */
+ onPaste?: () => void;
+ /** Editor mode: 'editing' (direct edits), 'suggesting' (track changes), or 'viewing' (read-only). Default: 'editing' */
+ mode?: EditorMode;
+ /** Callback when the editing mode changes */
+ onModeChange?: (mode: EditorMode) => void;
+ /**
+ * Callback when rendered DOM context is ready (for plugin overlays).
+ * Used by PluginHost to get access to the rendered page DOM for positioning.
+ */
+ onRenderedDomContextReady?: (context: RenderedDomContext) => void;
+ /**
+ * Plugin overlays to render inside the editor viewport.
+ * Passed from PluginHost to render plugin-specific overlays.
+ */
+ pluginOverlays?: ReactNode;
+}
+
+/**
+ * DocxEditor ref interface
+ */
+export interface DocxEditorRef {
+ /** Get the DocumentAgent for programmatic access */
+ getAgent: () => DocumentAgent | null;
+ /** Get the current document */
+ getDocument: () => Document | null;
+ /** Get the editor ref */
+ getEditorRef: () => PagedEditorRef | null;
+ /** Save the document to buffer. Pass { selective: false } to force full repack. */
+ save: (options?: { selective?: boolean }) => Promise
;
+ /** Set zoom level */
+ setZoom: (zoom: number) => void;
+ /** Get current zoom level */
+ getZoom: () => number;
+ /** Focus the editor */
+ focus: () => void;
+ /** Get current page number */
+ getCurrentPage: () => number;
+ /** Get total page count */
+ getTotalPages: () => number;
+ /** Scroll to a specific page */
+ scrollToPage: (pageNumber: number) => void;
+ /** Open print preview */
+ openPrintPreview: () => void;
+ /** Print the document directly */
+ print: () => void;
+}
+
+/**
+ * Editor internal state
+ */
+interface EditorState {
+ isLoading: boolean;
+ parseError: string | null;
+ zoom: number;
+ /** Current selection formatting for toolbar */
+ selectionFormatting: SelectionFormatting;
+ /** Current page number (1-indexed) */
+ currentPage: number;
+ /** Total page count */
+ totalPages: number;
+ /** Paragraph indent data for ruler */
+ paragraphIndentLeft: number;
+ paragraphIndentRight: number;
+ paragraphFirstLineIndent: number;
+ paragraphHangingIndent: boolean;
+ paragraphTabs: import('@eigenpal/docx-core/types/document').TabStop[] | null;
+ /** ProseMirror table context (for showing table toolbar) */
+ pmTableContext: TableContextInfo | null;
+ /** Image context when cursor is on an image node */
+ pmImageContext: {
+ pos: number;
+ wrapType: string;
+ displayMode: string;
+ cssFloat: string | null;
+ transform: string | null;
+ alt: string | null;
+ borderWidth: number | null;
+ borderColor: string | null;
+ borderStyle: string | null;
+ } | null;
+}
+
+export type { EditorMode } from './ui/EditingModeDropdown';
+
+// ============================================================================
+// MAIN COMPONENT
+// ============================================================================
+
+let nextCommentId = Date.now();
+const PENDING_COMMENT_ID = -1;
+
+function createComment(text: string, authorName: string, parentId?: number): Comment {
+ return {
+ id: nextCommentId++,
+ author: authorName,
+ date: new Date().toISOString(),
+ content: [
+ {
+ type: 'paragraph',
+ formatting: {},
+ content: [{ type: 'run', formatting: {}, content: [{ type: 'text', text }] }],
+ },
+ ],
+ ...(parentId !== undefined && { parentId }),
+ };
+}
+
+/**
+ * DocxEditor - Complete DOCX editor component
+ */
+export const DocxEditor = forwardRef(function DocxEditor(
+ {
+ documentBuffer,
+ document: initialDocument,
+ onSave,
+ author = 'User',
+ onChange,
+ onSelectionChange,
+ onError,
+ onFontsLoaded: onFontsLoadedCallback,
+ theme,
+ toolbar = 'compact',
+ showToolbarWhenReadOnly,
+ showZoomControl = true,
+ showPageNumbers = true,
+ enablePageNavigation = true,
+ pageNumberPosition = 'bottom-center',
+ pageNumberVariant = 'default',
+ showMarginGuides: _showMarginGuides = false,
+ marginGuideColor: _marginGuideColor,
+ showRuler: showRulerProp,
+ onShowRulerChange,
+ rulerUnit = 'inch',
+ initialZoom = 1.0,
+ readOnly: readOnlyProp = false,
+ toolbarExtra,
+ className = '',
+ style,
+ placeholder,
+ loadingIndicator,
+ showOutline: showOutlineProp = false,
+ showPrintButton = true,
+ printOptions: _printOptions,
+ onPrint,
+ onCopy: _onCopy,
+ onCut: _onCut,
+ onPaste: _onPaste,
+ mode: modeProp,
+ onModeChange,
+ externalPlugins,
+ onEditorViewReady,
+ onRenderedDomContextReady,
+ pluginOverlays,
+ },
+ ref
+) {
+ // State
+ const [state, setState] = useState({
+ isLoading: !!documentBuffer,
+ parseError: null,
+ zoom: initialZoom,
+ selectionFormatting: {},
+ currentPage: 1,
+ totalPages: 1,
+ paragraphIndentLeft: 0,
+ paragraphIndentRight: 0,
+ paragraphFirstLineIndent: 0,
+ paragraphHangingIndent: false,
+ paragraphTabs: null,
+ pmTableContext: null,
+ pmImageContext: null,
+ });
+
+ // Table properties dialog state
+ const [tablePropsOpen, setTablePropsOpen] = useState(false);
+ // Image position dialog state
+ const [imagePositionOpen, setImagePositionOpen] = useState(false);
+ // Image properties dialog state
+ const [imagePropsOpen, setImagePropsOpen] = useState(false);
+ // Footnote properties dialog state
+ const [footnotePropsOpen, setFootnotePropsOpen] = useState(false);
+ // Header/footer editing state
+ const [hfEditPosition, setHfEditPosition] = useState<'header' | 'footer' | null>(null);
+ // Document outline sidebar state
+ const [showOutline, setShowOutline] = useState(showOutlineProp);
+ const showOutlineRef = useRef(false);
+ showOutlineRef.current = showOutline;
+ const [outlineHeadings, setHeadingInfos] = useState([]);
+
+ // Comments sidebar state
+ const [showCommentsSidebar, setShowCommentsSidebar] = useState(false);
+ const [comments, setComments] = useState([]);
+ const [trackedChanges, setTrackedChanges] = useState([]);
+ const [localClipboardEnabled, setLocalClipboardEnabled] = useState(false);
+ const [localClipboard, setLocalClipboard] = useState<{ html?: string; text: string } | null>(
+ null
+ );
+ const [showMarksEnabled, setShowMarksEnabled] = useState(false);
+ const [layoutMode, setLayoutMode] = useState<'print' | 'web'>('print');
+ const [rulerVisible, setRulerVisible] = useState(showRulerProp ?? false);
+ const isRulerControlled = showRulerProp !== undefined && !!onShowRulerChange;
+ const effectiveShowRuler = isRulerControlled ? showRulerProp : rulerVisible;
+
+ useEffect(() => {
+ if (isRulerControlled) {
+ setRulerVisible(showRulerProp ?? false);
+ }
+ }, [showRulerProp, isRulerControlled]);
+
+ const [isAddingComment, setIsAddingComment] = useState(false);
+ const [commentSelectionRange, setCommentSelectionRange] = useState<{
+ from: number;
+ to: number;
+ } | null>(null);
+ const [addCommentYPosition, setAddCommentYPosition] = useState(null);
+ const [editingModeInternal, setEditingModeInternal] = useState(modeProp ?? 'editing');
+ const editingMode = modeProp ?? editingModeInternal;
+ const setEditingMode = (mode: EditorMode) => {
+ if (!modeProp) setEditingModeInternal(mode);
+ onModeChange?.(mode);
+ if (mode === 'suggesting') setShowCommentsSidebar(true);
+ };
+ // 'viewing' mode acts as read-only
+ const readOnly = readOnlyProp || editingMode === 'viewing';
+ const allowToolbarWhenReadOnly = showToolbarWhenReadOnly ?? toolbar === 'ribbon';
+ const shouldShowToolbar = toolbar !== undefined && (!readOnlyProp || allowToolbarWhenReadOnly);
+ const isRibbon = toolbar === 'ribbon';
+ // Floating "add comment" button position (relative to scroll container, null = hidden)
+ const [floatingCommentBtn, setFloatingCommentBtn] = useState<{
+ top: number;
+ left: number;
+ } | null>(null);
+
+ // Debounce timer for extractTrackedChanges (avoid full doc walk on every keystroke)
+ const extractTrackedChangesTimerRef = useRef | null>(null);
+
+ // Extract tracked changes from ProseMirror state
+ const extractTrackedChanges = useCallback(() => {
+ const view = pagedEditorRef.current?.getView();
+ if (!view) return;
+ const { doc, schema } = view.state;
+ const insertionType = schema.marks.insertion;
+ const deletionType = schema.marks.deletion;
+ if (!insertionType && !deletionType) return;
+
+ const raw: TrackedChangeEntry[] = [];
+ doc.descendants((node, pos) => {
+ if (!node.isText) return;
+ for (const mark of node.marks) {
+ if (mark.type === insertionType || mark.type === deletionType) {
+ raw.push({
+ type: mark.type === insertionType ? 'insertion' : 'deletion',
+ text: node.text || '',
+ author: (mark.attrs.author as string) || '',
+ date: mark.attrs.date as string | undefined,
+ from: pos,
+ to: pos + node.nodeSize,
+ revisionId: mark.attrs.revisionId as number,
+ });
+ }
+ }
+ });
+
+ // Merge adjacent entries with the same revisionId and type into one
+ const merged: TrackedChangeEntry[] = [];
+ for (const entry of raw) {
+ const last = merged[merged.length - 1];
+ if (
+ last &&
+ last.revisionId === entry.revisionId &&
+ last.type === entry.type &&
+ last.to === entry.from
+ ) {
+ last.text += entry.text;
+ last.to = entry.to;
+ } else {
+ merged.push({ ...entry });
+ }
+ }
+ setTrackedChanges(merged);
+ }, []);
+
+ // Clean up debounce timer on unmount
+ useEffect(() => {
+ return () => {
+ if (extractTrackedChangesTimerRef.current) {
+ clearTimeout(extractTrackedChangesTimerRef.current);
+ }
+ };
+ }, []);
+
+ // Sync outline visibility when prop changes
+ useEffect(() => {
+ setShowOutline(showOutlineProp);
+ if (showOutlineProp) {
+ const view = pagedEditorRef.current?.getView();
+ if (view) {
+ setHeadingInfos(collectHeadings(view.state.doc));
+ }
+ }
+ }, [showOutlineProp]);
+
+ // History hook for undo/redo - start with null document
+ const history = useDocumentHistory(initialDocument || null, {
+ maxEntries: 100,
+ groupingInterval: 500,
+ enableKeyboardShortcuts: true,
+ });
+
+ // Extract comments from document model on initial load
+ const commentsLoadedRef = useRef(false);
+ useEffect(() => {
+ if (commentsLoadedRef.current) return;
+ const doc = history.state;
+ if (!doc) return;
+ const bodyComments = doc.package?.document?.comments;
+ if (bodyComments && bodyComments.length > 0) {
+ setComments(bodyComments);
+ setShowCommentsSidebar(true);
+ commentsLoadedRef.current = true;
+ }
+ }, [history.state]);
+
+ // Extension manager — built once, provides schema + plugins + commands
+ const extensionManager = useMemo(() => {
+ const mgr = new ExtensionManager(createStarterKit());
+ mgr.buildSchema();
+ mgr.initializeRuntime();
+ return mgr;
+ }, []);
+
+ // Suggestion mode plugin — merged with external plugins
+ const suggestionPlugin = useMemo(
+ () => createSuggestionModePlugin(false, author),
+ [] // eslint-disable-line react-hooks/exhaustive-deps
+ );
+ const allExternalPlugins = useMemo(
+ () => [suggestionPlugin, ...(externalPlugins ?? [])],
+ [suggestionPlugin, externalPlugins]
+ );
+
+ // Refs
+ const pagedEditorRef = useRef(null);
+ const hfEditorRef = useRef(null);
+ const agentRef = useRef(null);
+ const containerRef = useRef(null);
+ // Save the last known selection for restoring after toolbar interactions
+ const lastSelectionRef = useRef<{ from: number; to: number } | null>(null);
+ const imageInputRef = useRef(null);
+ const editorContentRef = useRef(null);
+ const scrollContainerRef = useRef(null);
+ const toolbarWrapperRef = useRef(null);
+ const toolbarRoRef = useRef(null);
+ const [toolbarHeight, setToolbarHeight] = useState(0);
+ // Keep history.state accessible in stable callbacks without stale closures
+ const historyStateRef = useRef(history.state);
+ historyStateRef.current = history.state;
+ // Track current border color/width for border presets (like Google Docs)
+ const borderSpecRef = useRef({ style: 'single', size: 4, color: { rgb: '000000' } });
+
+ // Measure toolbar height for positioning the outline panel below it
+ const toolbarRefCallback = useCallback((el: HTMLDivElement | null) => {
+ toolbarWrapperRef.current = el;
+ // Clean up previous observer
+ if (toolbarRoRef.current) {
+ toolbarRoRef.current.disconnect();
+ toolbarRoRef.current = null;
+ }
+ if (!el) {
+ setToolbarHeight(0);
+ return;
+ }
+ setToolbarHeight(el.offsetHeight);
+ const ro = new ResizeObserver(() => {
+ setToolbarHeight(el.offsetHeight);
+ });
+ ro.observe(el);
+ toolbarRoRef.current = ro;
+ }, []);
+
+ // Cleanup ResizeObserver on unmount
+ useEffect(() => {
+ return () => {
+ toolbarRoRef.current?.disconnect();
+ };
+ }, []);
+
+ // Helper to get the active editor's view — returns HF editor view when in HF editing mode
+ const getActiveEditorView = useCallback(() => {
+ if (hfEditPosition && hfEditorRef.current) {
+ return hfEditorRef.current.getView();
+ }
+ return pagedEditorRef.current?.getView();
+ }, [hfEditPosition]);
+
+ // Helper to focus the active editor
+ const focusActiveEditor = useCallback(() => {
+ if (hfEditPosition && hfEditorRef.current) {
+ hfEditorRef.current.focus();
+ } else {
+ pagedEditorRef.current?.focus();
+ }
+ }, [hfEditPosition]);
+
+ // Helper to undo in the active editor
+ const undoActiveEditor = useCallback(() => {
+ if (hfEditPosition && hfEditorRef.current) {
+ hfEditorRef.current.undo();
+ } else {
+ pagedEditorRef.current?.undo();
+ }
+ }, [hfEditPosition]);
+
+ // Helper to redo in the active editor
+ const redoActiveEditor = useCallback(() => {
+ if (hfEditPosition && hfEditorRef.current) {
+ hfEditorRef.current.redo();
+ } else {
+ pagedEditorRef.current?.redo();
+ }
+ }, [hfEditPosition]);
+
+ // Find/Replace hook
+ const findReplace = useFindReplace();
+
+ // Hyperlink dialog hook
+ const hyperlinkDialog = useHyperlinkDialog();
+
+<<<<<<< HEAD
+ const getSelectedTextForFind = useCallback(() => {
+ const selection = window.getSelection();
+ return selection && !selection.isCollapsed ? selection.toString() : '';
+ }, []);
+
+ const handleOpenFind = useCallback(() => {
+ findReplace.openFind(getSelectedTextForFind());
+ }, [findReplace, getSelectedTextForFind]);
+
+ const handleOpenReplace = useCallback(() => {
+ findReplace.openReplace(getSelectedTextForFind());
+ }, [findReplace, getSelectedTextForFind]);
+
+ const handleToggleCommentsSidebar = useCallback(() => {
+ setShowCommentsSidebar((prev) => !prev);
+ }, []);
+
+ const captureLocalClipboard = useCallback(() => {
+ const selection = window.getSelection();
+ if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
+ const range = selection.getRangeAt(0);
+ const container = document.createElement('div');
+ container.appendChild(range.cloneContents());
+ const html = container.innerHTML;
+ const text = selection.toString();
+ if (!text && !html) return null;
+ return { html, text };
+ }, []);
+
+ const handleCopy = useCallback(() => {
+ const local = captureLocalClipboard();
+ if (local) setLocalClipboard(local);
+ _onCopy?.();
+ try {
+ document.execCommand('copy');
+ } catch {
+ // Best-effort copy; ignore failures.
+ }
+ }, [_onCopy, captureLocalClipboard]);
+
+ const handleCut = useCallback(() => {
+ const local = captureLocalClipboard();
+ if (local) setLocalClipboard(local);
+ _onCut?.();
+ try {
+ document.execCommand('cut');
+ } catch {
+ // Best-effort cut; ignore failures.
+ }
+ }, [_onCut, captureLocalClipboard]);
+
+ const handlePaste = useCallback(async () => {
+ _onPaste?.();
+
+ if (localClipboardEnabled && localClipboard) {
+ if (localClipboard.html) {
+ const inserted = document.execCommand('insertHTML', false, localClipboard.html);
+ if (!inserted && localClipboard.text) {
+ document.execCommand('insertText', false, localClipboard.text);
+ }
+ } else if (localClipboard.text) {
+ document.execCommand('insertText', false, localClipboard.text);
+ }
+ return;
+ }
+
+ let pasted = false;
+ try {
+ pasted = document.execCommand('paste');
+ } catch {
+ pasted = false;
+ }
+
+ if (!pasted && navigator.clipboard?.readText) {
+ try {
+ const text = await navigator.clipboard.readText();
+ if (text) {
+ document.execCommand('insertText', false, text);
+ }
+ } catch {
+ // Ignore clipboard read failures.
+ }
+ }
+ }, [_onPaste, localClipboard, localClipboardEnabled]);
+
+ const handleToggleLocalClipboard = useCallback(() => {
+ setLocalClipboardEnabled((prev) => !prev);
+ }, []);
+
+ const handleToggleShowMarks = useCallback(() => {
+ setShowMarksEnabled((prev) => !prev);
+ }, []);
+
+ const handleSetLayoutMode = useCallback((mode: 'print' | 'web') => {
+ setLayoutMode(mode);
+ }, []);
+
+ const handleToggleRuler = useCallback(() => {
+ const next = !effectiveShowRuler;
+ onShowRulerChange?.(next);
+ if (!isRulerControlled) {
+ setRulerVisible(next);
+ }
+ }, [effectiveShowRuler, onShowRulerChange, isRulerControlled]);
+
+ const handleToggleParagraphBorder = useCallback(() => {
+ const view = getActiveEditorView();
+ if (!view) return;
+ toggleParagraphBottomBorder(view.state, view.dispatch);
+ focusActiveEditor();
+ }, [getActiveEditorView, focusActiveEditor]);
+=======
+ // Page setup dialog state
+ const [showPageSetup, setShowPageSetup] = useState(false);
+ const handleOpenPageSetup = useCallback(() => setShowPageSetup(true), []);
+>>>>>>> main
+
+ // Hyperlink popup state (Google Docs-style floating popup on link click)
+ const [hyperlinkPopupData, setHyperlinkPopupData] = useState(null);
+
+ // Parse document buffer
+ useEffect(() => {
+ if (!documentBuffer) {
+ if (initialDocument) {
+ history.reset(initialDocument);
+ setState((prev) => ({ ...prev, isLoading: false }));
+ loadDocumentFonts(initialDocument).catch((err) => {
+ console.warn('Failed to load document fonts:', err);
+ });
+ }
+ return;
+ }
+
+ setState((prev) => ({ ...prev, isLoading: true, parseError: null }));
+
+ const parseDocument = async () => {
+ try {
+ const doc = await parseDocx(documentBuffer);
+ history.reset(doc);
+ setState((prev) => ({
+ ...prev,
+ isLoading: false,
+ parseError: null,
+ }));
+
+ loadDocumentFonts(doc).catch((err) => {
+ console.warn('Failed to load document fonts:', err);
+ });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Failed to parse document';
+ setState((prev) => ({
+ ...prev,
+ isLoading: false,
+ parseError: message,
+ }));
+ onError?.(error instanceof Error ? error : new Error(message));
+ }
+ };
+
+ parseDocument();
+ }, [documentBuffer, initialDocument, onError]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Update document when initialDocument changes
+ useEffect(() => {
+ if (initialDocument && !documentBuffer) {
+ history.reset(initialDocument);
+ }
+ }, [initialDocument, documentBuffer]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Create/update agent when document changes
+ useEffect(() => {
+ if (history.state) {
+ agentRef.current = new DocumentAgent(history.state);
+ } else {
+ agentRef.current = null;
+ }
+ }, [history.state]);
+
+ // Extract tracked changes once PM view is ready (after loading completes)
+ const trackedChangesLoadedRef = useRef(false);
+ useEffect(() => {
+ if (!state.isLoading && history.state) {
+ const timer = setTimeout(() => {
+ extractTrackedChanges();
+ // Auto-open sidebar once on initial load
+ if (!trackedChangesLoadedRef.current) {
+ trackedChangesLoadedRef.current = true;
+ // Check if we just populated tracked changes
+ setTrackedChanges((prev) => {
+ if (prev.length > 0) setShowCommentsSidebar(true);
+ return prev;
+ });
+ }
+ }, 200);
+ return () => clearTimeout(timer);
+ }
+ }, [state.isLoading, history.state, extractTrackedChanges]);
+
+ // Listen for font loading
+ useEffect(() => {
+ const cleanup = onFontsLoaded(() => {
+ onFontsLoadedCallback?.();
+ });
+ return cleanup;
+ }, [onFontsLoadedCallback]);
+
+ // Sync editing mode to ProseMirror suggestion mode plugin
+ useEffect(() => {
+ const view = pagedEditorRef.current?.getView();
+ if (view) {
+ setSuggestionMode(editingMode === 'suggesting', view.state, view.dispatch, author);
+ }
+ }, [editingMode, author]);
+
+ const pushDocument = useCallback(
+ (document: Document) => {
+ history.push(document);
+ return document;
+ },
+ [history]
+ );
+
+ // Handle document change
+ const handleDocumentChange = useCallback(
+ (newDocument: Document) => {
+ pushDocument(newDocument);
+ onChange?.(newDocument);
+ // Update outline headings if sidebar is open
+ if (showOutlineRef.current) {
+ const view = pagedEditorRef.current?.getView();
+ if (view) {
+ setHeadingInfos(collectHeadings(view.state.doc));
+ }
+ }
+ // Re-extract tracked changes after document change (debounced to avoid
+ // full-document walk on every keystroke in suggestion mode)
+ if (extractTrackedChangesTimerRef.current) {
+ clearTimeout(extractTrackedChangesTimerRef.current);
+ }
+ extractTrackedChangesTimerRef.current = setTimeout(extractTrackedChanges, 300);
+ },
+ [onChange, pushDocument, extractTrackedChanges]
+ );
+
+ // Handle selection changes from ProseMirror
+ const handleSelectionChange = useCallback(
+ (selectionState: SelectionState | null) => {
+ // Save selection for restoring after toolbar interactions
+ const view = getActiveEditorView();
+ if (view) {
+ const { from, to } = view.state.selection;
+ lastSelectionRef.current = { from, to };
+ }
+
+ // Also check table context from ProseMirror
+ let pmTableCtx: TableContextInfo | null = null;
+ if (view) {
+ pmTableCtx = getTableContext(view.state);
+ if (!pmTableCtx.isInTable) {
+ pmTableCtx = null;
+ }
+ }
+
+ // Sync borderSpecRef with the current cell's actual border color
+ if (pmTableCtx?.cellBorderColor) {
+ const colorVal = pmTableCtx.cellBorderColor;
+ // Resolve theme/auto colors to hex
+ let rgb = colorVal.rgb;
+ if (!rgb || rgb === 'auto') {
+ const resolved = resolveColor(colorVal, theme);
+ rgb = resolved.replace(/^#/, '');
+ }
+ borderSpecRef.current = {
+ ...borderSpecRef.current,
+ color: { rgb },
+ };
+ }
+
+ // Check if cursor is on an image (NodeSelection)
+ let pmImageCtx: typeof state.pmImageContext = null;
+ if (view) {
+ const sel = view.state.selection;
+ // NodeSelection has a `node` property
+ const selectedNode = (
+ sel as { node?: { type: { name: string }; attrs: Record } }
+ ).node;
+ if (selectedNode?.type.name === 'image') {
+ pmImageCtx = {
+ pos: sel.from,
+ wrapType: (selectedNode.attrs.wrapType as string) ?? 'inline',
+ displayMode: (selectedNode.attrs.displayMode as string) ?? 'inline',
+ cssFloat: (selectedNode.attrs.cssFloat as string) ?? null,
+ transform: (selectedNode.attrs.transform as string) ?? null,
+ alt: (selectedNode.attrs.alt as string) ?? null,
+ borderWidth: (selectedNode.attrs.borderWidth as number) ?? null,
+ borderColor: (selectedNode.attrs.borderColor as string) ?? null,
+ borderStyle: (selectedNode.attrs.borderStyle as string) ?? null,
+ };
+ }
+ }
+
+ if (!selectionState) {
+ setFloatingCommentBtn(null);
+ setState((prev) => ({
+ ...prev,
+ pmTableContext: pmTableCtx,
+ pmImageContext: pmImageCtx,
+ }));
+ return;
+ }
+
+ // Update toolbar formatting from ProseMirror selection
+ const { textFormatting, paragraphFormatting } = selectionState;
+
+ // Extract font family (prefer ascii, fall back to hAnsi)
+ const fontFamily = textFormatting.fontFamily?.ascii || textFormatting.fontFamily?.hAnsi;
+
+ // Extract text color as hex string
+ const textColor = textFormatting.color?.rgb ? `#${textFormatting.color.rgb}` : undefined;
+
+ // Build list state from numPr
+ const numPr = paragraphFormatting.numPr;
+ const listState = numPr
+ ? {
+ type: (numPr.numId === 1 ? 'bullet' : 'numbered') as 'bullet' | 'numbered',
+ level: numPr.ilvl ?? 0,
+ isInList: true,
+ numId: numPr.numId,
+ }
+ : undefined;
+
+ const formatting: SelectionFormatting = {
+ bold: textFormatting.bold,
+ italic: textFormatting.italic,
+ underline: !!textFormatting.underline,
+ strike: textFormatting.strike,
+ superscript: textFormatting.vertAlign === 'superscript',
+ subscript: textFormatting.vertAlign === 'subscript',
+ fontFamily,
+ fontSize: textFormatting.fontSize,
+ color: textColor,
+ highlight: textFormatting.highlight,
+ alignment: paragraphFormatting.alignment,
+ lineSpacing: paragraphFormatting.lineSpacing,
+ listState,
+ styleId: selectionState.styleId ?? undefined,
+ indentLeft: paragraphFormatting.indentLeft,
+ bidi: !!paragraphFormatting.bidi,
+ };
+ setState((prev) => ({
+ ...prev,
+ selectionFormatting: formatting,
+ paragraphIndentLeft: paragraphFormatting.indentLeft ?? 0,
+ paragraphIndentRight: paragraphFormatting.indentRight ?? 0,
+ paragraphFirstLineIndent: paragraphFormatting.indentFirstLine ?? 0,
+ paragraphHangingIndent: paragraphFormatting.hangingIndent ?? false,
+ paragraphTabs: paragraphFormatting.tabs ?? null,
+ pmTableContext: pmTableCtx,
+ pmImageContext: pmImageCtx,
+ }));
+
+ // Update floating comment button position
+ if (view && selectionState.hasSelection && !isAddingComment && !readOnly) {
+ const container = scrollContainerRef.current;
+ const parentEl = editorContentRef.current;
+ if (container && parentEl) {
+ const { from: selFrom } = view.state.selection;
+ const pagesEl = container.querySelector('.paged-editor__pages');
+ if (pagesEl) {
+ const pageEl = pagesEl.querySelector('.layout-page') as HTMLElement | null;
+ const spans = pagesEl.querySelectorAll('span[data-pm-start]');
+ for (const span of spans) {
+ const el = span as HTMLElement;
+ const pmStart = Number(el.dataset.pmStart);
+ const pmEnd = Number(el.dataset.pmEnd);
+ if (selFrom >= pmStart && selFrom <= pmEnd) {
+ const rect = el.getBoundingClientRect();
+ const parentRect = parentEl.getBoundingClientRect();
+ const top = rect.top - parentRect.top + container.scrollTop;
+ // Position at the right edge of the page (relative to editorContentRef)
+ const left = pageEl
+ ? pageEl.getBoundingClientRect().right - parentRect.left
+ : parentRect.width / 2 + 408;
+ setFloatingCommentBtn({ top, left });
+ break;
+ }
+ }
+ }
+ }
+ } else {
+ setFloatingCommentBtn(null);
+ }
+
+ // Notify parent
+ onSelectionChange?.(selectionState);
+ },
+ [onSelectionChange, isAddingComment, readOnly]
+ );
+
+ // Table selection hook
+ const tableSelection = useTableSelection({
+ document: history.state,
+ onChange: handleDocumentChange,
+ onSelectionChange: (_context) => {
+ // Could notify parent of table selection changes
+ },
+ });
+
+ // Keyboard shortcuts for Find/Replace (Ctrl+F, Ctrl+H) and delete table selection
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ // Check for Ctrl+F (Find) or Ctrl+H (Replace)
+ const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
+ const cmdOrCtrl = isMac ? e.metaKey : e.ctrlKey;
+
+ // Delete selected table from layout selection (non-ProseMirror selection)
+ if (!cmdOrCtrl && !e.shiftKey && !e.altKey) {
+ if (e.key === 'Delete' || e.key === 'Backspace') {
+ // If full table is selected via ProseMirror CellSelection, delete it.
+ const view = pagedEditorRef.current?.getView();
+ if (view) {
+ const sel = view.state.selection as { $anchorCell?: unknown; forEachCell?: unknown };
+ const isCellSel = '$anchorCell' in sel && typeof sel.forEachCell === 'function';
+ if (isCellSel) {
+ const context = getTableContext(view.state);
+ if (context.isInTable && context.table) {
+ let totalCells = 0;
+ context.table.descendants((node) => {
+ if (node.type.name === 'tableCell' || node.type.name === 'tableHeader') {
+ totalCells += 1;
+ }
+ });
+ let selectedCells = 0;
+ (sel as { forEachCell: (fn: () => void) => void }).forEachCell(() => {
+ selectedCells += 1;
+ });
+ if (totalCells > 0 && selectedCells >= totalCells) {
+ e.preventDefault();
+ pmDeleteTable(view.state, view.dispatch);
+ return;
+ }
+ }
+ }
+ }
+
+ if (tableSelection.state.tableIndex !== null) {
+ e.preventDefault();
+ tableSelection.handleAction('deleteTable');
+ return;
+ }
+ }
+ }
+
+ if (cmdOrCtrl && !e.shiftKey && !e.altKey) {
+ if (e.key.toLowerCase() === 'f') {
+ e.preventDefault();
+ handleOpenFind();
+ } else if (e.key.toLowerCase() === 'h') {
+ e.preventDefault();
+ handleOpenReplace();
+ } else if (e.key.toLowerCase() === 'k') {
+ e.preventDefault();
+ // Open hyperlink dialog
+ const view = pagedEditorRef.current?.getView();
+ if (view) {
+ const selectedText = getSelectedText(view.state);
+ const existingLink = getHyperlinkAttrs(view.state);
+ if (existingLink) {
+ hyperlinkDialog.openEdit({
+ url: existingLink.href,
+ displayText: selectedText,
+ tooltip: existingLink.tooltip,
+ });
+ } else {
+ hyperlinkDialog.openInsert(selectedText);
+ }
+ }
+ }
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [handleOpenFind, handleOpenReplace, hyperlinkDialog, tableSelection]);
+
+ // Handle table insert from toolbar
+ const handleInsertTable = useCallback(
+ (rows: number, columns: number) => {
+ const view = getActiveEditorView();
+ if (!view) return;
+ insertTable(rows, columns)(view.state, view.dispatch);
+ focusActiveEditor();
+ },
+ [getActiveEditorView, focusActiveEditor]
+ );
+
+ // Insert a page break at cursor
+ const handleInsertPageBreak = useCallback(() => {
+ const view = getActiveEditorView();
+ if (!view) return;
+ insertPageBreak(view.state, view.dispatch);
+ focusActiveEditor();
+ }, [getActiveEditorView, focusActiveEditor]);
+
+ // Insert a table of contents at cursor
+ const handleInsertTOC = useCallback(() => {
+ const view = getActiveEditorView();
+ if (!view) return;
+ generateTOC(view.state, view.dispatch);
+ focusActiveEditor();
+ }, [getActiveEditorView, focusActiveEditor]);
+
+ // Toggle document outline sidebar
+ const handleToggleOutline = useCallback(() => {
+ setShowOutline((prev) => {
+ if (!prev) {
+ // Opening: collect headings immediately
+ const view = pagedEditorRef.current?.getView();
+ if (view) {
+ setHeadingInfos(collectHeadings(view.state.doc));
+ }
+ }
+ return !prev;
+ });
+ }, []);
+
+ // Navigate to a heading from the outline
+ const handleHeadingInfoClick = useCallback((pmPos: number) => {
+ pagedEditorRef.current?.scrollToPosition(pmPos);
+ // Also set selection to the heading
+ pagedEditorRef.current?.setSelection(pmPos + 1);
+ pagedEditorRef.current?.focus();
+ }, []);
+
+ // Trigger file picker for image insert
+ const handleInsertImageClick = useCallback(() => {
+ imageInputRef.current?.click();
+ }, []);
+
+ // Handle file selection for image insert
+ const handleImageFileChange = useCallback(
+ (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ const view = getActiveEditorView();
+ if (!view) return;
+
+ const reader = new FileReader();
+ reader.onload = () => {
+ const dataUrl = reader.result as string;
+
+ // Create an Image element to get natural dimensions
+ const img = new Image();
+ img.onload = () => {
+ let width = img.naturalWidth;
+ let height = img.naturalHeight;
+
+ // Constrain to reasonable max width (content area of US Letter page at 96dpi)
+ const maxWidth = 612; // ~6.375 inches
+ if (width > maxWidth) {
+ const scale = maxWidth / width;
+ width = maxWidth;
+ height = Math.round(height * scale);
+ }
+
+ const rId = `rId_img_${Date.now()}`;
+ const imageNode = view.state.schema.nodes.image.create({
+ src: dataUrl,
+ alt: file.name,
+ width,
+ height,
+ rId,
+ wrapType: 'inline',
+ displayMode: 'inline',
+ });
+
+ const { from } = view.state.selection;
+ const tr = view.state.tr.insert(from, imageNode);
+ view.dispatch(tr.scrollIntoView());
+ focusActiveEditor();
+ };
+ img.src = dataUrl;
+ };
+ reader.readAsDataURL(file);
+
+ // Reset the input so the same file can be selected again
+ e.target.value = '';
+ },
+ [getActiveEditorView, focusActiveEditor]
+ );
+
+ // Handle shape insertion
+ // Handle image wrap type change
+ const handleImageWrapType = useCallback(
+ (wrapType: string) => {
+ const view = getActiveEditorView();
+ if (!view || !state.pmImageContext) return;
+
+ const pos = state.pmImageContext.pos;
+ const node = view.state.doc.nodeAt(pos);
+ if (!node || node.type.name !== 'image') return;
+
+ // Map wrap type to display mode + cssFloat
+ let displayMode = 'inline';
+ let cssFloat: string | null = null;
+
+ switch (wrapType) {
+ case 'inline':
+ displayMode = 'inline';
+ cssFloat = null;
+ break;
+ case 'square':
+ case 'tight':
+ case 'through':
+ displayMode = 'float';
+ cssFloat = 'left';
+ break;
+ case 'topAndBottom':
+ displayMode = 'block';
+ cssFloat = null;
+ break;
+ case 'behind':
+ case 'inFront':
+ displayMode = 'float';
+ cssFloat = 'none';
+ break;
+ case 'wrapLeft':
+ displayMode = 'float';
+ cssFloat = 'right';
+ wrapType = 'square';
+ break;
+ case 'wrapRight':
+ displayMode = 'float';
+ cssFloat = 'left';
+ wrapType = 'square';
+ break;
+ }
+
+ const tr = view.state.tr.setNodeMarkup(pos, undefined, {
+ ...node.attrs,
+ wrapType,
+ displayMode,
+ cssFloat,
+ });
+ view.dispatch(tr.scrollIntoView());
+ focusActiveEditor();
+ },
+ [getActiveEditorView, focusActiveEditor, state.pmImageContext]
+ );
+
+ // Handle image transform (rotate/flip)
+ const handleImageTransform = useCallback(
+ (action: 'rotateCW' | 'rotateCCW' | 'flipH' | 'flipV') => {
+ const view = getActiveEditorView();
+ if (!view || !state.pmImageContext) return;
+
+ const pos = state.pmImageContext.pos;
+ const node = view.state.doc.nodeAt(pos);
+ if (!node || node.type.name !== 'image') return;
+
+ const currentTransform = (node.attrs.transform as string) || '';
+
+ // Parse current rotation and flip state
+ const rotateMatch = currentTransform.match(/rotate\((-?\d+(?:\.\d+)?)deg\)/);
+ let rotation = rotateMatch ? parseFloat(rotateMatch[1]) : 0;
+ let hasFlipH = /scaleX\(-1\)/.test(currentTransform);
+ let hasFlipV = /scaleY\(-1\)/.test(currentTransform);
+
+ switch (action) {
+ case 'rotateCW':
+ rotation = (rotation + 90) % 360;
+ break;
+ case 'rotateCCW':
+ rotation = (rotation - 90 + 360) % 360;
+ break;
+ case 'flipH':
+ hasFlipH = !hasFlipH;
+ break;
+ case 'flipV':
+ hasFlipV = !hasFlipV;
+ break;
+ }
+
+ // Build new transform string
+ const parts: string[] = [];
+ if (rotation !== 0) parts.push(`rotate(${rotation}deg)`);
+ if (hasFlipH) parts.push('scaleX(-1)');
+ if (hasFlipV) parts.push('scaleY(-1)');
+ const newTransform = parts.length > 0 ? parts.join(' ') : null;
+
+ const tr = view.state.tr.setNodeMarkup(pos, undefined, {
+ ...node.attrs,
+ transform: newTransform,
+ });
+ view.dispatch(tr.scrollIntoView());
+ focusActiveEditor();
+ },
+ [getActiveEditorView, focusActiveEditor, state.pmImageContext]
+ );
+
+ // Apply image position changes
+ const handleApplyImagePosition = useCallback(
+ (data: ImagePositionData) => {
+ const view = getActiveEditorView();
+ if (!view || !state.pmImageContext) return;
+
+ const pos = state.pmImageContext.pos;
+ const node = view.state.doc.nodeAt(pos);
+ if (!node || node.type.name !== 'image') return;
+
+ const tr = view.state.tr.setNodeMarkup(pos, undefined, {
+ ...node.attrs,
+ position: {
+ horizontal: data.horizontal,
+ vertical: data.vertical,
+ },
+ distTop: data.distTop ?? node.attrs.distTop,
+ distBottom: data.distBottom ?? node.attrs.distBottom,
+ distLeft: data.distLeft ?? node.attrs.distLeft,
+ distRight: data.distRight ?? node.attrs.distRight,
+ });
+ view.dispatch(tr.scrollIntoView());
+ focusActiveEditor();
+ },
+ [getActiveEditorView, focusActiveEditor, state.pmImageContext]
+ );
+
+ // Open image properties dialog
+ const handleOpenImageProperties = useCallback(() => {
+ setImagePropsOpen(true);
+ }, []);
+
+ // Apply image properties (alt text + border)
+ const handleApplyImageProperties = useCallback(
+ (data: ImagePropertiesData) => {
+ const view = getActiveEditorView();
+ if (!view || !state.pmImageContext) return;
+
+ const pos = state.pmImageContext.pos;
+ const node = view.state.doc.nodeAt(pos);
+ if (!node || node.type.name !== 'image') return;
+
+ const tr = view.state.tr.setNodeMarkup(pos, undefined, {
+ ...node.attrs,
+ alt: data.alt ?? null,
+ borderWidth: data.borderWidth ?? null,
+ borderColor: data.borderColor ?? null,
+ borderStyle: data.borderStyle ?? null,
+ });
+ view.dispatch(tr.scrollIntoView());
+ focusActiveEditor();
+ },
+ [getActiveEditorView, focusActiveEditor, state.pmImageContext]
+ );
+
+ // Handle footnote/endnote properties update
+ const handleApplyFootnoteProperties = useCallback(
+ (
+ footnotePr: import('@eigenpal/docx-core/types/document').FootnoteProperties,
+ endnotePr: import('@eigenpal/docx-core/types/document').EndnoteProperties
+ ) => {
+ if (!history.state?.package) return;
+ const newDoc = {
+ ...history.state.package.document,
+ finalSectionProperties: {
+ ...history.state.package.document.finalSectionProperties,
+ footnotePr,
+ endnotePr,
+ },
+ };
+ pushDocument({
+ ...history.state,
+ package: {
+ ...history.state.package,
+ document: newDoc,
+ },
+ });
+ },
+ [history, pushDocument]
+ );
+
+ // Handle table action from Toolbar - use ProseMirror commands
+ const handleTableAction = useCallback(
+ (action: TableAction) => {
+ const view = getActiveEditorView();
+ if (!view) return;
+
+ switch (action) {
+ case 'addRowAbove':
+ addRowAbove(view.state, view.dispatch);
+ break;
+ case 'addRowBelow':
+ addRowBelow(view.state, view.dispatch);
+ break;
+ case 'addColumnLeft':
+ addColumnLeft(view.state, view.dispatch);
+ break;
+ case 'addColumnRight':
+ addColumnRight(view.state, view.dispatch);
+ break;
+ case 'deleteRow':
+ pmDeleteRow(view.state, view.dispatch);
+ break;
+ case 'deleteColumn':
+ pmDeleteColumn(view.state, view.dispatch);
+ break;
+ case 'deleteTable':
+ pmDeleteTable(view.state, view.dispatch);
+ break;
+ case 'selectTable':
+ pmSelectTable(view.state, view.dispatch);
+ break;
+ case 'selectRow':
+ pmSelectRow(view.state, view.dispatch);
+ break;
+ case 'selectColumn':
+ pmSelectColumn(view.state, view.dispatch);
+ break;
+ case 'mergeCells':
+ pmMergeCells(view.state, view.dispatch);
+ break;
+ case 'splitCell':
+ pmSplitCell(view.state, view.dispatch);
+ break;
+ // Border actions — use current border spec from toolbar
+ case 'borderAll':
+ setAllTableBorders(view.state, view.dispatch, borderSpecRef.current);
+ break;
+ case 'borderOutside':
+ setOutsideTableBorders(view.state, view.dispatch, borderSpecRef.current);
+ break;
+ case 'borderInside':
+ setInsideTableBorders(view.state, view.dispatch, borderSpecRef.current);
+ break;
+ case 'borderNone':
+ removeTableBorders(view.state, view.dispatch);
+ break;
+ // Per-side border actions (use current border spec)
+ case 'borderTop':
+ setCellBorder('top', borderSpecRef.current, true)(view.state, view.dispatch);
+ break;
+ case 'borderBottom':
+ setCellBorder('bottom', borderSpecRef.current, true)(view.state, view.dispatch);
+ break;
+ case 'borderLeft':
+ setCellBorder('left', borderSpecRef.current, true)(view.state, view.dispatch);
+ break;
+ case 'borderRight':
+ setCellBorder('right', borderSpecRef.current, true)(view.state, view.dispatch);
+ break;
+ default:
+ // Handle complex actions (with parameters)
+ if (typeof action === 'object') {
+ if (action.type === 'cellFillColor') {
+ setCellFillColor(action.color)(view.state, view.dispatch);
+ } else if (action.type === 'borderColor') {
+ const rgb = action.color.replace(/^#/, '');
+ borderSpecRef.current = { ...borderSpecRef.current, color: { rgb } };
+ setTableBorderColor(action.color)(view.state, view.dispatch);
+ } else if (action.type === 'borderWidth') {
+ borderSpecRef.current = { ...borderSpecRef.current, size: action.size };
+ setTableBorderWidth(action.size)(view.state, view.dispatch);
+ } else if (action.type === 'cellBorder') {
+ setCellBorder(action.side, {
+ style: action.style,
+ size: action.size,
+ color: { rgb: action.color.replace(/^#/, '') },
+ })(view.state, view.dispatch);
+ } else if (action.type === 'cellVerticalAlign') {
+ setCellVerticalAlign(action.align)(view.state, view.dispatch);
+ } else if (action.type === 'cellMargins') {
+ setCellMargins(action.margins)(view.state, view.dispatch);
+ } else if (action.type === 'cellTextDirection') {
+ setCellTextDirection(action.direction)(view.state, view.dispatch);
+ } else if (action.type === 'toggleNoWrap') {
+ toggleNoWrap()(view.state, view.dispatch);
+ } else if (action.type === 'rowHeight') {
+ setRowHeight(action.height, action.rule)(view.state, view.dispatch);
+ } else if (action.type === 'toggleHeaderRow') {
+ toggleHeaderRow()(view.state, view.dispatch);
+ } else if (action.type === 'distributeColumns') {
+ distributeColumns()(view.state, view.dispatch);
+ } else if (action.type === 'autoFitContents') {
+ autoFitContents()(view.state, view.dispatch);
+ } else if (action.type === 'openTableProperties') {
+ setTablePropsOpen(true);
+ } else if (action.type === 'tableProperties') {
+ setTableProperties(action.props)(view.state, view.dispatch);
+ } else if (action.type === 'applyTableStyle') {
+ // Resolve style data from built-in presets or document styles
+ let preset: TableStylePreset | undefined = getBuiltinTableStyle(action.styleId);
+ const currentDocForTable = historyStateRef.current;
+ if (!preset && currentDocForTable?.package.styles) {
+ const styleResolver = createStyleResolver(currentDocForTable.package.styles);
+ const docStyle = styleResolver.getStyle(action.styleId);
+ if (docStyle) {
+ // Convert to preset inline (same as documentStyleToPreset)
+ preset = { id: docStyle.styleId, name: docStyle.name ?? docStyle.styleId };
+ if (docStyle.tblPr?.borders) {
+ const b = docStyle.tblPr.borders;
+ preset.tableBorders = {};
+ for (const side of [
+ 'top',
+ 'bottom',
+ 'left',
+ 'right',
+ 'insideH',
+ 'insideV',
+ ] as const) {
+ const bs = b[side];
+ if (bs) {
+ preset.tableBorders[side] = {
+ style: bs.style,
+ size: bs.size,
+ color: bs.color?.rgb ? { rgb: bs.color.rgb } : undefined,
+ };
+ }
+ }
+ }
+ if (docStyle.tblStylePr) {
+ preset.conditionals = {};
+ for (const cond of docStyle.tblStylePr) {
+ const entry: Record = {};
+ if (cond.tcPr?.shading?.fill)
+ entry.backgroundColor = `#${cond.tcPr.shading.fill}`;
+ if (cond.tcPr?.borders) {
+ const borders: Record = {};
+ for (const s of ['top', 'bottom', 'left', 'right'] as const) {
+ const bs2 = cond.tcPr.borders[s];
+ if (bs2)
+ borders[s] = {
+ style: bs2.style,
+ size: bs2.size,
+ color: bs2.color?.rgb ? { rgb: bs2.color.rgb } : undefined,
+ };
+ }
+ entry.borders = borders;
+ }
+ if (cond.rPr?.bold) entry.bold = true;
+ if (cond.rPr?.color?.rgb) entry.color = `#${cond.rPr.color.rgb}`;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (preset.conditionals as any)[cond.type] = entry;
+ }
+ }
+ preset.look = { firstRow: true, lastRow: false, noHBand: false, noVBand: true };
+ }
+ }
+ if (preset) {
+ applyTableStyle({
+ styleId: preset.id,
+ tableBorders: preset.tableBorders,
+ conditionals: preset.conditionals,
+ look: preset.look,
+ })(view.state, view.dispatch);
+ }
+ }
+ } else {
+ // Fallback to legacy table selection handler for other actions
+ tableSelection.handleAction(action);
+ }
+ }
+
+ focusActiveEditor();
+ },
+ [tableSelection, getActiveEditorView, focusActiveEditor]
+ );
+
+ // Handle formatting action from toolbar
+ const handleFormat = useCallback(
+ (action: FormattingAction) => {
+ const view = getActiveEditorView();
+ if (!view) return;
+
+ // Focus editor first to ensure we can dispatch commands
+ view.focus();
+
+ // Restore selection if it was lost during toolbar interaction
+ // This happens when user clicks on dropdown menus (font picker, style picker, etc.)
+ // Only restore for the body editor — HF editor manages its own selection
+ const isBodyEditor = view === pagedEditorRef.current?.getView();
+ const { from, to } = view.state.selection;
+ const savedSelection = lastSelectionRef.current;
+
+ if (
+ isBodyEditor &&
+ savedSelection &&
+ (from !== savedSelection.from || to !== savedSelection.to)
+ ) {
+ // Selection was lost (focus moved to dropdown portal) - restore it
+ try {
+ const tr = view.state.tr.setSelection(
+ TextSelection.create(view.state.doc, savedSelection.from, savedSelection.to)
+ );
+ view.dispatch(tr);
+ } catch (e) {
+ // If restoration fails (e.g., positions are invalid after doc change), continue with current selection
+ console.warn('Could not restore selection:', e);
+ }
+ }
+
+ view.focus();
+
+ const updateSelectionFormatting = (
+ updater: (prev: SelectionFormatting) => SelectionFormatting
+ ) => {
+ setState((prev) => ({
+ ...prev,
+ selectionFormatting: updater(prev.selectionFormatting ?? {}),
+ }));
+ };
+
+ // Handle simple toggle actions
+ if (action === 'bold') {
+ toggleBold(view.state, view.dispatch);
+ updateSelectionFormatting((prev) => ({ ...prev, bold: !prev.bold }));
+ return;
+ }
+ if (action === 'italic') {
+ toggleItalic(view.state, view.dispatch);
+ updateSelectionFormatting((prev) => ({ ...prev, italic: !prev.italic }));
+ return;
+ }
+ if (action === 'underline') {
+ toggleUnderline(view.state, view.dispatch);
+ updateSelectionFormatting((prev) => ({ ...prev, underline: !prev.underline }));
+ return;
+ }
+ if (action === 'strikethrough') {
+ toggleStrike(view.state, view.dispatch);
+ updateSelectionFormatting((prev) => ({ ...prev, strike: !prev.strike }));
+ return;
+ }
+ if (action === 'superscript') {
+ toggleSuperscript(view.state, view.dispatch);
+ updateSelectionFormatting((prev) => {
+ const next = { ...prev };
+ const enabled = !prev.superscript;
+ next.superscript = enabled;
+ if (enabled) next.subscript = false;
+ return next;
+ });
+ return;
+ }
+ if (action === 'subscript') {
+ toggleSubscript(view.state, view.dispatch);
+ updateSelectionFormatting((prev) => {
+ const next = { ...prev };
+ const enabled = !prev.subscript;
+ next.subscript = enabled;
+ if (enabled) next.superscript = false;
+ return next;
+ });
+ return;
+ }
+ if (action === 'bulletList') {
+ toggleBulletList(view.state, view.dispatch);
+ return;
+ }
+ if (action === 'numberedList') {
+ toggleNumberedList(view.state, view.dispatch);
+ return;
+ }
+ if (action === 'indent') {
+ // Try list indent first, then paragraph indent
+ if (!increaseListLevel(view.state, view.dispatch)) {
+ increaseIndent()(view.state, view.dispatch);
+ }
+ return;
+ }
+ if (action === 'outdent') {
+ // Try list outdent first, then paragraph outdent
+ if (!decreaseListLevel(view.state, view.dispatch)) {
+ decreaseIndent()(view.state, view.dispatch);
+ }
+ return;
+ }
+ if (action === 'clearFormatting') {
+ clearFormatting(view.state, view.dispatch);
+ updateSelectionFormatting(() => ({}));
+ return;
+ }
+ if (action === 'setRtl') {
+ setRtl(view.state, view.dispatch);
+ return;
+ }
+ if (action === 'setLtr') {
+ setLtr(view.state, view.dispatch);
+ return;
+ }
+ if (action === 'insertLink') {
+ // Get the selected text for the hyperlink dialog
+ const selectedText = getSelectedText(view.state);
+ // Check if we're editing an existing link
+ const existingLink = getHyperlinkAttrs(view.state);
+ if (existingLink) {
+ hyperlinkDialog.openEdit({
+ url: existingLink.href,
+ displayText: selectedText,
+ tooltip: existingLink.tooltip,
+ });
+ } else {
+ hyperlinkDialog.openInsert(selectedText);
+ }
+ return;
+ }
+
+ // Handle object-based actions
+ if (typeof action === 'object') {
+ switch (action.type) {
+ case 'alignment':
+ setAlignment(action.value)(view.state, view.dispatch);
+ updateSelectionFormatting((prev) => ({ ...prev, alignment: action.value }));
+ break;
+ case 'textColor': {
+ // action.value can be a ColorValue object or a string like "#FF0000"
+ const colorVal = action.value;
+ if (typeof colorVal === 'string') {
+ setTextColor({ rgb: colorVal.replace('#', '') })(view.state, view.dispatch);
+ updateSelectionFormatting((prev) => ({ ...prev, color: colorVal }));
+ } else if (colorVal.auto) {
+ // "Automatic" — remove text color
+ clearTextColor(view.state, view.dispatch);
+ updateSelectionFormatting((prev) => ({ ...prev, color: undefined }));
+ } else {
+ setTextColor(colorVal)(view.state, view.dispatch);
+ updateSelectionFormatting((prev) => ({
+ ...prev,
+ color: colorVal?.rgb ? `#${colorVal.rgb}` : prev.color,
+ }));
+ }
+ break;
+ }
+ case 'highlightColor': {
+ // Convert hex to OOXML named highlight value (e.g., 'FFFF00' → 'yellow')
+ const highlightName = action.value ? mapHexToHighlightName(action.value) : '';
+ setHighlight(highlightName || action.value)(view.state, view.dispatch);
+ updateSelectionFormatting((prev) => ({ ...prev, highlight: action.value }));
+ break;
+ }
+ case 'fontSize':
+ // Convert points to half-points (OOXML uses half-points for font sizes)
+ setFontSize(pointsToHalfPoints(action.value))(view.state, view.dispatch);
+ updateSelectionFormatting((prev) => ({
+ ...prev,
+ fontSize: pointsToHalfPoints(action.value),
+ }));
+ break;
+ case 'fontFamily':
+ setFontFamily(action.value)(view.state, view.dispatch);
+ updateSelectionFormatting((prev) => ({ ...prev, fontFamily: action.value }));
+ break;
+ case 'lineSpacing':
+ setLineSpacing(action.value)(view.state, view.dispatch);
+ updateSelectionFormatting((prev) => ({ ...prev, lineSpacing: action.value }));
+ break;
+ case 'applyStyle': {
+ // Resolve style to get its formatting properties
+ // Use ref to avoid stale closure (handleFormat has [] deps)
+ const currentDoc = historyStateRef.current;
+ const styleResolver = currentDoc?.package.styles
+ ? createStyleResolver(currentDoc.package.styles)
+ : null;
+
+ if (styleResolver) {
+ const resolved = styleResolver.resolveParagraphStyle(action.value);
+ applyStyle(action.value, {
+ paragraphFormatting: resolved.paragraphFormatting,
+ runFormatting: resolved.runFormatting,
+ })(view.state, view.dispatch);
+ } else {
+ // No styles available, just set the styleId
+ applyStyle(action.value)(view.state, view.dispatch);
+ }
+ updateSelectionFormatting((prev) => ({ ...prev, styleId: action.value }));
+ break;
+ }
+ }
+ }
+ },
+ [getActiveEditorView]
+ );
+
+ // Handle zoom change
+ const handleZoomChange = useCallback((zoom: number) => {
+ setState((prev) => ({ ...prev, zoom }));
+ }, []);
+
+ const getViewportMetrics = useCallback(() => {
+ const scrollEl = scrollContainerRef.current;
+ if (!scrollEl) return null;
+ const rect = scrollEl.getBoundingClientRect();
+ const style = window.getComputedStyle(scrollEl);
+ const paddingX = parseFloat(style.paddingLeft || '0') + parseFloat(style.paddingRight || '0');
+ const paddingY = parseFloat(style.paddingTop || '0') + parseFloat(style.paddingBottom || '0');
+ let sidebarWidth = 0;
+ const sidebar = containerRef.current?.querySelector(
+ '.docx-comments-sidebar'
+ ) as HTMLElement | null;
+ if (sidebar) {
+ sidebarWidth = sidebar.getBoundingClientRect().width;
+ }
+ const width = rect.width - paddingX - sidebarWidth;
+ const height = rect.height - paddingY;
+ if (width <= 0 || height <= 0) return null;
+ return { width, height };
+ }, []);
+
+ const getPageSizePx = useCallback(() => {
+ const sp = history.state?.package.document?.finalSectionProperties;
+ if (!sp?.pageWidth || !sp?.pageHeight) return null;
+ return { width: twipsToPixels(sp.pageWidth), height: twipsToPixels(sp.pageHeight) };
+ }, [history.state]);
+
+ const handleZoomPageWidth = useCallback(() => {
+ const viewport = getViewportMetrics();
+ const page = getPageSizePx();
+ if (!viewport || !page || page.width <= 0) return;
+ const next = clampZoom(viewport.width / page.width, 0.5, 2);
+ handleZoomChange(next);
+ }, [getViewportMetrics, getPageSizePx, handleZoomChange]);
+
+ const handleZoomOnePage = useCallback(() => {
+ const viewport = getViewportMetrics();
+ const page = getPageSizePx();
+ if (!viewport || !page || page.width <= 0 || page.height <= 0) return;
+ const next = clampZoom(
+ Math.min(viewport.width / page.width, viewport.height / page.height),
+ 0.5,
+ 2
+ );
+ handleZoomChange(next);
+ }, [getViewportMetrics, getPageSizePx, handleZoomChange]);
+
+ // Handle hyperlink dialog submit
+ const handleHyperlinkSubmit = useCallback(
+ (data: HyperlinkData) => {
+ const view = getActiveEditorView();
+ if (!view) return;
+
+ const url = data.url || '';
+ const tooltip = data.tooltip;
+
+ // Check if we have a selection
+ const { empty } = view.state.selection;
+
+ if (empty && data.displayText) {
+ // No selection but display text provided - insert new linked text
+ insertHyperlink(data.displayText, url, tooltip)(view.state, view.dispatch);
+ } else if (!empty) {
+ // Have selection - apply hyperlink to it
+ setHyperlink(url, tooltip)(view.state, view.dispatch);
+ } else if (data.displayText) {
+ // Empty selection but display text provided
+ insertHyperlink(data.displayText, url, tooltip)(view.state, view.dispatch);
+ }
+
+ hyperlinkDialog.close();
+ focusActiveEditor();
+ },
+ [hyperlinkDialog, getActiveEditorView, focusActiveEditor]
+ );
+
+ // Shared: remove hyperlink mark and refocus editor
+ const doRemoveHyperlink = useCallback(() => {
+ const view = getActiveEditorView();
+ if (!view) return;
+ removeHyperlink(view.state, view.dispatch);
+ focusActiveEditor();
+ }, [getActiveEditorView, focusActiveEditor]);
+
+ // Handle hyperlink removal (from dialog)
+ const handleHyperlinkRemove = useCallback(() => {
+ doRemoveHyperlink();
+ hyperlinkDialog.close();
+ }, [hyperlinkDialog, doRemoveHyperlink]);
+
+ // Handle hyperlink popup (Google Docs-style)
+ const handleHyperlinkClick = useCallback(
+ (data: HyperlinkPopupData) => setHyperlinkPopupData(data),
+ []
+ );
+
+ const handleHyperlinkPopupNavigate = useCallback((href: string) => {
+ window.open(href, '_blank', 'noopener,noreferrer');
+ }, []);
+
+ const handleHyperlinkPopupCopy = useCallback((href: string) => {
+ navigator.clipboard.writeText(href).catch(() => {
+ // Fallback for older browsers
+ const textarea = document.createElement('textarea');
+ textarea.value = href;
+ textarea.style.position = 'fixed';
+ textarea.style.opacity = '0';
+ document.body.appendChild(textarea);
+ textarea.select();
+ document.execCommand('copy');
+ document.body.removeChild(textarea);
+ });
+ }, []);
+
+ const handleHyperlinkPopupEdit = useCallback(
+ (displayText: string, href: string) => {
+ const view = getActiveEditorView();
+ if (!view) return;
+
+ // Find the full hyperlink mark range at current cursor position
+ const hlType = view.state.schema.marks.hyperlink;
+ if (!hlType) return;
+
+ const { $from } = view.state.selection;
+ const linkMark = $from.marks().find((m) => m.type === hlType);
+
+ if (linkMark) {
+ // Collect all contiguous text nodes with the same hyperlink mark
+ const parent = $from.parent;
+ const parentStart = $from.start();
+
+ // Build ranges of consecutive hyperlink-marked nodes
+ type Range = { start: number; end: number };
+ const ranges: Range[] = [];
+ let currentRange: Range | null = null;
+
+ parent.forEach((node, offset) => {
+ const nodeStart = parentStart + offset;
+ const nodeEnd = nodeStart + node.nodeSize;
+ const hlMark = node.isText
+ ? node.marks.find((m) => m.type === hlType && m.attrs.href === linkMark.attrs.href)
+ : null;
+
+ if (hlMark) {
+ if (currentRange) {
+ currentRange.end = nodeEnd;
+ } else {
+ currentRange = { start: nodeStart, end: nodeEnd };
+ }
+ } else {
+ if (currentRange) {
+ ranges.push(currentRange);
+ currentRange = null;
+ }
+ }
+ });
+ if (currentRange) ranges.push(currentRange);
+
+ // Find the range that contains the cursor
+ const cursorPos = $from.pos;
+ const targetRange = ranges.find((r) => r.start <= cursorPos && cursorPos <= r.end);
+ if (!targetRange) return;
+
+ // Replace the text and mark
+ const tr = view.state.tr;
+ const newMark = hlType.create({ href, tooltip: linkMark.attrs.tooltip });
+ const textNode = view.state.schema.text(displayText, [
+ ...$from.marks().filter((m) => m.type !== hlType),
+ newMark,
+ ]);
+ tr.replaceWith(targetRange.start, targetRange.end, textNode);
+ view.dispatch(tr.scrollIntoView());
+ }
+
+ setHyperlinkPopupData(null);
+ focusActiveEditor();
+ },
+ [getActiveEditorView, focusActiveEditor]
+ );
+
+ const handleHyperlinkPopupRemove = useCallback(() => {
+ const view = getActiveEditorView();
+ if (!view) return;
+
+ const hlType = view.state.schema.marks.hyperlink;
+ if (!hlType) return;
+
+ const { $from } = view.state.selection;
+
+ // Try $from.marks() first, then check the node after the cursor
+ // (ProseMirror may not report marks at boundary positions)
+ let linkMark = $from.marks().find((m) => m.type === hlType);
+ if (!linkMark && $from.nodeAfter) {
+ linkMark = $from.nodeAfter.marks.find((m) => m.type === hlType);
+ }
+ if (!linkMark && $from.nodeBefore) {
+ linkMark = $from.nodeBefore.marks.find((m) => m.type === hlType);
+ }
+
+ // Fall back to searching by href from popup data
+ if (!linkMark && hyperlinkPopupData) {
+ const parent = $from.parent;
+ parent.forEach((node) => {
+ if (!linkMark && node.isText) {
+ const m = node.marks.find(
+ (mk) => mk.type === hlType && mk.attrs.href === hyperlinkPopupData.href
+ );
+ if (m) linkMark = m;
+ }
+ });
+ }
+
+ if (!linkMark) return;
+
+ // Find contiguous range of nodes with matching hyperlink mark
+ const parent = $from.parent;
+ const parentStart = $from.start();
+ type Range = { start: number; end: number };
+ const ranges: Range[] = [];
+ let currentRange: Range | null = null;
+
+ parent.forEach((node, offset) => {
+ const nodeStart = parentStart + offset;
+ const nodeEnd = nodeStart + node.nodeSize;
+ const hlMark = node.isText
+ ? node.marks.find((m) => m.type === hlType && m.attrs.href === linkMark!.attrs.href)
+ : null;
+
+ if (hlMark) {
+ if (currentRange) {
+ currentRange.end = nodeEnd;
+ } else {
+ currentRange = { start: nodeStart, end: nodeEnd };
+ }
+ } else {
+ if (currentRange) {
+ ranges.push(currentRange);
+ currentRange = null;
+ }
+ }
+ });
+ if (currentRange) ranges.push(currentRange);
+
+ const cursorPos = $from.pos;
+ const targetRange = ranges.find((r) => r.start <= cursorPos && cursorPos <= r.end);
+ if (!targetRange) return;
+
+ const tr = view.state.tr;
+ tr.removeMark(targetRange.start, targetRange.end, hlType);
+ view.dispatch(tr.scrollIntoView());
+
+ setHyperlinkPopupData(null);
+ focusActiveEditor();
+ toast('Link removed');
+ }, [getActiveEditorView, focusActiveEditor, hyperlinkPopupData]);
+
+ const handleHyperlinkPopupClose = useCallback(() => {
+ setHyperlinkPopupData(null);
+ }, []);
+
+ // Handle margin changes from rulers
+ const createMarginHandler = useCallback(
+ (property: 'marginLeft' | 'marginRight' | 'marginTop' | 'marginBottom') =>
+ (marginTwips: number) => {
+ if (!history.state || readOnly) return;
+ const newDoc = {
+ ...history.state,
+ package: {
+ ...history.state.package,
+ document: {
+ ...history.state.package.document,
+ finalSectionProperties: {
+ ...history.state.package.document.finalSectionProperties,
+ [property]: marginTwips,
+ },
+ },
+ },
+ };
+ handleDocumentChange(newDoc);
+ },
+ [history.state, readOnly, handleDocumentChange]
+ );
+
+ const handleLeftMarginChange = useMemo(
+ () => createMarginHandler('marginLeft'),
+ [createMarginHandler]
+ );
+ const handleRightMarginChange = useMemo(
+ () => createMarginHandler('marginRight'),
+ [createMarginHandler]
+ );
+ const handleTopMarginChange = useMemo(
+ () => createMarginHandler('marginTop'),
+ [createMarginHandler]
+ );
+ const handleBottomMarginChange = useMemo(
+ () => createMarginHandler('marginBottom'),
+ [createMarginHandler]
+ );
+
+ // Page setup apply handler
+ const handlePageSetupApply = useCallback(
+ (props: Partial) => {
+ if (!history.state || readOnly) return;
+ const newDoc = {
+ ...history.state,
+ package: {
+ ...history.state.package,
+ document: {
+ ...history.state.package.document,
+ finalSectionProperties: {
+ ...history.state.package.document.finalSectionProperties,
+ ...props,
+ },
+ },
+ },
+ };
+ handleDocumentChange(newDoc);
+ },
+ [history.state, readOnly, handleDocumentChange]
+ );
+
+ // Paragraph indent handlers (for ruler)
+ const handleIndentLeftChange = useCallback(
+ (twips: number) => {
+ const view = getActiveEditorView();
+ if (!view) return;
+ setIndentLeft(twips)(view.state, view.dispatch);
+ },
+ [getActiveEditorView]
+ );
+
+ const handleIndentRightChange = useCallback(
+ (twips: number) => {
+ const view = getActiveEditorView();
+ if (!view) return;
+ setIndentRight(twips)(view.state, view.dispatch);
+ },
+ [getActiveEditorView]
+ );
+
+ const handleFirstLineIndentChange = useCallback(
+ (twips: number) => {
+ const view = getActiveEditorView();
+ if (!view) return;
+ // If twips is negative, it's a hanging indent
+ if (twips < 0) {
+ setIndentFirstLine(-twips, true)(view.state, view.dispatch);
+ } else {
+ setIndentFirstLine(twips, false)(view.state, view.dispatch);
+ }
+ },
+ [getActiveEditorView]
+ );
+
+ const handleTabStopRemove = useCallback(
+ (positionTwips: number) => {
+ const view = getActiveEditorView();
+ if (!view) return;
+ removeTabStop(positionTwips)(view.state, view.dispatch);
+ },
+ [getActiveEditorView]
+ );
+
+ // Handle page navigation (from PageNavigator)
+ // TODO: Implement page navigation in ProseMirror
+ const handlePageNavigate = useCallback((_pageNumber: number) => {
+ // Page navigation not yet implemented for ProseMirror
+ }, []);
+
+ // Handle save
+ const handleSave = useCallback(
+ async (options?: { selective?: boolean }): Promise => {
+ if (!agentRef.current) return null;
+
+ try {
+ const agentDoc = agentRef.current.getDocument();
+
+ // Get the document from the PM editor state — this runs fromProseDoc which
+ // converts PM comment marks into commentRangeStart/End in the document body.
+ // The agent's internal document has the original parsed content and won't
+ // include markers for newly added comments.
+ const pmDoc = pagedEditorRef.current?.getDocument();
+ if (pmDoc?.package?.document) {
+ agentDoc.package.document.content = pmDoc.package.document.content;
+ }
+
+ // Sync React comments state (including new replies) back to the document model
+ agentDoc.package.document.comments = comments;
+
+ // Build selective save options from change tracker state
+ const useSelective = options?.selective !== false;
+ const view = pagedEditorRef.current?.getView();
+ let selectiveOptions: Parameters[0] = undefined;
+
+ if (useSelective && view) {
+ const editorState = view.state;
+ selectiveOptions = {
+ selective: {
+ changedParaIds: getChangedParagraphIds(editorState),
+ structuralChange: hasStructuralChanges(editorState),
+ hasUntrackedChanges: hasUntrackedChanges(editorState),
+ },
+ };
+ }
+
+ const buffer = await agentRef.current.toBuffer(selectiveOptions);
+
+ // Clear change tracker after successful save
+ if (view) {
+ view.dispatch(clearTrackedChanges(view.state));
+ }
+
+ onSave?.(buffer);
+ return buffer;
+ } catch (error) {
+ onError?.(error instanceof Error ? error : new Error('Failed to save document'));
+ return null;
+ }
+ },
+ [onSave, onError, comments]
+ );
+
+ // Handle error from editor
+ const handleEditorError = useCallback(
+ (error: Error) => {
+ onError?.(error);
+ },
+ [onError]
+ );
+
+ const handleDirectPrint = useCallback(() => {
+ // Find the pages container and clone its content into a clean print window
+ const pagesEl = containerRef.current?.querySelector('.paged-editor__pages');
+ if (!pagesEl) {
+ window.print();
+ onPrint?.();
+ return;
+ }
+
+ const printWindow = window.open('', '_blank');
+ if (!printWindow) {
+ // Popup blocked — fall back to window.print()
+ window.print();
+ onPrint?.();
+ return;
+ }
+
+ // Collect all @font-face rules from the current page
+ const fontFaceRules: string[] = [];
+ for (const sheet of Array.from(document.styleSheets)) {
+ try {
+ for (const rule of Array.from(sheet.cssRules)) {
+ if (rule instanceof CSSFontFaceRule) {
+ fontFaceRules.push(rule.cssText);
+ }
+ }
+ } catch {
+ // Cross-origin stylesheets can't be read — skip
+ }
+ }
+
+ // Clone pages and remove transforms/shadows
+ const pagesClone = pagesEl.cloneNode(true) as HTMLElement;
+ pagesClone.style.cssText = 'display: block; margin: 0; padding: 0;';
+ for (const page of Array.from(pagesClone.querySelectorAll('.layout-page'))) {
+ const el = page as HTMLElement;
+ el.style.boxShadow = 'none';
+ el.style.margin = '0';
+ }
+
+ printWindow.document.write(`
+Print
+
+${pagesClone.outerHTML}`);
+ printWindow.document.close();
+
+ // Wait for fonts/images then print
+ printWindow.onload = () => {
+ printWindow.print();
+ printWindow.close();
+ };
+
+ // Fallback if onload doesn't fire (some browsers)
+ setTimeout(() => {
+ if (!printWindow.closed) {
+ printWindow.print();
+ printWindow.close();
+ }
+ }, 1000);
+
+ onPrint?.();
+ }, [onPrint]);
+
+ // ============================================================================
+ // FIND/REPLACE HANDLERS
+ // ============================================================================
+
+ // Store the current find result for navigation
+ const findResultRef = useRef(null);
+
+ // Handle find operation
+ const handleFind = useCallback(
+ (searchText: string, options: FindOptions): FindResult | null => {
+ if (!history.state || !searchText.trim()) {
+ findResultRef.current = null;
+ return null;
+ }
+
+ const matches = findInDocument(history.state, searchText, options);
+ const result: FindResult = {
+ matches,
+ totalCount: matches.length,
+ currentIndex: 0,
+ };
+
+ findResultRef.current = result;
+ findReplace.setMatches(matches, 0);
+
+ // Scroll to first match
+ if (matches.length > 0 && containerRef.current) {
+ scrollToMatch(containerRef.current, matches[0]);
+ }
+
+ return result;
+ },
+ [history.state, findReplace]
+ );
+
+ // Handle find next
+ const handleFindNext = useCallback((): FindMatch | null => {
+ if (!findResultRef.current || findResultRef.current.matches.length === 0) {
+ return null;
+ }
+
+ const newIndex = findReplace.goToNextMatch();
+ const match = findResultRef.current.matches[newIndex];
+
+ // Scroll to the match
+ if (match && containerRef.current) {
+ scrollToMatch(containerRef.current, match);
+ }
+
+ return match || null;
+ }, [findReplace]);
+
+ // Handle find previous
+ const handleFindPrevious = useCallback((): FindMatch | null => {
+ if (!findResultRef.current || findResultRef.current.matches.length === 0) {
+ return null;
+ }
+
+ const newIndex = findReplace.goToPreviousMatch();
+ const match = findResultRef.current.matches[newIndex];
+
+ // Scroll to the match
+ if (match && containerRef.current) {
+ scrollToMatch(containerRef.current, match);
+ }
+
+ return match || null;
+ }, [findReplace]);
+
+ // Handle replace current match
+ const handleReplace = useCallback(
+ (replaceText: string): boolean => {
+ if (!history.state || !findResultRef.current || findResultRef.current.matches.length === 0) {
+ return false;
+ }
+
+ const currentMatch = findResultRef.current.matches[findResultRef.current.currentIndex];
+ if (!currentMatch) return false;
+
+ // Execute replace command
+ try {
+ const newDoc = executeCommand(history.state, {
+ type: 'replaceText',
+ range: {
+ start: {
+ paragraphIndex: currentMatch.paragraphIndex,
+ offset: currentMatch.startOffset,
+ },
+ end: {
+ paragraphIndex: currentMatch.paragraphIndex,
+ offset: currentMatch.endOffset,
+ },
+ },
+ text: replaceText,
+ });
+
+ handleDocumentChange(newDoc);
+ return true;
+ } catch (error) {
+ console.error('Replace failed:', error);
+ return false;
+ }
+ },
+ [history.state, handleDocumentChange]
+ );
+
+ // Handle replace all matches
+ const handleReplaceAll = useCallback(
+ (searchText: string, replaceText: string, options: FindOptions): number => {
+ if (!history.state || !searchText.trim()) {
+ return 0;
+ }
+
+ // Find all matches first
+ const matches = findInDocument(history.state, searchText, options);
+ if (matches.length === 0) return 0;
+
+ // Replace from end to start to maintain correct indices
+ let doc = history.state;
+ const sortedMatches = [...matches].sort((a, b) => {
+ if (a.paragraphIndex !== b.paragraphIndex) {
+ return b.paragraphIndex - a.paragraphIndex;
+ }
+ return b.startOffset - a.startOffset;
+ });
+
+ for (const match of sortedMatches) {
+ try {
+ doc = executeCommand(doc, {
+ type: 'replaceText',
+ range: {
+ start: {
+ paragraphIndex: match.paragraphIndex,
+ offset: match.startOffset,
+ },
+ end: {
+ paragraphIndex: match.paragraphIndex,
+ offset: match.endOffset,
+ },
+ },
+ text: replaceText,
+ });
+ } catch (error) {
+ console.error('Replace failed for match:', match, error);
+ }
+ }
+
+ handleDocumentChange(doc);
+ findResultRef.current = null;
+ findReplace.setMatches([], 0);
+
+ return matches.length;
+ },
+ [history.state, handleDocumentChange, findReplace]
+ );
+
+ // Expose ref methods
+ useImperativeHandle(
+ ref,
+ () => ({
+ getAgent: () => agentRef.current,
+ getDocument: () => history.state,
+ getEditorRef: () => pagedEditorRef.current,
+ save: handleSave,
+ setZoom: (zoom: number) => setState((prev) => ({ ...prev, zoom })),
+ getZoom: () => state.zoom,
+ focus: () => {
+ pagedEditorRef.current?.focus();
+ },
+ getCurrentPage: () => state.currentPage,
+ getTotalPages: () => state.totalPages,
+ scrollToPage: (_pageNumber: number) => {
+ // TODO: Implement page navigation in ProseMirror
+ },
+ openPrintPreview: handleDirectPrint,
+ print: handleDirectPrint,
+ }),
+ [history.state, state.zoom, state.currentPage, state.totalPages, handleSave, handleDirectPrint]
+ );
+
+ // Get header and footer content from document
+ const { headerContent, footerContent } = useMemo<{
+ headerContent: HeaderFooter | null;
+ footerContent: HeaderFooter | null;
+ }>(() => {
+ if (!history.state?.package) {
+ return { headerContent: null, footerContent: null };
+ }
+
+ const pkg = history.state.package;
+ const sectionProps = pkg.document?.finalSectionProperties;
+ const headers = pkg.headers;
+ const footers = pkg.footers;
+
+ let header: HeaderFooter | null = null;
+ let footer: HeaderFooter | null = null;
+
+ // Get default header from section references
+ if (headers && sectionProps?.headerReferences) {
+ const defaultRef = sectionProps.headerReferences.find((r) => r.type === 'default');
+ if (defaultRef?.rId) {
+ header = headers.get(defaultRef.rId) ?? null;
+ }
+ }
+
+ // Get default footer from section references
+ if (footers && sectionProps?.footerReferences) {
+ const defaultRef = sectionProps.footerReferences.find((r) => r.type === 'default');
+ if (defaultRef?.rId) {
+ footer = footers.get(defaultRef.rId) ?? null;
+ }
+ }
+
+ return { headerContent: header, footerContent: footer };
+ }, [history.state]);
+
+ // Handle header/footer double-click — open editing overlay
+ // If no header/footer exists, create an empty one so the user can add content
+ const handleHeaderFooterDoubleClick = useCallback(
+ (position: 'header' | 'footer') => {
+ const hf = position === 'header' ? headerContent : footerContent;
+ if (hf) {
+ setHfEditPosition(position);
+ return;
+ }
+
+ // Create empty header/footer for docs that don't have one yet
+ if (!history.state?.package) return;
+ const pkg = history.state.package;
+ const sectionProps = pkg.document?.finalSectionProperties;
+ if (!sectionProps) return;
+
+ const rId = `rId_new_${position}`;
+ const emptyHf: HeaderFooter = {
+ type: position === 'header' ? 'header' : 'footer',
+ hdrFtrType: 'default',
+ content: [{ type: 'paragraph', content: [] }],
+ };
+
+ const mapKey = position === 'header' ? 'headers' : 'footers';
+ const newMap = new Map(pkg[mapKey] ?? []);
+ newMap.set(rId, emptyHf);
+
+ const refKey = position === 'header' ? 'headerReferences' : 'footerReferences';
+ const existingRefs = sectionProps[refKey] ?? [];
+ const newRef = { type: 'default' as const, rId };
+
+ const newDoc: Document = {
+ ...history.state,
+ package: {
+ ...pkg,
+ [mapKey]: newMap,
+ document: pkg.document
+ ? {
+ ...pkg.document,
+ finalSectionProperties: {
+ ...sectionProps,
+ [refKey]: [...existingRefs, newRef],
+ },
+ }
+ : pkg.document,
+ },
+ };
+ pushDocument(newDoc);
+ setHfEditPosition(position);
+ },
+ [headerContent, footerContent, history, pushDocument]
+ );
+
+ // Handle header/footer save — update document package with edited content
+ const handleHeaderFooterSave = useCallback(
+ (
+ content: (
+ | import('@eigenpal/docx-core/types/document').Paragraph
+ | import('@eigenpal/docx-core/types/document').Table
+ )[]
+ ) => {
+ if (!hfEditPosition || !history.state?.package) {
+ setHfEditPosition(null);
+ return;
+ }
+
+ const pkg = history.state.package;
+ const sectionProps = pkg.document?.finalSectionProperties;
+ const refs =
+ hfEditPosition === 'header'
+ ? sectionProps?.headerReferences
+ : sectionProps?.footerReferences;
+ const defaultRef = refs?.find((r) => r.type === 'default');
+ const mapKey = hfEditPosition === 'header' ? 'headers' : 'footers';
+ const map = pkg[mapKey];
+
+ if (defaultRef?.rId && map) {
+ const existing = map.get(defaultRef.rId);
+ const updated: HeaderFooter = {
+ type: hfEditPosition,
+ hdrFtrType: 'default',
+ ...existing,
+ content,
+ };
+ const newMap = new Map(map);
+ newMap.set(defaultRef.rId, updated);
+
+ const newDoc: Document = {
+ ...history.state,
+ package: {
+ ...pkg,
+ [mapKey]: newMap,
+ },
+ };
+ pushDocument(newDoc);
+ }
+
+ setHfEditPosition(null);
+ },
+ [hfEditPosition, history, pushDocument]
+ );
+
+ // Handle body click while in HF editing mode — save + close
+ const handleBodyClick = useCallback(() => {
+ if (!hfEditPosition) return;
+ // Save if dirty, then close
+ const view = hfEditorRef.current?.getView();
+ if (view) {
+ const blocks = proseDocToBlocks(view.state.doc);
+ handleHeaderFooterSave(blocks);
+ } else {
+ setHfEditPosition(null);
+ }
+ }, [hfEditPosition, handleHeaderFooterSave]);
+
+ // Handle removing the header/footer entirely
+ const handleRemoveHeaderFooter = useCallback(() => {
+ if (!hfEditPosition || !history.state?.package) {
+ setHfEditPosition(null);
+ return;
+ }
+
+ const pkg = history.state.package;
+ const sectionProps = pkg.document?.finalSectionProperties;
+ const refKey = hfEditPosition === 'header' ? 'headerReferences' : 'footerReferences';
+ const mapKey = hfEditPosition === 'header' ? 'headers' : 'footers';
+ const refs = sectionProps?.[refKey];
+ const defaultRef = refs?.find((r) => r.type === 'default');
+
+ if (defaultRef?.rId) {
+ const newMap = new Map(pkg[mapKey] ?? []);
+ newMap.delete(defaultRef.rId);
+
+ const newRefs = (refs ?? []).filter((r) => r.rId !== defaultRef.rId);
+
+ const newDoc: Document = {
+ ...history.state,
+ package: {
+ ...pkg,
+ [mapKey]: newMap,
+ document: pkg.document
+ ? {
+ ...pkg.document,
+ finalSectionProperties: {
+ ...sectionProps,
+ [refKey]: newRefs,
+ },
+ }
+ : pkg.document,
+ },
+ };
+ pushDocument(newDoc);
+ }
+
+ setHfEditPosition(null);
+ }, [hfEditPosition, history, pushDocument]);
+
+ // Get the DOM element for the header/footer area on the first page
+ const getHfTargetElement = useCallback((pos: 'header' | 'footer'): HTMLElement | null => {
+ const pagesContainer = containerRef.current?.querySelector('.paged-editor__pages');
+ if (!pagesContainer) return null;
+ const className = pos === 'header' ? '.layout-page-header' : '.layout-page-footer';
+ return pagesContainer.querySelector(className);
+ }, []);
+
+ // Container styles - using overflow: auto so sticky toolbar works
+ const containerStyle: CSSProperties = {
+ display: 'flex',
+ flexDirection: 'column',
+ height: '100%',
+ width: '100%',
+ backgroundColor: 'var(--doc-bg-subtle)',
+ ...style,
+ };
+
+ const mainContentStyle: CSSProperties = {
+ display: 'flex',
+ flex: 1,
+ minHeight: 0, // Allow flex item to shrink below content size
+ minWidth: 0, // Allow flex item to shrink below content width on narrow viewports
+ flexDirection: 'row',
+ };
+
+ const editorContainerStyle: CSSProperties = {
+ flex: 1,
+ minHeight: 0,
+ minWidth: 0, // Allow flex item to shrink below content width on narrow viewports
+ overflow: 'auto', // This is the scroll container - sticky toolbar will stick to this
+ position: 'relative',
+ };
+
+ // Render loading state
+ if (state.isLoading) {
+ return (
+
+ {loadingIndicator || }
+
+ );
+ }
+
+ // Render error state
+ if (state.parseError) {
+ return (
+
+ );
+ }
+
+ // Render placeholder when no document
+ if (!history.state) {
+ return (
+
+ {placeholder || }
+
+ );
+ }
+
+ return (
+
+
+
+ {/* Main content area */}
+
+ {/* Wrapper for scroll container + outline overlay */}
+
+ {/* Ribbon/toolbar header - fixed above scroll container */}
+ {shouldShowToolbar && (
+
+ {isRibbon ? (
+
+ ) : (
+
+ {/* Comment & Track Changes buttons */}
+
+
+
+
+
+
+ {toolbarExtra}
+
+ )}
+
+ {/* Horizontal Ruler - fixed with ribbon header */}
+ {effectiveShowRuler && (
+
+
+
+ )}
+
+ )}
+
+ {/* Editor container - this is the scroll container */}
+
+ {/* Editor content wrapper */}
+
+ {/* Editor content area */}
+
{
+ // Focus editor when clicking on the background area (not the editor itself)
+ // Using mouseDown for immediate response before focus can be lost
+ if (e.target === e.currentTarget) {
+ e.preventDefault();
+ pagedEditorRef.current?.focus();
+ }
+ }}
+ >
+ {/* Vertical Ruler - fixed on left edge (hidden when readOnly prop is set) */}
+ {effectiveShowRuler && !readOnlyProp && (
+
+
+
+ )}
+
{
+ // Extract full selection state from PM and use the standard handler
+ const view = pagedEditorRef.current?.getView();
+ if (view) {
+ const selectionState = extractSelectionState(view.state);
+ handleSelectionChange(selectionState);
+ } else {
+ handleSelectionChange(null);
+ }
+ }}
+ externalPlugins={allExternalPlugins}
+ onReady={(ref) => {
+ onEditorViewReady?.(ref.getView()!);
+ }}
+ onRenderedDomContextReady={onRenderedDomContextReady}
+ pluginOverlays={pluginOverlays}
+ onHyperlinkClick={handleHyperlinkClick}
+ commentsSidebarOpen={showCommentsSidebar}
+ scrollContainerRef={scrollContainerRef}
+ sidebarOverlay={
+ showCommentsSidebar ? (
+ {
+ const sp = history.state?.package?.document?.finalSectionProperties;
+ return sp?.pageWidth ? Math.round(sp.pageWidth / 15) : 816;
+ })()}
+ editorContainerRef={scrollContainerRef}
+ onCommentResolve={(id) => {
+ setComments((prev) =>
+ prev.map((c) => (c.id === id ? { ...c, done: true } : c))
+ );
+ }}
+ onCommentDelete={(id) => {
+ setComments((prev) =>
+ prev.filter((c) => c.id !== id && c.parentId !== id)
+ );
+ }}
+ onCommentReply={(id, text) => {
+ setComments((prev) => [...prev, createComment(text, author, id)]);
+ }}
+ onAddComment={(addText) => {
+ const comment = createComment(addText, author);
+ // Replace pending comment mark with the real comment ID
+ const view = pagedEditorRef.current?.getView();
+ if (view && commentSelectionRange) {
+ const { from, to } = commentSelectionRange;
+ const pendingMark = view.state.schema.marks.comment.create({
+ commentId: PENDING_COMMENT_ID,
+ });
+ const realMark = view.state.schema.marks.comment.create({
+ commentId: comment.id,
+ });
+ const tr = view.state.tr
+ .removeMark(from, to, pendingMark)
+ .addMark(from, to, realMark);
+ view.dispatch(tr);
+ }
+ setComments((prev) => [...prev, comment]);
+ setIsAddingComment(false);
+ setCommentSelectionRange(null);
+ setAddCommentYPosition(null);
+ }}
+ onTrackedChangeReply={(revisionId, text) => {
+ setComments((prev) => [
+ ...prev,
+ createComment(text, author, revisionId),
+ ]);
+ }}
+ onCancelAddComment={() => {
+ // Remove pending comment highlight
+ const view = pagedEditorRef.current?.getView();
+ if (view && commentSelectionRange) {
+ const { from, to } = commentSelectionRange;
+ const pendingMark = view.state.schema.marks.comment.create({
+ commentId: PENDING_COMMENT_ID,
+ });
+ view.dispatch(view.state.tr.removeMark(from, to, pendingMark));
+ }
+ setIsAddingComment(false);
+ setCommentSelectionRange(null);
+ setAddCommentYPosition(null);
+ }}
+ onAcceptChange={(from, to) => {
+ const view = pagedEditorRef.current?.getView();
+ if (view) {
+ acceptChange(from, to)(view.state, view.dispatch);
+ extractTrackedChanges();
+ }
+ }}
+ onRejectChange={(from, to) => {
+ const view = pagedEditorRef.current?.getView();
+ if (view) {
+ rejectChange(from, to)(view.state, view.dispatch);
+ extractTrackedChanges();
+ }
+ }}
+ isAddingComment={isAddingComment}
+ addCommentYPosition={addCommentYPosition}
+ topOffset={0}
+ />
+ ) : undefined
+ }
+ />
+
+ {/* Floating "add comment" button — appears on right edge of page at selection */}
+ {floatingCommentBtn != null && !isAddingComment && !readOnly && (
+
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ const view = pagedEditorRef.current?.getView();
+ if (view) {
+ const { from, to } = view.state.selection;
+ if (from !== to) {
+ setCommentSelectionRange({ from, to });
+ const pendingMark = view.state.schema.marks.comment.create({
+ commentId: PENDING_COMMENT_ID,
+ });
+ const tr = view.state.tr.addMark(from, to, pendingMark);
+ tr.setSelection(TextSelection.create(tr.doc, to));
+ view.dispatch(tr);
+ }
+ }
+ setAddCommentYPosition(floatingCommentBtn.top);
+ if (!showCommentsSidebar) setShowCommentsSidebar(true);
+ setIsAddingComment(true);
+ setFloatingCommentBtn(null);
+ }}
+ style={{
+ position: 'absolute',
+ top: floatingCommentBtn.top,
+ left: floatingCommentBtn.left,
+ transform: 'translate(-50%, -50%)',
+ zIndex: 50,
+ width: 28,
+ height: 28,
+ borderRadius: 6,
+ border: '1px solid rgba(26, 115, 232, 0.3)',
+ backgroundColor: '#fff',
+ color: '#1a73e8',
+ cursor: 'pointer',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ boxShadow: '0 1px 3px rgba(60,64,67,0.2)',
+ transition: 'background-color 0.15s ease, box-shadow 0.15s ease',
+ }}
+ onMouseOver={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.backgroundColor =
+ 'rgba(26, 115, 232, 0.08)';
+ (e.currentTarget as HTMLButtonElement).style.boxShadow =
+ '0 1px 4px rgba(26, 115, 232, 0.3)';
+ }}
+ onMouseOut={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.backgroundColor = '#fff';
+ (e.currentTarget as HTMLButtonElement).style.boxShadow =
+ '0 1px 3px rgba(60,64,67,0.2)';
+ }}
+ >
+
+
+
+ )}
+
+ {/* Page navigation / indicator */}
+ {showPageNumbers &&
+ state.totalPages > 0 &&
+ (enablePageNavigation ? (
+
+ ) : (
+
+ ))}
+
+ {/* Inline Header/Footer Editor — positioned over the target area */}
+ {hfEditPosition &&
+ (hfEditPosition === 'header' ? headerContent : footerContent) &&
+ (() => {
+ const targetEl = getHfTargetElement(hfEditPosition);
+ const parentEl = editorContentRef.current;
+ if (!targetEl || !parentEl) return null;
+ return (
+ setHfEditPosition(null)}
+ onSelectionChange={handleSelectionChange}
+ onRemove={handleRemoveHeaderFooter}
+ />
+ );
+ })()}
+
+
+ {/* end editor flex wrapper */}
+
+ {/* end scroll container */}
+
+ {/* Document outline sidebar — absolutely positioned, doesn't scroll */}
+ {showOutline && (
+
setShowOutline(false)}
+ topOffset={toolbarHeight}
+ />
+ )}
+
+ {/* Comments sidebar is now rendered inside PagedEditor via sidebarOverlay prop */}
+
+ {/* Outline toggle button — absolutely positioned below toolbar */}
+ {!showOutline && (
+ e.stopPropagation()}
+ title="Show document outline"
+ style={{
+ position: 'absolute',
+ left: 48,
+ top: toolbarHeight + 12,
+ zIndex: 20,
+ background: 'transparent',
+ border: 'none',
+ borderRadius: '50%',
+ padding: 6,
+ cursor: 'pointer',
+ display: 'flex',
+ alignItems: 'center',
+ }}
+ >
+
+
+ )}
+
+ {/* end wrapper for scroll container + outline */}
+
+
+ {/* Hyperlink popup (Google Docs-style) */}
+
+
+ {/* Toast notifications */}
+
+
+ {/* Lazy-loaded dialogs — only fetched when first opened */}
+
+ {findReplace.state.isOpen && (
+
+ )}
+ {hyperlinkDialog.state.isOpen && (
+
+ )}
+ {tablePropsOpen && (
+ setTablePropsOpen(false)}
+ onApply={(props) => {
+ const view = getActiveEditorView();
+ if (view) {
+ setTableProperties(props)(view.state, view.dispatch);
+ }
+ }}
+ currentProps={
+ state.pmTableContext?.table?.attrs as Record | undefined
+ }
+ />
+ )}
+ {imagePositionOpen && (
+ setImagePositionOpen(false)}
+ onApply={handleApplyImagePosition}
+ />
+ )}
+ {imagePropsOpen && (
+ setImagePropsOpen(false)}
+ onApply={handleApplyImageProperties}
+ currentData={
+ state.pmImageContext
+ ? {
+ alt: state.pmImageContext.alt ?? undefined,
+ borderWidth: state.pmImageContext.borderWidth ?? undefined,
+ borderColor: state.pmImageContext.borderColor ?? undefined,
+ borderStyle: state.pmImageContext.borderStyle ?? undefined,
+ }
+ : undefined
+ }
+ />
+ )}
+ {showPageSetup && (
+ setShowPageSetup(false)}
+ onApply={handlePageSetupApply}
+ currentProps={history.state?.package.document?.finalSectionProperties}
+ />
+ )}
+ {footnotePropsOpen && (
+ setFootnotePropsOpen(false)}
+ onApply={handleApplyFootnoteProperties}
+ footnotePr={history.state?.package.document?.finalSectionProperties?.footnotePr}
+ endnotePr={history.state?.package.document?.finalSectionProperties?.endnotePr}
+ />
+ )}
+
+ {/* InlineHeaderFooterEditor is rendered inside the editor content area (position:relative div) */}
+ {/* Hidden file input for image insertion */}
+
+
+
+
+ );
+});
+
+// ============================================================================
+// EXPORTS
+// ============================================================================
+
+export default DocxEditor;
diff --git a/packages/react/src/components/EditorToolbar.tsx b/packages/react/src/components/EditorToolbar.tsx
index a1644d36..dcf2fcea 100644
--- a/packages/react/src/components/EditorToolbar.tsx
+++ b/packages/react/src/components/EditorToolbar.tsx
@@ -1,17 +1,27 @@
/**
- * EditorToolbar — Google Docs-style 2-level compound component.
+ * EditorToolbar — compound component with optional ribbon-style tabs.
*
- * Usage:
+ * Supports two layouts:
+ * 1. Classic (Google Docs): TitleBar + FormattingBar (default)
+ * 2. Tabbed (Word-style): TitleBar + ToolbarTabs + active tab content
+ *
+ * Usage (tabbed):
*
*
*
*
*
- *
- * Save
- *
+ *
*
- *
+ * {activeTab === 'home' && }
+ * {activeTab === 'review' && {...}}
*
*/
@@ -22,6 +32,10 @@ import { TitleBar, Logo, DocumentName, MenuBar, TitleBarRight } from './TitleBar
import type { TitleBarProps, LogoProps, DocumentNameProps, TitleBarRightProps } from './TitleBar';
import { FormattingBar } from './FormattingBar';
import type { FormattingBarProps } from './FormattingBar';
+import { ReviewBar } from './ReviewBar';
+import type { ReviewBarProps } from './ReviewBar';
+import { ToolbarTabs } from './ToolbarTabs';
+import type { ToolbarTabsProps } from './ToolbarTabs';
import { cn } from '../lib/utils';
// ============================================================================
@@ -36,6 +50,8 @@ interface EditorToolbarComponent {
MenuBar: typeof MenuBar;
TitleBarRight: typeof TitleBarRight;
FormattingBar: typeof FormattingBar;
+ ReviewBar: typeof ReviewBar;
+ ToolbarTabs: typeof ToolbarTabs;
}
function EditorToolbarBase({
@@ -63,6 +79,8 @@ EditorToolbar.DocumentName = DocumentName;
EditorToolbar.MenuBar = MenuBar;
EditorToolbar.TitleBarRight = TitleBarRight;
EditorToolbar.FormattingBar = FormattingBar;
+EditorToolbar.ReviewBar = ReviewBar;
+EditorToolbar.ToolbarTabs = ToolbarTabs;
export { EditorToolbar };
export type {
@@ -72,4 +90,6 @@ export type {
DocumentNameProps,
TitleBarRightProps,
FormattingBarProps,
+ ReviewBarProps,
+ ToolbarTabsProps,
};
diff --git a/packages/react/src/components/InlineHeaderFooterEditor.tsx b/packages/react/src/components/InlineHeaderFooterEditor.tsx
index f0ee815b..fe0c2a46 100644
--- a/packages/react/src/components/InlineHeaderFooterEditor.tsx
+++ b/packages/react/src/components/InlineHeaderFooterEditor.tsx
@@ -10,6 +10,7 @@ import React, {
useRef,
useEffect,
useCallback,
+ useMemo,
useState,
useImperativeHandle,
useLayoutEffect,
@@ -26,6 +27,7 @@ import { proseDocToBlocks } from '@eigenpal/docx-core/prosemirror/conversion/fro
import { extractSelectionState, type SelectionState } from '@eigenpal/docx-core/prosemirror';
import { createStarterKit } from '@eigenpal/docx-core/prosemirror/extensions/StarterKit';
import { ExtensionManager } from '@eigenpal/docx-core/prosemirror/extensions/ExtensionManager';
+import { createStyleResolver } from '@eigenpal/docx-core/prosemirror';
import type {
HeaderFooter,
Paragraph,
@@ -69,6 +71,10 @@ export interface InlineHeaderFooterEditorRef {
undo(): boolean;
/** Redo */
redo(): boolean;
+ /** Whether undo is available */
+ canUndo(): boolean;
+ /** Whether redo is available */
+ canRedo(): boolean;
}
// ============================================================================
@@ -149,6 +155,16 @@ export const InlineHeaderFooterEditor = forwardRef<
const editorContainerRef = useRef(null);
const viewRef = useRef(null);
const [isDirty, setIsDirty] = useState(false);
+
+ // Resolve default font size from document styles so the PM editor's
+ // line-height calculations use the correct base (not browser-default 16px)
+ const defaultFontSizePt = useMemo(() => {
+ if (!styles) return 11; // Word 2007+ default
+ const resolver = createStyleResolver(styles);
+ const resolved = resolver.resolveParagraphStyle(undefined);
+ // fontSize in document model is in half-points
+ return resolved.runFormatting?.fontSize ? (resolved.runFormatting.fontSize as number) / 2 : 11;
+ }, [styles]);
const [showOptions, setShowOptions] = useState(false);
const optionsRef = useRef(null);
@@ -292,6 +308,16 @@ export const InlineHeaderFooterEditor = forwardRef<
if (!view) return false;
return redo(view.state, view.dispatch);
},
+ canUndo: () => {
+ const view = viewRef.current;
+ if (!view) return false;
+ return undo(view.state);
+ },
+ canRedo: () => {
+ const view = viewRef.current;
+ if (!view) return false;
+ return redo(view.state);
+ },
}));
const label = position === 'header' ? 'Header' : 'Footer';
@@ -338,6 +364,7 @@ export const InlineHeaderFooterEditor = forwardRef<
style={{
minHeight: 40,
outline: 'none',
+ fontSize: `${defaultFontSizePt}pt`,
}}
/>
diff --git a/packages/react/src/components/ReviewBar.tsx b/packages/react/src/components/ReviewBar.tsx
new file mode 100644
index 00000000..9c2608af
--- /dev/null
+++ b/packages/react/src/components/ReviewBar.tsx
@@ -0,0 +1,58 @@
+/**
+ * ReviewBar — toolbar content for the "Review" tab.
+ *
+ * Shows track-changes controls (accept/reject, navigation), comment buttons,
+ * and the editing mode dropdown. Mirrors the FormattingBar pattern: reads
+ * props from EditorToolbarContext, renders ToolbarGroup/ToolbarButton.
+ *
+ * Separated from FormattingBar because these are conceptually different
+ * workflows (formatting vs. review) and Word/GDocs separate them too.
+ */
+
+import React, { useCallback } from 'react';
+import type { ReactNode } from 'react';
+import { cn } from '../lib/utils';
+
+export interface ReviewBarProps {
+ /** Additional CSS class name */
+ className?: string;
+ /** Custom items to render at the end of the review bar */
+ children?: ReactNode;
+ /** When true, renders with display:contents for inline flex flow */
+ inline?: boolean;
+}
+
+/**
+ * ReviewBar renders review/collaboration tools.
+ *
+ * Actual button wiring is done via `children` from the consumer (DocxEditor)
+ * because the review actions need access to the editor view instance, which
+ * lives in the component that owns the ProseMirror state — not in the toolbar.
+ */
+export function ReviewBar({ className, children, inline = false }: ReviewBarProps) {
+ const handleBarMouseDown = useCallback((e: React.MouseEvent) => {
+ const target = e.target as HTMLElement;
+ const isInteractive =
+ target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT';
+ if (!isInteractive) {
+ e.preventDefault();
+ }
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/react/src/components/Ribbon/Ribbon.tsx b/packages/react/src/components/Ribbon/Ribbon.tsx
new file mode 100644
index 00000000..18b38b99
--- /dev/null
+++ b/packages/react/src/components/Ribbon/Ribbon.tsx
@@ -0,0 +1,432 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import type { Style, Theme, SectionProperties } from '@eigenpal/docx-core/types/document';
+import type { ImageSizeDialogFocusTarget } from '../dialogs/ImageSizeDialog';
+import type { TableContextInfo } from '@eigenpal/docx-core/prosemirror';
+
+import type { FormattingAction, SelectionFormatting } from '../toolbarTypes';
+import type { EditorMode } from '../ui/EditingModeDropdown';
+import type { TableAction } from '../ui/TableToolbar';
+import type { SectionBreakType } from '../ui/BreaksDropdown';
+import { MaterialSymbol } from '../ui/MaterialSymbol';
+import { useToolbarItems, type RibbonItemModel } from '../toolbarItems';
+import { ribbonIcons } from './ribbonIcons';
+
+export interface RibbonProps {
+ currentFormatting?: SelectionFormatting;
+ documentStyles?: Style[];
+ theme?: Theme | null;
+ readOnly?: boolean;
+ onFormat?: (action: FormattingAction) => void;
+ onUndo?: () => void;
+ onRedo?: () => void;
+ onFind?: () => void;
+ onReplace?: () => void;
+ onPageSetup?: () => void;
+ onApplyPageSetup?: (props: Partial) => void;
+ sectionProperties?: SectionProperties | null;
+ onInsertTable?: (rows: number, columns: number) => void;
+ onInsertImage?: () => void;
+ onInsertPageBreak?: () => void;
+ onInsertSectionBreak?: (breakType: SectionBreakType) => void;
+ onInsertTOC?: () => void;
+ onUpdateTOC?: () => void;
+ onInsertFootnote?: () => void;
+ onInsertEndnote?: () => void;
+ onAcceptAllChanges?: () => void;
+ onRejectAllChanges?: () => void;
+ onSetIndentLeft?: (twips: number) => void;
+ onSetIndentRight?: (twips: number) => void;
+ onSetSpaceBefore?: (twips: number) => void;
+ onSetSpaceAfter?: (twips: number) => void;
+ onToggleCommentsSidebar?: () => void;
+ editingMode?: EditorMode;
+ onSetEditingMode?: (mode: EditorMode) => void;
+ zoom?: number;
+ onZoomChange?: (zoom: number) => void;
+ onToggleOutline?: () => void;
+ tableContext?: TableContextInfo | null;
+ onTableAction?: (action: TableAction) => void;
+ imageContext?: {
+ wrapType: string;
+ displayMode: string;
+ cssFloat: string | null;
+ } | null;
+ onCopy?: () => void;
+ onCut?: () => void;
+ onPaste?: () => void;
+ onToggleLocalClipboard?: () => void;
+ localClipboardEnabled?: boolean;
+ onToggleShowMarks?: () => void;
+ showMarksEnabled?: boolean;
+ onToggleParagraphBorder?: () => void;
+ rulerEnabled?: boolean;
+ onToggleRuler?: () => void;
+ onZoomPageWidth?: () => void;
+ onZoomOnePage?: () => void;
+ layoutMode?: 'print' | 'web';
+ onSetLayoutMode?: (mode: 'print' | 'web') => void;
+ onOpenHeaderFooter?: (position: 'header' | 'footer') => void;
+ onCloseHeaderFooter?: () => void;
+ hfEditPosition?: 'header' | 'footer' | null;
+ onOpenImageProperties?: () => void;
+ onOpenImageSize?: (focus?: ImageSizeDialogFocusTarget) => void;
+ onNewComment?: () => void;
+ onDeleteComment?: () => void;
+ onRefocusEditor?: () => void;
+}
+
+type ScrollState = {
+ canScrollLeft: boolean;
+ canScrollRight: boolean;
+};
+
+function useHorizontalScrollState(deps: React.DependencyList = []) {
+ const scrollRef = useRef(null);
+ const [state, setState] = useState({
+ canScrollLeft: false,
+ canScrollRight: false,
+ });
+
+ const update = useCallback(() => {
+ const el = scrollRef.current;
+ if (!el) return;
+ const maxScroll = el.scrollWidth - el.clientWidth;
+ setState({
+ canScrollLeft: el.scrollLeft > 0,
+ canScrollRight: el.scrollLeft < maxScroll - 1,
+ });
+ }, []);
+
+ useEffect(() => {
+ const el = scrollRef.current;
+ if (!el) return;
+
+ update();
+ const onScroll = () => update();
+
+ el.addEventListener('scroll', onScroll, { passive: true });
+ window.addEventListener('resize', onScroll);
+
+ return () => {
+ el.removeEventListener('scroll', onScroll);
+ window.removeEventListener('resize', onScroll);
+ };
+ }, [update, ...deps]);
+
+ const scrollBy = useCallback((direction: 'left' | 'right') => {
+ const el = scrollRef.current;
+ if (!el) return;
+ const delta = Math.max(120, Math.round(el.clientWidth * 0.6));
+ el.scrollBy({ left: direction === 'left' ? -delta : delta, behavior: 'smooth' });
+ }, []);
+
+ return {
+ scrollRef,
+ scrollState: state,
+ scrollBy,
+ };
+}
+
+function resolveIconName(iconId?: string): string | undefined {
+ if (!iconId) return undefined;
+ return ribbonIcons[iconId]?.material || iconId;
+}
+
+export function Ribbon({
+ currentFormatting = {},
+ documentStyles,
+ theme,
+ readOnly = false,
+ onFormat,
+ onUndo,
+ onRedo,
+ onFind,
+ onReplace,
+ onPageSetup,
+ onApplyPageSetup,
+ sectionProperties,
+ onInsertTable,
+ onInsertImage,
+ onInsertPageBreak,
+ onInsertSectionBreak,
+ onInsertTOC,
+ onUpdateTOC,
+ onInsertFootnote,
+ onInsertEndnote,
+ onAcceptAllChanges,
+ onRejectAllChanges,
+ onSetIndentLeft,
+ onSetIndentRight,
+ onSetSpaceBefore,
+ onSetSpaceAfter,
+ onToggleCommentsSidebar,
+ editingMode,
+ onSetEditingMode,
+ zoom,
+ onZoomChange,
+ onToggleOutline,
+ tableContext,
+ onTableAction,
+ imageContext,
+ onCopy,
+ onCut,
+ onPaste,
+ onToggleLocalClipboard,
+ localClipboardEnabled = false,
+ onToggleShowMarks,
+ showMarksEnabled = false,
+ onToggleParagraphBorder,
+ rulerEnabled,
+ onToggleRuler,
+ onZoomPageWidth,
+ onZoomOnePage,
+ layoutMode,
+ onSetLayoutMode,
+ onOpenHeaderFooter,
+ onCloseHeaderFooter,
+ hfEditPosition,
+ onOpenImageProperties,
+ onOpenImageSize,
+ onNewComment,
+ onDeleteComment,
+ onRefocusEditor,
+}: RibbonProps) {
+ const { ribbon, renderRibbonComponent } = useToolbarItems({
+ currentFormatting,
+ documentStyles,
+ theme,
+ readOnly,
+ onFormat,
+ onUndo,
+ onRedo,
+ onFind,
+ onReplace,
+ onPageSetup,
+ onApplyPageSetup,
+ sectionProperties,
+ onInsertTable,
+ onInsertImage,
+ onInsertPageBreak,
+ onInsertSectionBreak,
+ onInsertTOC,
+ onUpdateTOC,
+ onInsertFootnote,
+ onInsertEndnote,
+ onAcceptAllChanges,
+ onRejectAllChanges,
+ onSetIndentLeft,
+ onSetIndentRight,
+ onSetSpaceBefore,
+ onSetSpaceAfter,
+ onToggleCommentsSidebar,
+ editingMode,
+ onSetEditingMode,
+ zoom,
+ onZoomChange,
+ onToggleOutline,
+ tableContext,
+ onTableAction,
+ imageContext,
+ onCopy,
+ onCut,
+ onPaste,
+ onToggleLocalClipboard,
+ localClipboardEnabled,
+ onToggleShowMarks,
+ showMarksEnabled,
+ onToggleParagraphBorder,
+ showRulerEnabled: rulerEnabled,
+ onToggleRuler,
+ onZoomPageWidth,
+ onZoomOnePage,
+ layoutMode,
+ onSetLayoutMode,
+ onOpenHeaderFooter,
+ onCloseHeaderFooter,
+ hfEditPosition,
+ onOpenImageProperties,
+ onOpenImageSize,
+ onNewComment,
+ onDeleteComment,
+ onRefocusEditor,
+ });
+
+ const visibleTabs = ribbon;
+ const [activeTabId, setActiveTabId] = useState(() => visibleTabs[0]?.id ?? '');
+
+ useEffect(() => {
+ if (!visibleTabs.find((tab) => tab.id === activeTabId)) {
+ setActiveTabId(visibleTabs[0]?.id ?? '');
+ }
+ }, [activeTabId, visibleTabs]);
+
+ const activeTab = visibleTabs.find((tab) => tab.id === activeTabId) ?? visibleTabs[0];
+ const tabsScroll = useHorizontalScrollState([visibleTabs.length]);
+ const groupsScroll = useHorizontalScrollState([activeTab?.id]);
+
+ const renderItem = (item: RibbonItemModel) => {
+ const size =
+ item.size ?? (item.kind === 'button' && item.showLabel === false ? 'small' : 'medium');
+ const wrapperClassName = `ribbon__item ribbon__item--${size}`;
+
+ if (item.kind === 'component') {
+ const content = renderRibbonComponent(item.component, item.id);
+ if (!content) return null;
+ return (
+
+ {content}
+
+ );
+ }
+
+ const iconName = resolveIconName(item.icon);
+ const isIconOnly = item.showLabel === false;
+ const buttonClassName = [
+ 'ribbon__button',
+ item.disabled ? 'ribbon__button--disabled' : '',
+ item.isActive ? 'ribbon__button--active' : '',
+ isIconOnly ? 'ribbon__button--icon' : '',
+ ]
+ .filter(Boolean)
+ .join(' ');
+ const hasPressedState = typeof item.isActive === 'boolean';
+
+ const handlePress = () => {
+ if (item.disabled) return;
+ item.onClick?.();
+ };
+
+ return (
+
+ {
+ e.preventDefault();
+ handlePress();
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ handlePress();
+ }
+ }}
+ className={buttonClassName}
+ disabled={item.disabled}
+ aria-pressed={hasPressedState ? (item.isActive ? 'true' : 'false') : undefined}
+ aria-label={item.label}
+ data-testid={`ribbon-${item.id}`}
+ >
+ {iconName && }
+ {item.showLabel !== false && {item.label}}
+
+
+ );
+ };
+
+ return (
+
+
+
tabsScroll.scrollBy('left')}
+ >
+
+
+
+
+ {visibleTabs.map((tab) => {
+ const isActive = tab.id === activeTab?.id;
+ return (
+ setActiveTabId(tab.id)}
+ className={isActive ? 'ribbon__tab ribbon__tab--active' : 'ribbon__tab'}
+ >
+ {tab.label}
+
+ );
+ })}
+
+
+
tabsScroll.scrollBy('right')}
+ >
+
+
+
+
+
+
groupsScroll.scrollBy('left')}
+ >
+
+
+
+
+ {activeTab?.groups.map((group) => (
+
+
+ {group.items.map((item) => renderItem(item))}
+
+
{group.label}
+
+ ))}
+
+
+
groupsScroll.scrollBy('right')}
+ >
+
+
+
+
+ );
+}
+
+export default Ribbon;
diff --git a/packages/react/src/components/Ribbon/RibbonToolbar.tsx b/packages/react/src/components/Ribbon/RibbonToolbar.tsx
new file mode 100644
index 00000000..2fd1db75
--- /dev/null
+++ b/packages/react/src/components/Ribbon/RibbonToolbar.tsx
@@ -0,0 +1,10 @@
+import type { RibbonProps } from './Ribbon';
+import { Ribbon } from './Ribbon';
+
+export type RibbonToolbarProps = RibbonProps;
+
+export function RibbonToolbar(props: RibbonToolbarProps) {
+ return ;
+}
+
+export default RibbonToolbar;
diff --git a/packages/react/src/components/Ribbon/index.ts b/packages/react/src/components/Ribbon/index.ts
new file mode 100644
index 00000000..4cb66c5e
--- /dev/null
+++ b/packages/react/src/components/Ribbon/index.ts
@@ -0,0 +1,2 @@
+export { Ribbon } from './Ribbon';
+export { RibbonToolbar } from './RibbonToolbar';
diff --git a/packages/react/src/components/Ribbon/ribbonActions.test.ts b/packages/react/src/components/Ribbon/ribbonActions.test.ts
new file mode 100644
index 00000000..681c24e8
--- /dev/null
+++ b/packages/react/src/components/Ribbon/ribbonActions.test.ts
@@ -0,0 +1,84 @@
+import { describe, it, expect, vi } from 'bun:test';
+import { ribbonActions } from './ribbonActions';
+
+describe('ribbonActions (backfill)', () => {
+ it('acceptAllChanges triggers handler', () => {
+ const onAcceptAllChanges = vi.fn();
+ ribbonActions.acceptAllChanges({ onAcceptAllChanges });
+ expect(onAcceptAllChanges).toHaveBeenCalledTimes(1);
+ });
+
+ it('rejectAllChanges triggers handler', () => {
+ const onRejectAllChanges = vi.fn();
+ ribbonActions.rejectAllChanges({ onRejectAllChanges });
+ expect(onRejectAllChanges).toHaveBeenCalledTimes(1);
+ });
+
+ it('updateTOC triggers handler', () => {
+ const onUpdateTOC = vi.fn();
+ ribbonActions.updateTOC({ onUpdateTOC });
+ expect(onUpdateTOC).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('ribbonActions (missing commands)', () => {
+ it('margins triggers page setup handler', () => {
+ const onPageSetup = vi.fn();
+ ribbonActions.margins({ onPageSetup });
+ expect(onPageSetup).toHaveBeenCalledTimes(1);
+ });
+
+ it('orientation triggers page setup handler', () => {
+ const onPageSetup = vi.fn();
+ ribbonActions.orientation({ onPageSetup });
+ expect(onPageSetup).toHaveBeenCalledTimes(1);
+ });
+
+ it('size triggers page setup handler', () => {
+ const onPageSetup = vi.fn();
+ ribbonActions.size({ onPageSetup });
+ expect(onPageSetup).toHaveBeenCalledTimes(1);
+ });
+
+ it('image width opens image size dialog', () => {
+ const onOpenImageSize = vi.fn();
+ ribbonActions.imageWidth({ onOpenImageSize });
+ expect(onOpenImageSize).toHaveBeenCalledTimes(1);
+ });
+
+ it('image height opens image size dialog', () => {
+ const onOpenImageSize = vi.fn();
+ ribbonActions.imageHeight({ onOpenImageSize });
+ expect(onOpenImageSize).toHaveBeenCalledTimes(1);
+ });
+
+ it('aspect ratio opens image size dialog', () => {
+ const onOpenImageSize = vi.fn();
+ ribbonActions.aspectRatio({ onOpenImageSize });
+ expect(onOpenImageSize).toHaveBeenCalledTimes(1);
+ });
+
+ it('new comment triggers handler', () => {
+ const onNewComment = vi.fn();
+ ribbonActions.newComment({ onNewComment });
+ expect(onNewComment).toHaveBeenCalledTimes(1);
+ });
+
+ it('delete comment triggers handler', () => {
+ const onDeleteComment = vi.fn();
+ ribbonActions.deleteComment({ onDeleteComment });
+ expect(onDeleteComment).toHaveBeenCalledTimes(1);
+ });
+
+ it('insert footnote triggers handler', () => {
+ const onInsertFootnote = vi.fn();
+ ribbonActions.insertFootnote({ onInsertFootnote });
+ expect(onInsertFootnote).toHaveBeenCalledTimes(1);
+ });
+
+ it('insert endnote triggers handler', () => {
+ const onInsertEndnote = vi.fn();
+ ribbonActions.insertEndnote({ onInsertEndnote });
+ expect(onInsertEndnote).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/react/src/components/Ribbon/ribbonActions.ts b/packages/react/src/components/Ribbon/ribbonActions.ts
new file mode 100644
index 00000000..fee8bf47
--- /dev/null
+++ b/packages/react/src/components/Ribbon/ribbonActions.ts
@@ -0,0 +1,165 @@
+import type { FormattingAction, SelectionFormatting } from '../toolbarTypes';
+import type { EditorMode } from '../ui/EditingModeDropdown';
+import type { TableAction } from '../ui/TableToolbar';
+import { halfPointsToPoints } from '../ui/FontSizePicker';
+import type { ImageSizeDialogFocusTarget } from '../dialogs/ImageSizeDialog';
+
+export interface RibbonActionContext {
+ selectionFormatting?: SelectionFormatting;
+ onFormat?: (action: FormattingAction) => void;
+ onUndo?: () => void;
+ onRedo?: () => void;
+ onFind?: () => void;
+ onReplace?: () => void;
+ onPageSetup?: () => void;
+ onInsertPageBreak?: () => void;
+ onInsertImage?: () => void;
+ onInsertTOC?: () => void;
+ onUpdateTOC?: () => void;
+ onAcceptAllChanges?: () => void;
+ onRejectAllChanges?: () => void;
+ onInsertSectionBreak?: (breakType: 'nextPage' | 'continuous' | 'oddPage' | 'evenPage') => void;
+ onSetIndentLeft?: (twips: number) => void;
+ onSetIndentRight?: (twips: number) => void;
+ onSetSpaceBefore?: (twips: number) => void;
+ onSetSpaceAfter?: (twips: number) => void;
+ onToggleComments?: () => void;
+ editingMode?: EditorMode;
+ onSetEditingMode?: (mode: EditorMode) => void;
+ zoom?: number;
+ onZoomChange?: (zoom: number) => void;
+ onToggleOutline?: () => void;
+ onTableAction?: (action: TableAction) => void;
+ onOpenHeaderFooter?: (position: 'header' | 'footer') => void;
+ onCloseHeaderFooter?: () => void;
+ onOpenImageProperties?: () => void;
+ onOpenImageSize?: (focus?: ImageSizeDialogFocusTarget) => void;
+ onNewComment?: () => void;
+ onDeleteComment?: () => void;
+ onInsertFootnote?: () => void;
+ onInsertEndnote?: () => void;
+ onCopy?: () => void;
+ onCut?: () => void;
+ onPaste?: () => void;
+ onToggleLocalClipboard?: () => void;
+ localClipboardEnabled?: boolean;
+ onToggleShowMarks?: () => void;
+ showMarksEnabled?: boolean;
+ onToggleParagraphBorder?: () => void;
+ onToggleRuler?: () => void;
+ onZoomPageWidth?: () => void;
+ onZoomOnePage?: () => void;
+ onSetLayoutMode?: (mode: 'print' | 'web') => void;
+}
+
+const DEFAULT_FONT_SIZE = 11;
+const MIN_ZOOM = 0.5;
+const MAX_ZOOM = 2;
+
+function getCurrentFontSize(selection?: SelectionFormatting): number {
+ if (selection?.fontSize !== undefined) {
+ return halfPointsToPoints(selection.fontSize);
+ }
+ return DEFAULT_FONT_SIZE;
+}
+
+function clampZoom(value: number): number {
+ return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value));
+}
+
+export const ribbonActions: Record void> = {
+ undo: (ctx) => ctx.onUndo?.(),
+ redo: (ctx) => ctx.onRedo?.(),
+ bold: (ctx) => ctx.onFormat?.('bold'),
+ italic: (ctx) => ctx.onFormat?.('italic'),
+ underline: (ctx) => ctx.onFormat?.('underline'),
+ strikethrough: (ctx) => ctx.onFormat?.('strikethrough'),
+ superscript: (ctx) => ctx.onFormat?.('superscript'),
+ subscript: (ctx) => ctx.onFormat?.('subscript'),
+ clearFormatting: (ctx) => ctx.onFormat?.('clearFormatting'),
+ setLtr: (ctx) => ctx.onFormat?.('setLtr'),
+ setRtl: (ctx) => ctx.onFormat?.('setRtl'),
+ fontGrow: (ctx) => {
+ const next = getCurrentFontSize(ctx.selectionFormatting) + 1;
+ ctx.onFormat?.({ type: 'fontSize', value: next });
+ },
+ fontShrink: (ctx) => {
+ const next = Math.max(1, getCurrentFontSize(ctx.selectionFormatting) - 1);
+ ctx.onFormat?.({ type: 'fontSize', value: next });
+ },
+ insertLink: (ctx) => ctx.onFormat?.('insertLink'),
+ find: (ctx) => ctx.onFind?.(),
+ replace: (ctx) => ctx.onReplace?.(),
+ pageSetup: (ctx) => ctx.onPageSetup?.(),
+ pageBreak: (ctx) => ctx.onInsertPageBreak?.(),
+ insertImage: (ctx) => ctx.onInsertImage?.(),
+ insertTOC: (ctx) => ctx.onInsertTOC?.(),
+ updateTOC: (ctx) => ctx.onUpdateTOC?.(),
+ acceptAllChanges: (ctx) => ctx.onAcceptAllChanges?.(),
+ rejectAllChanges: (ctx) => ctx.onRejectAllChanges?.(),
+ margins: (ctx) => ctx.onPageSetup?.(),
+ orientation: (ctx) => ctx.onPageSetup?.(),
+ size: (ctx) => ctx.onPageSetup?.(),
+ imageWidth: (ctx) => ctx.onOpenImageSize?.('width'),
+ imageHeight: (ctx) => ctx.onOpenImageSize?.('height'),
+ aspectRatio: (ctx) => ctx.onOpenImageSize?.('lock'),
+ newComment: (ctx) => ctx.onNewComment?.(),
+ deleteComment: (ctx) => ctx.onDeleteComment?.(),
+ insertFootnote: (ctx) => ctx.onInsertFootnote?.(),
+ insertEndnote: (ctx) => ctx.onInsertEndnote?.(),
+ toggleComments: (ctx) => ctx.onToggleComments?.(),
+ trackChanges: (ctx) => {
+ if (!ctx.onSetEditingMode) return;
+ const next = ctx.editingMode === 'suggesting' ? 'editing' : 'suggesting';
+ ctx.onSetEditingMode(next);
+ },
+ readOnly: (ctx) => ctx.onSetEditingMode?.('viewing'),
+ printLayout: (ctx) => ctx.onSetLayoutMode?.('print'),
+ webLayout: (ctx) => ctx.onSetLayoutMode?.('web'),
+ zoomIn: (ctx) => {
+ if (!ctx.onZoomChange) return;
+ const current = ctx.zoom ?? 1;
+ ctx.onZoomChange(clampZoom(Math.round((current + 0.1) * 10) / 10));
+ },
+ zoomOut: (ctx) => {
+ if (!ctx.onZoomChange) return;
+ const current = ctx.zoom ?? 1;
+ ctx.onZoomChange(clampZoom(Math.round((current - 0.1) * 10) / 10));
+ },
+ zoom100: (ctx) => ctx.onZoomChange?.(1),
+ toggleNavigationPane: (ctx) => ctx.onToggleOutline?.(),
+ borderAll: (ctx) => ctx.onTableAction?.('borderAll'),
+ borderOutside: (ctx) => ctx.onTableAction?.('borderOutside'),
+ borderInside: (ctx) => ctx.onTableAction?.('borderInside'),
+ borderNone: (ctx) => ctx.onTableAction?.('borderNone'),
+ cut: (ctx) => ctx.onCut?.(),
+ copy: (ctx) => ctx.onCopy?.(),
+ paste: (ctx) => ctx.onPaste?.(),
+ localClipboard: (ctx) => ctx.onToggleLocalClipboard?.(),
+ showMarks: (ctx) => ctx.onToggleShowMarks?.(),
+ showBookmarks: (ctx) => ctx.onToggleShowMarks?.(),
+ borders: (ctx) => ctx.onToggleParagraphBorder?.(),
+ toggleRuler: (ctx) => ctx.onToggleRuler?.(),
+ zoomPageWidth: (ctx) => ctx.onZoomPageWidth?.(),
+ zoomOnePage: (ctx) => ctx.onZoomOnePage?.(),
+ toggleHeaderRow: (ctx) => ctx.onTableAction?.({ type: 'toggleHeaderRow' }),
+ addRowAbove: (ctx) => ctx.onTableAction?.('addRowAbove'),
+ addRowBelow: (ctx) => ctx.onTableAction?.('addRowBelow'),
+ addColumnLeft: (ctx) => ctx.onTableAction?.('addColumnLeft'),
+ addColumnRight: (ctx) => ctx.onTableAction?.('addColumnRight'),
+ mergeCells: (ctx) => ctx.onTableAction?.('mergeCells'),
+ splitCells: (ctx) => ctx.onTableAction?.('splitCell'),
+ distributeColumns: (ctx) => ctx.onTableAction?.({ type: 'distributeColumns' }),
+ autoFitContents: (ctx) => ctx.onTableAction?.({ type: 'autoFitContents' }),
+ alignTop: (ctx) => ctx.onTableAction?.({ type: 'cellVerticalAlign', align: 'top' }),
+ alignCenter: (ctx) => ctx.onTableAction?.({ type: 'cellVerticalAlign', align: 'center' }),
+ alignBottom: (ctx) => ctx.onTableAction?.({ type: 'cellVerticalAlign', align: 'bottom' }),
+ deleteRow: (ctx) => ctx.onTableAction?.('deleteRow'),
+ deleteColumn: (ctx) => ctx.onTableAction?.('deleteColumn'),
+ deleteTable: (ctx) => ctx.onTableAction?.('deleteTable'),
+ tableProperties: (ctx) => ctx.onTableAction?.({ type: 'openTableProperties' }),
+ openHeader: (ctx) => ctx.onOpenHeaderFooter?.('header'),
+ openFooter: (ctx) => ctx.onOpenHeaderFooter?.('footer'),
+ closeHeaderFooter: (ctx) => ctx.onCloseHeaderFooter?.(),
+ imageAltText: (ctx) => ctx.onOpenImageProperties?.(),
+};
diff --git a/packages/react/src/components/Ribbon/ribbonConfig.full.test.ts b/packages/react/src/components/Ribbon/ribbonConfig.full.test.ts
new file mode 100644
index 00000000..c4567702
--- /dev/null
+++ b/packages/react/src/components/Ribbon/ribbonConfig.full.test.ts
@@ -0,0 +1,19 @@
+import { describe, it, expect } from 'bun:test';
+import { ribbonConfig } from './ribbonConfig';
+
+describe('ribbonConfig (full)', () => {
+ it('includes all primary tabs', () => {
+ const labels = ribbonConfig.tabs.map((t) => t.label);
+ expect(labels).toContain('Home');
+ expect(labels).toContain('Insert');
+ expect(labels).toContain('Layout');
+ expect(labels).toContain('Review');
+ expect(labels).toContain('View');
+ expect(labels).toContain('References');
+ expect(labels).toContain('Developer');
+ expect(labels).toContain('Table Design');
+ expect(labels).toContain('Table Layout');
+ expect(labels).toContain('Header & Footer');
+ expect(labels).toContain('Picture Format');
+ });
+});
diff --git a/packages/react/src/components/Ribbon/ribbonConfig.test.ts b/packages/react/src/components/Ribbon/ribbonConfig.test.ts
new file mode 100644
index 00000000..ddca2e59
--- /dev/null
+++ b/packages/react/src/components/Ribbon/ribbonConfig.test.ts
@@ -0,0 +1,10 @@
+import { describe, it, expect } from 'bun:test';
+import { ribbonConfig } from './ribbonConfig';
+
+describe('ribbonConfig', () => {
+ it('includes Home and Insert tabs', () => {
+ const labels = ribbonConfig.tabs.map((tab) => tab.label);
+ expect(labels).toContain('Home');
+ expect(labels).toContain('Insert');
+ });
+});
diff --git a/packages/react/src/components/Ribbon/ribbonConfig.ts b/packages/react/src/components/Ribbon/ribbonConfig.ts
new file mode 100644
index 00000000..b57ae0ac
--- /dev/null
+++ b/packages/react/src/components/Ribbon/ribbonConfig.ts
@@ -0,0 +1,512 @@
+export type RibbonContextKey = 'table' | 'headerFooter' | 'image';
+
+export type RibbonItemType = 'button' | 'component';
+
+export type RibbonItemSize = 'small' | 'medium' | 'large';
+
+export type RibbonButtonItem = {
+ id: string;
+ type: 'button';
+ label: string;
+ icon?: string;
+ actionId?: string;
+ showLabel?: boolean;
+ allowInReadOnly?: boolean;
+ size?: RibbonItemSize;
+};
+
+export type RibbonComponentId =
+ | 'fontFamily'
+ | 'fontSize'
+ | 'textColor'
+ | 'highlightColor'
+ | 'stylePicker'
+ | 'listButtons'
+ | 'alignmentButtons'
+ | 'lineSpacing'
+ | 'tableGrid'
+ | 'tableBorderPicker'
+ | 'tableBorderColor'
+ | 'tableBorderWidth'
+ | 'tableCellFill'
+ | 'tableStyleGallery'
+ | 'zoomControl'
+ | 'editingMode'
+ | 'breaksDropdown'
+ | 'paragraphIndent'
+ | 'paragraphSpacing'
+ | 'pageMargins'
+ | 'pageOrientation'
+ | 'pageSize';
+
+export type RibbonComponentItem = {
+ id: string;
+ type: 'component';
+ component: RibbonComponentId;
+ size?: RibbonItemSize;
+};
+
+export type RibbonItem = RibbonButtonItem | RibbonComponentItem;
+
+export type RibbonGroup = {
+ id: string;
+ label: string;
+ items: RibbonItem[];
+};
+
+export type RibbonTab = {
+ id: string;
+ label: string;
+ groups: RibbonGroup[];
+ when?: RibbonContextKey;
+};
+
+const button = (
+ id: string,
+ label: string,
+ icon?: string,
+ actionId?: string,
+ options?: { showLabel?: boolean; allowInReadOnly?: boolean; size?: RibbonItemSize }
+): RibbonButtonItem => ({
+ id,
+ type: 'button',
+ label,
+ icon,
+ actionId,
+ showLabel: options?.showLabel,
+ allowInReadOnly: options?.allowInReadOnly,
+ size: options?.size,
+});
+
+const component = (
+ id: string,
+ componentId: RibbonComponentId,
+ options?: { size?: RibbonItemSize }
+): RibbonComponentItem => ({
+ id,
+ type: 'component',
+ component: componentId,
+ size: options?.size,
+});
+
+export const ribbonConfig: { tabs: RibbonTab[] } = {
+ tabs: [
+ {
+ id: 'home',
+ label: 'Home',
+ groups: [
+ {
+ id: 'history',
+ label: 'History',
+ items: [
+ button('undo', 'Undo', 'undo', 'undo', { showLabel: true, allowInReadOnly: false }),
+ button('redo', 'Redo', 'redo', 'redo', { showLabel: true, allowInReadOnly: false }),
+ ],
+ },
+ {
+ id: 'clipboard',
+ label: 'Clipboard',
+ items: [
+ button('cut', 'Cut', 'cut', 'cut', { showLabel: true }),
+ button('copy', 'Copy', 'copy', 'copy', { showLabel: true, allowInReadOnly: true }),
+ button('paste', 'Paste', 'paste', 'paste', { showLabel: true, size: 'large' }),
+ button('localClipboard', 'Local Clipboard', 'clipboard', 'localClipboard', {
+ showLabel: true,
+ }),
+ ],
+ },
+ {
+ id: 'font',
+ label: 'Font',
+ items: [
+ component('fontFamily', 'fontFamily'),
+ component('fontSize', 'fontSize'),
+ button('fontGrow', 'Grow Font', 'fontGrow', 'fontGrow'),
+ button('fontShrink', 'Shrink Font', 'fontShrink', 'fontShrink'),
+ button('bold', 'Bold', 'bold', 'bold'),
+ button('italic', 'Italic', 'italic', 'italic'),
+ button('underline', 'Underline', 'underline', 'underline'),
+ button('strikethrough', 'Strikethrough', 'strikethrough', 'strikethrough'),
+ button('superscript', 'Superscript', 'superscript', 'superscript'),
+ button('subscript', 'Subscript', 'subscript', 'subscript'),
+ component('textColor', 'textColor'),
+ component('highlightColor', 'highlightColor'),
+ button('clearFormatting', 'Clear Formatting', 'clearFormatting', 'clearFormatting'),
+ ],
+ },
+ {
+ id: 'paragraph',
+ label: 'Paragraph',
+ items: [
+ component('listButtons', 'listButtons'),
+ button('showMarks', 'Show/Hide Marks', 'showMarks', 'showMarks'),
+ component('alignmentButtons', 'alignmentButtons'),
+ component('lineSpacing', 'lineSpacing'),
+ button('ltrText', 'Left-to-Right', 'ltr', 'setLtr'),
+ button('rtlText', 'Right-to-Left', 'rtl', 'setRtl'),
+ button('borders', 'Borders', 'borders', 'borders'),
+ ],
+ },
+ {
+ id: 'styles',
+ label: 'Styles',
+ items: [component('stylePicker', 'stylePicker', { size: 'large' })],
+ },
+ {
+ id: 'find',
+ label: 'Find',
+ items: [
+ button('find', 'Find', 'find', 'find', { allowInReadOnly: true }),
+ button('replace', 'Replace', 'replace', 'replace'),
+ ],
+ },
+ ],
+ },
+ {
+ id: 'insert',
+ label: 'Insert',
+ groups: [
+ {
+ id: 'pages',
+ label: 'Pages',
+ items: [button('pageBreak', 'Page Break', 'pageBreak', 'pageBreak')],
+ },
+ {
+ id: 'table',
+ label: 'Table',
+ items: [component('tableGrid', 'tableGrid', { size: 'large' })],
+ },
+ {
+ id: 'illustrations',
+ label: 'Illustrations',
+ items: [button('image', 'Image', 'image', 'insertImage')],
+ },
+ {
+ id: 'links',
+ label: 'Links',
+ items: [button('link', 'Link', 'link', 'insertLink')],
+ },
+ {
+ id: 'toc',
+ label: 'Table of Contents',
+ items: [button('tableOfContents', 'Table of Contents', 'toc', 'insertTOC')],
+ },
+ {
+ id: 'bookmarks',
+ label: 'Bookmarks',
+ items: [button('bookmark', 'Bookmark', 'bookmark', 'bookmark')],
+ },
+ {
+ id: 'comments',
+ label: 'Comments',
+ items: [button('newComment', 'New Comment', 'newComment', 'newComment')],
+ },
+ {
+ id: 'headerFooter',
+ label: 'Header & Footer',
+ items: [
+ button('insertHeader', 'Header', 'header', 'openHeader'),
+ button('insertFooter', 'Footer', 'footer', 'openFooter'),
+ button('pageNumber', 'Page Number', 'pageNumber', 'pageNumber'),
+ ],
+ },
+ ],
+ },
+ {
+ id: 'layout',
+ label: 'Layout',
+ groups: [
+ {
+ id: 'pageSetup',
+ label: 'Page Setup',
+ items: [
+ button('pageSetup', 'Page Setup', 'pageSetup', 'pageSetup'),
+ component('margins', 'pageMargins'),
+ component('orientation', 'pageOrientation'),
+ component('size', 'pageSize'),
+ button('columns', 'Columns', 'columns', 'columns'),
+ component('breaks', 'breaksDropdown'),
+ ],
+ },
+ {
+ id: 'layoutParagraph',
+ label: 'Paragraph',
+ items: [
+ component('paragraphIndent', 'paragraphIndent', { size: 'large' }),
+ component('paragraphSpacing', 'paragraphSpacing', { size: 'large' }),
+ ],
+ },
+ ],
+ },
+ {
+ id: 'review',
+ label: 'Review',
+ groups: [
+ {
+ id: 'reviewComments',
+ label: 'Comments',
+ items: [
+ button('reviewNewComment', 'New Comment', 'newComment', 'newComment'),
+ button('reviewPrevious', 'Previous', 'previous', 'previousComment'),
+ button('reviewNext', 'Next', 'next', 'nextComment'),
+ button('reviewShowComments', 'Show Comments', 'showComments', 'toggleComments', {
+ allowInReadOnly: true,
+ }),
+ button('reviewDelete', 'Delete', 'delete', 'deleteComment'),
+ ],
+ },
+ {
+ id: 'tracking',
+ label: 'Tracking',
+ items: [
+ component('editingMode', 'editingMode'),
+ button('trackChanges', 'Track Changes', 'trackChanges', 'trackChanges'),
+ button('acceptAll', 'Accept All', 'acceptAll', 'acceptAllChanges'),
+ button('rejectAll', 'Reject All', 'rejectAll', 'rejectAllChanges'),
+ ],
+ },
+ {
+ id: 'protect',
+ label: 'Protect',
+ items: [button('protectDoc', 'Protect Document', 'protect', 'protectDocument')],
+ },
+ ],
+ },
+ {
+ id: 'view',
+ label: 'View',
+ groups: [
+ {
+ id: 'views',
+ label: 'Views',
+ items: [
+ button('readOnly', 'Read Only', 'readOnly', 'readOnly', { allowInReadOnly: true }),
+ button('printLayout', 'Print Layout', 'printLayout', 'printLayout', {
+ allowInReadOnly: true,
+ }),
+ button('webLayout', 'Web Layout', 'webLayout', 'webLayout', {
+ allowInReadOnly: true,
+ }),
+ ],
+ },
+ {
+ id: 'zoom',
+ label: 'Zoom',
+ items: [
+ button('zoomIn', 'Zoom In', 'zoomIn', 'zoomIn', { allowInReadOnly: true }),
+ button('zoomOut', 'Zoom Out', 'zoomOut', 'zoomOut', { allowInReadOnly: true }),
+ button('zoom100', '100%', 'zoom100', 'zoom100', { allowInReadOnly: true }),
+ button('zoomOnePage', 'One Page', 'onePage', 'zoomOnePage', {
+ allowInReadOnly: true,
+ }),
+ button('zoomPageWidth', 'Page Width', 'pageWidth', 'zoomPageWidth', {
+ allowInReadOnly: true,
+ }),
+ ],
+ },
+ {
+ id: 'show',
+ label: 'Show',
+ items: [
+ button('showRuler', 'Ruler', 'ruler', 'toggleRuler', { allowInReadOnly: true }),
+ button('showBookmarks', 'Show Bookmarks', 'showBookmarks', 'showBookmarks', {
+ allowInReadOnly: true,
+ }),
+ button('navigationPane', 'Navigation Pane', 'navigationPane', 'toggleNavigationPane', {
+ allowInReadOnly: true,
+ }),
+ ],
+ },
+ ],
+ },
+ {
+ id: 'references',
+ label: 'References',
+ groups: [
+ {
+ id: 'referencesToc',
+ label: 'Table of Contents',
+ items: [
+ button('refToc', 'Table of Contents', 'toc', 'insertTOC'),
+ button('updateTable', 'Update Table', 'updateTable', 'updateTOC'),
+ ],
+ },
+ {
+ id: 'footnotes',
+ label: 'Footnotes',
+ items: [
+ button('insertFootnote', 'Insert Footnote', 'footnote', 'insertFootnote'),
+ button('insertEndnote', 'Insert Endnote', 'endnote', 'insertEndnote'),
+ ],
+ },
+ ],
+ },
+ {
+ id: 'developer',
+ label: 'Developer',
+ groups: [
+ {
+ id: 'formFields',
+ label: 'Form Fields',
+ items: [button('formFields', 'Form Fields', 'formFields', 'formFields')],
+ },
+ {
+ id: 'controls',
+ label: 'Controls',
+ items: [button('contentControl', 'Content Control', 'contentControl', 'contentControl')],
+ },
+ {
+ id: 'mapping',
+ label: 'Mapping',
+ items: [button('xmlMapping', 'XML Mapping Pane', 'xmlMapping', 'xmlMapping')],
+ },
+ {
+ id: 'developerProtect',
+ label: 'Protect',
+ items: [
+ button('restrictEditing', 'Restrict Editing', 'restrictEditing', 'restrictEditing'),
+ ],
+ },
+ ],
+ },
+ {
+ id: 'tableDesign',
+ label: 'Table Design',
+ when: 'table',
+ groups: [
+ {
+ id: 'tableStyles',
+ label: 'Table Styles',
+ items: [component('tableStyleGallery', 'tableStyleGallery', { size: 'large' })],
+ },
+ {
+ id: 'tableBorders',
+ label: 'Borders',
+ items: [
+ button('borderAll', 'All Borders', 'borderAll', 'borderAll'),
+ button('borderOutside', 'Outside Borders', 'borderOutside', 'borderOutside'),
+ button('borderInside', 'Inside Borders', 'borderInside', 'borderInside'),
+ button('borderNone', 'No Border', 'borderNone', 'borderNone'),
+ component('tableBorderColor', 'tableBorderColor'),
+ component('tableBorderWidth', 'tableBorderWidth'),
+ ],
+ },
+ {
+ id: 'tableShading',
+ label: 'Shading',
+ items: [component('tableCellFill', 'tableCellFill')],
+ },
+ {
+ id: 'tableOptions',
+ label: 'Options',
+ items: [button('toggleHeaderRow', 'Header Row', 'headerRow', 'toggleHeaderRow')],
+ },
+ ],
+ },
+ {
+ id: 'tableLayout',
+ label: 'Table Layout',
+ when: 'table',
+ groups: [
+ {
+ id: 'rowsColumns',
+ label: 'Rows & Columns',
+ items: [
+ button('insertRowAbove', 'Insert Above', 'addRowAbove', 'addRowAbove'),
+ button('insertRowBelow', 'Insert Below', 'addRowBelow', 'addRowBelow'),
+ button('insertColumnLeft', 'Insert Left', 'addColumnLeft', 'addColumnLeft'),
+ button('insertColumnRight', 'Insert Right', 'addColumnRight', 'addColumnRight'),
+ ],
+ },
+ {
+ id: 'merge',
+ label: 'Merge',
+ items: [
+ button('mergeCells', 'Merge Cells', 'mergeCells', 'mergeCells'),
+ button('splitCells', 'Split Cells', 'splitCells', 'splitCells'),
+ ],
+ },
+ {
+ id: 'cellSize',
+ label: 'Cell Size',
+ items: [
+ button(
+ 'distributeColumns',
+ 'Distribute Columns',
+ 'distributeColumns',
+ 'distributeColumns'
+ ),
+ button('autoFit', 'AutoFit Contents', 'autoFit', 'autoFitContents'),
+ ],
+ },
+ {
+ id: 'alignment',
+ label: 'Alignment',
+ items: [
+ button('alignTop', 'Align Top', 'alignTop', 'alignTop'),
+ button('alignCenter', 'Align Center', 'alignCenter', 'alignCenter'),
+ button('alignBottom', 'Align Bottom', 'alignBottom', 'alignBottom'),
+ ],
+ },
+ {
+ id: 'delete',
+ label: 'Delete',
+ items: [
+ button('deleteRow', 'Delete Row', 'deleteRow', 'deleteRow'),
+ button('deleteColumn', 'Delete Column', 'deleteColumn', 'deleteColumn'),
+ button('deleteTable', 'Delete Table', 'deleteTable', 'deleteTable'),
+ ],
+ },
+ {
+ id: 'properties',
+ label: 'Properties',
+ items: [
+ button('tableProperties', 'Table Properties', 'tableProperties', 'tableProperties'),
+ ],
+ },
+ ],
+ },
+ {
+ id: 'headerFooter',
+ label: 'Header & Footer',
+ when: 'headerFooter',
+ groups: [
+ {
+ id: 'hfEdit',
+ label: 'Header & Footer',
+ items: [
+ button('hfHeader', 'Header', 'header', 'openHeader'),
+ button('hfFooter', 'Footer', 'footer', 'openFooter'),
+ button('hfClose', 'Close', 'close', 'closeHeaderFooter', { allowInReadOnly: true }),
+ ],
+ },
+ {
+ id: 'hfNumbers',
+ label: 'Page Numbers',
+ items: [button('hfPageNumber', 'Page Number', 'pageNumber', 'pageNumber')],
+ },
+ ],
+ },
+ {
+ id: 'pictureFormat',
+ label: 'Picture Format',
+ when: 'image',
+ groups: [
+ {
+ id: 'pictureSize',
+ label: 'Size',
+ items: [
+ button('imageWidth', 'Width', 'imageWidth', 'imageWidth'),
+ button('imageHeight', 'Height', 'imageHeight', 'imageHeight'),
+ button('aspectRatio', 'Aspect Ratio', 'aspectRatio', 'aspectRatio'),
+ ],
+ },
+ {
+ id: 'pictureAccessibility',
+ label: 'Accessibility',
+ items: [button('altText', 'Alt Text', 'altText', 'imageAltText')],
+ },
+ ],
+ },
+ ],
+};
diff --git a/packages/react/src/components/Ribbon/ribbonIcons.test.ts b/packages/react/src/components/Ribbon/ribbonIcons.test.ts
new file mode 100644
index 00000000..ba57c3e1
--- /dev/null
+++ b/packages/react/src/components/Ribbon/ribbonIcons.test.ts
@@ -0,0 +1,31 @@
+import { describe, it, expect } from 'bun:test';
+import { ribbonConfig, type RibbonItem } from './ribbonConfig';
+import { ribbonIcons } from './ribbonIcons';
+
+function collectIcons(items: RibbonItem[]): string[] {
+ const ids: string[] = [];
+ for (const item of items) {
+ if (item.type === 'button' && item.icon) {
+ ids.push(item.icon);
+ }
+ }
+ return ids;
+}
+
+describe('ribbonIcons', () => {
+ it('covers all icon ids referenced in ribbon config', () => {
+ const iconIds = new Set();
+ for (const tab of ribbonConfig.tabs) {
+ for (const group of tab.groups) {
+ for (const icon of collectIcons(group.items)) {
+ iconIds.add(icon);
+ }
+ }
+ }
+
+ for (const iconId of iconIds) {
+ expect(ribbonIcons[iconId]).toBeDefined();
+ expect(ribbonIcons[iconId]?.material).toBeTruthy();
+ }
+ });
+});
diff --git a/packages/react/src/components/Ribbon/ribbonIcons.ts b/packages/react/src/components/Ribbon/ribbonIcons.ts
new file mode 100644
index 00000000..8eba4188
--- /dev/null
+++ b/packages/react/src/components/Ribbon/ribbonIcons.ts
@@ -0,0 +1,98 @@
+export type RibbonIconMapping = {
+ lucide?: string;
+ material?: string;
+};
+
+export const ribbonIcons: Record = {
+ undo: { lucide: 'Undo2', material: 'undo' },
+ redo: { lucide: 'Redo2', material: 'redo' },
+ cut: { lucide: 'Scissors', material: 'delete' },
+ copy: { lucide: 'Copy', material: 'format_paint' },
+ paste: { lucide: 'ClipboardPaste', material: 'add' },
+ clipboard: { lucide: 'Clipboard', material: 'drag_indicator' },
+ fontGrow: { lucide: 'TextCursorInput', material: 'add' },
+ fontShrink: { lucide: 'TextCursorInput', material: 'remove' },
+ bold: { lucide: 'Bold', material: 'format_bold' },
+ italic: { lucide: 'Italic', material: 'format_italic' },
+ underline: { lucide: 'Underline', material: 'format_underlined' },
+ strikethrough: { lucide: 'Strikethrough', material: 'strikethrough_s' },
+ superscript: { lucide: 'Superscript', material: 'superscript' },
+ subscript: { lucide: 'Subscript', material: 'subscript' },
+ clearFormatting: { lucide: 'Eraser', material: 'format_clear' },
+ showMarks: { lucide: 'Pilcrow', material: 'text_rotation_none' },
+ borders: { lucide: 'BorderAll', material: 'border_all' },
+ ltr: { lucide: 'AlignLeft', material: 'format_textdirection_l_to_r' },
+ rtl: { lucide: 'AlignRight', material: 'format_textdirection_r_to_l' },
+ find: { lucide: 'Search', material: 'open_with' },
+ replace: { lucide: 'Replace', material: 'settings' },
+ pageBreak: { lucide: 'FileBreak', material: 'page_break' },
+ image: { lucide: 'Image', material: 'image' },
+ link: { lucide: 'Link', material: 'link' },
+ toc: { lucide: 'ListOrdered', material: 'format_list_numbered' },
+ bookmark: { lucide: 'Bookmark', material: 'comment' },
+ newComment: { lucide: 'MessageSquarePlus', material: 'add_comment' },
+ header: { lucide: 'Heading', material: 'horizontal_rule' },
+ footer: { lucide: 'Heading', material: 'horizontal_rule' },
+ pageNumber: { lucide: 'Hash', material: 'page_break' },
+ margins: { lucide: 'SquareDashed', material: 'padding' },
+ orientation: { lucide: 'RotateCw', material: 'swap_horiz' },
+ size: { lucide: 'Square', material: 'fit_width' },
+ columns: { lucide: 'Columns', material: 'view_column' },
+ breaks: { lucide: 'Split', material: 'page_break' },
+ pageSetup: { lucide: 'Settings', material: 'settings' },
+ indentLeft: { lucide: 'IndentDecrease', material: 'format_indent_decrease' },
+ indentRight: { lucide: 'IndentIncrease', material: 'format_indent_increase' },
+ spacingBefore: { lucide: 'LineHeight', material: 'format_line_spacing' },
+ spacingAfter: { lucide: 'LineHeight', material: 'format_line_spacing' },
+ previous: { lucide: 'ChevronLeft', material: 'keyboard_arrow_left' },
+ next: { lucide: 'ChevronRight', material: 'keyboard_arrow_right' },
+ showComments: { lucide: 'MessageSquare', material: 'comment' },
+ delete: { lucide: 'Trash2', material: 'delete' },
+ trackChanges: { lucide: 'FileEdit', material: 'rate_review' },
+ acceptAll: { lucide: 'Check', material: 'check' },
+ rejectAll: { lucide: 'X', material: 'close' },
+ protect: { lucide: 'Shield', material: 'settings' },
+ readOnly: { lucide: 'Eye', material: 'visibility' },
+ printLayout: { lucide: 'LayoutGrid', material: 'table' },
+ webLayout: { lucide: 'Globe', material: 'open_with' },
+ zoomIn: { lucide: 'ZoomIn', material: 'add' },
+ zoomOut: { lucide: 'ZoomOut', material: 'remove' },
+ zoom100: { lucide: 'Percent', material: 'fit_width' },
+ onePage: { lucide: 'File', material: 'height' },
+ pageWidth: { lucide: 'Width', material: 'fit_width' },
+ ruler: { lucide: 'Ruler', material: 'horizontal_rule' },
+ showBookmarks: { lucide: 'Bookmark', material: 'comment' },
+ navigationPane: { lucide: 'List', material: 'format_list_bulleted' },
+ updateTable: { lucide: 'RefreshCw', material: 'redo' },
+ footnote: { lucide: 'ArrowDownRight', material: 'superscript' },
+ endnote: { lucide: 'ArrowUpRight', material: 'subscript' },
+ formFields: { lucide: 'FormInput', material: 'edit_note' },
+ contentControl: { lucide: 'Sliders', material: 'tune' },
+ xmlMapping: { lucide: 'Network', material: 'settings' },
+ restrictEditing: { lucide: 'Lock', material: 'settings' },
+ borderAll: { lucide: 'BorderAll', material: 'border_all' },
+ borderOutside: { lucide: 'BorderOuter', material: 'border_outer' },
+ borderInside: { lucide: 'BorderInner', material: 'border_inner' },
+ borderNone: { lucide: 'BorderNone', material: 'border_clear' },
+ headerRow: { lucide: 'Table2', material: 'table_rows' },
+ addRowAbove: { lucide: 'TableRows', material: 'add' },
+ addRowBelow: { lucide: 'TableRows', material: 'add' },
+ addColumnLeft: { lucide: 'TableColumns', material: 'add' },
+ addColumnRight: { lucide: 'TableColumns', material: 'add' },
+ mergeCells: { lucide: 'Merge', material: 'call_merge' },
+ splitCells: { lucide: 'Split', material: 'call_split' },
+ distributeColumns: { lucide: 'Columns3', material: 'view_column' },
+ autoFit: { lucide: 'FitHorizontal', material: 'fit_width' },
+ alignTop: { lucide: 'AlignVerticalJustifyStart', material: 'vertical_align_top' },
+ alignCenter: { lucide: 'AlignVerticalJustifyCenter', material: 'vertical_align_center' },
+ alignBottom: { lucide: 'AlignVerticalJustifyEnd', material: 'vertical_align_bottom' },
+ deleteRow: { lucide: 'Delete', material: 'delete' },
+ deleteColumn: { lucide: 'Delete', material: 'delete' },
+ deleteTable: { lucide: 'Delete', material: 'delete' },
+ tableProperties: { lucide: 'Settings', material: 'settings' },
+ close: { lucide: 'X', material: 'close' },
+ imageWidth: { lucide: 'ArrowLeftRight', material: 'fit_width' },
+ imageHeight: { lucide: 'ArrowUpDown', material: 'height' },
+ aspectRatio: { lucide: 'Crop', material: 'swap_horiz' },
+ altText: { lucide: 'Text', material: 'comment' },
+};
diff --git a/packages/react/src/components/TextContextMenu.tsx b/packages/react/src/components/TextContextMenu.tsx
index 7a5823df..dcfed03f 100644
--- a/packages/react/src/components/TextContextMenu.tsx
+++ b/packages/react/src/components/TextContextMenu.tsx
@@ -122,7 +122,8 @@ const DEFAULT_MENU_ITEMS: TextContextMenuItem[] = [
dividerAfter: true,
},
{ action: 'delete', label: 'Delete', shortcut: 'Del', dividerAfter: true },
- { action: 'selectAll', label: 'Select All', shortcut: 'Ctrl+A' },
+ { action: 'selectAll', label: 'Select All', shortcut: 'Ctrl+A', dividerAfter: true },
+ { action: 'addComment', label: 'Comment', shortcut: 'Ctrl+Alt+M' },
];
// ============================================================================
diff --git a/packages/react/src/components/TitleBar.tsx b/packages/react/src/components/TitleBar.tsx
index daf8788d..4aa844ac 100644
--- a/packages/react/src/components/TitleBar.tsx
+++ b/packages/react/src/components/TitleBar.tsx
@@ -13,6 +13,7 @@ import type { ReactNode } from 'react';
import { MenuDropdown } from './ui/MenuDropdown';
import type { MenuEntry } from './ui/MenuDropdown';
import { TableGridInline } from './ui/TableGridInline';
+import { ToolbarTabs } from './ToolbarTabs';
import { useEditorToolbar } from './EditorToolbarContext';
import type { FormattingAction } from './Toolbar';
@@ -265,7 +266,7 @@ export function TitleBar({ children }: TitleBarProps) {
logoItem = child;
} else if (child.type === TitleBarRight) {
rightItem = child;
- } else if (child.type === MenuBar) {
+ } else if (child.type === MenuBar || child.type === ToolbarTabs) {
menuBarItems.push(child);
} else {
middleTopItems.push(child);
diff --git a/packages/react/src/components/Toolbar.tsx b/packages/react/src/components/Toolbar.tsx
index 1ef4fa74..d9f76208 100644
--- a/packages/react/src/components/Toolbar.tsx
+++ b/packages/react/src/components/Toolbar.tsx
@@ -7,97 +7,22 @@
* - Superscript, Subscript buttons
* - Shows active state for current selection formatting
* - Applies formatting to selection
- *
- * Classic single-row layout: menus (File, Format, Insert) + formatting icons.
- * Uses FormattingBar internally for the icon toolbar.
*/
-import React, { useCallback, useRef } from 'react';
+import React, { Fragment, useEffect, useRef } from 'react';
import type { CSSProperties, ReactNode } from 'react';
-import type {
- ColorValue,
- ParagraphAlignment,
- Style,
- Theme,
-} from '@eigenpal/docx-core/types/document';
+import type { ColorValue, Style, Theme } from '@eigenpal/docx-core/types/document';
+import type { FormattingAction, SelectionFormatting } from './toolbarTypes';
+import { useToolbarItems } from './toolbarItems';
import { Button } from './ui/Button';
import { Tooltip } from './ui/Tooltip';
-import { MenuDropdown } from './ui/MenuDropdown';
-import type { MenuEntry } from './ui/MenuDropdown';
-import { TableGridInline } from './ui/TableGridInline';
import type { TableAction } from './ui/TableToolbar';
-import type { ListState } from './ui/ListButtons';
import { cn } from '../lib/utils';
-import { FormattingBar } from './FormattingBar';
// ============================================================================
// TYPES
// ============================================================================
-/**
- * Current formatting state of the selection
- */
-export interface SelectionFormatting {
- /** Whether selected text is bold */
- bold?: boolean;
- /** Whether selected text is italic */
- italic?: boolean;
- /** Whether selected text is underlined */
- underline?: boolean;
- /** Whether selected text has strikethrough */
- strike?: boolean;
- /** Whether selected text is superscript */
- superscript?: boolean;
- /** Whether selected text is subscript */
- subscript?: boolean;
- /** Font family of selected text */
- fontFamily?: string;
- /** Font size of selected text (in half-points) */
- fontSize?: number;
- /** Text color */
- color?: string;
- /** Highlight color */
- highlight?: string;
- /** Paragraph alignment */
- alignment?: ParagraphAlignment;
- /** List state of the current paragraph */
- listState?: ListState;
- /** Line spacing in twips (OOXML value, 240 = single spacing) */
- lineSpacing?: number;
- /** Paragraph style ID */
- styleId?: string;
- /** Paragraph left indentation in twips */
- indentLeft?: number;
- /** Whether the paragraph is RTL (bidi) */
- bidi?: boolean;
-}
-
-/**
- * Formatting action types
- */
-export type FormattingAction =
- | 'bold'
- | 'italic'
- | 'underline'
- | 'strikethrough'
- | 'superscript'
- | 'subscript'
- | 'clearFormatting'
- | 'bulletList'
- | 'numberedList'
- | 'indent'
- | 'outdent'
- | 'insertLink'
- | 'setRtl'
- | 'setLtr'
- | { type: 'fontFamily'; value: string }
- | { type: 'fontSize'; value: number }
- | { type: 'textColor'; value: ColorValue | string }
- | { type: 'highlightColor'; value: string }
- | { type: 'alignment'; value: ParagraphAlignment }
- | { type: 'lineSpacing'; value: number }
- | { type: 'applyStyle'; value: string };
-
/**
* Props for the Toolbar component
*/
@@ -150,6 +75,10 @@ export interface ToolbarProps {
onPrint?: () => void;
/** Whether to show print button (default: true) */
showPrintButton?: boolean;
+ /** Callback for "Save as DOCX" in the File menu */
+ onSaveAsDocx?: () => void;
+ /** Callback for "Save as PDF" in the File menu */
+ onSaveAsPdf?: () => void;
/** Whether to show zoom control (default: true) */
showZoomControl?: boolean;
/** Current zoom level (1.0 = 100%) */
@@ -238,6 +167,12 @@ export interface ToolbarGroupProps {
className?: string;
}
+// ============================================================================
+// STYLES
+// ============================================================================
+
+// Toolbar uses Tailwind classes now - see the component JSX for styling
+
// ============================================================================
// SUBCOMPONENTS
// ============================================================================
@@ -254,6 +189,7 @@ export function ToolbarButton({
className,
ariaLabel,
}: ToolbarButtonProps) {
+ // Generate testid from ariaLabel or title
const testId =
ariaLabel?.toLowerCase().replace(/\s+/g, '-') ||
title
@@ -262,6 +198,7 @@ export function ToolbarButton({
.replace(/\([^)]*\)/g, '')
.trim();
+ // Prevent mousedown from stealing focus from the editor selection
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
};
@@ -324,48 +261,172 @@ export function ToolbarSeparator() {
// ============================================================================
/**
- * Classic single-row formatting toolbar: menus + formatting icons.
- * Uses FormattingBar internally with inline mode so everything stays in one flex row.
+ * Formatting toolbar with all controls
*/
export function Toolbar({
- children,
+ currentFormatting = {},
+ onFormat,
+ onUndo,
+ onRedo,
+ canUndo = false,
+ canRedo = false,
+ disabled = false,
className,
style,
- disabled = false,
- onFormat,
+ enableShortcuts = true,
+ editorRef,
+ children,
+ showFontPicker = true,
+ showFontSizePicker = true,
+ showTextColorPicker = true,
+ showHighlightColorPicker = true,
+ showAlignmentButtons = true,
+ showListButtons = true,
+ showLineSpacingPicker = true,
+ showStylePicker = true,
+ documentStyles,
+ theme,
onPrint,
showPrintButton = true,
- onPageSetup,
- onInsertImage,
+ showZoomControl = true,
+ zoom,
+ onZoomChange,
+ onRefocusEditor,
onInsertTable,
showTableInsert = true,
+ onInsertImage,
onInsertPageBreak,
onInsertTOC,
- onRefocusEditor,
- ...restProps
+ imageContext,
+ onImageWrapType,
+ onImageTransform,
+ onOpenImageProperties,
+ onPageSetup,
+ tableContext,
+ onTableAction,
}: ToolbarProps) {
const toolbarRef = useRef(null);
- const handleFormat = useCallback(
- (action: FormattingAction) => {
- if (!disabled && onFormat) {
- onFormat(action);
- }
- },
- [disabled, onFormat]
- );
+ const { compact, actions } = useToolbarItems({
+ currentFormatting,
+ documentStyles,
+ theme,
+ disabled,
+ canUndo,
+ canRedo,
+ onFormat,
+ onUndo,
+ onRedo,
+ onPrint,
+ onPageSetup,
+ showPrintButton,
+ showFontPicker,
+ showFontSizePicker,
+ showTextColorPicker,
+ showHighlightColorPicker,
+ showAlignmentButtons,
+ showListButtons,
+ showLineSpacingPicker,
+ showStylePicker,
+ showZoomControl,
+ zoom,
+ onZoomChange,
+ onRefocusEditor,
+ onInsertTable,
+ showTableInsert,
+ onInsertImage,
+ onInsertPageBreak,
+ onInsertTOC,
+ imageContext,
+ onImageWrapType,
+ onImageTransform,
+ onOpenImageProperties,
+ tableContext,
+ onTableAction,
+ });
+
+ const { format, align } = actions;
+
+ /**
+ * Keyboard shortcuts handler
+ */
+ useEffect(() => {
+ if (!enableShortcuts) return;
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ // Only process if editor has focus or toolbar has focus
+ const target = event.target as HTMLElement;
+ const editorContainer = editorRef?.current;
+ const toolbarContainer = toolbarRef.current;
+
+ const isInEditor = editorContainer?.contains(target);
+ const isInToolbar = toolbarContainer?.contains(target);
- const handleTableInsert = useCallback(
- (rows: number, columns: number) => {
- if (!disabled && onInsertTable) {
- onInsertTable(rows, columns);
- requestAnimationFrame(() => onRefocusEditor?.());
+ if (!isInEditor && !isInToolbar) return;
+
+ const isCtrl = event.ctrlKey || event.metaKey;
+
+ if (isCtrl && !event.altKey) {
+ switch (event.key.toLowerCase()) {
+ case 'b':
+ event.preventDefault();
+ format('bold');
+ break;
+ case 'i':
+ event.preventDefault();
+ format('italic');
+ break;
+ case 'u':
+ event.preventDefault();
+ format('underline');
+ break;
+ case '=':
+ // Ctrl+= for subscript (common shortcut)
+ if (event.shiftKey) {
+ event.preventDefault();
+ format('superscript');
+ } else {
+ event.preventDefault();
+ format('subscript');
+ }
+ break;
+ // Alignment shortcuts
+ case 'l':
+ event.preventDefault();
+ align('left');
+ break;
+ case 'e':
+ event.preventDefault();
+ align('center');
+ break;
+ case 'r':
+ event.preventDefault();
+ align('right');
+ break;
+ case 'j':
+ event.preventDefault();
+ align('both');
+ break;
+ case 'k':
+ event.preventDefault();
+ format('insertLink');
+ break;
+ // Undo/Redo handled by useHistory hook
+ }
}
- },
- [disabled, onInsertTable, onRefocusEditor]
- );
+ };
- const handleToolbarMouseDown = useCallback((e: React.MouseEvent) => {
+ // Add listener to document
+ document.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [enableShortcuts, format, align, editorRef]);
+
+ // Prevent toolbar clicks from stealing focus and refocus editor
+ const handleToolbarMouseDown = (e: React.MouseEvent) => {
+ // Allow clicks on input/select elements to work normally
const target = e.target as HTMLElement;
const isInteractive =
target.tagName === 'INPUT' ||
@@ -374,27 +435,28 @@ export function Toolbar({
target.tagName === 'OPTION';
if (!isInteractive) {
+ // Prevent the mousedown from stealing focus
e.preventDefault();
}
- }, []);
-
- const handleToolbarMouseUp = useCallback(
- (e: React.MouseEvent) => {
- const target = e.target as HTMLElement;
- const activeEl = document.activeElement as HTMLElement;
- const isSelectActive =
- target.tagName === 'SELECT' ||
- target.tagName === 'OPTION' ||
- activeEl?.tagName === 'SELECT';
-
- if (isSelectActive) return;
-
- requestAnimationFrame(() => {
- onRefocusEditor?.();
- });
- },
- [onRefocusEditor]
- );
+ };
+
+ // Refocus editor after toolbar click (called on mouseup)
+ const handleToolbarMouseUp = (e: React.MouseEvent) => {
+ // Don't refocus if user is interacting with a select/input
+ const target = e.target as HTMLElement;
+ const activeEl = document.activeElement as HTMLElement;
+ const isSelectActive =
+ target.tagName === 'SELECT' || target.tagName === 'OPTION' || activeEl?.tagName === 'SELECT';
+
+ if (isSelectActive) {
+ return; // Let the select keep focus
+ }
+
+ // Use requestAnimationFrame to ensure the click action completes first
+ requestAnimationFrame(() => {
+ onRefocusEditor?.();
+ });
+ };
return (
- {/* File Menu */}
- {(showPrintButton && onPrint) || onPageSetup ? (
-
- ) : null}
-
- {/* Format Menu */}
-
handleFormat('setLtr'),
- } as MenuEntry,
- {
- icon: 'format_textdirection_r_to_l',
- label: 'Right-to-left text',
- onClick: () => handleFormat('setRtl'),
- } as MenuEntry,
- ]}
- />
-
- {/* Insert Menu */}
- void) => (
- {
- handleTableInsert(rows, cols);
- closeMenu();
- }}
- />
- ),
- } as MenuEntry,
- ]
- : []),
- ...(onInsertImage || (showTableInsert && onInsertTable)
- ? [{ type: 'separator' as const } as MenuEntry]
- : []),
- {
- icon: 'page_break',
- label: 'Page break',
- onClick: onInsertPageBreak,
- disabled: !onInsertPageBreak,
- },
- {
- icon: 'format_list_numbered',
- label: 'Table of contents',
- onClick: onInsertTOC,
- disabled: !onInsertTOC,
- },
- ]}
- />
-
- {/* Formatting icons — rendered inline (display:contents) */}
-
- {children}
-
+ {compact.map((entry) => {
+ if (entry.kind === 'group') {
+ return (
+
+ {entry.items.map((item) =>
+ item.kind === 'button' ? (
+
+ {item.icon}
+
+ ) : (
+ {item.node}
+ )
+ )}
+
+ );
+ }
+
+ if (entry.kind === 'button') {
+ return (
+
+ {entry.icon}
+
+ );
+ }
+
+ return {entry.node};
+ })}
+
+ {children}
);
}
+export type { SelectionFormatting, FormattingAction } from './toolbarTypes';
+
// ============================================================================
// RE-EXPORTED UTILITIES (from toolbarUtils.ts)
// ============================================================================
diff --git a/packages/react/src/components/Toolbar.tsx.orig b/packages/react/src/components/Toolbar.tsx.orig
new file mode 100644
index 00000000..714e87d9
--- /dev/null
+++ b/packages/react/src/components/Toolbar.tsx.orig
@@ -0,0 +1,908 @@
+/**
+ * Formatting Toolbar Component
+ *
+ * A toolbar with formatting controls for the DOCX editor:
+ * - Font family picker
+ * - Bold (Ctrl+B), Italic (Ctrl+I), Underline (Ctrl+U), Strikethrough
+ * - Superscript, Subscript buttons
+ * - Shows active state for current selection formatting
+ * - Applies formatting to selection
+ */
+
+import React, { Fragment, useEffect, useRef } from 'react';
+import type { CSSProperties, ReactNode } from 'react';
+import type { ColorValue, Style, Theme } from '@eigenpal/docx-core/types/document';
+import type { FormattingAction, SelectionFormatting } from './toolbarTypes';
+import { useToolbarItems } from './toolbarItems';
+import { Button } from './ui/Button';
+import { Tooltip } from './ui/Tooltip';
+import type { TableAction } from './ui/TableToolbar';
+import { cn } from '../lib/utils';
+
+// ============================================================================
+// TYPES
+// ============================================================================
+
+/**
+<<<<<<< HEAD
+=======
+ * Current formatting state of the selection
+ */
+export interface SelectionFormatting {
+ /** Whether selected text is bold */
+ bold?: boolean;
+ /** Whether selected text is italic */
+ italic?: boolean;
+ /** Whether selected text is underlined */
+ underline?: boolean;
+ /** Whether selected text has strikethrough */
+ strike?: boolean;
+ /** Whether selected text is superscript */
+ superscript?: boolean;
+ /** Whether selected text is subscript */
+ subscript?: boolean;
+ /** Font family of selected text */
+ fontFamily?: string;
+ /** Font size of selected text (in half-points) */
+ fontSize?: number;
+ /** Text color */
+ color?: string;
+ /** Highlight color */
+ highlight?: string;
+ /** Paragraph alignment */
+ alignment?: ParagraphAlignment;
+ /** List state of the current paragraph */
+ listState?: ListState;
+ /** Line spacing in twips (OOXML value, 240 = single spacing) */
+ lineSpacing?: number;
+ /** Paragraph style ID */
+ styleId?: string;
+ /** Paragraph left indentation in twips */
+ indentLeft?: number;
+ /** Whether the paragraph is RTL (bidi) */
+ bidi?: boolean;
+}
+
+/**
+ * Formatting action types
+ */
+export type FormattingAction =
+ | 'bold'
+ | 'italic'
+ | 'underline'
+ | 'strikethrough'
+ | 'superscript'
+ | 'subscript'
+ | 'clearFormatting'
+ | 'bulletList'
+ | 'numberedList'
+ | 'indent'
+ | 'outdent'
+ | 'insertLink'
+ | 'setRtl'
+ | 'setLtr'
+ | { type: 'fontFamily'; value: string }
+ | { type: 'fontSize'; value: number }
+ | { type: 'textColor'; value: ColorValue | string }
+ | { type: 'highlightColor'; value: string }
+ | { type: 'alignment'; value: ParagraphAlignment }
+ | { type: 'lineSpacing'; value: number }
+ | { type: 'applyStyle'; value: string };
+
+/**
+>>>>>>> main
+ * Props for the Toolbar component
+ */
+export interface ToolbarProps {
+ /** Current formatting of the selection */
+ currentFormatting?: SelectionFormatting;
+ /** Callback when a formatting action is triggered */
+ onFormat?: (action: FormattingAction) => void;
+ /** Callback for undo action */
+ onUndo?: () => void;
+ /** Callback for redo action */
+ onRedo?: () => void;
+ /** Whether undo is available */
+ canUndo?: boolean;
+ /** Whether redo is available */
+ canRedo?: boolean;
+ /** Whether the toolbar is disabled */
+ disabled?: boolean;
+ /** Additional CSS class name */
+ className?: string;
+ /** Additional inline styles */
+ style?: CSSProperties;
+ /** Whether to enable keyboard shortcuts (default: true) */
+ enableShortcuts?: boolean;
+ /** Ref to the editor container for keyboard events */
+ editorRef?: React.RefObject;
+ /** Custom toolbar items to render */
+ children?: ReactNode;
+ /** Whether to show font family picker (default: true) */
+ showFontPicker?: boolean;
+ /** Whether to show font size picker (default: true) */
+ showFontSizePicker?: boolean;
+ /** Whether to show text color picker (default: true) */
+ showTextColorPicker?: boolean;
+ /** Whether to show highlight color picker (default: true) */
+ showHighlightColorPicker?: boolean;
+ /** Whether to show alignment buttons (default: true) */
+ showAlignmentButtons?: boolean;
+ /** Whether to show list buttons (default: true) */
+ showListButtons?: boolean;
+ /** Whether to show line spacing picker (default: true) */
+ showLineSpacingPicker?: boolean;
+ /** Whether to show style picker (default: true) */
+ showStylePicker?: boolean;
+ /** Document styles for the style picker */
+ documentStyles?: Style[];
+ /** Theme for the style picker */
+ theme?: Theme | null;
+ /** Callback for print action */
+ onPrint?: () => void;
+ /** Whether to show print button (default: true) */
+ showPrintButton?: boolean;
+ /** Whether to show zoom control (default: true) */
+ showZoomControl?: boolean;
+ /** Current zoom level (1.0 = 100%) */
+ zoom?: number;
+ /** Callback when zoom changes */
+ onZoomChange?: (zoom: number) => void;
+ /** Callback to refocus the editor after toolbar interactions */
+ onRefocusEditor?: () => void;
+ /** Callback when a table should be inserted */
+ onInsertTable?: (rows: number, columns: number) => void;
+ /** Whether to show table insert button (default: true) */
+ showTableInsert?: boolean;
+ /** Callback when user wants to insert an image */
+ onInsertImage?: () => void;
+ /** Callback when user wants to insert a page break */
+ onInsertPageBreak?: () => void;
+ /** Callback when user wants to insert a table of contents */
+ onInsertTOC?: () => void;
+ /** Callback when user wants to insert a shape */
+ onInsertShape?: (data: {
+ shapeType: string;
+ width: number;
+ height: number;
+ fillColor?: string;
+ fillType?: string;
+ outlineWidth?: number;
+ outlineColor?: string;
+ }) => void;
+ /** Image context when an image is selected */
+ imageContext?: {
+ wrapType: string;
+ displayMode: string;
+ cssFloat: string | null;
+ } | null;
+ /** Callback when image wrap type changes */
+ onImageWrapType?: (wrapType: string) => void;
+ /** Callback for image transform (rotate/flip) */
+ onImageTransform?: (action: 'rotateCW' | 'rotateCCW' | 'flipH' | 'flipV') => void;
+ /** Callback to open image properties dialog (alt text + border) */
+ onOpenImageProperties?: () => void;
+ /** Callback to open page setup dialog */
+ onPageSetup?: () => void;
+ /** Table context when cursor is in a table */
+ tableContext?: {
+ isInTable: boolean;
+ rowCount?: number;
+ columnCount?: number;
+ canSplitCell?: boolean;
+ hasMultiCellSelection?: boolean;
+ cellBorderColor?: ColorValue;
+ cellBackgroundColor?: string;
+ } | null;
+ /** Callback when a table action is triggered */
+ onTableAction?: (action: TableAction) => void;
+}
+
+/**
+ * Props for individual toolbar buttons
+ */
+export interface ToolbarButtonProps {
+ /** Whether the button is in active/pressed state */
+ active?: boolean;
+ /** Whether the button is disabled */
+ disabled?: boolean;
+ /** Button title/tooltip */
+ title?: string;
+ /** Click handler */
+ onClick?: () => void;
+ /** Button content */
+ children: ReactNode;
+ /** Additional CSS class name */
+ className?: string;
+ /** ARIA label for accessibility */
+ ariaLabel?: string;
+}
+
+/**
+ * Props for toolbar button groups
+ */
+export interface ToolbarGroupProps {
+ /** Group label for accessibility */
+ label?: string;
+ /** Group content */
+ children: ReactNode;
+ /** Additional CSS class name */
+ className?: string;
+}
+
+// ============================================================================
+// STYLES
+// ============================================================================
+
+// Toolbar uses Tailwind classes now - see the component JSX for styling
+
+// ============================================================================
+// SUBCOMPONENTS
+// ============================================================================
+
+/**
+ * Individual toolbar button with shadcn styling
+ */
+export function ToolbarButton({
+ active = false,
+ disabled = false,
+ title,
+ onClick,
+ children,
+ className,
+ ariaLabel,
+}: ToolbarButtonProps) {
+ // Generate testid from ariaLabel or title
+ const testId =
+ ariaLabel?.toLowerCase().replace(/\s+/g, '-') ||
+ title
+ ?.toLowerCase()
+ .replace(/\s+/g, '-')
+ .replace(/\([^)]*\)/g, '')
+ .trim();
+
+ // Prevent mousedown from stealing focus from the editor selection
+ const handleMouseDown = (e: React.MouseEvent) => {
+ e.preventDefault();
+ };
+
+ const button = (
+
+ {children}
+
+ );
+
+ if (title) {
+ return {button};
+ }
+
+ return button;
+}
+
+/**
+ * Toolbar button group with modern styling
+ */
+export function ToolbarGroup({ label, children, className }: ToolbarGroupProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * Toolbar separator
+ */
+export function ToolbarSeparator() {
+ return ;
+}
+
+// ============================================================================
+<<<<<<< HEAD
+=======
+// ICON SIZE CONSTANT
+// ============================================================================
+
+const ICON_SIZE = 18;
+
+// ============================================================================
+>>>>>>> main
+// MAIN COMPONENT
+// ============================================================================
+
+/**
+ * Formatting toolbar with all controls
+ */
+export function Toolbar({
+ currentFormatting = {},
+ onFormat,
+ onUndo,
+ onRedo,
+ canUndo = false,
+ canRedo = false,
+ disabled = false,
+ className,
+ style,
+ enableShortcuts = true,
+ editorRef,
+ children,
+ showFontPicker = true,
+ showFontSizePicker = true,
+ showTextColorPicker = true,
+ showHighlightColorPicker = true,
+ showAlignmentButtons = true,
+ showListButtons = true,
+ showLineSpacingPicker = true,
+ showStylePicker = true,
+ documentStyles,
+ theme,
+ onPrint,
+ showPrintButton = true,
+ showZoomControl = true,
+ zoom,
+ onZoomChange,
+ onRefocusEditor,
+ onInsertTable,
+ showTableInsert = true,
+ onInsertImage,
+ onInsertPageBreak,
+ onInsertTOC,
+ imageContext,
+ onImageWrapType,
+ onImageTransform,
+ onOpenImageProperties,
+ onPageSetup,
+ tableContext,
+ onTableAction,
+}: ToolbarProps) {
+ const toolbarRef = useRef(null);
+
+ const { compact, actions } = useToolbarItems({
+ currentFormatting,
+ documentStyles,
+ theme,
+ disabled,
+ canUndo,
+ canRedo,
+ onFormat,
+ onUndo,
+ onRedo,
+ onPrint,
+ showPrintButton,
+ showFontPicker,
+ showFontSizePicker,
+ showTextColorPicker,
+ showHighlightColorPicker,
+ showAlignmentButtons,
+ showListButtons,
+ showLineSpacingPicker,
+ showStylePicker,
+ showZoomControl,
+ zoom,
+ onZoomChange,
+ onRefocusEditor,
+ onInsertTable,
+ showTableInsert,
+ onInsertImage,
+ onInsertPageBreak,
+ onInsertTOC,
+ imageContext,
+ onImageWrapType,
+ onImageTransform,
+ onOpenImageProperties,
+ tableContext,
+ onTableAction,
+ });
+
+ const { format, align } = actions;
+
+ /**
+ * Keyboard shortcuts handler
+ */
+ useEffect(() => {
+ if (!enableShortcuts) return;
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ // Only process if editor has focus or toolbar has focus
+ const target = event.target as HTMLElement;
+ const editorContainer = editorRef?.current;
+ const toolbarContainer = toolbarRef.current;
+
+ const isInEditor = editorContainer?.contains(target);
+ const isInToolbar = toolbarContainer?.contains(target);
+
+ if (!isInEditor && !isInToolbar) return;
+
+ const isCtrl = event.ctrlKey || event.metaKey;
+
+ if (isCtrl && !event.altKey) {
+ switch (event.key.toLowerCase()) {
+ case 'b':
+ event.preventDefault();
+ format('bold');
+ break;
+ case 'i':
+ event.preventDefault();
+ format('italic');
+ break;
+ case 'u':
+ event.preventDefault();
+ format('underline');
+ break;
+ case '=':
+ // Ctrl+= for subscript (common shortcut)
+ if (event.shiftKey) {
+ event.preventDefault();
+ format('superscript');
+ } else {
+ event.preventDefault();
+ format('subscript');
+ }
+ break;
+ // Alignment shortcuts
+ case 'l':
+ event.preventDefault();
+ align('left');
+ break;
+ case 'e':
+ event.preventDefault();
+ align('center');
+ break;
+ case 'r':
+ event.preventDefault();
+ align('right');
+ break;
+ case 'j':
+ event.preventDefault();
+ align('both');
+ break;
+ case 'k':
+ event.preventDefault();
+ format('insertLink');
+ break;
+ // Undo/Redo handled by useHistory hook
+ }
+ }
+ };
+
+ // Add listener to document
+ document.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [enableShortcuts, format, align, editorRef]);
+
+ // Prevent toolbar clicks from stealing focus and refocus editor
+ const handleToolbarMouseDown = (e: React.MouseEvent) => {
+ // Allow clicks on input/select elements to work normally
+ const target = e.target as HTMLElement;
+ const isInteractive =
+ target.tagName === 'INPUT' ||
+ target.tagName === 'TEXTAREA' ||
+ target.tagName === 'SELECT' ||
+ target.tagName === 'OPTION';
+
+ if (!isInteractive) {
+ // Prevent the mousedown from stealing focus
+ e.preventDefault();
+ }
+ };
+
+ // Refocus editor after toolbar click (called on mouseup)
+ const handleToolbarMouseUp = (e: React.MouseEvent) => {
+ // Don't refocus if user is interacting with a select/input
+ const target = e.target as HTMLElement;
+ const activeEl = document.activeElement as HTMLElement;
+ const isSelectActive =
+ target.tagName === 'SELECT' || target.tagName === 'OPTION' || activeEl?.tagName === 'SELECT';
+
+ if (isSelectActive) {
+ return; // Let the select keep focus
+ }
+
+ // Use requestAnimationFrame to ensure the click action completes first
+ requestAnimationFrame(() => {
+ onRefocusEditor?.();
+ });
+ };
+
+ return (
+
+<<<<<<< HEAD
+ {compact.map((entry) => {
+ if (entry.kind === 'group') {
+ return (
+
+ {entry.items.map((item) =>
+ item.kind === 'button' ? (
+
+ {item.icon}
+
+ ) : (
+ {item.node}
+ )
+ )}
+
+ );
+ }
+
+ if (entry.kind === 'button') {
+ return (
+=======
+ {/* File Menu */}
+ {(showPrintButton && onPrint) || onPageSetup ? (
+
+ ) : null}
+
+ {/* Format Menu */}
+
handleFormat('setLtr'),
+ } as MenuEntry,
+ {
+ icon: 'format_textdirection_r_to_l',
+ label: 'Right-to-left text',
+ onClick: () => handleFormat('setRtl'),
+ } as MenuEntry,
+ ]}
+ />
+
+ {/* Insert Menu */}
+ void) => (
+ {
+ handleTableInsert(rows, cols);
+ closeMenu();
+ }}
+ />
+ ),
+ } as MenuEntry,
+ ]
+ : []),
+ ...(onInsertImage || (showTableInsert && onInsertTable)
+ ? [{ type: 'separator' as const } as MenuEntry]
+ : []),
+ {
+ icon: 'page_break',
+ label: 'Page break',
+ onClick: onInsertPageBreak,
+ disabled: !onInsertPageBreak,
+ },
+ {
+ icon: 'format_list_numbered',
+ label: 'Table of contents',
+ onClick: onInsertTOC,
+ disabled: !onInsertTOC,
+ },
+ ]}
+ />
+
+ {/* Undo/Redo Group */}
+
+
+
+
+
+
+
+
+
+ {/* Zoom Control */}
+ {showZoomControl && (
+
+
+
+ )}
+
+ {/* Style Picker */}
+ {showStylePicker && (
+
+
+
+ )}
+
+ {/* Font Family and Size Pickers */}
+ {(showFontPicker || showFontSizePicker) && (
+
+ {showFontPicker && (
+
+ )}
+ {showFontSizePicker && (
+
+ )}
+
+ )}
+
+ {/* Text Formatting Group */}
+
+ handleFormat('bold')}
+ active={currentFormatting.bold}
+ disabled={disabled}
+ title="Bold (Ctrl+B)"
+ ariaLabel="Bold"
+ >
+
+
+ handleFormat('italic')}
+ active={currentFormatting.italic}
+ disabled={disabled}
+ title="Italic (Ctrl+I)"
+ ariaLabel="Italic"
+ >
+
+
+ handleFormat('underline')}
+ active={currentFormatting.underline}
+ disabled={disabled}
+ title="Underline (Ctrl+U)"
+ ariaLabel="Underline"
+ >
+
+
+ handleFormat('strikethrough')}
+ active={currentFormatting.strike}
+ disabled={disabled}
+ title="Strikethrough"
+ ariaLabel="Strikethrough"
+ >
+
+
+ {showTextColorPicker && (
+
+ )}
+ {showHighlightColorPicker && (
+
+ )}
+ handleFormat('insertLink')}
+ disabled={disabled}
+ title="Insert link (Ctrl+K)"
+ ariaLabel="Insert link"
+ >
+
+
+
+
+ {/* Superscript/Subscript Group */}
+
+ handleFormat('superscript')}
+ active={currentFormatting.superscript}
+ disabled={disabled}
+ title="Superscript (Ctrl+Shift+=)"
+ ariaLabel="Superscript"
+ >
+
+
+ handleFormat('subscript')}
+ active={currentFormatting.subscript}
+ disabled={disabled}
+ title="Subscript (Ctrl+=)"
+ ariaLabel="Subscript"
+ >
+
+
+
+
+ {/* Alignment Dropdown */}
+ {showAlignmentButtons && (
+
+
+
+ )}
+
+ {/* List Buttons and Line Spacing */}
+ {(showListButtons || showLineSpacingPicker) && (
+
+ {showListButtons && (
+ 0}
+ />
+ )}
+ {showLineSpacingPicker && (
+
+ )}
+
+ )}
+
+ {/* Image controls - shown when image is selected */}
+ {imageContext && onImageWrapType && (
+
+
+ {onImageTransform && (
+
+ )}
+ {onOpenImageProperties && (
+>>>>>>> main
+
+ {entry.icon}
+
+ );
+ }
+
+ return {entry.node};
+ })}
+
+ {children}
+
+ );
+}
+
+export type { SelectionFormatting, FormattingAction } from './toolbarTypes';
+
+// ============================================================================
+// RE-EXPORTED UTILITIES (from toolbarUtils.ts)
+// ============================================================================
+
+export {
+ getSelectionFormatting,
+ applyFormattingAction,
+ hasActiveFormatting,
+ mapHexToHighlightName,
+} from './toolbarUtils';
+
+export default Toolbar;
diff --git a/packages/react/src/components/ToolbarTabs.tsx b/packages/react/src/components/ToolbarTabs.tsx
new file mode 100644
index 00000000..b40711c1
--- /dev/null
+++ b/packages/react/src/components/ToolbarTabs.tsx
@@ -0,0 +1,95 @@
+/**
+ * ToolbarTabs — Word-style ribbon tab switcher.
+ *
+ * Renders a row of tab buttons that control which toolbar bar is visible
+ * (e.g. Home → FormattingBar, Review → ReviewBar). Intended to sit inside
+ * the TitleBar alongside the MenuBar.
+ *
+ * Styling matches the MenuDropdown trigger buttons for visual consistency.
+ */
+
+import type { CSSProperties } from 'react';
+
+export interface ToolbarTab {
+ /** Unique tab identifier */
+ id: string;
+ /** Display label */
+ label: string;
+}
+
+export interface ToolbarTabsProps {
+ /** Available tabs */
+ tabs: ToolbarTab[];
+ /** Currently active tab id */
+ activeTab: string;
+ /** Called when the user clicks a tab */
+ onTabChange: (tabId: string) => void;
+ /** Whether tabs are disabled */
+ disabled?: boolean;
+}
+
+const baseStyle: CSSProperties = {
+ display: 'flex',
+ alignItems: 'center',
+ padding: '2px 10px',
+ border: 'none',
+ background: 'transparent',
+ borderRadius: '4px 4px 0 0',
+ cursor: 'pointer',
+ fontSize: 13,
+ fontWeight: 400,
+ color: 'var(--doc-text, #374151)',
+ whiteSpace: 'nowrap',
+ height: 28,
+ lineHeight: '28px',
+ borderBottomWidth: 2,
+ borderBottomStyle: 'solid',
+ borderBottomColor: 'transparent',
+ transition: 'border-color 0.15s, background 0.15s',
+};
+
+const activeStyle: CSSProperties = {
+ ...baseStyle,
+ fontWeight: 500,
+ borderBottomColor: 'var(--doc-primary, #1a73e8)',
+ color: 'var(--doc-primary, #1a73e8)',
+};
+
+export function ToolbarTabs({ tabs, activeTab, onTabChange, disabled = false }: ToolbarTabsProps) {
+ return (
+
+ {tabs.map((tab) => {
+ const isActive = tab.id === activeTab;
+ return (
+ e.preventDefault()}
+ onClick={() => !disabled && onTabChange(tab.id)}
+ style={isActive ? activeStyle : baseStyle}
+ onMouseOver={(e) => {
+ if (!isActive) {
+ (e.currentTarget as HTMLButtonElement).style.background =
+ 'var(--doc-hover, #f3f4f6)';
+ }
+ }}
+ onMouseOut={(e) => {
+ if (!isActive) {
+ (e.currentTarget as HTMLButtonElement).style.background = 'transparent';
+ }
+ }}
+ >
+ {tab.label}
+
+ );
+ })}
+
+ );
+}
diff --git a/packages/react/src/components/dialogs/ImageSizeDialog.tsx b/packages/react/src/components/dialogs/ImageSizeDialog.tsx
new file mode 100644
index 00000000..2d2ed152
--- /dev/null
+++ b/packages/react/src/components/dialogs/ImageSizeDialog.tsx
@@ -0,0 +1,280 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import type { CSSProperties } from 'react';
+import { applyLockedDimensionChange } from './imageSizeUtils';
+
+export type ImageSizeDialogFocusTarget = 'width' | 'height' | 'lock';
+
+export interface ImageSizeDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onApply: (size: { width: number; height: number }) => void;
+ initialWidth?: number;
+ initialHeight?: number;
+ initialLock?: boolean;
+ autoFocus?: ImageSizeDialogFocusTarget;
+}
+
+const overlayStyle: CSSProperties = {
+ position: 'fixed',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ zIndex: 10000,
+};
+
+const dialogStyle: CSSProperties = {
+ backgroundColor: 'white',
+ borderRadius: 8,
+ boxShadow: '0 4px 20px rgba(0, 0, 0, 0.15)',
+ minWidth: 360,
+ maxWidth: 420,
+ width: '100%',
+ margin: 20,
+};
+
+const headerStyle: CSSProperties = {
+ padding: '16px 20px 12px',
+ borderBottom: '1px solid var(--doc-border)',
+ fontSize: 16,
+ fontWeight: 600,
+};
+
+const bodyStyle: CSSProperties = {
+ padding: '16px 20px',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 12,
+};
+
+const rowStyle: CSSProperties = {
+ display: 'flex',
+ alignItems: 'center',
+ gap: 10,
+};
+
+const labelStyle: CSSProperties = {
+ width: 64,
+ fontSize: 13,
+ color: 'var(--doc-text-muted)',
+};
+
+const inputStyle: CSSProperties = {
+ flex: 1,
+ padding: '6px 8px',
+ border: '1px solid var(--doc-border)',
+ borderRadius: 4,
+ fontSize: 13,
+};
+
+const unitStyle: CSSProperties = {
+ fontSize: 11,
+ color: 'var(--doc-text-muted)',
+ width: 20,
+};
+
+const footerStyle: CSSProperties = {
+ padding: '12px 20px 16px',
+ borderTop: '1px solid var(--doc-border)',
+ display: 'flex',
+ justifyContent: 'flex-end',
+ gap: 8,
+};
+
+const btnStyle: CSSProperties = {
+ padding: '6px 16px',
+ fontSize: 13,
+ border: '1px solid var(--doc-border)',
+ borderRadius: 4,
+ cursor: 'pointer',
+};
+
+const lockButtonStyle: CSSProperties = {
+ padding: '6px 10px',
+ borderRadius: 4,
+ border: '1px solid var(--doc-border)',
+ backgroundColor: 'white',
+ fontSize: 12,
+ cursor: 'pointer',
+};
+
+const lockButtonActiveStyle: CSSProperties = {
+ ...lockButtonStyle,
+ backgroundColor: 'var(--doc-primary)',
+ borderColor: 'var(--doc-primary)',
+ color: 'white',
+};
+
+export function ImageSizeDialog({
+ isOpen,
+ onClose,
+ onApply,
+ initialWidth = 0,
+ initialHeight = 0,
+ initialLock = true,
+ autoFocus,
+}: ImageSizeDialogProps): React.ReactElement | null {
+ const [width, setWidth] = useState(initialWidth);
+ const [height, setHeight] = useState(initialHeight);
+ const [lockAspect, setLockAspect] = useState(initialLock);
+ const [ratio, setRatio] = useState(0);
+ const widthRef = useRef(null);
+ const heightRef = useRef(null);
+ const lockRef = useRef(null);
+
+ useEffect(() => {
+ if (!isOpen) return;
+ setWidth(initialWidth);
+ setHeight(initialHeight);
+ setLockAspect(initialLock);
+ if (initialWidth > 0 && initialHeight > 0) {
+ setRatio(initialWidth / initialHeight);
+ } else {
+ setRatio(0);
+ }
+ }, [isOpen, initialWidth, initialHeight, initialLock]);
+
+ useEffect(() => {
+ if (!isOpen) return;
+ let target: HTMLElement | null = null;
+ if (autoFocus === 'width') target = widthRef.current;
+ if (autoFocus === 'height') target = heightRef.current;
+ if (autoFocus === 'lock') target = lockRef.current;
+ if (!target) return;
+ target.focus({ preventScroll: true });
+ if (target instanceof HTMLInputElement) {
+ target.select();
+ }
+ }, [isOpen, autoFocus]);
+
+ const handleWidthChange = useCallback(
+ (value: number) => {
+ const next = applyLockedDimensionChange({
+ width,
+ height,
+ ratio,
+ lock: lockAspect,
+ changed: 'width',
+ value,
+ });
+ setWidth(next.width);
+ setHeight(next.height);
+ },
+ [width, height, ratio, lockAspect]
+ );
+
+ const handleHeightChange = useCallback(
+ (value: number) => {
+ const next = applyLockedDimensionChange({
+ width,
+ height,
+ ratio,
+ lock: lockAspect,
+ changed: 'height',
+ value,
+ });
+ setWidth(next.width);
+ setHeight(next.height);
+ },
+ [width, height, ratio, lockAspect]
+ );
+
+ const handleToggleLock = useCallback(() => {
+ setLockAspect((prev) => {
+ const next = !prev;
+ if (next && width > 0 && height > 0) {
+ setRatio(width / height);
+ }
+ return next;
+ });
+ }, [width, height]);
+
+ const canApply = Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0;
+
+ if (!isOpen) return null;
+
+ return (
+
+
e.stopPropagation()}
+ onMouseDown={(e) => e.stopPropagation()}
+ role="dialog"
+ aria-label="Image size"
+ data-testid="image-size-dialog"
+ >
+
Image Size
+
+
+
+ handleWidthChange(Number(e.target.value) || 0)}
+ data-testid="image-size-width"
+ ref={widthRef}
+ />
+ px
+
+
+
+ handleHeightChange(Number(e.target.value) || 0)}
+ data-testid="image-size-height"
+ ref={heightRef}
+ />
+ px
+
+
+
+
+ {lockAspect ? 'On' : 'Off'}
+
+
+
+
+
+ Cancel
+
+ {
+ if (canApply) onApply({ width, height });
+ }}
+ disabled={!canApply}
+ >
+ Apply
+
+
+
+
+ );
+}
+
+export default ImageSizeDialog;
diff --git a/packages/react/src/components/dialogs/imageSizeUtils.test.ts b/packages/react/src/components/dialogs/imageSizeUtils.test.ts
new file mode 100644
index 00000000..d7931ef6
--- /dev/null
+++ b/packages/react/src/components/dialogs/imageSizeUtils.test.ts
@@ -0,0 +1,43 @@
+import { describe, it, expect } from 'bun:test';
+import { applyLockedDimensionChange } from './imageSizeUtils';
+
+describe('imageSizeUtils', () => {
+ it('locks height when width changes', () => {
+ const result = applyLockedDimensionChange({
+ width: 200,
+ height: 100,
+ ratio: 2,
+ lock: true,
+ changed: 'width',
+ value: 400,
+ });
+ expect(result.width).toBe(400);
+ expect(result.height).toBe(200);
+ });
+
+ it('locks width when height changes', () => {
+ const result = applyLockedDimensionChange({
+ width: 200,
+ height: 100,
+ ratio: 2,
+ lock: true,
+ changed: 'height',
+ value: 50,
+ });
+ expect(result.width).toBe(100);
+ expect(result.height).toBe(50);
+ });
+
+ it('does not lock when disabled', () => {
+ const result = applyLockedDimensionChange({
+ width: 200,
+ height: 100,
+ ratio: 2,
+ lock: false,
+ changed: 'width',
+ value: 123,
+ });
+ expect(result.width).toBe(123);
+ expect(result.height).toBe(100);
+ });
+});
diff --git a/packages/react/src/components/dialogs/imageSizeUtils.ts b/packages/react/src/components/dialogs/imageSizeUtils.ts
new file mode 100644
index 00000000..aa27a4e1
--- /dev/null
+++ b/packages/react/src/components/dialogs/imageSizeUtils.ts
@@ -0,0 +1,20 @@
+export function applyLockedDimensionChange(args: {
+ width: number;
+ height: number;
+ ratio: number;
+ lock: boolean;
+ changed: 'width' | 'height';
+ value: number;
+}): { width: number; height: number } {
+ const { width, height, ratio, lock, changed, value } = args;
+
+ if (!lock || !ratio || ratio <= 0) {
+ return changed === 'width' ? { width: value, height } : { width, height: value };
+ }
+
+ if (changed === 'width') {
+ return { width: value, height: Math.round(value / ratio) };
+ }
+
+ return { width: Math.round(value * ratio), height: value };
+}
diff --git a/packages/react/src/components/footnoteUtils.test.ts b/packages/react/src/components/footnoteUtils.test.ts
new file mode 100644
index 00000000..f07bdc51
--- /dev/null
+++ b/packages/react/src/components/footnoteUtils.test.ts
@@ -0,0 +1,33 @@
+import { describe, it, expect } from 'bun:test';
+import type { Footnote } from '@eigenpal/docx-core/types/document';
+import { getNextNoteId, createEmptyFootnote, createEmptyEndnote } from './footnoteUtils';
+
+describe('getNextNoteId', () => {
+ it('returns 1 when no notes are present', () => {
+ expect(getNextNoteId()).toBe(1);
+ });
+
+ it('ignores separators and returns max+1', () => {
+ const notes: Footnote[] = [
+ { type: 'footnote', id: -1, noteType: 'separator', content: [] },
+ { type: 'footnote', id: 1, noteType: 'normal', content: [{ type: 'paragraph', content: [] }] },
+ { type: 'footnote', id: 3, noteType: 'normal', content: [{ type: 'paragraph', content: [] }] },
+ ];
+ expect(getNextNoteId(notes)).toBe(4);
+ });
+});
+
+describe('createEmptyFootnote/createEmptyEndnote', () => {
+ it('creates empty note bodies with one paragraph', () => {
+ const footnote = createEmptyFootnote(2);
+ const endnote = createEmptyEndnote(5);
+
+ expect(footnote.type).toBe('footnote');
+ expect(footnote.id).toBe(2);
+ expect(footnote.content).toEqual([{ type: 'paragraph', content: [] }]);
+
+ expect(endnote.type).toBe('endnote');
+ expect(endnote.id).toBe(5);
+ expect(endnote.content).toEqual([{ type: 'paragraph', content: [] }]);
+ });
+});
diff --git a/packages/react/src/components/footnoteUtils.ts b/packages/react/src/components/footnoteUtils.ts
new file mode 100644
index 00000000..97963225
--- /dev/null
+++ b/packages/react/src/components/footnoteUtils.ts
@@ -0,0 +1,37 @@
+import type { Endnote, Footnote, Paragraph } from '@eigenpal/docx-core/types/document';
+
+function createEmptyParagraph(): Paragraph {
+ return { type: 'paragraph', content: [] };
+}
+
+function isNormalNote(note: Footnote | Endnote): boolean {
+ return (note.noteType ?? 'normal') === 'normal' && note.id > 0;
+}
+
+export function getNextNoteId(notes?: Array): number {
+ if (!notes || notes.length === 0) return 1;
+ let maxId = 0;
+ for (const note of notes) {
+ if (!isNormalNote(note)) continue;
+ if (note.id > maxId) maxId = note.id;
+ }
+ return maxId + 1;
+}
+
+export function createEmptyFootnote(id: number): Footnote {
+ return {
+ type: 'footnote',
+ id,
+ noteType: 'normal',
+ content: [createEmptyParagraph()],
+ };
+}
+
+export function createEmptyEndnote(id: number): Endnote {
+ return {
+ type: 'endnote',
+ id,
+ noteType: 'normal',
+ content: [createEmptyParagraph()],
+ };
+}
diff --git a/packages/react/src/components/sidebar/AddCommentCard.tsx b/packages/react/src/components/sidebar/AddCommentCard.tsx
index 2ffb72b7..7552207a 100644
--- a/packages/react/src/components/sidebar/AddCommentCard.tsx
+++ b/packages/react/src/components/sidebar/AddCommentCard.tsx
@@ -1,21 +1,80 @@
-import { useState } from 'react';
+import { useState, useRef, useCallback } from 'react';
import type { SidebarItemRenderProps } from '../../plugin-api/types';
import { submitButtonStyle, CANCEL_BUTTON_STYLE } from './cardUtils';
+import { MentionDropdown, type MentionProvider, type MentionUser } from './MentionDropdown';
export interface AddCommentCardProps extends SidebarItemRenderProps {
- onSubmit?: (text: string) => void;
+ onSubmit?: (text: string, mentions?: MentionUser[]) => void;
onCancel?: () => void;
+ mentionProvider?: MentionProvider;
}
-export function AddCommentCard({ measureRef, onSubmit, onCancel }: AddCommentCardProps) {
+export function AddCommentCard({
+ measureRef,
+ onSubmit,
+ onCancel,
+ mentionProvider,
+}: AddCommentCardProps) {
const [text, setText] = useState('');
+ const [mentionQuery, setMentionQuery] = useState(null);
+ const [mentions, setMentions] = useState([]);
+ const textareaRef = useRef(null);
- const handleSubmit = () => {
+ const handleChange = useCallback(
+ (e: React.ChangeEvent) => {
+ const value = e.target.value;
+ setText(value);
+
+ if (!mentionProvider) {
+ setMentionQuery(null);
+ return;
+ }
+
+ const cursorPos = e.target.selectionStart ?? value.length;
+ const textBeforeCursor = value.slice(0, cursorPos);
+ const atIndex = textBeforeCursor.lastIndexOf('@');
+
+ if (atIndex >= 0) {
+ const charBefore = atIndex > 0 ? textBeforeCursor[atIndex - 1] : ' ';
+ if (atIndex === 0 || charBefore === ' ' || charBefore === '\n') {
+ const query = textBeforeCursor.slice(atIndex + 1);
+ if (!query.includes(' ') && !query.includes('\n')) {
+ setMentionQuery(query);
+ return;
+ }
+ }
+ }
+ setMentionQuery(null);
+ },
+ [mentionProvider]
+ );
+
+ const handleMentionSelect = useCallback(
+ (user: MentionUser) => {
+ const cursorPos = textareaRef.current?.selectionStart ?? text.length;
+ const textBeforeCursor = text.slice(0, cursorPos);
+ const atIndex = textBeforeCursor.lastIndexOf('@');
+ if (atIndex >= 0) {
+ const before = text.slice(0, atIndex);
+ const after = text.slice(cursorPos);
+ const newText = `${before}@${user.name} ${after}`;
+ setText(newText);
+ setMentions((prev) => [...prev, user]);
+ }
+ setMentionQuery(null);
+ textareaRef.current?.focus();
+ },
+ [text]
+ );
+
+ const handleSubmit = useCallback(() => {
if (text.trim()) {
- onSubmit?.(text.trim());
+ onSubmit?.(text.trim(), mentions.length > 0 ? mentions : undefined);
setText('');
+ setMentions([]);
+ setMentionQuery(null);
}
- };
+ }, [text, mentions, onSubmit]);
return (