mosage 0.2.0 → 0.8.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.
- package/README.md +16 -15
- package/bin.js +2 -0
- package/dist/build-C7NW_3Pk.js +14 -0
- package/dist/check-CP4873Wx.js +41 -0
- package/dist/cli/bin.d.ts +1 -0
- package/dist/cli/bin.js +228 -0
- package/dist/config-DPm1BBAb.js +2619 -0
- package/dist/config-TlTe7Ona.d.ts +24 -0
- package/dist/context-BqsdSrAQ.js +1084 -0
- package/dist/dev-Biz42qlu.js +17 -0
- package/dist/diagram-xlVDekYk.js +763 -0
- package/dist/export-Bi6nuxjT.js +31 -0
- package/dist/import-D2jNB07F.js +25 -0
- package/dist/index.d.ts +455 -0
- package/dist/index.js +693 -0
- package/dist/{cli.js → init-Bbtj2pxF.js} +34 -68
- package/dist/preview-CLm51aRt.js +19 -0
- package/dist/sdk-DjpX6mCv.js +51 -0
- package/dist/vite/index.d.ts +25 -0
- package/dist/vite/index.js +2 -0
- package/env.d.ts +83 -0
- package/package.json +59 -12
- package/{template/.agents/skills → skills}/create-doc/SKILL.md +1 -1
- package/{template/.agents/skills → skills}/create-theme/SKILL.md +1 -1
- package/{template/.agents/skills → skills}/doc-authoring/SKILL.md +8 -9
- package/{template/.agents/skills → skills}/doc-authoring/references/assets.md +1 -1
- package/{template/.agents/skills → skills}/doc-authoring/references/design-system.md +1 -1
- package/{template/.agents/skills → skills}/doc-authoring/references/long-form.md +4 -4
- package/{template/.agents/skills → skills}/doc-authoring/references/pagination.md +1 -1
- package/src/app/app.tsx +42 -0
- package/src/app/components/data-table.tsx +196 -0
- package/src/app/components/design-panel/design-panel.tsx +318 -0
- package/src/app/components/design-panel/design-provider.tsx +121 -0
- package/src/app/components/design-panel/use-design.ts +85 -0
- package/src/app/components/diagram.tsx +76 -0
- package/src/app/components/doc-assets.tsx +129 -0
- package/src/app/components/doc-search.tsx +248 -0
- package/src/app/components/doc-sidebar.tsx +162 -0
- package/src/app/components/flow-page.tsx +93 -0
- package/src/app/components/footnote.tsx +204 -0
- package/src/app/components/image-placeholder.tsx +50 -0
- package/src/app/components/inspector/inspector.tsx +518 -0
- package/src/app/components/numbering.tsx +224 -0
- package/src/app/components/page-frame.tsx +70 -0
- package/src/app/components/sidebar/folder-item.tsx +212 -0
- package/src/app/components/sidebar/icon-picker.tsx +99 -0
- package/src/app/components/sidebar/sidebar.tsx +252 -0
- package/src/app/components/table-of-contents.tsx +93 -0
- package/src/app/components/theme-toggle.tsx +50 -0
- package/src/app/components/themes/markdown.tsx +249 -0
- package/src/app/components/themes/theme-preview.tsx +74 -0
- package/src/app/components/ui/menu.tsx +143 -0
- package/src/app/index.html +12 -0
- package/src/app/lib/agent-bridge.ts +140 -0
- package/src/app/lib/assets.ts +151 -0
- package/src/app/lib/design-presets.ts +109 -0
- package/src/app/lib/design.ts +88 -0
- package/src/app/lib/diagnostics.ts +282 -0
- package/src/app/lib/doc-preview.tsx +29 -0
- package/src/app/lib/docs.ts +26 -0
- package/src/app/lib/docx/extract.ts +1623 -0
- package/src/app/lib/docx/fonts.test.ts +136 -0
- package/src/app/lib/docx/fonts.ts +166 -0
- package/src/app/lib/docx/media.ts +102 -0
- package/src/app/lib/docx/model.ts +206 -0
- package/src/app/lib/docx/paragraph.test.ts +92 -0
- package/src/app/lib/docx/paragraph.ts +107 -0
- package/src/app/lib/docx/props.ts +187 -0
- package/src/app/lib/docx/styles.ts +306 -0
- package/src/app/lib/docx/units.ts +35 -0
- package/src/app/lib/docx/write.test.ts +507 -0
- package/src/app/lib/docx/write.ts +581 -0
- package/src/app/lib/docx/xml.ts +39 -0
- package/src/app/lib/export-docx.ts +289 -0
- package/src/app/lib/export-dom.ts +318 -0
- package/src/app/lib/export-html.ts +156 -0
- package/src/app/lib/export-image.ts +70 -0
- package/src/app/lib/export-pdf.ts +165 -0
- package/src/app/lib/flow-measure.test.ts +31 -0
- package/src/app/lib/flow-measure.ts +183 -0
- package/src/app/lib/flow.test.ts +110 -0
- package/src/app/lib/flow.ts +136 -0
- package/src/app/lib/folders.ts +192 -0
- package/src/app/lib/footnotes.test.tsx +102 -0
- package/src/app/lib/footnotes.ts +94 -0
- package/src/app/lib/inspector/fiber.ts +99 -0
- package/src/app/lib/labels.test.ts +18 -0
- package/src/app/lib/labels.ts +181 -0
- package/src/app/lib/outline.ts +118 -0
- package/src/app/lib/page-context.tsx +43 -0
- package/src/app/lib/page-range.test.ts +95 -0
- package/src/app/lib/page-range.ts +90 -0
- package/src/app/lib/print-ready.ts +69 -0
- package/src/app/lib/rasterize.ts +173 -0
- package/src/app/lib/scan.ts +26 -0
- package/src/app/lib/sdk.test.ts +32 -0
- package/src/app/lib/sdk.ts +115 -0
- package/src/app/lib/themes.ts +31 -0
- package/src/app/lib/use-doc-module.ts +53 -0
- package/src/app/lib/use-doc-pages.ts +147 -0
- package/src/app/lib/utils.ts +6 -0
- package/src/app/lib/view-mode.test.ts +91 -0
- package/src/app/lib/view-mode.ts +104 -0
- package/src/app/main.tsx +14 -0
- package/src/app/routes/assets.tsx +257 -0
- package/src/app/routes/doc.tsx +877 -0
- package/src/app/routes/home-shell.tsx +203 -0
- package/src/app/routes/home.tsx +269 -0
- package/src/app/routes/themes.tsx +121 -0
- package/src/app/styles.css +97 -0
- package/src/app/virtual.d.ts +30 -0
- package/template/AGENTS.md +1 -1
- package/template/README.md +24 -52
- package/template/docs/getting-started/index.tsx +2 -2
- package/template/mosage.config.ts +1 -1
- package/template/package.json +1 -1
- package/template/tsconfig.json +1 -1
- package/README.zh-TW.md +0 -28
- /package/{template/.agents/skills → skills}/apply-comments/SKILL.md +0 -0
- /package/{template/.agents/skills → skills}/current-doc/SKILL.md +0 -0
- /package/{template/.agents/skills → skills}/doc-authoring/references/tables-and-charts.md +0 -0
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import { Check, Loader2, MessageSquarePlus, X } from 'lucide-react';
|
|
2
|
+
import {
|
|
3
|
+
type CSSProperties,
|
|
4
|
+
Fragment,
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useLayoutEffect,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from 'react';
|
|
11
|
+
import { createPortal } from 'react-dom';
|
|
12
|
+
import { candidateLocs, formatLocs } from '../../lib/inspector/fiber';
|
|
13
|
+
import { TOC_ENTRY_ATTR } from '../table-of-contents';
|
|
14
|
+
|
|
15
|
+
type TextPart = { kind: 'text'; index: number; value: string } | { kind: 'markup'; label: string };
|
|
16
|
+
|
|
17
|
+
type ResolvedTarget = {
|
|
18
|
+
line: number;
|
|
19
|
+
column: number;
|
|
20
|
+
editable: boolean;
|
|
21
|
+
text: string;
|
|
22
|
+
parts: TextPart[];
|
|
23
|
+
reason?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const LOC_ATTR = 'data-od-loc';
|
|
27
|
+
|
|
28
|
+
export type InspectorTarget = {
|
|
29
|
+
line: number;
|
|
30
|
+
column: number;
|
|
31
|
+
anchor: HTMLElement;
|
|
32
|
+
tag: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Same visual language as open-slide's inspector: dashed on hover, solid on
|
|
36
|
+
// selection, both in the same blue.
|
|
37
|
+
const FRAME_STYLE: Record<'hover' | 'selected', CSSProperties> = {
|
|
38
|
+
hover: { outline: '1.5px dashed #3b82f6', background: 'rgba(59,130,246,0.05)' },
|
|
39
|
+
selected: { outline: '2px solid #3b82f6', background: 'rgba(59,130,246,0.1)' },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function normalize(value: string): string {
|
|
43
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function targetFrom(el: Element | null): InspectorTarget | null {
|
|
47
|
+
// A contents row is generated from a heading and written nowhere; the words
|
|
48
|
+
// to edit are the heading's. Clicking the row selects that instead, so the
|
|
49
|
+
// list stays what it is — a view — and still answers to a click.
|
|
50
|
+
const entry = el?.closest?.(`[${TOC_ENTRY_ATTR}]`)?.getAttribute(TOC_ENTRY_ATTR);
|
|
51
|
+
const heading = entry ? document.getElementById(entry) : null;
|
|
52
|
+
const from = heading ?? el;
|
|
53
|
+
const host = (from as HTMLElement | null)?.closest?.(`[${LOC_ATTR}]`) as HTMLElement | null;
|
|
54
|
+
const raw = host?.getAttribute(LOC_ATTR);
|
|
55
|
+
if (!host || !raw) return null;
|
|
56
|
+
const [line, column] = raw.split(':').map(Number);
|
|
57
|
+
if (!Number.isFinite(line) || !Number.isFinite(column)) return null;
|
|
58
|
+
return { line, column, anchor: host, tag: host.tagName.toLowerCase() };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type Rect = { left: number; top: number; width: number; height: number };
|
|
62
|
+
|
|
63
|
+
function sameRect(a: Rect | null, b: Rect) {
|
|
64
|
+
return (
|
|
65
|
+
a !== null &&
|
|
66
|
+
a.left === b.left &&
|
|
67
|
+
a.top === b.top &&
|
|
68
|
+
a.width === b.width &&
|
|
69
|
+
a.height === b.height
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function Frame({
|
|
74
|
+
anchor,
|
|
75
|
+
container,
|
|
76
|
+
variant,
|
|
77
|
+
}: {
|
|
78
|
+
anchor: HTMLElement | null;
|
|
79
|
+
container: HTMLElement;
|
|
80
|
+
variant: 'hover' | 'selected';
|
|
81
|
+
}) {
|
|
82
|
+
const [rect, setRect] = useState<Rect | null>(null);
|
|
83
|
+
|
|
84
|
+
// Deliberately no dependency array. Opening a side pane re-zooms and re-centres
|
|
85
|
+
// the pages, which moves the anchor without resizing it — ResizeObserver never
|
|
86
|
+
// sees that, and the zoom lands a frame after the observer would have fired.
|
|
87
|
+
// Measuring after every render is what keeps the frame on its element; the
|
|
88
|
+
// value comparison below stops that from looping.
|
|
89
|
+
useLayoutEffect(() => {
|
|
90
|
+
if (!anchor?.isConnected) {
|
|
91
|
+
setRect(null);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// The overlay is absolutely positioned inside the scroller, so its origin
|
|
95
|
+
// is the content box — frames live in content coordinates and scroll along
|
|
96
|
+
// with the pages.
|
|
97
|
+
const measure = () => {
|
|
98
|
+
const a = anchor.getBoundingClientRect();
|
|
99
|
+
const c = container.getBoundingClientRect();
|
|
100
|
+
const next = {
|
|
101
|
+
left: a.left - c.left + container.scrollLeft,
|
|
102
|
+
top: a.top - c.top + container.scrollTop,
|
|
103
|
+
width: a.width,
|
|
104
|
+
height: a.height,
|
|
105
|
+
};
|
|
106
|
+
setRect((prev) => (sameRect(prev, next) ? prev : next));
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
measure();
|
|
110
|
+
const ro = new ResizeObserver(measure);
|
|
111
|
+
ro.observe(anchor);
|
|
112
|
+
ro.observe(container);
|
|
113
|
+
container.addEventListener('scroll', measure, { passive: true });
|
|
114
|
+
return () => {
|
|
115
|
+
ro.disconnect();
|
|
116
|
+
container.removeEventListener('scroll', measure);
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (!rect) return null;
|
|
121
|
+
return (
|
|
122
|
+
<div
|
|
123
|
+
aria-hidden
|
|
124
|
+
className="pointer-events-none absolute rounded-[2px]"
|
|
125
|
+
style={{ ...rect, ...FRAME_STYLE[variant] }}
|
|
126
|
+
/>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
type Props = {
|
|
131
|
+
docId: string;
|
|
132
|
+
/** The scroll container the pages live in. */
|
|
133
|
+
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
134
|
+
onExit: () => void;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export function Inspector({ docId, containerRef, onExit }: Props) {
|
|
138
|
+
const [container, setContainer] = useState<HTMLElement | null>(null);
|
|
139
|
+
const [hover, setHover] = useState<InspectorTarget | null>(null);
|
|
140
|
+
const [selected, setSelected] = useState<InspectorTarget | null>(null);
|
|
141
|
+
const [target, setTarget] = useState<ResolvedTarget | null>(null);
|
|
142
|
+
const [drafts, setDrafts] = useState<Record<number, string>>({});
|
|
143
|
+
const [note, setNote] = useState('');
|
|
144
|
+
const [busy, setBusy] = useState(false);
|
|
145
|
+
const [status, setStatus] = useState<string | null>(null);
|
|
146
|
+
const panelRef = useRef<HTMLElement>(null);
|
|
147
|
+
const editorRef = useRef<HTMLDivElement>(null);
|
|
148
|
+
|
|
149
|
+
useLayoutEffect(() => setContainer(containerRef.current), [containerRef]);
|
|
150
|
+
|
|
151
|
+
const insidePanel = useCallback(
|
|
152
|
+
(node: EventTarget | null) => panelRef.current?.contains(node as Node) ?? false,
|
|
153
|
+
[],
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
if (!container) return;
|
|
158
|
+
|
|
159
|
+
const onMove = (e: PointerEvent) => {
|
|
160
|
+
if (insidePanel(e.target)) return;
|
|
161
|
+
setHover(targetFrom(e.target as Element));
|
|
162
|
+
};
|
|
163
|
+
const onClick = (e: MouseEvent) => {
|
|
164
|
+
if (insidePanel(e.target)) return;
|
|
165
|
+
const target = targetFrom(e.target as Element);
|
|
166
|
+
if (!target) return;
|
|
167
|
+
e.preventDefault();
|
|
168
|
+
e.stopPropagation();
|
|
169
|
+
// A contents row sends the selection to a heading pages away. Without
|
|
170
|
+
// this the panel fills in and the document sits where it was, which
|
|
171
|
+
// reads as nothing having happened.
|
|
172
|
+
if (!target.anchor.contains(e.target as Node)) {
|
|
173
|
+
target.anchor.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
174
|
+
}
|
|
175
|
+
setSelected(target);
|
|
176
|
+
setStatus(null);
|
|
177
|
+
setNote('');
|
|
178
|
+
};
|
|
179
|
+
const onKey = (e: KeyboardEvent) => {
|
|
180
|
+
if (e.key !== 'Escape') return;
|
|
181
|
+
if (selected) {
|
|
182
|
+
setSelected(null);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
onExit();
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
container.addEventListener('pointermove', onMove, true);
|
|
189
|
+
container.addEventListener('click', onClick, true);
|
|
190
|
+
window.addEventListener('keydown', onKey, true);
|
|
191
|
+
return () => {
|
|
192
|
+
container.removeEventListener('pointermove', onMove, true);
|
|
193
|
+
container.removeEventListener('click', onClick, true);
|
|
194
|
+
window.removeEventListener('keydown', onKey, true);
|
|
195
|
+
};
|
|
196
|
+
}, [container, insidePanel, onExit, selected]);
|
|
197
|
+
|
|
198
|
+
// Report the pick to the dev server so `current.json` can answer "this
|
|
199
|
+
// element" for an agent. Clearing the selection clears it there too.
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
if (!import.meta.hot) return;
|
|
202
|
+
import.meta.hot.send('mosage:current', {
|
|
203
|
+
selection: selected
|
|
204
|
+
? {
|
|
205
|
+
line: selected.line,
|
|
206
|
+
column: selected.column,
|
|
207
|
+
tagName: selected.anchor.tagName.toLowerCase(),
|
|
208
|
+
text: normalize(selected.anchor.textContent ?? '').slice(0, 120),
|
|
209
|
+
}
|
|
210
|
+
: null,
|
|
211
|
+
});
|
|
212
|
+
}, [selected]);
|
|
213
|
+
|
|
214
|
+
// Read the element's text from source, not from the DOM: only source can say
|
|
215
|
+
// whether it is a single editable text child or markup we must not touch.
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
if (!selected) {
|
|
218
|
+
setTarget(null);
|
|
219
|
+
setDrafts({});
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
let cancelled = false;
|
|
223
|
+
const params = new URLSearchParams({
|
|
224
|
+
docId,
|
|
225
|
+
locs: formatLocs(candidateLocs(selected.anchor)),
|
|
226
|
+
shown: normalize(selected.anchor.textContent ?? '').slice(0, 400),
|
|
227
|
+
});
|
|
228
|
+
fetch(`/__edit/text?${params}`)
|
|
229
|
+
.then((res) => res.json())
|
|
230
|
+
.then((body: ResolvedTarget & { error?: string }) => {
|
|
231
|
+
if (cancelled) return;
|
|
232
|
+
if (body.error) {
|
|
233
|
+
setTarget(null);
|
|
234
|
+
setStatus(body.error);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
// Last line of defence: every run the source offers has to be visible
|
|
238
|
+
// in the element the user clicked. A drifted resolution would
|
|
239
|
+
// otherwise let a save rewrite someone else's words.
|
|
240
|
+
const shownText = normalize(selected.anchor.textContent ?? '');
|
|
241
|
+
const runs = (body.parts ?? []).filter((part) => part.kind === 'text');
|
|
242
|
+
const belongs =
|
|
243
|
+
runs.length > 0 &&
|
|
244
|
+
runs.every((part) => part.kind === 'text' && shownText.includes(normalize(part.value)));
|
|
245
|
+
setTarget(
|
|
246
|
+
body.editable && !belongs
|
|
247
|
+
? { ...body, editable: false, reason: 'source text does not match this element' }
|
|
248
|
+
: body,
|
|
249
|
+
);
|
|
250
|
+
setDrafts({});
|
|
251
|
+
})
|
|
252
|
+
.catch(() => {
|
|
253
|
+
if (!cancelled) setStatus('could not read source');
|
|
254
|
+
});
|
|
255
|
+
return () => {
|
|
256
|
+
cancelled = true;
|
|
257
|
+
};
|
|
258
|
+
}, [selected, docId]);
|
|
259
|
+
|
|
260
|
+
const textParts = (target?.parts ?? []).filter(
|
|
261
|
+
(part): part is Extract<TextPart, { kind: 'text' }> => part.kind === 'text',
|
|
262
|
+
);
|
|
263
|
+
const dirty = textParts.filter(
|
|
264
|
+
(part) => drafts[part.index] !== undefined && drafts[part.index] !== part.value,
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const saveText = async () => {
|
|
268
|
+
if (!target || !selected || dirty.length === 0 || busy) return;
|
|
269
|
+
setBusy(true);
|
|
270
|
+
try {
|
|
271
|
+
for (const part of dirty) {
|
|
272
|
+
const res = await fetch('/__edit/text', {
|
|
273
|
+
method: 'PUT',
|
|
274
|
+
headers: { 'content-type': 'application/json' },
|
|
275
|
+
body: JSON.stringify({
|
|
276
|
+
docId,
|
|
277
|
+
line: target.line,
|
|
278
|
+
column: target.column,
|
|
279
|
+
index: part.index,
|
|
280
|
+
expected: part.value,
|
|
281
|
+
shown: normalize(selected.anchor.textContent ?? ''),
|
|
282
|
+
text: drafts[part.index],
|
|
283
|
+
}),
|
|
284
|
+
});
|
|
285
|
+
const body = (await res.json()) as { ok?: boolean; error?: string };
|
|
286
|
+
if (!res.ok || !body.ok) {
|
|
287
|
+
setStatus(body.error ?? 'Save failed');
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
setStatus('Saved to source');
|
|
292
|
+
// Editing shifts what follows, so the captured location is good for one
|
|
293
|
+
// write. Reselect to edit again.
|
|
294
|
+
setSelected(null);
|
|
295
|
+
} finally {
|
|
296
|
+
setBusy(false);
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const saveComment = async () => {
|
|
301
|
+
if (!selected || note.trim() === '' || busy) return;
|
|
302
|
+
setBusy(true);
|
|
303
|
+
try {
|
|
304
|
+
const res = await fetch('/__edit/comment', {
|
|
305
|
+
method: 'POST',
|
|
306
|
+
headers: { 'content-type': 'application/json' },
|
|
307
|
+
body: JSON.stringify({
|
|
308
|
+
docId,
|
|
309
|
+
line: target?.line ?? selected.line,
|
|
310
|
+
column: target?.column ?? selected.column,
|
|
311
|
+
note,
|
|
312
|
+
hint: selected.tag,
|
|
313
|
+
}),
|
|
314
|
+
});
|
|
315
|
+
const body = (await res.json()) as { ok?: boolean; error?: string };
|
|
316
|
+
if (res.ok && body.ok) {
|
|
317
|
+
setNote('');
|
|
318
|
+
setStatus('Marked in source — run /apply-comments');
|
|
319
|
+
} else {
|
|
320
|
+
setStatus(body.error ?? 'Could not add comment');
|
|
321
|
+
}
|
|
322
|
+
} finally {
|
|
323
|
+
setBusy(false);
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const overlay = container
|
|
328
|
+
? createPortal(
|
|
329
|
+
<>
|
|
330
|
+
<div className="pointer-events-none absolute inset-0 z-30">
|
|
331
|
+
<Frame
|
|
332
|
+
anchor={hover?.anchor === selected?.anchor ? null : (hover?.anchor ?? null)}
|
|
333
|
+
container={container}
|
|
334
|
+
variant="hover"
|
|
335
|
+
/>
|
|
336
|
+
<Frame anchor={selected?.anchor ?? null} container={container} variant="selected" />
|
|
337
|
+
</div>
|
|
338
|
+
<div className="pointer-events-none sticky bottom-3 z-40 mx-auto w-fit rounded-full bg-foreground/90 px-3 py-1.5 text-[11px] text-background">
|
|
339
|
+
{selected ? 'Esc to deselect' : 'Click an element · Esc to exit'}
|
|
340
|
+
</div>
|
|
341
|
+
</>,
|
|
342
|
+
container,
|
|
343
|
+
)
|
|
344
|
+
: null;
|
|
345
|
+
|
|
346
|
+
return (
|
|
347
|
+
<>
|
|
348
|
+
{overlay}
|
|
349
|
+
{selected && (
|
|
350
|
+
<aside
|
|
351
|
+
ref={panelRef}
|
|
352
|
+
className="flex w-72 flex-none flex-col border-border border-l bg-background"
|
|
353
|
+
>
|
|
354
|
+
<header className="flex h-10 flex-none items-center justify-between border-border border-b px-3">
|
|
355
|
+
<span className="font-mono text-[11px] text-muted-foreground">
|
|
356
|
+
<{selected.tag}> · {target?.line ?? selected.line}:
|
|
357
|
+
{target?.column ?? selected.column}
|
|
358
|
+
</span>
|
|
359
|
+
<button
|
|
360
|
+
type="button"
|
|
361
|
+
aria-label="Close"
|
|
362
|
+
onClick={() => setSelected(null)}
|
|
363
|
+
className="flex size-5 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
364
|
+
>
|
|
365
|
+
<X className="size-3.5" />
|
|
366
|
+
</button>
|
|
367
|
+
</header>
|
|
368
|
+
|
|
369
|
+
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
|
370
|
+
<span className="block text-[10px] text-muted-foreground uppercase tracking-wider">
|
|
371
|
+
Text
|
|
372
|
+
</span>
|
|
373
|
+
{target === null ? (
|
|
374
|
+
<div className="grid h-16 place-items-center">
|
|
375
|
+
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
|
376
|
+
</div>
|
|
377
|
+
) : target.editable ? (
|
|
378
|
+
<>
|
|
379
|
+
{/*
|
|
380
|
+
* One field, not one per run. A sentence interrupted by five
|
|
381
|
+
* <code> spans is still one sentence, and bordering each
|
|
382
|
+
* fragment turned a paragraph into eleven stacked boxes.
|
|
383
|
+
*
|
|
384
|
+
* The runs are laid out as the sentence reads, with the markup
|
|
385
|
+
* between them as inert chips. Nothing re-renders while typing:
|
|
386
|
+
* the children never change, so React leaves the DOM — and the
|
|
387
|
+
* caret — alone, and the text is read back out of it.
|
|
388
|
+
*/}
|
|
389
|
+
{target.parts.length > 1 ? (
|
|
390
|
+
// biome-ignore lint/a11y/useSemanticElements: a textarea cannot hold the inert markup chips
|
|
391
|
+
<div
|
|
392
|
+
key={`${target.line}:${target.column}`}
|
|
393
|
+
ref={editorRef}
|
|
394
|
+
contentEditable
|
|
395
|
+
suppressContentEditableWarning
|
|
396
|
+
role="textbox"
|
|
397
|
+
tabIndex={0}
|
|
398
|
+
aria-label="Text"
|
|
399
|
+
onInput={() => {
|
|
400
|
+
const host = editorRef.current;
|
|
401
|
+
if (!host) return;
|
|
402
|
+
const next: Record<number, string> = {};
|
|
403
|
+
for (const run of host.querySelectorAll<HTMLElement>('[data-run]')) {
|
|
404
|
+
next[Number(run.dataset.run)] = run.textContent ?? '';
|
|
405
|
+
}
|
|
406
|
+
setDrafts(next);
|
|
407
|
+
}}
|
|
408
|
+
onKeyDown={(e) => {
|
|
409
|
+
// A newline would be a <br> the runs cannot carry.
|
|
410
|
+
if (e.key === 'Enter') {
|
|
411
|
+
e.preventDefault();
|
|
412
|
+
void saveText();
|
|
413
|
+
}
|
|
414
|
+
}}
|
|
415
|
+
className="mt-1 rounded border border-border px-2 py-1.5 text-xs leading-relaxed outline-none focus-within:border-foreground/40"
|
|
416
|
+
>
|
|
417
|
+
{target.parts.map((part, index) =>
|
|
418
|
+
part.kind === 'markup' ? (
|
|
419
|
+
// A chip carries no whitespace, so `real<code>/<code>`
|
|
420
|
+
// is one unbreakable word without these.
|
|
421
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: parts are positional
|
|
422
|
+
<Fragment key={`m${index}`}>
|
|
423
|
+
<wbr />
|
|
424
|
+
<span
|
|
425
|
+
contentEditable={false}
|
|
426
|
+
title="Markup is kept as written"
|
|
427
|
+
className="mx-0.5 select-none rounded bg-muted px-1 py-px font-mono text-[10px] text-muted-foreground"
|
|
428
|
+
>
|
|
429
|
+
{part.label}
|
|
430
|
+
</span>
|
|
431
|
+
<wbr />
|
|
432
|
+
</Fragment>
|
|
433
|
+
) : (
|
|
434
|
+
<span key={`t${part.index}`} data-run={part.index}>
|
|
435
|
+
{part.value}
|
|
436
|
+
</span>
|
|
437
|
+
),
|
|
438
|
+
)}
|
|
439
|
+
</div>
|
|
440
|
+
) : (
|
|
441
|
+
target.parts.map((part) =>
|
|
442
|
+
part.kind === 'markup' ? null : (
|
|
443
|
+
<textarea
|
|
444
|
+
key={`t${part.index}`}
|
|
445
|
+
value={drafts[part.index] ?? part.value}
|
|
446
|
+
onChange={(e) =>
|
|
447
|
+
setDrafts((prev) => ({ ...prev, [part.index]: e.target.value }))
|
|
448
|
+
}
|
|
449
|
+
onKeyDown={(e) => {
|
|
450
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) void saveText();
|
|
451
|
+
}}
|
|
452
|
+
rows={Math.min(
|
|
453
|
+
14,
|
|
454
|
+
Math.max(
|
|
455
|
+
2,
|
|
456
|
+
(drafts[part.index] ?? part.value).split('\n').length,
|
|
457
|
+
Math.ceil((drafts[part.index] ?? part.value).length / 40),
|
|
458
|
+
),
|
|
459
|
+
)}
|
|
460
|
+
className={`mt-1 w-full resize-y rounded border border-border bg-transparent px-2 py-1.5 outline-none focus:border-foreground/40 ${
|
|
461
|
+
selected?.tag === 'pre'
|
|
462
|
+
? 'whitespace-pre font-mono text-[11px]'
|
|
463
|
+
: 'text-xs'
|
|
464
|
+
}`}
|
|
465
|
+
/>
|
|
466
|
+
),
|
|
467
|
+
)
|
|
468
|
+
)}
|
|
469
|
+
<button
|
|
470
|
+
type="button"
|
|
471
|
+
onClick={saveText}
|
|
472
|
+
disabled={busy || dirty.length === 0}
|
|
473
|
+
className="mt-1.5 flex w-full items-center justify-center gap-1.5 rounded bg-primary px-2 py-1.5 text-primary-foreground text-xs disabled:opacity-50"
|
|
474
|
+
>
|
|
475
|
+
{busy ? (
|
|
476
|
+
<Loader2 className="size-3 animate-spin" />
|
|
477
|
+
) : (
|
|
478
|
+
<Check className="size-3" />
|
|
479
|
+
)}
|
|
480
|
+
{dirty.length > 1 ? `Save ${dirty.length} edits` : 'Save text'}
|
|
481
|
+
</button>
|
|
482
|
+
</>
|
|
483
|
+
) : (
|
|
484
|
+
<p className="mt-1 rounded border border-border bg-muted px-2 py-1.5 text-[11px] text-muted-foreground">
|
|
485
|
+
{target.reason ?? 'Not editable here.'}
|
|
486
|
+
</p>
|
|
487
|
+
)}
|
|
488
|
+
|
|
489
|
+
<span className="mt-4 block text-[10px] text-muted-foreground uppercase tracking-wider">
|
|
490
|
+
Comment for the agent
|
|
491
|
+
</span>
|
|
492
|
+
<textarea
|
|
493
|
+
value={note}
|
|
494
|
+
onChange={(e) => setNote(e.target.value)}
|
|
495
|
+
onKeyDown={(e) => {
|
|
496
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) void saveComment();
|
|
497
|
+
}}
|
|
498
|
+
rows={3}
|
|
499
|
+
placeholder="make this bold, shorten to one line…"
|
|
500
|
+
className="mt-1 w-full resize-y rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/60 focus:border-foreground/40"
|
|
501
|
+
/>
|
|
502
|
+
<button
|
|
503
|
+
type="button"
|
|
504
|
+
onClick={saveComment}
|
|
505
|
+
disabled={busy || note.trim() === ''}
|
|
506
|
+
className="mt-1.5 flex w-full items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs transition-colors hover:bg-accent disabled:opacity-50"
|
|
507
|
+
>
|
|
508
|
+
<MessageSquarePlus className="size-3" />
|
|
509
|
+
Mark comment
|
|
510
|
+
</button>
|
|
511
|
+
|
|
512
|
+
{status && <p className="mt-2 text-[11px] text-muted-foreground">{status}</p>}
|
|
513
|
+
</div>
|
|
514
|
+
</aside>
|
|
515
|
+
)}
|
|
516
|
+
</>
|
|
517
|
+
);
|
|
518
|
+
}
|