Signal drop!
Relay (operand.online) is unreachable.
Usually, a dropped signal means an upgrade is happening. Hold on!
Sorry, no connección.
Hang in there while we get back on track
gram: docs
> ./examples/vite/src/App.tsx
import React, { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { createEmptyDocument, findStartPosForParaId } from '@eigenpal/docx-editor-core';
import { setSuggestionMode } from '@eigenpal/docx-editor-core/prosemirror/plugins';
// Re-exported by core, so the demo needs no direct `prosemirror-state` dep
// (which would break the production build — it isn't in examples/vite deps).
import { TextSelection } from '@eigenpal/docx-editor-core/prosemirror';
import {
acceptChangeById,
rejectChangeById,
acceptAllChanges,
rejectAllChanges,
addRowBelow,
deleteRow,
insertTable,
insertImageNode,
} from '@eigenpal/docx-editor-core/prosemirror/commands';
import { loadFont } from '@eigenpal/docx-editor-core/utils';
import { DocxEditor, type DocxEditorRef } from '@eigenpal/docx-editor-react';
import {
AgentChatLog,
type AgentMessage,
getToolDisplayName,
} from '@eigenpal/docx-editor-agents/react';
import { ExampleSwitcher } from '../../shared/ExampleSwitcher';
import { AdapterSwitcher } from '../../shared/AdapterSwitcher';
function extractDocumentText(value: unknown): string {
if (!value || typeof value !== 'object') return '';
const maybeText = (value as { text?: unknown }).text;
if (typeof maybeText === 'string') return maybeText;
return Object.values(value)
.map((child) =>
Array.isArray(child)
? child.map((item) => extractDocumentText(item)).join('')
: extractDocumentText(child)
)
.join('');
}
const styles: Record<string, React.CSSProperties> = {
container: {
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
overflow: 'hidden',
background: '#f8fafc',
},
main: {
flex: 1,
display: 'flex',
overflow: 'hidden',
},
fileInputLabel: {
padding: '6px 12px',
background: 'var(--doc-text)',
color: 'var(--doc-on-primary)',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 500,
transition: 'background 0.15s',
whiteSpace: 'nowrap',
},
button: {
padding: '6px 12px',
background: 'var(--doc-surface)',
border: '1px solid var(--doc-border)',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 500,
color: 'var(--doc-text)',
transition: 'all 0.15s',
whiteSpace: 'nowrap',
},
newButton: {
padding: '6px 12px',
background: 'var(--doc-bg-subtle)',
color: 'var(--doc-text)',
border: '1px solid var(--doc-border)',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 500,
transition: 'all 0.15s',
whiteSpace: 'nowrap',
},
status: {
fontSize: '12px',
color: 'var(--doc-text-muted)',
padding: '4px 8px',
background: 'var(--doc-bg-subtle)',
borderRadius: '4px',
},
};
function useResponsiveLayout() {
const calcZoom = () => {
const pageWidth = 816 + 48; // 8.5in * 96dpi + padding
const vw = window.innerWidth;
return vw < pageWidth ? Math.max(0.35, Math.floor((vw / pageWidth) * 20) / 20) : 1.0;
};
const [zoom, setZoom] = useState(calcZoom);
const [isMobile, setIsMobile] = useState(() => window.innerWidth <= 768);
useEffect(() => {
const onResize = () => {
setZoom(calcZoom());
setIsMobile(window.innerWidth <= 768);
};
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return { zoom, isMobile };
}
/** Fumadocs-style segmented light/dark toggle (sun/moon, sliding highlight). */
function ThemeToggle({
value,
onChange,
}: {
value: 'light' | 'dark';
onChange: (m: 'light' | 'dark') => void;
}) {
const options: { mode: 'light' | 'dark'; label: string; icon: React.ReactNode }[] = [
{
mode: 'light',
label: 'Light',
icon: (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</svg>
),
},
{
mode: 'dark',
label: 'Dark',
icon: (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
),
},
];
return (
<div
role="radiogroup"
aria-label="Color theme"
onMouseDown={(e) => e.stopPropagation()}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 2,
padding: 2,
borderRadius: 9999,
border: '1px solid var(--doc-border)',
background: 'var(--doc-bg-subtle)',
}}
>
{options.map((opt) => {
const selected = value === opt.mode;
return (
<button
key={opt.mode}
type="button"
role="radio"
aria-checked={selected}
title={`${opt.label} mode`}
onClick={() => onChange(opt.mode)}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 26,
height: 26,
border: 'none',
borderRadius: 9999,
cursor: 'pointer',
transition: 'background 0.15s, color 0.15s',
background: selected ? 'var(--doc-surface)' : 'transparent',
boxShadow: selected ? '0 1px 2px var(--doc-shadow-subtle)' : 'none',
color: selected ? 'var(--doc-text)' : 'var(--doc-text-subtle)',
}}
>
{opt.icon}
</button>
);
})}
</div>
);
}
export function App() {
const randomAuthor = useMemo(
() => `Docx Editor User ${Math.floor(Math.random() * 900) + 100}`,
[]
);
const editorRef = useRef<DocxEditorRef>(null);
const [currentDocument, setCurrentDocument] = useState<Document | null>(null);
const [documentBuffer, setDocumentBuffer] = useState<ArrayBuffer | null>(null);
const [fileName, setFileName] = useState<string>('docx-editor-demo.docx');
const [status, setStatus] = useState<string>('');
const [colorMode, setColorMode] = useState<'light' | 'dark'>('light');
const disableFindReplaceShortcuts = useMemo(
() => new URLSearchParams(window.location.search).get('disableFindReplaceShortcuts') === '1',
[]
);
// E2E hook: ?customFonts=1 wires a custom-font registration against the
// bundled fixture so the Playwright suite can verify the `fonts` prop both
// injects @font-face and renders glyphs from the loaded face.
const customFonts = useMemo(() => {
if (typeof window === 'undefined') return undefined;
const params = new URLSearchParams(window.location.search);
if (params.get('customFonts') !== '1') return undefined;
return [
{ family: 'E2E Custom Font', src: '/e2e-fixtures/inter-regular.woff2' },
{ family: 'E2E Custom Font', src: '/e2e-fixtures/inter-bold.woff2', weight: 700 },
];
}, []);
// E2E hook: ?googleFont=Pacifico demonstrates the existing Google Fonts
// path. The `fonts` prop is for self-hosted faces; for Google Fonts call
// `loadFont(name)` from `@eigenpal/docx-editor-core/utils` directly.
const googleFontName = useMemo(() => {
if (typeof window === 'undefined') return null;
return new URLSearchParams(window.location.search).get('googleFont');
}, []);
useEffect(() => {
if (googleFontName) void loadFont(googleFontName);
}, [googleFontName]);
// E2E opt-in: ?e2e=1 in URL, MODE=test, or VITE_DOCX_EDITOR_E2E=1. Gates the
// Playwright debug hooks below. By default E2E still loads the demo fixture
// (so existing tests are unaffected); ?empty=1 boots from an empty document
// instead, giving tests that build their own content a deterministic start
// that doesn't race the demo fetch.
const { isE2E, e2eBootEmpty } = useMemo(() => {
if (typeof window === 'undefined') return { isE2E: false, e2eBootEmpty: false };
const params = new URLSearchParams(window.location.search);
const env = import.meta.env;
const e2e =
params.get('e2e') === '1' || env.MODE === 'test' || env.VITE_DOCX_EDITOR_E2E === '1';
return { isE2E: e2e, e2eBootEmpty: e2e && params.get('empty') === '1' };
}, []);
const { zoom: autoZoom, isMobile } = useResponsiveLayout();
useEffect(() => {
// Only expose Playwright/E2E hooks under an explicit opt-in. Otherwise
// this leaks an internal API into the public demo at docx-editor.dev.
if (!isE2E) return;
window.__DOCX_EDITOR_E2E__ = {
// Raw body EditorView — lets specs build precise PM states (e.g. a line
// with mixed font sizes) without driving the toolbar UI.
getView: () => editorRef.current?.getEditorRef()?.getView?.() ?? null,
getPmStartForParaId: (paraId: string) => {
const state = editorRef.current?.getEditorRef()?.getState?.();
if (!state || !paraId) return null;
return findStartPosForParaId(state.doc, paraId);
},
getSelectionAnchor: () => {
const state = editorRef.current?.getEditorRef()?.getState?.();
return state?.selection.anchor ?? null;
},
getTextblockEndForParaId: (paraId: string) => {
const state = editorRef.current?.getEditorRef()?.getState?.();
if (!state || !paraId) return null;
const start = findStartPosForParaId(state.doc, paraId);
if (start == null) return null;
const node = state.doc.nodeAt(start);
return node?.isTextblock === true ? start + 1 + node.content.size : null;
},
getFirstTextblockParaId: () => {
const view = editorRef.current?.getEditorRef()?.getView?.();
if (!view) return null;
let found: string | null = null;
view.state.doc.descendants((node) => {
if (node.isTextblock && node.attrs?.paraId) {
found = String(node.attrs.paraId);
return false;
}
return true;
});
return found;
},
getLastTextblockParaId: () => {
const view = editorRef.current?.getEditorRef()?.getView?.();
if (!view) return null;
let found: string | null = null;
view.state.doc.descendants((node) => {
if (node.isTextblock && node.attrs?.paraId) {
found = String(node.attrs.paraId);
}
return true;
});
return found;
},
scrollToParaId: (paraId: string) => editorRef.current?.scrollToParaId(paraId) ?? false,
scrollToPosition: (pmPos: number) => {
editorRef.current?.scrollToPosition(pmPos);
},
getDocSize: () => {
const state = editorRef.current?.getEditorRef()?.getState?.();
return state?.doc.content.size ?? null;
},
highlightRange: (from: number, to: number) => {
editorRef.current?.highlightRange(from, to);
},
scrollToCommentId: (commentId: number) =>
editorRef.current?.scrollToCommentId(commentId) ?? false,
scrollToChangeId: (revisionId: number) =>
editorRef.current?.scrollToChangeId(revisionId) ?? false,
scrollToPage: (pageNumber: number) => {
editorRef.current?.scrollToPage(pageNumber);
},
getTotalPages: () => editorRef.current?.getTotalPages() ?? 0,
getCurrentPage: () => editorRef.current?.getCurrentPage() ?? 0,
saveByteLength: async () => {
const buffer = await editorRef.current?.save();
return buffer?.byteLength ?? null;
},
// Content-control (SDT) addressing surface.
agentGetContentControls: (filter?: {
tag?: string;
alias?: string;
id?: number;
type?: string;
}) =>
editorRef.current
?.getContentControls(filter as Parameters<typeof editorRef.current.getContentControls>[0])
.map((c) => ({ tag: c.tag, alias: c.alias, sdtType: c.sdtType, text: c.text })) ?? [],
agentSetContentControlContent: (
filter: { tag?: string; alias?: string; id?: number },
text: string,
options?: { force?: boolean }
) => editorRef.current?.setContentControlContent(filter, text, options) ?? false,
agentRemoveContentControl: (
filter: { tag?: string; alias?: string; id?: number },
options?: { force?: boolean; keepContent?: boolean }
) => editorRef.current?.removeContentControl(filter, options) ?? false,
agentScrollToContentControl: (filter: { tag?: string; alias?: string; id?: number }) =>
editorRef.current?.scrollToContentControl(filter) ?? false,
// Agent-bridge surface — drives the same paths the live agent uses.
agentAddComment: (opts: { paraId: string; text: string; author?: string; search?: string }) =>
editorRef.current?.addComment({
paraId: opts.paraId,
text: opts.text,
author: opts.author ?? 'E2E',
search: opts.search,
}) ?? null,
agentProposeChange: (opts: {
paraId: string;
search: string;
replaceWith: string;
author?: string;
}) =>
editorRef.current?.proposeChange({
paraId: opts.paraId,
search: opts.search,
replaceWith: opts.replaceWith,
author: opts.author ?? 'E2E',
}) ?? false,
agentReplyComment: (commentId: number, text: string, author = 'E2E') =>
editorRef.current?.replyToComment(commentId, text, author) ?? null,
agentResolveComment: (commentId: number) => editorRef.current?.resolveComment(commentId),
agentFind: (query: string) => editorRef.current?.findInDocument(query) ?? [],
agentSelection: () => editorRef.current?.getSelectionInfo() ?? null,
agentGetCommentCount: () => editorRef.current?.getComments().length ?? 0,
// Event subscriptions — count fires so tests can assert listeners are wired.
agentOnContentChangeCount: 0,
agentOnSelectionChangeCount: 0,
agentSubscribeContentChange: () => {
const hook = window.__DOCX_EDITOR_E2E__;
if (!hook) return () => undefined;
const unsub = editorRef.current?.onContentChange(() => {
hook.agentOnContentChangeCount = (hook.agentOnContentChangeCount ?? 0) + 1;
});
return unsub ?? (() => undefined);
},
agentSubscribeSelectionChange: () => {
const hook = window.__DOCX_EDITOR_E2E__;
if (!hook) return () => undefined;
const unsub = editorRef.current?.onSelectionChange(() => {
hook.agentOnSelectionChangeCount = (hook.agentOnSelectionChangeCount ?? 0) + 1;
});
return unsub ?? (() => undefined);
},
agentApplyFormatting: (opts: {
paraId: string;
search?: string;
marks: Parameters<NonNullable<typeof editorRef.current>['applyFormatting']>[0]['marks'];
}) => editorRef.current?.applyFormatting(opts) ?? false,
agentSetParagraphStyle: (opts: { paraId: string; styleId: string }) =>
editorRef.current?.setParagraphStyle(opts) ?? false,
agentGetPageContent: (pageNumber: number) =>
editorRef.current?.getPageContent(pageNumber) ?? null,
agentGetDocumentText: () => extractDocumentText(editorRef.current?.getDocument()),
// Tracked structural revisions (#614). Drive the suggesting-mode plugin
// and the new id-based accept/reject commands directly against the
// active PM view, so tests don't depend on React mode-prop wiring.
setSuggestionMode: (active: boolean, authorOverride?: string) => {
const view = editorRef.current?.getEditorRef()?.getView?.();
if (!view) return false;
setSuggestionMode(active, view.state, view.dispatch, authorOverride ?? randomAuthor);
return true;
},
getParagraphRevisionAt: (index: number) => {
const view = editorRef.current?.getEditorRef()?.getView?.();
if (!view) return null;
let count = 0;
let out: { pPrIns: unknown; pPrDel: unknown } | null = null;
view.state.doc.descendants((node) => {
if (out != null) return false;
if (node.type.name !== 'paragraph') return true;
if (count === index) {
out = {
pPrIns: (node.attrs as Record<string, unknown>).pPrIns ?? null,
pPrDel: (node.attrs as Record<string, unknown>).pPrDel ?? null,
};
return false;
}
count += 1;
return true;
});
return out;
},
acceptChangeById: (revisionId: number) => {
const view = editorRef.current?.getEditorRef()?.getView?.();
if (!view) return false;
return acceptChangeById(revisionId)(view.state, view.dispatch);
},
rejectChangeById: (revisionId: number) => {
const view = editorRef.current?.getEditorRef()?.getView?.();
if (!view) return false;
return rejectChangeById(revisionId)(view.state, view.dispatch);
},
acceptAllChanges: () => {
const view = editorRef.current?.getEditorRef()?.getView?.();
if (!view) return false;
return acceptAllChanges()(view.state, view.dispatch);
},
rejectAllChanges: () => {
const view = editorRef.current?.getEditorRef()?.getView?.();
if (!view) return false;
return rejectAllChanges()(view.state, view.dispatch);
},
// Test-only: read full attrs of the Nth paragraph, including new
// revision attrs (pPrIns/pPrDel/pPrChange).
getParagraphAttrs: (index: number) => {
const view = editorRef.current?.getEditorRef()?.getView?.();
if (!view) return null;
let count = 0;
let out: Record<string, unknown> | null = null;
view.state.doc.descendants((node) => {
if (out != null) return false;
if (node.type.name !== 'paragraph') return true;
if (count === index) {
out = { ...node.attrs };
return false;
}
count += 1;
return true;
});
return out;
},
// Test-only: insert a 1x1 table at the cursor (replaces selection),
// bypassing the toolbar. Used by the trIns spec.
// Calls the real insertTable command — exercises the suggesting-mode
// tracking path (trIns + cellMarker:ins) when used after setSuggestionMode.
insertTable: (rows: number, cols: number) => {
const view = editorRef.current?.getEditorRef()?.getView?.();
if (!view) return false;
return insertTable(rows, cols)(view.state, view.dispatch);
},
// Test-only: insert an inline image at the cursor via the same helper the
// UI uses, so it is wrapped in the `insertion` mark under suggesting mode.
insertImage: (src: string, width = 80, height = 60) => {
const view = editorRef.current?.getEditorRef()?.getView?.();
if (!view) return false;
const imageNode = view.state.schema.nodes.image.create({
src,
alt: 'test image',
width,
height,
wrapType: 'inline',
displayMode: 'inline',
});
return insertImageNode(view.state, view.dispatch, imageNode, view.state.selection.from);
},
// Test-only: select the first image (a text selection spanning the atom)
// so a following Backspace/Delete exercises the suggesting-mode
// atom-deletion path.
selectFirstImage: () => {
const view = editorRef.current?.getEditorRef()?.getView?.();
if (!view) return false;
let imgPos: number | null = null;
view.state.doc.descendants((node, pos) => {
if (imgPos != null) return false;
if (node.type.name === 'image') {
imgPos = pos;
return false;
}
return true;
});
if (imgPos == null) return false;
const tr = view.state.tr.setSelection(
TextSelection.create(view.state.doc, imgPos, imgPos + 1)
);
view.dispatch(tr);
view.focus();
return true;
},
plantSimpleTable: () => {
const view = editorRef.current?.getEditorRef()?.getView?.();
if (!view) return false;
const { schema } = view.state;
const cellPara = schema.node('paragraph', {}, [schema.text('A')]);
const cell = schema.node('tableCell', { colspan: 1, rowspan: 1 }, [cellPara]);
const row = schema.node('tableRow', {}, [cell]);
const table = schema.node('table', {}, [row]);
view.dispatch(view.state.tr.replaceSelectionWith(table));
return true;
},
// Test-only: count the rows of the first table in the doc.
countTableRows: () => {
const view = editorRef.current?.getEditorRef()?.getView?.();
if (!view) return 0;
let count = 0;
let inFirstTable = false;
view.state.doc.descendants((node) => {
if (node.type.name === 'table') {
if (inFirstTable) return false;
inFirstTable = true;
return true;
}
if (inFirstTable && node.type.name === 'tableRow') count += 1;
return false;
});
return count;
},
// Test-only: place the caret inside the first cell of the first
// table in the doc so suggesting-mode commands like `addRowBelow`
// have a row index to work with.
focusFirstTableCell: () => {
const view = editorRef.current?.getEditorRef()?.getView?.();
if (!view) return false;
let target: number | null = null;
view.state.doc.descendants((node, pos) => {
if (target != null) return false;
if (node.type.name === 'tableCell' || node.type.name === 'tableHeader') {
// `pos + 2` works when the first child is a paragraph (the
// typical case). If it's a nested table, `TextSelection.near`
// snaps forward to the next text-allowing position.
target = pos + 2;
return false;
}
return true;
});
if (target == null) return false;
// Use the constructor on the live selection to avoid a direct
// `prosemirror-state` dependency in the demo's package.json.
const SelectionCtor = view.state.selection.constructor as unknown as {
near: (
$pos: import('prosemirror-model').ResolvedPos
) => import('prosemirror-state').Selection;
};
const tr = view.state.tr.setSelection(SelectionCtor.near(view.state.doc.resolve(target)));
view.dispatch(tr);
view.focus();
return true;
},
// Test-only: dispatch the `addRowBelow` table command. Suggesting-mode
// active → sets `trIns` on the new row + `cellMarker: ins` on each cell.
addRowBelow: () => {
const view = editorRef.current?.getEditorRef()?.getView?.();
if (!view) return false;
return addRowBelow(view.state, view.dispatch);
},
// Test-only: dispatch the schema-free `deleteRow` table command.
// Suggesting-mode active → sets `trDel` on the row + `cellMarker: del`.
deleteCurrentRow: () => {
const view = editorRef.current?.getEditorRef()?.getView?.();
if (!view) return false;
return deleteRow(view.state, view.dispatch);
},
// Test-only: plant trIns on the first table row in the document.
// Returns false if no table exists.
plantTableRowInsertion: (revisionId: number) => {
const view = editorRef.current?.getEditorRef()?.getView?.();
if (!view) return false;
let rowPos: number | null = null;
let rowNode: import('prosemirror-model').Node | null = null;
view.state.doc.descendants((node, pos) => {
if (rowPos != null) return false;
if (node.type.name === 'tableRow') {
rowPos = pos;
rowNode = node;
return false;
}
return true;
});
if (rowPos == null || rowNode == null) return false;
view.dispatch(
view.state.tr.setNodeMarkup(rowPos, undefined, {
...(rowNode as import('prosemirror-model').Node).attrs,
trIns: {
revisionId,
author: 'Jane',
date: new Date().toISOString(),
},
})
);
return true;
},
getFirstTableRowAttrs: () => {
const view = editorRef.current?.getEditorRef()?.getView?.();
if (!view) return null;
let out: Record<string, unknown> | null = null;
view.state.doc.descendants((node) => {
if (out != null) return false;
if (node.type.name === 'tableRow') {
out = { ...node.attrs };
return false;
}
return true;
});
return out;
},
// Test-only: plant a pPrChange entry on the first paragraph for
// round-trip / reject-restore verification. `current` and `paraAttrs`
// let a test simulate a post-save/reload state (e.g. a list-creation
// suggestion whose empty prior `<w:pPr/>` round-tripped without numPr).
plantParagraphPropertyChange: (
revisionId: number,
prior: unknown,
current?: unknown,
paraAttrs?: Record<string, unknown>
) => {
const view = editorRef.current?.getEditorRef()?.getView?.();
if (!view) return false;
let firstParaPos: number | null = null;
let firstPara: import('prosemirror-model').Node | null = null;
view.state.doc.descendants((node, pos) => {
if (firstParaPos != null) return false;
if (node.type.name === 'paragraph') {
firstParaPos = pos;
firstPara = node;
return false;
}
return true;
});
if (firstParaPos == null || firstPara == null) return false;
const tr = view.state.tr.setNodeMarkup(firstParaPos, undefined, {
...(firstPara as import('prosemirror-model').Node).attrs,
...(paraAttrs ?? {}),
pPrChange: [
{
type: 'paragraphPropertyChange',
info: { id: revisionId, author: 'Jane', date: new Date().toISOString() },
previousFormatting: prior,
...(current !== undefined ? { currentFormatting: current } : {}),
},
],
});
view.dispatch(tr);
return true;
},
};
return () => {
delete window.__DOCX_EDITOR_E2E__;
};
}, [isE2E, randomAuthor]);
// Set once the user starts their own document (New / open a file). The demo
// fixture fetch below resolves asynchronously and must NOT clobber that
// choice if it lands afterwards — otherwise New during the (slow) demo load
// silently restores the demo. This was the root cause of the formatting /
// text-editing E2E flakes: `newDocument` cleared the doc, then the late
// fetch repopulated it and subsequent edits landed on the demo content.
const userStartedOwnDocRef = useRef(false);
// Bumped on New / open to force a fresh DocxEditor instance (see handlers).
const [docVersion, setDocVersion] = useState(0);
useEffect(() => {
// Under E2E with ?empty=1, boot empty so tests get a deterministic,
// known starting document instead of racing this async fixture fetch.
if (e2eBootEmpty) {
setCurrentDocument(createEmptyDocument());
setFileName('Untitled.docx');
return;
}
fetch(`${import.meta.env.BASE_URL}docx-editor-demo.docx`)
.then((res) => res.arrayBuffer())
.then((buffer) => {
if (userStartedOwnDocRef.current) return; // user already moved on
setDocumentBuffer(buffer);
setFileName('docx-editor-demo.docx');
})
.catch(() => {
if (userStartedOwnDocRef.current) return;
setCurrentDocument(createEmptyDocument());
setFileName('Untitled.docx');
});
}, [e2eBootEmpty]);
const handleNewDocument = useCallback(() => {
userStartedOwnDocRef.current = true;
setCurrentDocument(createEmptyDocument());
setDocumentBuffer(null);
setFileName('Untitled.docx');
setStatus('');
// Force a fresh editor instance. Switching the `documentBuffer` prop from a
// loaded buffer back to an empty `document` does not reliably re-init the
// editor's content, so remount it via a changing key — otherwise "New"
// leaves the previous document in the editor.
setDocVersion((v) => v + 1);
}, []);
const handleFileSelect = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
userStartedOwnDocRef.current = true;
setStatus('Loading...');
const buffer = await file.arrayBuffer();
setCurrentDocument(null);
setDocumentBuffer(buffer);
setFileName(file.name);
setStatus('');
setDocVersion((v) => v + 1);
} catch {
setStatus('Error loading file');
}
}, []);
const handleSave = useCallback(async () => {
if (!editorRef.current) return;
try {
setStatus('Saving...');
const buffer = await editorRef.current.save();
if (buffer) {
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName || 'document.docx';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setStatus('Saved!');
setTimeout(() => setStatus(''), 2000);
}
} catch {
setStatus('Save failed');
}
}, [fileName]);
const handleError = useCallback((error: Error) => {
console.error('Editor error:', error);
setStatus(`Error: ${error.message}`);
}, []);
const renderLogo = useCallback(
() => (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<AdapterSwitcher current="react" />
<ExampleSwitcher current="Vite" />
</div>
),
[]
);
const renderTitleBarRight = useCallback(
() => (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<ThemeToggle value={colorMode} onChange={setColorMode} />
<label style={styles.fileInputLabel} onMouseDown={(e) => e.stopPropagation()}>
<input
type="file"
accept=".docx"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
Open DOCX
</label>
<button style={styles.newButton} onClick={handleNewDocument}>
New
</button>
<button style={styles.button} onClick={handleSave}>
Save
</button>
{status && <span style={styles.status}>{status}</span>}
</div>
),
[handleFileSelect, handleNewDocument, handleSave, status, colorMode]
);
// Opt-in agent panel for E2E + manual smoke testing. Adds the right-hand
// panel + toolbar toggle when ?agentPanel=1 (or VITE_DOCX_EDITOR_AGENT_PANEL=1)
// is set, so the live demo at docx-editor.dev stays unchanged.
const showAgentPanel = (() => {
if (typeof window === 'undefined') return false;
const params = new URLSearchParams(window.location.search);
if (params.get('agentPanel') === '1' || params.get('agentTimeline') === '1') return true;
return import.meta.env.VITE_DOCX_EDITOR_AGENT_PANEL === '1';
})();
// Fixture for the AgentTimeline e2e test. `?agentTimeline=streaming` boots
// with an in-flight turn (timeline expanded, spinner). `?agentTimeline=done`
// boots with a completed turn (timeline collapsed). `?agentTimeline=long`
// boots with 8 calls so the test can assert the "+N earlier steps" cap.
// Falls back to no fixture so other agent-panel tests are unaffected.
const agentTimelineFixture: AgentMessage[] | null = (() => {
if (typeof window === 'undefined') return null;
const mode = new URLSearchParams(window.location.search).get('agentTimeline');
if (!mode) return null;
const isStreaming = mode === 'streaming';
if (mode === 'long') {
const calls: NonNullable<AgentMessage['toolCalls']> = [
{ id: 't1', name: 'read_document', status: 'done', result: '...' },
...Array.from({ length: 7 }, (_, i) => ({
id: `t${i + 2}`,
name: 'add_comment',
status: 'done' as const,
result: `Comment ${i + 1} added.`,
})),
];
return [
{ id: 'u1', role: 'user', text: 'Roast everything.' },
{
id: 'a1',
role: 'assistant',
text: 'Done — 7 comments.',
status: 'done',
toolCalls: calls,
},
];
}
return [
{ id: 'u1', role: 'user', text: 'Roast my doc.' },
{
id: 'a1',
role: 'assistant',
text: isStreaming ? '' : 'Done — left 3 comments.',
status: isStreaming ? 'streaming' : 'done',
toolCalls: [
{ id: 't1', name: 'read_document', status: 'done', result: '...' },
{ id: 't2', name: 'add_comment', status: 'done', result: 'Comment 1 added.' },
{
id: 't3',
name: 'add_comment',
status: isStreaming ? 'running' : 'done',
result: isStreaming ? undefined : 'Comment 2 added.',
},
],
},
];
})();
return (
<div style={styles.container}>
<main style={styles.main}>
<DocxEditor
key={docVersion}
ref={editorRef}
document={documentBuffer ? undefined : currentDocument}
documentBuffer={documentBuffer}
author={randomAuthor}
colorMode={colorMode}
onError={handleError}
showToolbar={true}
showRuler={!isMobile}
showZoomControl={true}
initialZoom={autoZoom}
disableFindReplaceShortcuts={disableFindReplaceShortcuts}
fonts={customFonts}
watermarkPresets={['SAMPLE', 'DEMO ONLY', 'PREVIEW', 'NOT FOR DISTRIBUTION']}
renderLogo={renderLogo}
documentName={fileName}
onDocumentNameChange={setFileName}
renderTitleBarRight={renderTitleBarRight}
agentPanel={
showAgentPanel
? {
render: ({ close }) => (
<div
data-testid="agent-panel-content"
style={{ flex: 1, padding: 16, overflow: 'auto' }}
>
{agentTimelineFixture && (
<AgentChatLog
messages={agentTimelineFixture}
autoScroll={false}
humanizeToolName={getToolDisplayName}
/>
)}
<p style={{ marginTop: 12, fontSize: 13, color: '#6b7280' }}>
BYO chat goes here. This is the demo's placeholder content.
</p>
<button
type="button"
onClick={close}
style={{
marginTop: 8,
padding: '6px 10px',
fontSize: 12,
background: '#f1f5f9',
border: '1px solid #e2e8f0',
borderRadius: 6,
cursor: 'pointer',
}}
>
Close from inside
</button>
</div>
),
}
: undefined
}
/>
</main>
</div>
);
}