@anthusai/papyrus 2.0.0-next.1 → 2.0.0-next.10
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.
- package/amplify/data/resource.js +7 -0
- package/amplify/data/resource.ts +8 -0
- package/amplify/data/schema.js +8 -0
- package/amplify/data/schema.ts +9 -0
- package/amplify/functions/assignment-action/data-access.js +88 -0
- package/amplify/functions/assignment-action/data-access.ts +87 -0
- package/amplify/functions/content-actions/handler.py +7 -0
- package/amplify/functions/model-attachment-upload/handler.ts +1 -0
- package/amplify/plugins/editorial-checks/function/handler.py +14 -0
- package/amplify/plugins/editorial-checks/function/requirements.txt +1 -0
- package/amplify/plugins/editorial-checks/function/resource.js +43 -0
- package/amplify/plugins/editorial-checks/function/resource.ts +60 -0
- package/amplify/plugins/editorial-checks/papyrus.plugin.js +71 -0
- package/amplify/plugins/editorial-checks/papyrus.plugin.ts +87 -0
- package/amplify/plugins/editorial-checks/schema.js +94 -0
- package/amplify/plugins/editorial-checks/schema.ts +96 -0
- package/amplify/plugins/registry.js +2 -0
- package/amplify/plugins/registry.ts +2 -0
- package/amplify/site-backend.js +5 -0
- package/amplify/site-backend.ts +11 -0
- package/components/editorial-checks-admin-panel.tsx +337 -0
- package/components/editorial-finding-card.tsx +162 -0
- package/components/editorial-findings-panel.tsx +91 -0
- package/components/editorial-preview-popover.tsx +103 -0
- package/components/markus-body-editor.tsx +213 -0
- package/components/markus-ir-preview.tsx +147 -6
- package/components/newsroom-article-editor.tsx +138 -11
- package/components/newsroom-board-view.tsx +6 -7
- package/components/newsroom-ops-shell.tsx +7 -3
- package/components/newsroom-organizer.css +57 -61
- package/components/newsroom-theme-fonts/broadsheet-green.css +10 -0
- package/components/newsroom-theme-fonts/broadsheet-green.tsx +6 -0
- package/components/newsroom-theme-fonts/newspaper-gothic.css +9 -0
- package/components/newsroom-theme-fonts/newspaper-gothic.tsx +6 -0
- package/components/newsroom-theme-fonts/night-desk.css +10 -0
- package/components/newsroom-theme-fonts/night-desk.tsx +6 -0
- package/components/newsroom-theme-fonts/refined-newsprint.css +11 -0
- package/components/newsroom-theme-fonts/refined-newsprint.tsx +6 -0
- package/components/newsroom-theme.css +39 -0
- package/components/newsroom-theme.tsx +33 -0
- package/components/reference-cyclotron-priming.tsx +12 -13
- package/components/reference-cyclotron-status.tsx +4 -4
- package/components/reference-cyclotron-suggestion.tsx +6 -6
- package/components/reference-live-agreement.tsx +10 -10
- package/components/reference-priming.tsx +1 -1
- package/components/reference-relevance-review.tsx +1 -1
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +19 -1
- package/components/use-editorial-checks.ts +131 -0
- package/lib/cyclotron-suggestions.ts +9 -9
- package/lib/editorial-checks-backend.ts +16 -0
- package/lib/editorial-checks-client.ts +194 -0
- package/lib/editorial-checks-demo.ts +147 -0
- package/lib/editorial-checks-settings.ts +24 -0
- package/lib/editorial-checks.ts +178 -0
- package/lib/editorial-preview.ts +228 -0
- package/lib/newsroom-board.ts +27 -2
- package/lib/relevance-status.ts +4 -4
- package/lib/site-brand.ts +17 -0
- package/package.json +34 -2
- package/routes.manifest.json +1 -1
- package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-OFL.txt +0 -93
- package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +0 -93
- package/components/newsroom-newsprint-board.css +0 -7
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { CheckIcon, SparklesIcon, XIcon } from "lucide-react";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { Button } from "./ui/button";
|
|
6
|
+
import { Input } from "./ui/input";
|
|
7
|
+
import { Spinner } from "./ui/spinner";
|
|
8
|
+
import { describeProbability, type EditorialFinding, type RewriteResult, type Span } from "../lib/editorial-checks";
|
|
9
|
+
|
|
10
|
+
export type RewriteTarget = { finding: EditorialFinding } | { selection: Span };
|
|
11
|
+
export type ApplyRewrite = (option: { span: Span; replacement: string; expected: string }, finding: EditorialFinding | null) => void;
|
|
12
|
+
|
|
13
|
+
export type FindingActions = {
|
|
14
|
+
onSelect?: (finding: EditorialFinding) => void;
|
|
15
|
+
onDismiss: (finding: EditorialFinding, reason: string) => void;
|
|
16
|
+
onAccept: (finding: EditorialFinding) => void;
|
|
17
|
+
onSuggest: (target: RewriteTarget, note: string | null) => Promise<RewriteResult>;
|
|
18
|
+
onApply: ApplyRewrite;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type SuggestionState =
|
|
22
|
+
| { state: "loading" }
|
|
23
|
+
| { state: "ready"; result: RewriteResult }
|
|
24
|
+
| { state: "error"; message: string };
|
|
25
|
+
|
|
26
|
+
/** Ask for rewrite options for a target and let the editor pick one. Shared by findings and selections. */
|
|
27
|
+
export function useRewriteOptions(actions: Pick<FindingActions, "onSuggest">) {
|
|
28
|
+
const [suggestion, setSuggestion] = useState<SuggestionState | null>(null);
|
|
29
|
+
const request = async (target: RewriteTarget, note: string | null) => {
|
|
30
|
+
setSuggestion({ state: "loading" });
|
|
31
|
+
try {
|
|
32
|
+
setSuggestion({ state: "ready", result: await actions.onSuggest(target, note) });
|
|
33
|
+
} catch (error) {
|
|
34
|
+
setSuggestion({ state: "error", message: error instanceof Error ? error.message : "Could not get rewrite options." });
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
return { suggestion, request, clear: () => setSuggestion(null) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function RewriteOptions({
|
|
41
|
+
suggestion,
|
|
42
|
+
finding,
|
|
43
|
+
onApply,
|
|
44
|
+
}: {
|
|
45
|
+
suggestion: SuggestionState | null;
|
|
46
|
+
finding: EditorialFinding | null;
|
|
47
|
+
onApply: ApplyRewrite;
|
|
48
|
+
}) {
|
|
49
|
+
if (!suggestion) return null;
|
|
50
|
+
if (suggestion.state === "loading") {
|
|
51
|
+
return <p className="flex items-center gap-2 text-muted-foreground" data-editorial-rewrite-loading><Spinner /> Asking for rewrite options…</p>;
|
|
52
|
+
}
|
|
53
|
+
if (suggestion.state === "error") return <p className="text-destructive" role="alert">{suggestion.message}</p>;
|
|
54
|
+
const { result } = suggestion;
|
|
55
|
+
if (result.options.length === 0) return <p className="text-muted-foreground">No usable options this time. Try again or add a note.</p>;
|
|
56
|
+
return (
|
|
57
|
+
<div className="space-y-2" data-editorial-rewrite-options>
|
|
58
|
+
<p className="text-xs text-muted-foreground" data-editorial-rewrite-target>Rewriting: “{result.target.excerpt}”</p>
|
|
59
|
+
<ol className="space-y-2">
|
|
60
|
+
{result.options.map((option, index) => (
|
|
61
|
+
<li className="space-y-1 rounded-md bg-muted/40 p-2" data-editorial-rewrite-option={index} key={option.id}>
|
|
62
|
+
<p>{option.replacement}</p>
|
|
63
|
+
<p className="text-xs text-muted-foreground">{option.rationale}</p>
|
|
64
|
+
{option.factVerificationRequired ? <p className="text-xs text-destructive">Changes a factual claim: verify before using.</p> : null}
|
|
65
|
+
{option.unresolvedQuestions.length ? (
|
|
66
|
+
<ul className="list-disc pl-4 text-xs text-muted-foreground">
|
|
67
|
+
{option.unresolvedQuestions.map((question) => <li key={question}>{question}</li>)}
|
|
68
|
+
</ul>
|
|
69
|
+
) : null}
|
|
70
|
+
<Button
|
|
71
|
+
data-editorial-use-option
|
|
72
|
+
onClick={() => onApply({ span: option.span, replacement: option.replacement, expected: result.target.excerpt }, finding)}
|
|
73
|
+
size="xs"
|
|
74
|
+
type="button"
|
|
75
|
+
>
|
|
76
|
+
Use this
|
|
77
|
+
</Button>
|
|
78
|
+
</li>
|
|
79
|
+
))}
|
|
80
|
+
</ol>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One finding: what is wrong, how sure, and what the editor can do about it. */
|
|
86
|
+
export function EditorialFindingCard({
|
|
87
|
+
finding,
|
|
88
|
+
active = false,
|
|
89
|
+
note,
|
|
90
|
+
actions,
|
|
91
|
+
}: {
|
|
92
|
+
finding: EditorialFinding;
|
|
93
|
+
active?: boolean;
|
|
94
|
+
note: string;
|
|
95
|
+
actions: FindingActions;
|
|
96
|
+
}) {
|
|
97
|
+
const [dismissing, setDismissing] = useState<string | null>(null);
|
|
98
|
+
const rewrites = useRewriteOptions(actions);
|
|
99
|
+
return (
|
|
100
|
+
<div
|
|
101
|
+
className="space-y-1 rounded-lg border border-border p-2 data-[active=true]:border-ring"
|
|
102
|
+
data-active={active}
|
|
103
|
+
data-editorial-finding-item={finding.id}
|
|
104
|
+
>
|
|
105
|
+
<button className="block w-full text-left" onClick={() => actions.onSelect?.(finding)} type="button">
|
|
106
|
+
<span className="block">{finding.message}</span>
|
|
107
|
+
{finding.excerpt ? <q className="block truncate text-muted-foreground">{finding.excerptText ?? finding.excerpt}</q> : null}
|
|
108
|
+
<span className="text-xs text-muted-foreground">
|
|
109
|
+
{finding.status === "review" ? "Needs your judgment · " : ""}
|
|
110
|
+
{describeProbability(finding) ?? (finding.lane === "rules" ? "Rule" : "")}
|
|
111
|
+
</span>
|
|
112
|
+
</button>
|
|
113
|
+
<div className="flex flex-wrap gap-1">
|
|
114
|
+
{finding.replacement !== undefined ? (
|
|
115
|
+
<Button
|
|
116
|
+
data-editorial-use-replacement={finding.replacement}
|
|
117
|
+
onClick={() => actions.onApply({ span: finding.span, replacement: finding.replacement ?? "", expected: finding.excerpt }, finding)}
|
|
118
|
+
size="xs"
|
|
119
|
+
type="button"
|
|
120
|
+
variant="outline"
|
|
121
|
+
>
|
|
122
|
+
<CheckIcon aria-hidden="true" /> {finding.replacement ? `Use “${finding.replacement}”` : "Remove"}
|
|
123
|
+
</Button>
|
|
124
|
+
) : null}
|
|
125
|
+
{finding.excerpt ? (
|
|
126
|
+
<Button data-editorial-suggest onClick={() => void rewrites.request({ finding }, note.trim() || null)} size="xs" type="button" variant="outline">
|
|
127
|
+
<SparklesIcon aria-hidden="true" /> Suggest rewrites
|
|
128
|
+
</Button>
|
|
129
|
+
) : null}
|
|
130
|
+
{finding.status === "review" ? (
|
|
131
|
+
<Button onClick={() => actions.onAccept(finding)} size="xs" type="button" variant="ghost">
|
|
132
|
+
It's a problem
|
|
133
|
+
</Button>
|
|
134
|
+
) : null}
|
|
135
|
+
<Button data-editorial-dismiss onClick={() => setDismissing("")} size="xs" type="button" variant="ghost">
|
|
136
|
+
<XIcon aria-hidden="true" /> Dismiss
|
|
137
|
+
</Button>
|
|
138
|
+
</div>
|
|
139
|
+
{dismissing !== null ? (
|
|
140
|
+
<form
|
|
141
|
+
className="flex gap-1"
|
|
142
|
+
onSubmit={(event) => {
|
|
143
|
+
event.preventDefault();
|
|
144
|
+
actions.onDismiss(finding, dismissing);
|
|
145
|
+
setDismissing(null);
|
|
146
|
+
}}
|
|
147
|
+
>
|
|
148
|
+
<Input
|
|
149
|
+
aria-label="Why dismiss?"
|
|
150
|
+
autoFocus
|
|
151
|
+
className="h-8"
|
|
152
|
+
onChange={(event) => setDismissing(event.target.value)}
|
|
153
|
+
placeholder="Why? (optional, helps tune this policy)"
|
|
154
|
+
value={dismissing}
|
|
155
|
+
/>
|
|
156
|
+
<Button data-editorial-confirm-dismiss size="xs" type="submit">Dismiss</Button>
|
|
157
|
+
</form>
|
|
158
|
+
) : null}
|
|
159
|
+
<RewriteOptions finding={finding} onApply={actions.onApply} suggestion={rewrites.suggestion} />
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { SparklesIcon } from "lucide-react";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { Badge } from "./ui/badge";
|
|
6
|
+
import { Button } from "./ui/button";
|
|
7
|
+
import { Input } from "./ui/input";
|
|
8
|
+
import { Spinner } from "./ui/spinner";
|
|
9
|
+
import { groupByPolicy, laneNotice, visibleFindings, type EditorialCheckResult, type Span } from "../lib/editorial-checks";
|
|
10
|
+
import { EditorialFindingCard, RewriteOptions, useRewriteOptions, type FindingActions } from "./editorial-finding-card";
|
|
11
|
+
|
|
12
|
+
export type CheckStatus = { state: "idle" } | { state: "checking" } | { state: "error"; message: string };
|
|
13
|
+
|
|
14
|
+
type Props = FindingActions & {
|
|
15
|
+
result: EditorialCheckResult | null;
|
|
16
|
+
status: CheckStatus;
|
|
17
|
+
dismissed: ReadonlySet<string>;
|
|
18
|
+
activeFindingId: string | null;
|
|
19
|
+
selection: Span;
|
|
20
|
+
onCheck: () => void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const SEVERITY_LABEL = { error: "Must fix", warn: "Should fix", info: "Consider" } as const;
|
|
24
|
+
const SEVERITY_VARIANT = { error: "default", warn: "secondary", info: "muted" } as const;
|
|
25
|
+
|
|
26
|
+
export function EditorialFindingsPanel(props: Props) {
|
|
27
|
+
const { result, status, dismissed, activeFindingId, selection } = props;
|
|
28
|
+
const [note, setNote] = useState("");
|
|
29
|
+
const selectionRewrites = useRewriteOptions(props);
|
|
30
|
+
const findings = result ? visibleFindings(result.findings, dismissed) : [];
|
|
31
|
+
const groups = groupByPolicy(findings);
|
|
32
|
+
const notice = result ? laneNotice(result) : null;
|
|
33
|
+
const hasSelection = selection.end > selection.start;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<section aria-label="Editorial checks" className="space-y-3 rounded-xl border border-border bg-card p-3 text-sm" data-editorial-findings>
|
|
37
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
38
|
+
<h3 className="font-medium">Editorial checks</h3>
|
|
39
|
+
{status.state === "checking" ? <Spinner aria-label="Checking" /> : null}
|
|
40
|
+
<span className="text-muted-foreground" data-editorial-findings-count>
|
|
41
|
+
{result ? `${findings.length} open` : "Not checked yet"}
|
|
42
|
+
</span>
|
|
43
|
+
<Button className="ml-auto" data-editorial-check-now disabled={status.state === "checking"} onClick={props.onCheck} size="xs" type="button" variant="outline">
|
|
44
|
+
Check now
|
|
45
|
+
</Button>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
{status.state === "error" ? <p className="text-destructive" role="alert">{status.message}</p> : null}
|
|
49
|
+
{notice ? <p className="text-muted-foreground" data-editorial-lane-notice>{notice}</p> : null}
|
|
50
|
+
|
|
51
|
+
<div className="flex flex-wrap items-center gap-2" data-editorial-rewrite-tools>
|
|
52
|
+
<Input
|
|
53
|
+
aria-label="Note for rewrites"
|
|
54
|
+
className="h-8 max-w-xs flex-1"
|
|
55
|
+
onChange={(event) => setNote(event.target.value)}
|
|
56
|
+
placeholder="Note for rewrites, e.g. shorter"
|
|
57
|
+
value={note}
|
|
58
|
+
/>
|
|
59
|
+
<Button
|
|
60
|
+
disabled={!hasSelection}
|
|
61
|
+
onClick={() => void selectionRewrites.request({ selection }, note.trim() || null)}
|
|
62
|
+
size="xs"
|
|
63
|
+
title={hasSelection ? "Get rewrite options for the selected text" : "Select text in the body first"}
|
|
64
|
+
type="button"
|
|
65
|
+
variant="outline"
|
|
66
|
+
>
|
|
67
|
+
<SparklesIcon aria-hidden="true" /> Rewrite selection
|
|
68
|
+
</Button>
|
|
69
|
+
</div>
|
|
70
|
+
<RewriteOptions finding={null} onApply={props.onApply} suggestion={selectionRewrites.suggestion} />
|
|
71
|
+
|
|
72
|
+
{result && findings.length === 0 ? <p className="text-muted-foreground">No open findings.</p> : null}
|
|
73
|
+
{groups.map((group) => (
|
|
74
|
+
<div className="space-y-2" data-editorial-policy={group.policyId} key={group.policyId}>
|
|
75
|
+
<div className="flex items-center gap-2">
|
|
76
|
+
<Badge variant={SEVERITY_VARIANT[group.severity]}>{SEVERITY_LABEL[group.severity]}</Badge>
|
|
77
|
+
<strong>{group.policyId}</strong>
|
|
78
|
+
<span className="text-muted-foreground">{group.findings.length}</span>
|
|
79
|
+
</div>
|
|
80
|
+
<ul className="space-y-2">
|
|
81
|
+
{group.findings.map((finding) => (
|
|
82
|
+
<li key={finding.id}>
|
|
83
|
+
<EditorialFindingCard actions={props} active={finding.id === activeFindingId} finding={finding} note={note} />
|
|
84
|
+
</li>
|
|
85
|
+
))}
|
|
86
|
+
</ul>
|
|
87
|
+
</div>
|
|
88
|
+
))}
|
|
89
|
+
</section>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -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
|
+
});
|