From 556d9bce96875a0e0ce3e99b5a7d8ea25864ae5a Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Mon, 30 Mar 2026 11:59:37 -0700 Subject: [PATCH 1/6] chore: bump to 0.0.33 --- packages/agent-use/CHANGELOG.md | 6 ++++++ packages/agent-use/package.json | 2 +- packages/react/CHANGELOG.md | 6 ++++++ packages/react/package.json | 2 +- 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/agent-use/CHANGELOG.md b/packages/agent-use/CHANGELOG.md index 9612fd9b..e9d5cfeb 100644 --- a/packages/agent-use/CHANGELOG.md +++ b/packages/agent-use/CHANGELOG.md @@ -1,5 +1,11 @@ # @eigenpal/docx-editor-agents +## 0.0.33 + +### Patch Changes + +- Add i18n + ## 0.0.32 ### Patch Changes diff --git a/packages/agent-use/package.json b/packages/agent-use/package.json index 148523f6..ee94f851 100644 --- a/packages/agent-use/package.json +++ b/packages/agent-use/package.json @@ -1,6 +1,6 @@ { "name": "@eigenpal/docx-editor-agents", - "version": "0.0.32", + "version": "0.0.33", "description": "Agent-friendly API for DOCX document review — read, comment, propose changes, accept/reject tracked changes", "main": "./dist/index.js", "module": "./dist/index.mjs", diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 079270dc..e76bb48f 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -1,5 +1,11 @@ # @eigenpal/docx-js-editor +## 0.0.33 + +### Patch Changes + +- Add i18n + ## 0.0.32 ### Patch Changes diff --git a/packages/react/package.json b/packages/react/package.json index 173cbca4..7a55e89f 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@eigenpal/docx-js-editor", - "version": "0.0.32", + "version": "0.0.33", "description": "A browser-based DOCX template editor with variable insertion support", "main": "./dist/index.js", "module": "./dist/index.mjs", From 51341199d1e50cdc53e5b0905d81ee4ed87a4202 Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Thu, 2 Apr 2026 11:54:13 +0200 Subject: [PATCH 2/6] refactor: use cursor-based mark detection for sidebar expansion Replace the DOM click handler in UnifiedSidebar with ProseMirror cursor-driven detection in DocxEditor. When the cursor lands on a comment, insertion, or deletion mark, the matching sidebar card expands automatically. This also works with keyboard navigation, not just clicks. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/react/src/components/DocxEditor.tsx | 38 +++++++++++- .../react/src/components/UnifiedSidebar.tsx | 59 ++++--------------- 2 files changed, 47 insertions(+), 50 deletions(-) diff --git a/packages/react/src/components/DocxEditor.tsx b/packages/react/src/components/DocxEditor.tsx index da11094c..af4fbcb0 100644 --- a/packages/react/src/components/DocxEditor.tsx +++ b/packages/react/src/components/DocxEditor.tsx @@ -3898,6 +3898,42 @@ body { background: white; } if (view) { const selectionState = extractSelectionState(view.state); handleSelectionChange(selectionState); + + // Detect comment/tracked-change marks at cursor to open sidebar card + const marks = + view.state.storedMarks || view.state.selection.$from.marks(); + let cursorSidebarItem: string | null = null; + for (const mark of marks) { + if (mark.type.name === 'comment' && mark.attrs.commentId != null) { + cursorSidebarItem = `comment-${mark.attrs.commentId}`; + break; + } + if (mark.type.name === 'insertion' && mark.attrs.revisionId != null) { + const revId = String(mark.attrs.revisionId); + const prefix = `tc-${revId}-`; + let match = allSidebarItems.find((i) => i.id.startsWith(prefix)); + if (!match && revisionIdAliases) { + const aliasedId = revisionIdAliases.get(revId); + if (aliasedId) { + match = allSidebarItems.find((i) => i.id === aliasedId); + } + } + if (match) { + cursorSidebarItem = match.id; + break; + } + } + if (mark.type.name === 'deletion' && mark.attrs.revisionId != null) { + const revId = String(mark.attrs.revisionId); + const prefix = `tc-${revId}-`; + const match = allSidebarItems.find((i) => i.id.startsWith(prefix)); + if (match) { + cursorSidebarItem = match.id; + break; + } + } + } + setExpandedSidebarItem(cursorSidebarItem); } else { handleSelectionChange(null); } @@ -3929,7 +3965,7 @@ body { background: white; } zoom={state.zoom} editorContainerRef={scrollContainerRef} onExpandedItemChange={setExpandedSidebarItem} - revisionIdAliases={revisionIdAliases} + activeItemId={expandedSidebarItem} /> )} ; onExpandedItemChange?: (itemId: string | null) => void; - /** Maps alternate revision IDs to sidebar item IDs (e.g. insertion side of replacements). */ - revisionIdAliases?: Map; + /** Controlled: sidebar item to expand based on cursor position. */ + activeItemId?: string | null; } export function UnifiedSidebar({ @@ -33,11 +33,18 @@ export function UnifiedSidebar({ zoom, editorContainerRef, onExpandedItemChange, - revisionIdAliases, + activeItemId, }: UnifiedSidebarProps) { const { t } = useTranslation(); const [expandedItem, setExpandedItem] = useState(null); + // Sync internal state when cursor-driven activeItemId changes + useEffect(() => { + if (activeItemId !== undefined) { + setExpandedItem(activeItemId); + } + }, [activeItemId]); + // Notify parent when expanded item changes (via effect, not in setState updater) useEffect(() => { onExpandedItemChange?.(expandedItem); @@ -147,52 +154,6 @@ export function UnifiedSidebar({ }; }, [expandedItem]); - useEffect(() => { - const container = editorContainerRef?.current; - if (!container) return; - - const pagesEl = container.querySelector('.paged-editor__pages'); - if (!pagesEl) return; - - const handleDocClick = (e: MouseEvent) => { - const target = e.target as HTMLElement; - if (sidebarRef.current?.contains(target)) return; - - if (pagesEl.contains(target)) { - const commentEl = target.closest('[data-comment-id]') as HTMLElement | null; - if (commentEl?.dataset.commentId) { - setExpandedItem(`comment-${commentEl.dataset.commentId}`); - return; - } - const changeEl = - (target.closest('.docx-insertion') as HTMLElement | null) || - (target.closest('.docx-deletion') as HTMLElement | null); - if (changeEl?.dataset.revisionId) { - const revId = changeEl.dataset.revisionId; - const prefix = `tc-${revId}-`; - let match = items.find((i) => i.id.startsWith(prefix)); - // For replacement tracked changes, the insertion part has a different - // revisionId than the card. Look up the alias to find the correct card. - if (!match && revisionIdAliases) { - const aliasedItemId = revisionIdAliases.get(revId); - if (aliasedItemId) { - match = items.find((i) => i.id === aliasedItemId); - } - } - if (match) { - setExpandedItem(match.id); - return; - } - } - } - - setExpandedItem(null); - }; - - container.addEventListener('click', handleDocClick); - return () => container.removeEventListener('click', handleDocClick); - }, [editorContainerRef, items, revisionIdAliases]); - const getMeasureRef = useCallback((itemId: string): ((el: HTMLDivElement | null) => void) => { let fn = measureRefsRef.current.get(itemId); if (!fn) { From e19288723bb7db6ad592ec85246a382fe11154c7 Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Thu, 2 Apr 2026 12:06:50 +0200 Subject: [PATCH 3/6] fix: reliable mark detection and eliminate sidebar state loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes: 1. Mark detection: $from.marks() misses inclusive:false marks at cursor boundaries. Now checks nodeAfter/nodeBefore marks first for reliable comment and tracked change detection. 2. Sidebar crash: Remove bidirectional state sync (activeItemId → internal state → onExpandedItemChange → parent) that caused max update depth. Make sidebar fully controlled — parent owns expansion state, sidebar just reads activeItemId prop. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/react/src/components/DocxEditor.tsx | 10 ++++++-- .../react/src/components/UnifiedSidebar.tsx | 24 +++++++------------ 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/packages/react/src/components/DocxEditor.tsx b/packages/react/src/components/DocxEditor.tsx index af4fbcb0..6ed912bd 100644 --- a/packages/react/src/components/DocxEditor.tsx +++ b/packages/react/src/components/DocxEditor.tsx @@ -3899,9 +3899,15 @@ body { background: white; } const selectionState = extractSelectionState(view.state); handleSelectionChange(selectionState); - // Detect comment/tracked-change marks at cursor to open sidebar card + // Detect comment/tracked-change marks at cursor to open sidebar card. + // These marks use inclusive:false, so $from.marks() misses them at + // boundaries. Check nodeAfter/nodeBefore marks for reliable detection. + const $from = view.state.selection.$from; const marks = - view.state.storedMarks || view.state.selection.$from.marks(); + view.state.storedMarks || + $from.nodeAfter?.marks || + $from.nodeBefore?.marks || + $from.marks(); let cursorSidebarItem: string | null = null; for (const mark of marks) { if (mark.type.name === 'comment' && mark.attrs.commentId != null) { diff --git a/packages/react/src/components/UnifiedSidebar.tsx b/packages/react/src/components/UnifiedSidebar.tsx index 93d42b90..b36d57d0 100644 --- a/packages/react/src/components/UnifiedSidebar.tsx +++ b/packages/react/src/components/UnifiedSidebar.tsx @@ -36,19 +36,8 @@ export function UnifiedSidebar({ activeItemId, }: UnifiedSidebarProps) { const { t } = useTranslation(); - const [expandedItem, setExpandedItem] = useState(null); - - // Sync internal state when cursor-driven activeItemId changes - useEffect(() => { - if (activeItemId !== undefined) { - setExpandedItem(activeItemId); - } - }, [activeItemId]); - - // Notify parent when expanded item changes (via effect, not in setState updater) - useEffect(() => { - onExpandedItemChange?.(expandedItem); - }, [expandedItem, onExpandedItemChange]); + // Fully controlled: parent owns expansion state via activeItemId + const expandedItem = activeItemId ?? null; const [initialPositionsDone, setInitialPositionsDone] = useState(false); const cardHeightsRef = useRef>(new Map()); @@ -171,9 +160,12 @@ export function UnifiedSidebar({ return fn; }, []); - const toggleExpand = useCallback((itemId: string) => { - setExpandedItem((prev) => (prev === itemId ? null : itemId)); - }, []); + const toggleExpand = useCallback( + (itemId: string) => { + onExpandedItemChange?.(expandedItem === itemId ? null : itemId); + }, + [expandedItem, onExpandedItemChange] + ); if (items.length === 0) return null; From d25fa4a38c3c32d82efb3b3ffc0ce858e6cc5af3 Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Thu, 2 Apr 2026 12:23:08 +0200 Subject: [PATCH 4/6] fix: collect marks from all sources instead of OR chain Empty mark arrays are truthy in JS, so the OR chain (storedMarks || nodeAfter?.marks || nodeBefore?.marks) short-circuited on the first source with any node, even if that node had no comment/tracked-change marks. Now spreads all sources into a single array so we always find the mark. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/react/src/components/DocxEditor.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/react/src/components/DocxEditor.tsx b/packages/react/src/components/DocxEditor.tsx index 6ed912bd..a7a939de 100644 --- a/packages/react/src/components/DocxEditor.tsx +++ b/packages/react/src/components/DocxEditor.tsx @@ -3901,13 +3901,15 @@ body { background: white; } // Detect comment/tracked-change marks at cursor to open sidebar card. // These marks use inclusive:false, so $from.marks() misses them at - // boundaries. Check nodeAfter/nodeBefore marks for reliable detection. + // boundaries. Collect marks from all sources (nodeAfter, nodeBefore, + // storedMarks) since the OR chain short-circuits on empty arrays. const $from = view.state.selection.$from; - const marks = - view.state.storedMarks || - $from.nodeAfter?.marks || - $from.nodeBefore?.marks || - $from.marks(); + const marks = [ + ...(view.state.storedMarks ?? []), + ...($from.nodeAfter?.marks ?? []), + ...($from.nodeBefore?.marks ?? []), + ...$from.marks(), + ]; let cursorSidebarItem: string | null = null; for (const mark of marks) { if (mark.type.name === 'comment' && mark.attrs.commentId != null) { From dbcc83dc5a16fc36b55bf48fc9aa70b93819dac8 Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Thu, 2 Apr 2026 12:32:04 +0200 Subject: [PATCH 5/6] fix: auto-open sidebar when clicking comment or tracked change The sidebar items only exist in allSidebarItems when showCommentsSidebar is true, but commentSidebarItems always has them. Search commentSidebarItems for mark matching, and auto-set showCommentsSidebar=true when a match is found so clicking on a comment or tracked change opens the sidebar. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/react/src/components/DocxEditor.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/react/src/components/DocxEditor.tsx b/packages/react/src/components/DocxEditor.tsx index a7a939de..c2681b76 100644 --- a/packages/react/src/components/DocxEditor.tsx +++ b/packages/react/src/components/DocxEditor.tsx @@ -3903,6 +3903,8 @@ body { background: white; } // These marks use inclusive:false, so $from.marks() misses them at // boundaries. Collect marks from all sources (nodeAfter, nodeBefore, // storedMarks) since the OR chain short-circuits on empty arrays. + // Search commentSidebarItems (always populated) not allSidebarItems + // (which is empty when sidebar is closed) so we can auto-open it. const $from = view.state.selection.$from; const marks = [ ...(view.state.storedMarks ?? []), @@ -3919,11 +3921,13 @@ body { background: white; } if (mark.type.name === 'insertion' && mark.attrs.revisionId != null) { const revId = String(mark.attrs.revisionId); const prefix = `tc-${revId}-`; - let match = allSidebarItems.find((i) => i.id.startsWith(prefix)); + let match = commentSidebarItems.find((i) => + i.id.startsWith(prefix) + ); if (!match && revisionIdAliases) { const aliasedId = revisionIdAliases.get(revId); if (aliasedId) { - match = allSidebarItems.find((i) => i.id === aliasedId); + match = commentSidebarItems.find((i) => i.id === aliasedId); } } if (match) { @@ -3934,13 +3938,18 @@ body { background: white; } if (mark.type.name === 'deletion' && mark.attrs.revisionId != null) { const revId = String(mark.attrs.revisionId); const prefix = `tc-${revId}-`; - const match = allSidebarItems.find((i) => i.id.startsWith(prefix)); + const match = commentSidebarItems.find((i) => + i.id.startsWith(prefix) + ); if (match) { cursorSidebarItem = match.id; break; } } } + if (cursorSidebarItem) { + setShowCommentsSidebar(true); + } setExpandedSidebarItem(cursorSidebarItem); } else { handleSelectionChange(null); From 993e37f19e57761d8b35cae59465cce8ea9f4afa Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Thu, 2 Apr 2026 17:09:27 +0200 Subject: [PATCH 6/6] refactor: unify insertion/deletion handling and stabilize toggleExpand 1. Merge near-duplicate insertion and deletion mark handling into a single block with shared prefix-search and alias-resolution logic. 2. Stabilize toggleExpand callback with a ref so it doesn't recreate on every expandedItem change, avoiding unnecessary card re-renders on each cursor move. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/react/src/components/DocxEditor.tsx | 26 +++++++------------ .../react/src/components/UnifiedSidebar.tsx | 7 +++-- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/packages/react/src/components/DocxEditor.tsx b/packages/react/src/components/DocxEditor.tsx index c2681b76..8ae4dd4e 100644 --- a/packages/react/src/components/DocxEditor.tsx +++ b/packages/react/src/components/DocxEditor.tsx @@ -3900,11 +3900,9 @@ body { background: white; } handleSelectionChange(selectionState); // Detect comment/tracked-change marks at cursor to open sidebar card. - // These marks use inclusive:false, so $from.marks() misses them at - // boundaries. Collect marks from all sources (nodeAfter, nodeBefore, - // storedMarks) since the OR chain short-circuits on empty arrays. - // Search commentSidebarItems (always populated) not allSidebarItems - // (which is empty when sidebar is closed) so we can auto-open it. + // Collect marks from all sources — inclusive:false marks aren't + // reported by $from.marks() at boundaries, and empty arrays are + // truthy so an OR chain would short-circuit. const $from = view.state.selection.$from; const marks = [ ...(view.state.storedMarks ?? []), @@ -3918,12 +3916,17 @@ body { background: white; } cursorSidebarItem = `comment-${mark.attrs.commentId}`; break; } - if (mark.type.name === 'insertion' && mark.attrs.revisionId != null) { + if ( + (mark.type.name === 'insertion' || mark.type.name === 'deletion') && + mark.attrs.revisionId != null + ) { const revId = String(mark.attrs.revisionId); const prefix = `tc-${revId}-`; let match = commentSidebarItems.find((i) => i.id.startsWith(prefix) ); + // Insertion side of a replacement has a different revisionId; + // check alias map to find the correct sidebar card. if (!match && revisionIdAliases) { const aliasedId = revisionIdAliases.get(revId); if (aliasedId) { @@ -3935,17 +3938,6 @@ body { background: white; } break; } } - if (mark.type.name === 'deletion' && mark.attrs.revisionId != null) { - const revId = String(mark.attrs.revisionId); - const prefix = `tc-${revId}-`; - const match = commentSidebarItems.find((i) => - i.id.startsWith(prefix) - ); - if (match) { - cursorSidebarItem = match.id; - break; - } - } } if (cursorSidebarItem) { setShowCommentsSidebar(true); diff --git a/packages/react/src/components/UnifiedSidebar.tsx b/packages/react/src/components/UnifiedSidebar.tsx index b36d57d0..51364425 100644 --- a/packages/react/src/components/UnifiedSidebar.tsx +++ b/packages/react/src/components/UnifiedSidebar.tsx @@ -38,6 +38,9 @@ export function UnifiedSidebar({ const { t } = useTranslation(); // Fully controlled: parent owns expansion state via activeItemId const expandedItem = activeItemId ?? null; + // Ref keeps toggleExpand stable so card children don't re-render on every cursor move + const expandedItemRef = useRef(expandedItem); + expandedItemRef.current = expandedItem; const [initialPositionsDone, setInitialPositionsDone] = useState(false); const cardHeightsRef = useRef>(new Map()); @@ -162,9 +165,9 @@ export function UnifiedSidebar({ const toggleExpand = useCallback( (itemId: string) => { - onExpandedItemChange?.(expandedItem === itemId ? null : itemId); + onExpandedItemChange?.(expandedItemRef.current === itemId ? null : itemId); }, - [expandedItem, onExpandedItemChange] + [onExpandedItemChange] ); if (items.length === 0) return null;