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'}
}