From a34adbb2938cbf5054c633c9f28f69cd052b89fc Mon Sep 17 00:00:00 2001 From: JSv4 Date: Thu, 24 Sep 2026 08:22:22 -0500 Subject: [PATCH] Fix review control surfaces found on the NVCA charter Labels and comments now target the selected text instead of the whole paragraph, and the quick-action bar shows what it will act on. Selecting text inside an already-labelled or otherwise read-only paragraph updates the target, so Label no longer applies to the previously selected block. - Exclude injected annotation labels from canvas and selection text, so annotated paragraphs stay editable and their offsets stay native. - Report selections made in read-only blocks when a canvas editor is attached. - Record pure insertions as zero-width native edits; appending after a period no longer produces a spurious tracked deletion. The borrowed neighbour write remains as a fallback for inline structures. - Honour Word's w:trackRevisions setting when opening a document. - AnnotationsPanel/CommentsPanel accept a span, preview their target, take focus requests, and comments thread replies under their parent with optional host-supplied quoted context and document order. - Workspace: always-visible tracking toggle, one reviewer identity for revisions, comments and labels, formatting toolbar on every panel, quick actions that return for each new selection, tooltip labels that no longer cover neighbouring lines, no per-fragment highlight padding, and Show comment waits for an in-flight re-pagination. --- demo/App.css | 5 ++ demo/App.tsx | 88 ++++++++++++++++++++-------- src/components/AnnotationsPanel.tsx | 39 ++++++++---- src/components/CommentsPanel.tsx | 57 ++++++++++++++---- src/components/PaginatedDocument.tsx | 10 +++- src/documentSettings.test.ts | 40 +++++++++++++ src/documentSettings.ts | 35 +++++++++++ src/editing/canvasDom.ts | 3 +- src/editing/selection.ts | 2 +- src/editing/text.test.ts | 5 ++ src/editing/text.ts | 39 +++++++----- src/hooks/useSessionFeatures.ts | 20 ++++++- src/index.ts | 6 +- src/session.ts | 8 ++- src/styles/features.css | 3 + tests/browser/canvas.spec.ts | 36 ++++++++++++ tests/browser/workspace.spec.ts | 53 +++++++++++++++++ 17 files changed, 375 insertions(+), 74 deletions(-) create mode 100644 src/documentSettings.test.ts create mode 100644 src/documentSettings.ts diff --git a/demo/App.css b/demo/App.css index 522beee..cdac197 100644 --- a/demo/App.css +++ b/demo/App.css @@ -41,6 +41,9 @@ kbd, .keycap { font-family: inherit; font-size: 10px; line-height: 1.3; border: .document-state { display: inline-flex; align-items: center; gap: 7px; color: #758268; font-size: 10px; } .document-state i { width: 5px; height: 5px; border-radius: 50%; background: #8eaa72; } .document-state i.is-busy { animation: pulse 1s ease-in-out infinite; } +.tracking-state { display: inline-flex; align-items: center; gap: 6px; margin-left: 12px; padding: 4px 9px; border: 1px solid #e3e8dc; border-radius: 99px; background: transparent; color: #87937b; font-size: 10px; cursor: pointer; } +.tracking-state:hover { border-color: #c8d4ba; color: #4f6341; } +.tracking-state.is-tracking { border-color: #e6b8b0; background: #fbefec; color: #9b3f31; } .selection-hint { color: #89957d; font-size: 10px; } .workspace-layout { display: grid; grid-template-columns: minmax(0, 1fr) 360px; flex: 1; min-height: 0; } .workspace-layout.with-navigator { grid-template-columns: 235px minmax(0, 1fr) 360px; } @@ -53,6 +56,7 @@ kbd, .keycap { font-family: inherit; font-size: 10px; line-height: 1.3; border: .selection-actions button:hover { background: #f0f5e8; color: #34512a; } .selection-dot { width: 5px; height: 5px; background: #9db985; border-radius: 50%; margin: 0 5px 0 8px; } .selection-actions .selection-dismiss { padding: 7px 4px; color: #97a48a; } +.selection-target { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 8px 0 2px; margin-right: 2px; border-right: 1px solid #e7ecdf; color: #4f6341; font: italic 11px/1.4 Georgia, serif; } .app .rdv-viewer { --rdv-background: #eaece5; --rdv-toolbar-bg: #f4f5ef; --rdv-toolbar-border: #e1e4d9; --rdv-btn-bg: transparent; --rdv-btn-bg-hover: #e2e6d9; --rdv-btn-color: #697363; --rdv-btn-radius: 5px; --rdv-input-bg: #e9ecdf; --rdv-input-color: #4c5947; --rdv-input-muted: #7b896c; --rdv-separator-color: #dbdfd2; --rdv-shadow: none; --rdv-border-radius: 0px; --rdv-toolbar-padding: 8px 22px; --rdv-min-height: 0px; --rdv-max-height: none; --rdv-page-gap: 24px; } .workspace-document .rdv-viewer { height: 100%; width: 100%; } .workspace-document .rdv-toolbar { min-height: 46px; } @@ -199,3 +203,4 @@ kbd, .keycap { font-family: inherit; font-size: 10px; line-height: 1.3; border: .workspace-document .rdv-zoom-group { gap: 0; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; } } +@media (max-width: 720px) { .tracking-state { margin-left: 8px; padding: 5px 7px; } .tracking-state > span { display: none; } } diff --git a/demo/App.tsx b/demo/App.tsx index 103d22f..3e4b0f6 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -1,5 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { AnnotationsPanel, CommentsPanel, DocxodusProvider, DocumentViewer, EditorToolbar, ExportPanel, HistoryPanel, SessionEditorPanel, VerificationPanel, downloadDocument, useDocxSession, useDocumentEditor, useDocumentHistory, useSessionQuery } from '../src'; +import { TrackedChangeMode } from 'docxodus/core'; +import type { CommentListEntry } from 'docxodus/core'; +import { AnnotationsPanel, CommentsPanel, DocxodusProvider, DocumentViewer, EditorToolbar, ExportPanel, HistoryPanel, SessionEditorPanel, VerificationPanel, downloadDocument, useDocxSession, useDocumentEditor, useDocumentHistory, useSelectionTarget, useSessionQuery } from '../src'; import type { DocxSession, PageCitation } from '../src'; import { Icon } from '../src/components/Icon'; import type { IconName } from '../src/components/Icon'; @@ -14,6 +16,9 @@ import './App.css'; const WASM_BASE_PATH = import.meta.env.BASE_URL + 'wasm/'; const FINGERPRINT = 'react-studio-v12.6.2'; +// One identity for tracked changes, comments and labels made in this workspace. +const REVIEWER = 'Reviewer'; +const UNAVAILABLE = 'This location is not available in the current page layout. Wait for pagination to finish and try again.'; const snapshotBytes = (session: DocxSession) => session.save(); const docxMime = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; type Panel = 'edit' | 'review' | 'comments' | 'annotations' | 'history' | 'verify' | 'export'; @@ -27,8 +32,14 @@ const tabs: { id: Panel; label: string; icon: IconName; hint: string }[] = [ function Workspace() { const [tab, setTab] = useState('document'); const [panel, setPanel] = useState('edit'); - const [requestedAnchor, setAnchor] = useState(); - const [quickActions, setQuickActions] = useState(true); + // A click on read-only content picks a block without an editor text selection. + // It is cleared whenever the editor's own selection changes, so the newest wins. + const [requested, setRequested] = useState<{ anchorId: string; after: unknown }>(); + const [dismissedTarget, setDismissedTarget] = useState(null); + const [focusRequest, setFocusRequest] = useState(0); + const [layoutVersion, setLayoutVersion] = useState(0); + const pendingCitation = useRef<{ id: string; timer: ReturnType } | null>(null); + const documentArea = useRef(null); const [citation, setCitation] = useState(); const [preview, setPreview] = useState(null); const [error, setError] = useState(null); @@ -44,11 +55,18 @@ function Workspace() { const input = useRef(null); const previewDialog = useRef(null); const openSequence = useRef(0); - const session = useDocxSession(undefined, { settings: { emitMarkdownPatch: false } }); + const session = useDocxSession(undefined, { settings: { emitMarkdownPatch: false, revisionAuthor: REVIEWER } }); const { controller, open: openSession } = session; const editor = useDocumentEditor(controller, { onError: cause => setError(cause.message) }); - const { canvasEditor, select } = editor; - const anchor = editor.selection?.anchorId ?? requestedAnchor; + const { canvasEditor, select, selectText } = editor; + const requestedAnchor = requested && requested.after === editor.selection ? requested.anchorId : undefined; + const setAnchor = useCallback((anchorId?: string) => setRequested(anchorId ? { anchorId, after: editor.selection } : undefined), [editor.selection]); + const anchor = requestedAnchor ?? editor.selection?.anchorId; + const span = !requestedAnchor && editor.selection?.span?.length ? editor.selection.span : null; + const target = useSelectionTarget(controller, anchor, span); + const targetKey = anchor ? `${anchor}:${span?.start ?? ''}:${span?.length ?? ''}` : ''; + const quickActions = !!target && dismissedTarget !== targetKey; + const tracking = editor.state.trackedChanges === TrackedChangeMode.RenderInline; const bytes = useSessionQuery(panel === 'verify' || panel === 'export' ? controller : undefined, snapshotBytes).data; const documentCounts = useCallback((session: DocxSession) => ({ comments: session.listComments().length, revisions: controller.getRevisions().length }), [controller]); const counts = useSessionQuery(controller, documentCounts, { scope: 'document' }).data; @@ -56,20 +74,44 @@ function Workspace() { const busy = session.isLoading || sampleLoading; const ready = !!session.session; - const showPanel = useCallback((next: Panel) => { setPanel(next); setFocused(false); setTab('document'); }, []); + const showPanel = useCallback((next: Panel, focusInput = false) => { setPanel(next); setFocused(false); setTab('document'); setFocusRequest(value => focusInput ? value + 1 : 0); }, []); + /** Resolve a page citation, waiting for an in-flight re-pagination before reporting failure. */ + const cite = useCallback((id: string, final: boolean) => { + const current = controller.getSnapshot(); + if (!current.session) return true; + const next = current.session.getPageCitation(id, { documentVersion: current.version, rendererFingerprint: FINGERPRINT }); + if (next.availability === 'available') { setCitation(next); setError(null); return true; } + if (final) setError(UNAVAILABLE); + return false; + }, [controller]); const showAnchor = useCallback((id: string) => { if (!canvasEditor.commit()) return; - select(id); - setAnchor(id); setQuickActions(true); + // Comments and other stories only navigate; the paragraph selection stays put. + if (/^(p|h|li):/.test(id)) { select(id); setAnchor(id); setDismissedTarget(null); } + if (pendingCitation.current) clearTimeout(pendingCitation.current.timer); + pendingCitation.current = null; try { - const current = controller.getSnapshot(); - if (current.session) { - const next = current.session.getPageCitation(id, { documentVersion: current.version, rendererFingerprint: FINGERPRINT }); - if (next.availability === 'available') { setCitation(next); setError(null); } - else setError('This location is not available in the current page layout. Wait for pagination to finish and try again.'); - } + // An edit (a reply, a resolved thread) re-paginates; retry once that layout lands. + if (!cite(id, false)) pendingCitation.current = { id, timer: setTimeout(() => { if (pendingCitation.current?.id === id) { pendingCitation.current = null; setError(UNAVAILABLE); } }, 10_000) }; } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); } - }, [controller, canvasEditor, select]); + }, [canvasEditor, select, setAnchor, cite]); + const paginated = useCallback(() => { + setLayoutVersion(value => value + 1); + const pending = pendingCitation.current; + if (!pending) return; + clearTimeout(pending.timer); pendingCitation.current = null; + try { cite(pending.id, true); } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); } + }, [cite]); + useEffect(() => () => { if (pendingCitation.current) clearTimeout(pendingCitation.current.timer); }, []); + /** Quote each thread's highlighted text and order threads as they appear on the pages. */ + const describeComment = useCallback((comment: CommentListEntry) => { + void layoutVersion; + const root = Array.from(documentArea.current?.querySelectorAll('div') ?? []).find(node => node.shadowRoot)?.shadowRoot; + const highlights = Array.from(root?.querySelectorAll('#pagination-container .comment-highlight[data-comment-id]') ?? []); + const own = highlights.filter(node => node.dataset.commentId === String(comment.id)); + if (!own.length) return undefined; + return { text: own.map(node => node.textContent ?? '').join('').replace(/\s+/g, ' ').trim(), order: highlights.indexOf(own[0]) }; + }, [layoutVersion]); const open = useCallback(async (source: File | Uint8Array | 'blank', name?: string, restore = false) => { const sequence = ++openSequence.current; try { @@ -82,7 +124,7 @@ function Workspace() { setDocumentId(source instanceof File ? `${source.name}:${source.size}:${source.lastModified}` : crypto.randomUUID()); setPanel('edit'); setPreview(null); } - setAnchor(undefined); setCitation(undefined); setPage(1); setPages(0); setTab('document'); + setRequested(undefined); setCitation(undefined); setPage(1); setPages(0); setTab('document'); } catch (cause) { if (sequence === openSequence.current) setError(cause instanceof Error ? cause.message : String(cause)); } }, [openSession, canvasEditor]); const download = useCallback(() => { if (controller.getSnapshot().session && canvasEditor.commit()) downloadDocument(controller.save(), filename, docxMime); }, [controller, filename, canvasEditor]); @@ -104,7 +146,7 @@ function Workspace() { { id: 'new', label: 'New document', hint: 'Start with a blank page', icon: 'plus', run: create, disabled: busy }, { id: 'find', label: 'Find in document', hint: 'Search text and jump to its page', icon: 'search', run: find, shortcut: '⌘ F', disabled: !ready }, ...tabs.map(item => ({ id: item.id, label: item.label === 'Edit' ? 'Edit a paragraph' : item.label === 'Review' ? 'Review changes' : item.label, hint: item.hint, icon: item.icon, run: () => showPanel(item.id), disabled: !ready })), - { id: 'annotations', label: 'Label selected text', hint: 'Add structured annotations', icon: 'label', run: () => showPanel('annotations'), disabled: !ready }, + { id: 'annotations', label: 'Label selected text', hint: 'Add structured annotations', icon: 'label', run: () => showPanel('annotations', true), disabled: !ready }, { id: 'verify', label: 'Verify the document', hint: 'Inspect package integrity and deliverable findings', icon: 'shield', run: () => showPanel('verify'), disabled: !ready }, { id: 'export', label: 'Export options', hint: 'Create a standalone, paginated HTML document', icon: 'download', run: () => showPanel('export'), disabled: !ready }, { id: 'download', label: 'Download DOCX', hint: 'Keep a Word copy of your current document', icon: 'document', run: download, shortcut: '⌘ S', disabled: !ready }, @@ -148,18 +190,18 @@ function Workspace() { {!ready && !busy ?

A NEW PERSPECTIVE ON DOCUMENTS

Your document.
A clearer view.

A place to shape ideas, follow every change, and move a document forward.

Or drop a Word document anywhere.

Your documents are processed in your browser.
{[['review', 'A better review', 'Track the words, structure and details.'], ['history', 'Room to explore', 'Checkpoint a draft. Try a change. Go back.'], ['shield', 'A confident handoff', 'Inspect the document before you export.']].map(([icon, title, detail]) =>
{title}

{detail}

)}
-
: <>
{busy ? 'Opening your document…' : session.version > 0 ? 'Edited in this session' : 'Local document'}
{editor.canvasState.pending ? 'Editing on the page…' : 'Click on the page to type'}
+ : <>
{busy ? 'Opening your document…' : session.version > 0 ? 'Edited in this session' : 'Local document'}{ready && }
{editor.canvasState.pending ? 'Editing on the page…' : 'Click on the page to type'}
{navigating && !focused && setNavigating(false)} />} -
{panel === 'edit' && !focused && } { setAnchor(id); setQuickActions(true); }} onError={cause => setError(cause.message)} onPageChange={(next, total) => { setPage(next); setPages(total); }} onPaginationComplete={result => setPages(result.totalPages)} showUploadButton={false} showRevisionsTab={false} fitMode="page-width" defaultSettings={{ renderTrackedChanges: true, commentMode: 'inline', annotationMode: 'above' }} /> - {anchor && quickActions && !focused &&
} +
{!focused && } { setAnchor(id); setDismissedTarget(null); }} onTextSelectionChange={selected => { if (selected) selectText(selected); }} onError={cause => setError(cause.message)} onPageChange={(next, total) => { setPage(next); setPages(total); }} onPaginationComplete={result => { setPages(result.totalPages); paginated(); }} showUploadButton={false} showRevisionsTab={false} fitMode="page-width" defaultSettings={{ renderTrackedChanges: true, commentMode: 'inline', annotationMode: 'tooltip' }} /> + {target && quickActions && !focused &&
{target.span ? `“${target.text}”` : 'Paragraph'}
}