@anthusai/papyrus 2.0.3 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/amplify/plugins/editorial-checks/function/handler.py +11 -0
  2. package/amplify/plugins/editorial-checks/function/requirements.txt +1 -0
  3. package/amplify/plugins/editorial-checks/function/resource.js +43 -0
  4. package/amplify/plugins/editorial-checks/function/resource.ts +60 -0
  5. package/amplify/plugins/editorial-checks/papyrus.plugin.js +50 -0
  6. package/amplify/plugins/editorial-checks/papyrus.plugin.ts +63 -0
  7. package/amplify/plugins/editorial-checks/schema.js +94 -0
  8. package/amplify/plugins/editorial-checks/schema.ts +96 -0
  9. package/amplify/plugins/registry.js +2 -0
  10. package/amplify/plugins/registry.ts +2 -0
  11. package/components/editorial-checks-admin-panel.tsx +337 -0
  12. package/components/editorial-finding-card.tsx +162 -0
  13. package/components/editorial-findings-panel.tsx +91 -0
  14. package/components/editorial-preview-popover.tsx +103 -0
  15. package/components/markus-body-editor.tsx +213 -0
  16. package/components/markus-ir-preview.tsx +147 -6
  17. package/components/newsroom-article-editor.tsx +138 -11
  18. package/components/newsroom-board-view.tsx +6 -7
  19. package/components/newsroom-ops-shell.tsx +7 -3
  20. package/components/newsroom-organizer.css +57 -61
  21. package/components/newsroom-theme-fonts/broadsheet-green.css +10 -0
  22. package/components/newsroom-theme-fonts/broadsheet-green.tsx +6 -0
  23. package/components/newsroom-theme-fonts/newspaper-gothic.css +9 -0
  24. package/components/newsroom-theme-fonts/newspaper-gothic.tsx +6 -0
  25. package/components/newsroom-theme-fonts/night-desk.css +10 -0
  26. package/components/newsroom-theme-fonts/night-desk.tsx +6 -0
  27. package/components/newsroom-theme-fonts/refined-newsprint.css +11 -0
  28. package/components/newsroom-theme-fonts/refined-newsprint.tsx +6 -0
  29. package/components/newsroom-theme.css +39 -0
  30. package/components/newsroom-theme.tsx +33 -0
  31. package/components/topic-steering-workspace.tsx +19 -1
  32. package/components/use-editorial-checks.ts +131 -0
  33. package/lib/editorial-checks-backend.ts +16 -0
  34. package/lib/editorial-checks-client.ts +194 -0
  35. package/lib/editorial-checks-demo.ts +147 -0
  36. package/lib/editorial-checks-settings.ts +24 -0
  37. package/lib/editorial-checks.ts +178 -0
  38. package/lib/editorial-preview.ts +228 -0
  39. package/lib/newsroom-board.ts +27 -2
  40. package/lib/site-brand.ts +17 -0
  41. package/package.json +34 -2
  42. package/routes.manifest.json +1 -1
  43. package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
  44. package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
  45. package/components/newsroom-fonts/playfair-display-OFL.txt +0 -93
  46. package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
  47. package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
  48. package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +0 -93
  49. package/components/newsroom-newsprint-board.css +0 -7
@@ -0,0 +1,103 @@
1
+ "use client";
2
+
3
+ import { SparklesIcon, XIcon } from "lucide-react";
4
+ import { useEffect, useRef, useState } from "react";
5
+ import { Button } from "./ui/button";
6
+ import { Input } from "./ui/input";
7
+ import type { EditorialFinding, Span } from "../lib/editorial-checks";
8
+ import { EditorialFindingCard, RewriteOptions, useRewriteOptions, type FindingActions } from "./editorial-finding-card";
9
+
10
+ /** What the popover is about: findings at a marked passage, or a passage the editor selected. */
11
+ export type PreviewPopoverTarget =
12
+ | { kind: "findings"; findingIds: string[]; anchor: DOMRect }
13
+ | { kind: "selection"; source: Span; anchor: DOMRect };
14
+
15
+ const WIDTH = 352;
16
+
17
+ /**
18
+ * Floats next to the passage on the preview, inside `container` (which must be
19
+ * positioned). Escape or the close button dismisses it.
20
+ */
21
+ export function EditorialPreviewPopover({
22
+ target,
23
+ findings,
24
+ body,
25
+ container,
26
+ actions,
27
+ onClose,
28
+ }: {
29
+ target: PreviewPopoverTarget;
30
+ findings: EditorialFinding[];
31
+ body: string;
32
+ container: HTMLElement | null;
33
+ actions: FindingActions;
34
+ onClose: () => void;
35
+ }) {
36
+ const [note, setNote] = useState("");
37
+ const panel = useRef<HTMLDivElement | null>(null);
38
+ const selectionRewrites = useRewriteOptions(actions);
39
+
40
+ useEffect(() => {
41
+ const onKey = (event: KeyboardEvent) => {
42
+ if (event.key === "Escape") onClose();
43
+ };
44
+ window.addEventListener("keydown", onKey);
45
+ return () => window.removeEventListener("keydown", onKey);
46
+ }, [onClose]);
47
+
48
+ useEffect(() => {
49
+ panel.current?.focus();
50
+ }, [target]);
51
+
52
+ const box = container?.getBoundingClientRect();
53
+ const top = box ? target.anchor.bottom - box.top + container!.scrollTop + 6 : 0;
54
+ const left = box ? Math.max(0, Math.min(target.anchor.left - box.left, box.width - WIDTH)) : 0;
55
+ const shown = target.kind === "findings" ? findings.filter((finding) => target.findingIds.includes(finding.id)) : [];
56
+
57
+ return (
58
+ <div
59
+ aria-label={target.kind === "findings" ? "Editorial findings for this passage" : "Rewrite the selected passage"}
60
+ className="absolute z-20 max-h-[28rem] space-y-2 overflow-auto rounded-xl border border-border bg-popover p-3 text-sm text-popover-foreground shadow-lg outline-none"
61
+ data-editorial-preview-popover={target.kind}
62
+ ref={panel}
63
+ role="dialog"
64
+ style={{ top, left, width: WIDTH }}
65
+ tabIndex={-1}
66
+ >
67
+ <div className="flex items-center gap-2">
68
+ <Input
69
+ aria-label="Note for rewrites"
70
+ className="h-8 flex-1"
71
+ onChange={(event) => setNote(event.target.value)}
72
+ placeholder="Note for rewrites, e.g. shorter"
73
+ value={note}
74
+ />
75
+ <Button aria-label="Close" onClick={onClose} size="icon-xs" type="button" variant="ghost">
76
+ <XIcon aria-hidden="true" />
77
+ </Button>
78
+ </div>
79
+ {target.kind === "findings" ? (
80
+ shown.length ? (
81
+ shown.map((finding) => <EditorialFindingCard actions={actions} active finding={finding} key={finding.id} note={note} />)
82
+ ) : (
83
+ <p className="text-muted-foreground">This finding was resolved.</p>
84
+ )
85
+ ) : (
86
+ <div className="space-y-2" data-editorial-selection-rewrite>
87
+ <q className="block text-muted-foreground">{body.slice(target.source.start, target.source.end)}</q>
88
+ <Button
89
+ data-editorial-rewrite-selection
90
+ onClick={() => {
91
+ void selectionRewrites.request({ selection: target.source }, note.trim() || null);
92
+ }}
93
+ size="xs"
94
+ type="button"
95
+ >
96
+ <SparklesIcon aria-hidden="true" /> Suggest rewrites
97
+ </Button>
98
+ <RewriteOptions finding={null} onApply={actions.onApply} suggestion={selectionRewrites.suggestion} />
99
+ </div>
100
+ )}
101
+ </div>
102
+ );
103
+ }
@@ -0,0 +1,213 @@
1
+ "use client";
2
+
3
+ import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
4
+ import { markdown } from "@codemirror/lang-markdown";
5
+ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
6
+ import { EditorState, RangeSetBuilder, StateEffect, StateField } from "@codemirror/state";
7
+ import { Decoration, type DecorationSet, EditorView, keymap, placeholder as placeholderExtension } from "@codemirror/view";
8
+ import { tags } from "@lezer/highlight";
9
+ import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
10
+ import type { EditorialFinding } from "../lib/editorial-checks";
11
+
12
+ /** The textarea-shaped surface the article editor already drives (focus, selection, value). */
13
+ export type MarkusBodyEditorHandle = {
14
+ readonly value: string;
15
+ readonly selectionStart: number;
16
+ readonly selectionEnd: number;
17
+ focus: () => void;
18
+ setSelectionRange: (start: number, end: number) => void;
19
+ /** Scroll to and select a span, e.g. a finding the editor clicked in the findings panel. */
20
+ reveal: (start: number, end: number) => void;
21
+ };
22
+
23
+ type Props = {
24
+ value: string;
25
+ onChange: (value: string) => void;
26
+ findings?: EditorialFinding[];
27
+ activeFindingId?: string | null;
28
+ onFindingClick?: (findingId: string) => void;
29
+ onSelectionChange?: (selection: { start: number; end: number }) => void;
30
+ placeholder?: string;
31
+ ariaLabel?: string;
32
+ };
33
+
34
+ type MarkedFinding = Pick<EditorialFinding, "id" | "span" | "severity" | "status" | "message">;
35
+
36
+ /** Markup is told apart by weight, emphasis and opacity, never by a fixed colour, so it reads in light and dark themes. */
37
+ const markusHighlight = HighlightStyle.define([
38
+ { tag: tags.heading, fontWeight: "700" },
39
+ { tag: tags.strong, fontWeight: "700" },
40
+ { tag: tags.emphasis, fontStyle: "italic" },
41
+ { tag: tags.strikethrough, textDecoration: "line-through" },
42
+ { tag: tags.link, textDecoration: "underline" },
43
+ { tag: [tags.url, tags.processingInstruction, tags.meta, tags.contentSeparator, tags.labelName], opacity: "0.6" },
44
+ { tag: tags.monospace, opacity: "0.85" },
45
+ { tag: tags.quote, fontStyle: "italic", opacity: "0.85" },
46
+ ]);
47
+
48
+ const setFindings = StateEffect.define<{ findings: MarkedFinding[]; activeId: string | null }>();
49
+
50
+ function decorationsFor(findings: MarkedFinding[], activeId: string | null, length: number): DecorationSet {
51
+ const builder = new RangeSetBuilder<Decoration>();
52
+ const sorted = [...findings]
53
+ .filter((finding) => finding.span.start < finding.span.end && finding.span.end <= length)
54
+ .sort((a, b) => a.span.start - b.span.start || a.span.end - b.span.end);
55
+ for (const finding of sorted) {
56
+ builder.add(
57
+ finding.span.start,
58
+ finding.span.end,
59
+ Decoration.mark({
60
+ class: [
61
+ "cm-editorial-finding",
62
+ `cm-editorial-${finding.severity}`,
63
+ `cm-editorial-${finding.status}`,
64
+ finding.id === activeId ? "cm-editorial-active" : "",
65
+ ].join(" "),
66
+ attributes: { "data-editorial-finding": finding.id, title: finding.message },
67
+ }),
68
+ );
69
+ }
70
+ return builder.finish();
71
+ }
72
+
73
+ /** Findings are drawn as marks; edits move them with the text until the next check replaces them. */
74
+ const findingField = StateField.define<DecorationSet>({
75
+ create: () => Decoration.none,
76
+ update(marks, transaction) {
77
+ let next = marks.map(transaction.changes);
78
+ for (const effect of transaction.effects) {
79
+ if (effect.is(setFindings)) next = decorationsFor(effect.value.findings, effect.value.activeId, transaction.state.doc.length);
80
+ }
81
+ return next;
82
+ },
83
+ provide: (field) => EditorView.decorations.from(field),
84
+ });
85
+
86
+ const theme = EditorView.theme({
87
+ "&": { fontSize: "0.875rem", border: "1px solid var(--border)", borderRadius: "0.5rem", backgroundColor: "var(--background)", color: "var(--foreground)" },
88
+ ".cm-cursor": { borderLeftColor: "var(--foreground)" },
89
+ "&.cm-focused": { outline: "2px solid var(--ring)", outlineOffset: "1px" },
90
+ ".cm-content": { fontFamily: "var(--font-mono, ui-monospace, monospace)", minHeight: "28rem", padding: "0.75rem 0" },
91
+ ".cm-line": { padding: "0 0.75rem" },
92
+ ".cm-editorial-finding": { textDecorationLine: "underline", textDecorationStyle: "wavy", textUnderlineOffset: "3px", cursor: "pointer" },
93
+ ".cm-editorial-error": { textDecorationColor: "var(--destructive)" },
94
+ ".cm-editorial-warn": { textDecorationColor: "oklch(0.75 0.16 70)" },
95
+ ".cm-editorial-info": { textDecorationColor: "oklch(0.65 0.12 250)" },
96
+ ".cm-editorial-review": { textDecorationStyle: "dotted" },
97
+ ".cm-editorial-active": { backgroundColor: "color-mix(in oklch, var(--ring) 22%, transparent)" },
98
+ });
99
+
100
+ /** Replace only the stretch that differs, so finding marks elsewhere keep their place. */
101
+ export function minimalChange(before: string, after: string): { from: number; to: number; insert: string } {
102
+ let prefix = 0;
103
+ const limit = Math.min(before.length, after.length);
104
+ while (prefix < limit && before.charCodeAt(prefix) === after.charCodeAt(prefix)) prefix += 1;
105
+ let suffix = 0;
106
+ while (
107
+ suffix < limit - prefix &&
108
+ before.charCodeAt(before.length - 1 - suffix) === after.charCodeAt(after.length - 1 - suffix)
109
+ ) suffix += 1;
110
+ return { from: prefix, to: before.length - suffix, insert: after.slice(prefix, after.length - suffix) };
111
+ }
112
+
113
+ export const MarkusBodyEditor = forwardRef<MarkusBodyEditorHandle, Props>(function MarkusBodyEditor(
114
+ { value, onChange, findings = [], activeFindingId = null, onFindingClick, onSelectionChange, placeholder, ariaLabel = "Body" },
115
+ ref,
116
+ ) {
117
+ const host = useRef<HTMLDivElement | null>(null);
118
+ const view = useRef<EditorView | null>(null);
119
+ const callbacks = useRef({ onChange, onFindingClick, onSelectionChange });
120
+ callbacks.current = { onChange, onFindingClick, onSelectionChange };
121
+
122
+ useEffect(() => {
123
+ if (!host.current) return;
124
+ const editor = new EditorView({
125
+ parent: host.current,
126
+ state: EditorState.create({
127
+ doc: value,
128
+ extensions: [
129
+ history(),
130
+ keymap.of([...defaultKeymap, ...historyKeymap]),
131
+ markdown(),
132
+ syntaxHighlighting(markusHighlight),
133
+ EditorView.lineWrapping,
134
+ findingField,
135
+ theme,
136
+ placeholder ? placeholderExtension(placeholder) : [],
137
+ EditorView.contentAttributes.of({
138
+ "aria-label": ariaLabel,
139
+ "aria-multiline": "true",
140
+ "data-newsroom-article-body": "",
141
+ id: "newsroom-article-body",
142
+ spellcheck: "false",
143
+ }),
144
+ EditorView.updateListener.of((update) => {
145
+ if (update.docChanged) callbacks.current.onChange(update.state.doc.toString());
146
+ if (update.selectionSet || update.docChanged) {
147
+ const main = update.state.selection.main;
148
+ callbacks.current.onSelectionChange?.({ start: main.from, end: main.to });
149
+ }
150
+ }),
151
+ EditorView.domEventHandlers({
152
+ click(event) {
153
+ const mark = (event.target as HTMLElement | null)?.closest("[data-editorial-finding]");
154
+ const id = mark?.getAttribute("data-editorial-finding");
155
+ if (id) callbacks.current.onFindingClick?.(id);
156
+ return false;
157
+ },
158
+ }),
159
+ ],
160
+ }),
161
+ });
162
+ view.current = editor;
163
+ // Browser specs read the document through the host element (CodeMirror only renders visible lines).
164
+ (host.current as HTMLDivElement & { papyrusEditorValue?: () => string }).papyrusEditorValue = () => editor.state.doc.toString();
165
+ return () => {
166
+ editor.destroy();
167
+ view.current = null;
168
+ };
169
+ // The editor is created once; `value` changes are synced below.
170
+ // eslint-disable-next-line react-hooks/exhaustive-deps
171
+ }, []);
172
+
173
+ useEffect(() => {
174
+ const editor = view.current;
175
+ if (!editor) return;
176
+ const current = editor.state.doc.toString();
177
+ if (current !== value) editor.dispatch({ changes: minimalChange(current, value) });
178
+ }, [value]);
179
+
180
+ useEffect(() => {
181
+ view.current?.dispatch({ effects: setFindings.of({ findings, activeId: activeFindingId }) });
182
+ }, [findings, activeFindingId]);
183
+
184
+ useImperativeHandle(
185
+ ref,
186
+ () => ({
187
+ get value() {
188
+ return view.current?.state.doc.toString() ?? value;
189
+ },
190
+ get selectionStart() {
191
+ return view.current?.state.selection.main.from ?? 0;
192
+ },
193
+ get selectionEnd() {
194
+ return view.current?.state.selection.main.to ?? 0;
195
+ },
196
+ focus() {
197
+ view.current?.focus();
198
+ },
199
+ setSelectionRange(start: number, end: number) {
200
+ view.current?.dispatch({ selection: { anchor: start, head: end }, scrollIntoView: true });
201
+ },
202
+ reveal(start: number, end: number) {
203
+ const editor = view.current;
204
+ if (!editor) return;
205
+ editor.dispatch({ selection: { anchor: start, head: end }, effects: EditorView.scrollIntoView(start, { y: "center" }) });
206
+ editor.focus();
207
+ },
208
+ }),
209
+ [value],
210
+ );
211
+
212
+ return <div data-newsroom-article-body-editor ref={host} />;
213
+ });
@@ -1,4 +1,6 @@
1
- import type { ReactNode } from "react";
1
+ import type { MouseEvent as ReactMouseEvent, ReactNode } from "react";
2
+ import { CITATION_TOKEN, planPreview, selectionToSource, splitByMarks, type BlockAnnotation, type PreviewMark, type PreviewPlan } from "../lib/editorial-preview";
3
+ import type { EditorialCheckResult, EditorialFinding, Span } from "../lib/editorial-checks";
2
4
  import {
3
5
  parseMarkusDocument,
4
6
  type MarkusBlock,
@@ -17,7 +19,20 @@ type PreviewSidecar = {
17
19
  bibliography: string[];
18
20
  };
19
21
 
22
+ /** Editorial findings drawn on the preview (see lib/editorial-preview.ts). */
23
+ export type PreviewEditorial = {
24
+ /** A check result computed for the same body the preview shows, and the findings to draw from it. */
25
+ result: EditorialCheckResult;
26
+ findings: EditorialFinding[];
27
+ activeFindingIds?: readonly string[];
28
+ /** A marked passage was clicked. */
29
+ onOpenMark: (findingIds: string[], anchor: HTMLElement) => void;
30
+ /** Text was selected inside one prose block: the source span of the sentences it touches. */
31
+ onSelectSource?: (source: Span, anchor: DOMRect) => void;
32
+ };
33
+
20
34
  type PreviewContext = {
35
+ editorial?: PreviewEditorial & { plan: PreviewPlan; blocks: Map<string, BlockAnnotation> };
21
36
  sidecar: PreviewSidecar;
22
37
  citationNumbers: Map<string, number>;
23
38
  imageUrls: Record<string, string>;
@@ -91,7 +106,7 @@ function renderText(text: string, context: PreviewContext): ReactNode {
91
106
  if (!keys) continue;
92
107
  if (index > cursor) parts.push(text.slice(cursor, index));
93
108
  parts.push(
94
- <sup className="text-primary" key={`${index}-${token}`}>
109
+ <sup className="text-primary" data-editorial-skip key={`${index}-${token}`}>
95
110
  {keys.map((key) => `[${context.citationNumbers.get(key)}]`).join("")}
96
111
  </sup>,
97
112
  );
@@ -127,7 +142,7 @@ function renderInlines(inlines: MarkusInline[], context: PreviewContext): ReactN
127
142
  }
128
143
  case "image":
129
144
  return (
130
- <span className="rounded border border-dashed border-border px-1 text-xs text-muted-foreground" key={index}>
145
+ <span className="rounded border border-dashed border-border px-1 text-xs text-muted-foreground" data-editorial-skip key={index}>
131
146
  {`Image: ${node.src ?? ""} (${node.alt})`}
132
147
  </span>
133
148
  );
@@ -141,6 +156,92 @@ function renderInlines(inlines: MarkusInline[], context: PreviewContext): ReactN
141
156
  });
142
157
  }
143
158
 
159
+ const MARK_CLASSES: Record<PreviewMark["severity"], string> = {
160
+ error: "decoration-destructive",
161
+ warn: "decoration-amber-500",
162
+ info: "decoration-sky-500",
163
+ };
164
+
165
+ function markElement(text: string, mark: PreviewMark, key: string, context: PreviewContext): ReactNode {
166
+ const active = mark.findingIds.some((id) => context.editorial?.activeFindingIds?.includes(id));
167
+ return (
168
+ <mark
169
+ className={`cursor-pointer bg-transparent text-inherit underline underline-offset-4 ${mark.status === "review" ? "decoration-dotted" : "decoration-wavy"} ${MARK_CLASSES[mark.severity]} ${active ? "rounded-sm bg-ring/25" : ""}`}
170
+ data-editorial-mark={mark.findingIds.join(" ")}
171
+ key={key}
172
+ onClick={(event) => {
173
+ event.preventDefault();
174
+ context.editorial?.onOpenMark(mark.findingIds, event.currentTarget);
175
+ }}
176
+ >
177
+ {text}
178
+ </mark>
179
+ );
180
+ }
181
+
182
+ /** Like renderInlines, but splits rendered text at editorial marks (offsets as in inlineLeaves). */
183
+ function renderMarkedInlines(inlines: MarkusInline[], context: PreviewContext, marks: PreviewMark[], cursor: { at: number }): ReactNode[] {
184
+ const marked = (text: string, keyPrefix: string): ReactNode[] => {
185
+ const start = cursor.at;
186
+ cursor.at += text.length;
187
+ return splitByMarks(start, start + text.length, marks).map((piece, pieceIndex) => {
188
+ const slice = text.slice(piece.start - start, piece.end - start);
189
+ return piece.mark ? markElement(slice, piece.mark, `${keyPrefix}-${pieceIndex}`, context) : slice;
190
+ });
191
+ };
192
+ return inlines.map((node, index) => {
193
+ switch (node.type) {
194
+ case "text": {
195
+ const parts: ReactNode[] = [];
196
+ let last = 0;
197
+ for (const match of node.text.matchAll(CITATION_TOKEN)) {
198
+ const at = match.index ?? 0;
199
+ if (at > last) parts.push(...marked(node.text.slice(last, at), `${index}-${last}`));
200
+ parts.push(<span key={`${index}-t${at}`}>{renderText(match[0], context)}</span>);
201
+ last = at + match[0].length;
202
+ }
203
+ if (last < node.text.length) parts.push(...marked(node.text.slice(last), `${index}-${last}`));
204
+ return <span key={index}>{parts}</span>;
205
+ }
206
+ case "emphasis":
207
+ return <em key={index}>{renderMarkedInlines(node.children, context, marks, cursor)}</em>;
208
+ case "strong":
209
+ return <strong key={index}>{renderMarkedInlines(node.children, context, marks, cursor)}</strong>;
210
+ case "strikethrough":
211
+ return <s key={index}>{renderMarkedInlines(node.children, context, marks, cursor)}</s>;
212
+ case "code_span":
213
+ return <code className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]" key={index}>{marked(node.code, `${index}`)}</code>;
214
+ case "link": {
215
+ const children = renderMarkedInlines(node.children, context, marks, cursor);
216
+ const custom = node.href ? context.renderLink?.(node.href, children) : null;
217
+ if (custom) return <span key={index}>{custom}</span>;
218
+ return node.href ? (
219
+ <a className="text-primary underline" href={node.href} key={index} rel="noopener noreferrer" target="_blank">{children}</a>
220
+ ) : (
221
+ <span key={index}>{children}</span>
222
+ );
223
+ }
224
+ case "soft_break":
225
+ cursor.at += 1;
226
+ return <span key={index}>{" "}</span>;
227
+ case "hard_break":
228
+ cursor.at += 1;
229
+ return <br key={index} />;
230
+ default:
231
+ return renderInlines([node], context)[0];
232
+ }
233
+ });
234
+ }
235
+
236
+ /** Render a prose block's inlines, with editorial marks when the block has an annotation. */
237
+ function proseInlines(block: { inline: MarkusInline[] }, context: PreviewContext): { children: ReactNode[]; blockKey?: string } {
238
+ const annotation = context.editorial?.plan.get(block);
239
+ if (!context.editorial || !annotation) return { children: renderInlines(block.inline, context) };
240
+ const blockKey = String(context.editorial.blocks.size);
241
+ context.editorial.blocks.set(blockKey, annotation);
242
+ return { children: renderMarkedInlines(block.inline, context, annotation.marks, { at: 0 }), blockKey };
243
+ }
244
+
144
245
  function soleToken(inlines: MarkusInline[]): string | null {
145
246
  if (inlines.length !== 1 || inlines[0].type !== "text") return null;
146
247
  const text = inlines[0].text.trim();
@@ -170,12 +271,14 @@ function renderBlock(block: MarkusBlock, index: number, context: PreviewContext)
170
271
  </ol>
171
272
  );
172
273
  }
173
- return <p className="my-3 leading-relaxed" key={index}>{renderInlines(block.inline, context)}</p>;
274
+ const prose = proseInlines(block, context);
275
+ return <p className="my-3 leading-relaxed" data-editorial-block={prose.blockKey} key={index}>{prose.children}</p>;
174
276
  }
175
277
  case "heading": {
176
278
  const level = Math.min(Math.max(block.level, 1), 6);
177
279
  const Tag = `h${level}` as "h1";
178
- return <Tag className={HEADING_CLASSES[level - 1]} key={index}>{renderInlines(block.inline, context)}</Tag>;
280
+ const prose = proseInlines(block, context);
281
+ return <Tag className={HEADING_CLASSES[level - 1]} data-editorial-block={prose.blockKey} key={index}>{prose.children}</Tag>;
179
282
  }
180
283
  case "list": {
181
284
  const items = block.items.map((item, itemIndex) => (
@@ -310,12 +413,45 @@ function renderBlocks(nodes: MarkusNode[], context: PreviewContext): ReactNode[]
310
413
  );
311
414
  }
312
415
 
416
+ /** Characters of rendered text from the start of `block` to (node, offset), skipping citation and image furniture. */
417
+ function renderedOffset(block: HTMLElement, node: Node, offset: number): number {
418
+ const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
419
+ let total = 0;
420
+ for (let current = walker.nextNode(); current; current = walker.nextNode()) {
421
+ const skipped = current.parentElement?.closest("[data-editorial-skip]");
422
+ if (current === node) return total + (skipped ? 0 : offset);
423
+ if (!skipped) total += current.textContent?.length ?? 0;
424
+ }
425
+ return total;
426
+ }
427
+
428
+ function previewSelectionHandler(context: PreviewContext) {
429
+ return (event: ReactMouseEvent<HTMLDivElement>) => {
430
+ const editorial = context.editorial;
431
+ if (!editorial?.onSelectSource) return;
432
+ const selection = window.getSelection();
433
+ if (!selection || selection.isCollapsed || selection.rangeCount === 0) return;
434
+ const range = selection.getRangeAt(0);
435
+ const startBlock = (range.startContainer.parentElement ?? null)?.closest<HTMLElement>("[data-editorial-block]");
436
+ const endBlock = (range.endContainer.parentElement ?? null)?.closest<HTMLElement>("[data-editorial-block]");
437
+ if (!startBlock || startBlock !== endBlock || !event.currentTarget.contains(startBlock)) return;
438
+ const annotation = editorial.blocks.get(startBlock.dataset.editorialBlock ?? "");
439
+ const source = selectionToSource(annotation, {
440
+ start: renderedOffset(startBlock, range.startContainer, range.startOffset),
441
+ end: renderedOffset(startBlock, range.endContainer, range.endOffset),
442
+ });
443
+ if (source) editorial.onSelectSource(source, range.getBoundingClientRect());
444
+ };
445
+ }
446
+
313
447
  export function MarkusIrPreview({
314
448
  bodyIr,
315
449
  imageUrls = {},
316
450
  dynamicDirectives = {},
317
451
  renderLink,
452
+ editorial,
318
453
  }: {
454
+ editorial?: PreviewEditorial;
319
455
  bodyIr: unknown;
320
456
  imageUrls?: Record<string, string>;
321
457
  /** Live components for leaf directives whose content the host app computes, keyed by directive name. */
@@ -333,8 +469,13 @@ export function MarkusIrPreview({
333
469
  imageUrls,
334
470
  dynamicDirectives,
335
471
  renderLink,
472
+ editorial: editorial ? { ...editorial, plan: planPreview(document, editorial.result, editorial.findings), blocks: new Map() } : undefined,
336
473
  };
337
- return <div data-markus-preview>{renderBlocks(document.children, context)}</div>;
474
+ return (
475
+ <div data-markus-preview onMouseUp={editorial?.onSelectSource ? previewSelectionHandler(context) : undefined}>
476
+ {renderBlocks(document.children, context)}
477
+ </div>
478
+ );
338
479
  } catch (error) {
339
480
  return (
340
481
  <p className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive" data-markus-preview-error role="alert">