The redline pipeline
From "change clause 9" to native Word tracked changes — proposal, review, and the accept that bakes the edit into the canonical .docx.
apps/web/src/components/DocumentViewer.tsx251 lines · DocumentViewer L22–250
Outline 1 symbols
- DocumentViewer function export
1import { useEffect, useRef, useState } from "react"
2import { useDocxodus } from "docxodus/react"
3import { CommentRenderMode } from "docxodus"
4import {
5 fetchRedlinedBytes,
6 fetchRedlines,
7 acceptRedline,
8 rejectRedline,
9 acceptAllRedlines,
10 rejectAllRedlines,
11 convertPdfToDocx,
12 documentContentUrl,
13 type Redline,
14} from "../api/ingest"
15
16// In-browser redline viewer. Fetches the matter's document with every pending
17// redline applied as native tracked changes and converts it to HTML with the WASM
18// runtime — insertions render green, deletions struck red, the redline lawyers
19// expect. The change list alongside it accepts or rejects each proposal, one block
20// at a time or the whole document at once. Bytes convert client-side, so the
21// document never leaves the browser for a third-party service.
22export default function DocumentViewer({
23 matterId,
24 name,
25 focusId,
26 onClose,
27 onChanged,
28 onConverted,
29 onSaveTemplate,
30}: {
31 matterId: string
32 name: string
33 // A redline id to scroll to and highlight on open, set when the viewer was
34 // opened from a chat redline preview's "View in document" link.
35 focusId?: number
36 onClose: () => void
37 onChanged?: () => void
38 // Called with the new .docx name after a PDF is converted, so the parent can
39 // swap the viewer onto the freshly indexed editable document.
40 onConverted?: (name: string) => void
41 // Hand this document to the drafter to turn into a reusable template (a chat
42 // with the prompt prefilled). DOCX only — the redline pipeline and templates
43 // are DOCX-only. Omitted on surfaces that do not offer the action.
44 onSaveTemplate?: () => void
45}) {
46 // PDFs render natively in an <iframe>; the docxodus WASM path and the redline
47 // pipeline are DOCX-only. A PDF carries no redlines until it is converted.
48 const isPdf = name.toLowerCase().endsWith(".pdf")
49 const [converting, setConverting] = useState(false)
50 const { isReady, error: wasmError, convertToHtml } = useDocxodus("/wasm/")
51 const [html, setHtml] = useState<string>()
52 const [redlines, setRedlines] = useState<Redline[]>([])
53 const [error, setError] = useState<string>()
54 const [busy, setBusy] = useState<number | "all">()
55 // Bumped after an accept/reject resolves so the load effect re-runs — the
56 // document re-renders and the change list shrinks without duplicating fetch logic.
57 const [reload, setReload] = useState(0)
58 const focusRef = useRef<HTMLDivElement>(null)
59
60 useEffect(() => {
61 if (!isReady || isPdf) return
62 let cancelled = false
63 setHtml(undefined)
64 setError(undefined)
65 Promise.all([fetchRedlinedBytes(matterId, name), fetchRedlines(matterId, name)])
66 .then(async ([bytes, changes]) => {
67 const out = await convertToHtml(bytes, {
68 renderTrackedChanges: true,
69 showDeletedContent: true,
70 renderMoveOperations: true,
71 renderHeadersAndFooters: true,
72 commentRenderMode: CommentRenderMode.Margin,
73 })
74 if (cancelled) return
75 setHtml(out)
76 setRedlines(changes)
77 })
78 .catch((e) => !cancelled && setError(e instanceof Error ? e.message : String(e)))
79 return () => {
80 cancelled = true
81 }
82 }, [isReady, matterId, name, reload])
83
84 // Once the change list has rendered, scroll the proposal the chat link pointed
85 // at into view and highlight it, so the lawyer lands on that exact change rather
86 // than the top of a long list.
87 useEffect(() => {
88 if (focusId !== undefined) focusRef.current?.scrollIntoView({ block: "center" })
89 }, [focusId, redlines])
90
91 async function resolve(action: () => Promise<void>, key: number | "all") {
92 setBusy(key)
93 try {
94 await action()
95 onChanged?.()
96 setReload((n) => n + 1)
97 } finally {
98 setBusy(undefined)
99 }
100 }
101
102 // Download the redlined .docx — the document with pending changes as native Word
103 // tracked changes — so it can be sent to counsel to accept or reject in Word.
104 // With nothing pending this is simply the current clean document.
105 async function download() {
106 const bytes = await fetchRedlinedBytes(matterId, name)
107 const url = URL.createObjectURL(
108 new Blob([bytes as BlobPart], { type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }),
109 )
110 const a = document.createElement("a")
111 a.href = url
112 a.download = redlines.length ? `${name.replace(/\.docx$/i, "")} (tracked changes).docx` : name
113 a.click()
114 URL.revokeObjectURL(url)
115 }
116
117 // Convert this PDF into an editable .docx sibling, index it, and swap the viewer
118 // onto the new document so it can be redlined like any other contract.
119 async function convert() {
120 setConverting(true)
121 setError(undefined)
122 try {
123 const result = await convertPdfToDocx(matterId, name)
124 onChanged?.()
125 onConverted?.(result.name)
126 } catch (e) {
127 setError(e instanceof Error ? e.message : String(e))
128 } finally {
129 setConverting(false)
130 }
131 }
132
133 if (isPdf)
134 return (
135 <div className="viewer-overlay" onClick={onClose}>
136 <div className="viewer-panel" onClick={(e) => e.stopPropagation()}>
137 <div className="viewer-bar">
138 <span className="viewer-title">
139 <span className="viewer-doctype">PDF</span>
140 <span className="viewer-title-name">{name}</span>
141 </span>
142 <div className="viewer-bar-actions">
143 <button
144 onClick={convert}
145 disabled={converting}
146 title="Convert this PDF to an editable Word document so it can be redlined"
147 >
148 {converting ? "Converting..." : "Convert to DOCX"}
149 </button>
150 <button onClick={onClose}>Close</button>
151 </div>
152 </div>
153 <div className="viewer-body">
154 <div className="viewer-doc">
155 {error && <p className="muted">{error}</p>}
156 <iframe className="pdf-render" title={name} src={documentContentUrl(matterId, name)} />
157 </div>
158 </div>
159 </div>
160 </div>
161 )
162
163 return (
164 <div className="viewer-overlay" onClick={onClose}>
165 <div className="viewer-panel" onClick={(e) => e.stopPropagation()}>
166 <div className="viewer-bar">
167 <span className="viewer-title">
168 <span className="viewer-doctype">DOCX</span>
169 <span className="viewer-title-name">{name}</span>
170 </span>
171 <div className="viewer-bar-actions">
172 <button className="primary" onClick={download} disabled={!html} title="Download as a Word file with tracked changes">
173 {redlines.length ? "Download redline" : "Download"}
174 </button>
175 {onSaveTemplate && (
176 <button onClick={onSaveTemplate} title="Turn this document into a reusable template (with client details removed)">
177 Save as template
178 </button>
179 )}
180 <button onClick={onClose}>Close</button>
181 </div>
182 </div>
183 <div className="viewer-body">
184 <div className="viewer-doc">
185 {wasmError && <p className="muted">Viewer failed to load: {wasmError.message}</p>}
186 {error && <p className="muted">{error}</p>}
187 {!wasmError && !error && !html && (
188 <div className="docx-render docx-skeleton" aria-label="Rendering document">
189 {Array.from({ length: 14 }, (_, i) => <div key={i} className="sk-line" />)}
190 </div>
191 )}
192 {html && <div className="docx-render" dangerouslySetInnerHTML={{ __html: html }} />}
193 </div>
194 {redlines.length > 0 && (
195 <aside className="changes-panel">
196 <div className="changes-head">
197 <span className="changes-count">
198 <strong>{redlines.length}</strong> pending change{redlines.length === 1 ? "" : "s"}
199 </span>
200 <div className="changes-actions">
201 <button
202 className="btn-accept"
203 disabled={busy !== undefined}
204 onClick={() => resolve(() => acceptAllRedlines(matterId, name), "all")}
205 >
206 Accept all
207 </button>
208 <button
209 className="btn-reject"
210 disabled={busy !== undefined}
211 onClick={() => resolve(() => rejectAllRedlines(matterId, name), "all")}
212 >
213 Reject all
214 </button>
215 </div>
216 </div>
217 {redlines.map((r) => (
218 <div
219 className={`change-card${r.id === focusId ? " focused" : ""}`}
220 key={r.id}
221 ref={r.id === focusId ? focusRef : undefined}
222 >
223 <div className="change-author">{r.author}</div>
224 {r.old_text && <div className="change-old">{r.old_text}</div>}
225 <div className="change-new">{r.new_text}</div>
226 <div className="change-buttons">
227 <button
228 className="btn-accept"
229 disabled={busy !== undefined}
230 onClick={() => resolve(() => acceptRedline(matterId, r.id), r.id)}
231 >
232 {busy === r.id ? "..." : "Accept"}
233 </button>
234 <button
235 className="btn-reject"
236 disabled={busy !== undefined}
237 onClick={() => resolve(() => rejectRedline(matterId, r.id), r.id)}
238 >
239 Reject
240 </button>
241 </div>
242 </div>
243 ))}
244 </aside>
245 )}
246 </div>
247 </div>
248 </div>
249 )
250}
251