The Atlas doc.haus documentation, bound to its code
108 documents

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.tsx233 lines · DocumentViewer L22–232
Outline 1 symbols
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}: {
30 matterId: string
31 name: string
32 // A redline id to scroll to and highlight on open, set when the viewer was
33 // opened from a chat redline preview's "View in document" link.
34 focusId?: number
35 onClose: () => void
36 onChanged?: () => void
37 // Called with the new .docx name after a PDF is converted, so the parent can
38 // swap the viewer onto the freshly indexed editable document.
39 onConverted?: (name: string) => void
40}) {
41 // PDFs render natively in an <iframe>; the docxodus WASM path and the redline
42 // pipeline are DOCX-only. A PDF carries no redlines until it is converted.
43 const isPdf = name.toLowerCase().endsWith(".pdf")
44 const [converting, setConverting] = useState(false)
45 const { isReady, error: wasmError, convertToHtml } = useDocxodus("/wasm/")
46 const [html, setHtml] = useState<string>()
47 const [redlines, setRedlines] = useState<Redline[]>([])
48 const [error, setError] = useState<string>()
49 const [busy, setBusy] = useState<number | "all">()
50 // Bumped after an accept/reject resolves so the load effect re-runs — the
51 // document re-renders and the change list shrinks without duplicating fetch logic.
52 const [reload, setReload] = useState(0)
53 const focusRef = useRef<HTMLDivElement>(null)
54
55 useEffect(() => {
56 if (!isReady || isPdf) return
57 let cancelled = false
58 setHtml(undefined)
59 setError(undefined)
60 Promise.all([fetchRedlinedBytes(matterId, name), fetchRedlines(matterId, name)])
61 .then(async ([bytes, changes]) => {
62 const out = await convertToHtml(bytes, {
63 renderTrackedChanges: true,
64 showDeletedContent: true,
65 renderMoveOperations: true,
66 renderHeadersAndFooters: true,
67 commentRenderMode: CommentRenderMode.Margin,
68 })
69 if (cancelled) return
70 setHtml(out)
71 setRedlines(changes)
72 })
73 .catch((e) => !cancelled && setError(e instanceof Error ? e.message : String(e)))
74 return () => {
75 cancelled = true
76 }
77 }, [isReady, matterId, name, reload])
78
79 // Once the change list has rendered, scroll the proposal the chat link pointed
80 // at into view and highlight it, so the lawyer lands on that exact change rather
81 // than the top of a long list.
82 useEffect(() => {
83 if (focusId !== undefined) focusRef.current?.scrollIntoView({ block: "center" })
84 }, [focusId, redlines])
85
86 async function resolve(action: () => Promise<void>, key: number | "all") {
87 setBusy(key)
88 try {
89 await action()
90 onChanged?.()
91 setReload((n) => n + 1)
92 } finally {
93 setBusy(undefined)
94 }
95 }
96
97 // Download the redlined .docx — the document with pending changes as native Word
98 // tracked changes — so it can be sent to counsel to accept or reject in Word.
99 // With nothing pending this is simply the current clean document.
100 async function download() {
101 const bytes = await fetchRedlinedBytes(matterId, name)
102 const url = URL.createObjectURL(
103 new Blob([bytes as BlobPart], { type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }),
104 )
105 const a = document.createElement("a")
106 a.href = url
107 a.download = redlines.length ? `${name.replace(/\.docx$/i, "")} (tracked changes).docx` : name
108 a.click()
109 URL.revokeObjectURL(url)
110 }
111
112 // Convert this PDF into an editable .docx sibling, index it, and swap the viewer
113 // onto the new document so it can be redlined like any other contract.
114 async function convert() {
115 setConverting(true)
116 setError(undefined)
117 try {
118 const result = await convertPdfToDocx(matterId, name)
119 onChanged?.()
120 onConverted?.(result.name)
121 } catch (e) {
122 setError(e instanceof Error ? e.message : String(e))
123 } finally {
124 setConverting(false)
125 }
126 }
127
128 if (isPdf)
129 return (
130 <div className="viewer-overlay" onClick={onClose}>
131 <div className="viewer-panel" onClick={(e) => e.stopPropagation()}>
132 <div className="viewer-bar">
133 <span className="viewer-title">{name}</span>
134 <div className="viewer-bar-actions">
135 <button
136 onClick={convert}
137 disabled={converting}
138 title="Convert this PDF to an editable Word document so it can be redlined"
139 >
140 {converting ? "Converting..." : "Convert to DOCX"}
141 </button>
142 <button onClick={onClose}>Close</button>
143 </div>
144 </div>
145 <div className="viewer-body">
146 <div className="viewer-doc">
147 {error && <p className="muted">{error}</p>}
148 <iframe className="pdf-render" title={name} src={documentContentUrl(matterId, name)} />
149 </div>
150 </div>
151 </div>
152 </div>
153 )
154
155 return (
156 <div className="viewer-overlay" onClick={onClose}>
157 <div className="viewer-panel" onClick={(e) => e.stopPropagation()}>
158 <div className="viewer-bar">
159 <span className="viewer-title">{name}</span>
160 <div className="viewer-bar-actions">
161 <button onClick={download} disabled={!html} title="Download as a Word file with tracked changes">
162 {redlines.length ? "Download redline" : "Download"}
163 </button>
164 <button onClick={onClose}>Close</button>
165 </div>
166 </div>
167 <div className="viewer-body">
168 <div className="viewer-doc">
169 {wasmError && <p className="muted">Viewer failed to load: {wasmError.message}</p>}
170 {error && <p className="muted">{error}</p>}
171 {!wasmError && !error && !html && (
172 <p className="muted">{isReady ? `Rendering ${name}...` : "Loading viewer..."}</p>
173 )}
174 {html && <div className="docx-render" dangerouslySetInnerHTML={{ __html: html }} />}
175 </div>
176 {redlines.length > 0 && (
177 <aside className="changes-panel">
178 <div className="changes-head">
179 <span>
180 {redlines.length} pending change{redlines.length === 1 ? "" : "s"}
181 </span>
182 <div className="changes-actions">
183 <button
184 className="btn-accept"
185 disabled={busy !== undefined}
186 onClick={() => resolve(() => acceptAllRedlines(matterId, name), "all")}
187 >
188 Accept all
189 </button>
190 <button
191 className="btn-reject"
192 disabled={busy !== undefined}
193 onClick={() => resolve(() => rejectAllRedlines(matterId, name), "all")}
194 >
195 Reject all
196 </button>
197 </div>
198 </div>
199 {redlines.map((r) => (
200 <div
201 className={`change-card${r.id === focusId ? " focused" : ""}`}
202 key={r.id}
203 ref={r.id === focusId ? focusRef : undefined}
204 >
205 <div className="change-author">{r.author}</div>
206 {r.old_text && <div className="change-old">{r.old_text}</div>}
207 <div className="change-new">{r.new_text}</div>
208 <div className="change-buttons">
209 <button
210 className="btn-accept"
211 disabled={busy !== undefined}
212 onClick={() => resolve(() => acceptRedline(matterId, r.id), r.id)}
213 >
214 {busy === r.id ? "..." : "Accept"}
215 </button>
216 <button
217 className="btn-reject"
218 disabled={busy !== undefined}
219 onClick={() => resolve(() => rejectRedline(matterId, r.id), r.id)}
220 >
221 Reject
222 </button>
223 </div>
224 </div>
225 ))}
226 </aside>
227 )}
228 </div>
229 </div>
230 </div>
231 )
232}
233