@karimsa/mdxserve 0.2.0 → 0.4.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 (57) hide show
  1. package/README.md +60 -10
  2. package/client/CodeBlock.tsx +24 -0
  3. package/client/DocView.tsx +9 -2
  4. package/client/ListingView.tsx +14 -10
  5. package/client/MarkdownTable.tsx +190 -0
  6. package/client/MdSection.tsx +56 -21
  7. package/client/MdSectionEditor.tsx +149 -2
  8. package/client/Mermaid.tsx +33 -7
  9. package/client/Settings.css +17 -0
  10. package/client/Settings.tsx +132 -0
  11. package/client/Table.tsx +2 -14
  12. package/client/app.css +339 -1
  13. package/client/block-state/README.md +39 -0
  14. package/client/block-state/storage.ts +73 -0
  15. package/client/block-state/useBlockState.ts +120 -0
  16. package/client/builtins/DataTable.tsx +269 -0
  17. package/client/builtins/chart-data.ts +22 -10
  18. package/client/builtins/index.ts +10 -0
  19. package/client/data-table/ColumnMenu.tsx +168 -0
  20. package/client/data-table/ColumnOptions.tsx +141 -0
  21. package/client/data-table/TableCell.tsx +76 -0
  22. package/client/data-table/TableHeaderCell.tsx +94 -0
  23. package/client/data-table/model.ts +194 -0
  24. package/client/data-table/navigation.ts +34 -0
  25. package/client/data-table/table-state.ts +196 -0
  26. package/client/data-table/useColumnMenu.ts +84 -0
  27. package/client/data-table/useTableState.ts +36 -0
  28. package/client/design/base/prose.css +4 -0
  29. package/client/diagrams/Dialog.tsx +577 -0
  30. package/client/diagrams/EditDialog.tsx +67 -0
  31. package/client/diagrams/Preferences.tsx +213 -0
  32. package/client/diagrams/edit-block.ts +93 -0
  33. package/client/diagrams/edit-context.ts +4 -0
  34. package/client/diagrams/session-id.ts +8 -0
  35. package/client/diagrams/slash.ts +94 -0
  36. package/client/diagrams/source.ts +73 -0
  37. package/client/diagrams/style.css +599 -0
  38. package/client/entry.tsx +2 -0
  39. package/client/mdx-components-base.ts +2 -2
  40. package/client/router.ts +8 -1
  41. package/client/shell/AppShell.tsx +38 -15
  42. package/client/shell/Sidebar.tsx +20 -2
  43. package/client/shell/StandaloneShell.tsx +19 -6
  44. package/client/shell/TocRail.tsx +4 -3
  45. package/client/shell/TopBar.tsx +17 -5
  46. package/client/state.ts +14 -0
  47. package/client/ui/ConfirmDeleteDialog.tsx +48 -91
  48. package/client/ui/ExpandModal.tsx +63 -104
  49. package/client/ui/Modal.tsx +123 -0
  50. package/client/ui/ResizeHandle.tsx +1 -1
  51. package/client/ui/SearchDialog.tsx +81 -88
  52. package/client/ui/modal.css +42 -0
  53. package/dist/cli.js +2191 -778
  54. package/dist/diagram-worker.js +17 -0
  55. package/dist/registry.json +297 -0
  56. package/package.json +42 -2
  57. package/skills/mdxserve/SKILL.md +56 -1
@@ -0,0 +1,36 @@
1
+ import { z } from "zod";
2
+ import { useBlockState } from "../block-state/useBlockState";
3
+ import type { BlockStateScope } from "../block-state/storage";
4
+ import { emptyState, type Column } from "./model";
5
+ import { normalizeTableState } from "./table-state";
6
+
7
+ const savedStateSchema = z.object({
8
+ columnTypes: z
9
+ .record(z.string(), z.enum(["text", "number", "percent", "time", "bytes", "currency"]))
10
+ .optional(),
11
+ sort: z.object({ key: z.string(), descending: z.boolean() }).optional(),
12
+ filters: z.record(
13
+ z.string(),
14
+ z.object({
15
+ pattern: z.string().optional(),
16
+ unit: z.string().optional(),
17
+ min: z.string().optional(),
18
+ max: z.string().optional(),
19
+ }),
20
+ ),
21
+ units: z.record(z.string(), z.string()),
22
+ });
23
+
24
+ function legacyTableKeys(scope: BlockStateScope): string[] {
25
+ return [`mdxserve:table:v1:${scope.documentPath}:${scope.blockId}`];
26
+ }
27
+
28
+ export function useTableState(scope: BlockStateScope, columns: Column[], persist = true) {
29
+ return useBlockState(scope, {
30
+ persist,
31
+ schema: savedStateSchema,
32
+ initialState: emptyState,
33
+ normalize: (state) => normalizeTableState(state, columns),
34
+ legacyKeys: legacyTableKeys,
35
+ });
36
+ }
@@ -20,6 +20,10 @@
20
20
  .mdx-section {
21
21
  position: relative;
22
22
  }
23
+ /* Standalone diagrams already provide Edit beside Copy in their toolbar. */
24
+ .mdx-section:has(> [data-mermaid-editable]:first-child:nth-last-child(2)) > .mdx-section-edit {
25
+ display: none;
26
+ }
23
27
  /* The direct-child rhythm rules below therefore have to reach three levels:
24
28
  `.mdx-prose` itself (docs with no sections, or content before the first
25
29
  one), `.mdx-section` (the normal wrapped case), and `.mdx-prose .ProseMirror`
@@ -0,0 +1,577 @@
1
+ import { AnimatePresence } from "framer-motion";
2
+ import { sessionId } from "./session-id";
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { Modal } from "../ui/Modal";
5
+ import { trpcClient } from "../api";
6
+ import { MermaidDiagram } from "../Mermaid";
7
+ import { DiagramPreferences } from "./Preferences";
8
+ import Braces from "lucide-react/dist/esm/icons/braces.mjs";
9
+ import Network from "lucide-react/dist/esm/icons/network.mjs";
10
+ import Paperclip from "lucide-react/dist/esm/icons/paperclip.mjs";
11
+ import Play from "lucide-react/dist/esm/icons/play.mjs";
12
+ import Settings from "lucide-react/dist/esm/icons/settings.mjs";
13
+ import CloseIcon from "lucide-react/dist/esm/icons/x.mjs";
14
+
15
+ type Draft = { mermaid: string; assumptions: string[]; changes: string[] };
16
+ function policy(source: string) {
17
+ return (
18
+ source.length <= 65536 &&
19
+ /^\s*(erDiagram|flowchart|graph|sequenceDiagram)\b/.test(source) &&
20
+ !/%%\{|<[A-Za-z/!]|`|^\s*(click|link|links)\s|javascript\s*:|https?:\/\//im.test(source)
21
+ );
22
+ }
23
+ export function MermaidDialog({
24
+ initialSource,
25
+ directSave = false,
26
+ saving = false,
27
+ saveError = "",
28
+ saveDisabled = false,
29
+ onClose,
30
+ onInsert,
31
+ }: {
32
+ initialSource?: string;
33
+ directSave?: boolean;
34
+ saving?: boolean;
35
+ saveError?: string;
36
+ saveDisabled?: boolean;
37
+ onClose: () => void;
38
+ onInsert: (source: string) => void;
39
+ }) {
40
+ const [shake, setShake] = useState(0);
41
+ const [session] = useState(sessionId);
42
+ const revision = useRef(0);
43
+ const [text, setText] = useState(initialSource ?? "");
44
+ const [image, setImage] = useState<{ id: string; url: string; name: string } | null>(null);
45
+ const [agentEnabled, setAgentEnabled] = useState(false);
46
+ const [settingsReady, setSettingsReady] = useState(false);
47
+ const gutter = useRef<HTMLDivElement>(null);
48
+ const [draft, setDraft] = useState<Draft | null>(null);
49
+ const [status, setStatus] = useState("Describe a diagram or drop an image.");
50
+ const [error, setError] = useState("");
51
+ const [current, setCurrent] = useState(false);
52
+ const [rendered, setRendered] = useState("");
53
+ const [code, setCode] = useState(false);
54
+ const [preferences, setPreferences] = useState(false);
55
+ const [preferenceVersion, setPreferenceVersion] = useState(0);
56
+ const [uploading, setUploading] = useState(false);
57
+ const [manual, setManual] = useState(false);
58
+ const [composing, setComposing] = useState(false);
59
+ const uploadRevision = useRef(0);
60
+ const mounted = useRef(true);
61
+ const flush = useRef<() => void>(() => {});
62
+ const fileInput = useRef<HTMLInputElement>(null);
63
+ useEffect(() => {
64
+ if (gutter.current) gutter.current.style.transform = "translateY(0)";
65
+ }, [code]);
66
+ useEffect(() => {
67
+ const owner = session;
68
+ mounted.current = true;
69
+ void trpcClient.getDiagramPreferences
70
+ .query({})
71
+ .then((settings) => {
72
+ if (mounted.current) {
73
+ setAgentEnabled(settings.agent !== "disabled");
74
+ setSettingsReady(true);
75
+ }
76
+ })
77
+ .catch(() => {
78
+ if (mounted.current)
79
+ setError("Could not load settings. Close and reopen the dialog to retry.");
80
+ });
81
+ return () => {
82
+ mounted.current = false;
83
+ void trpcClient.releaseDiagramSession.mutate({ session: owner }).catch(() => {});
84
+ };
85
+ }, [session]);
86
+ useEffect(() => {
87
+ const owner = session;
88
+ return () => {
89
+ if (image) {
90
+ URL.revokeObjectURL(image.url);
91
+ void trpcClient.discardDiagramImage
92
+ .mutate({ session: owner, imageId: image.id })
93
+ .catch(() => {});
94
+ }
95
+ };
96
+ }, [image, session]);
97
+ useEffect(() => {
98
+ const changed = () => {
99
+ void trpcClient.getDiagramPreferences
100
+ .query({})
101
+ .then((result) => {
102
+ setAgentEnabled(result.agent !== "disabled");
103
+ setPreferenceVersion((value) => value + 1);
104
+ })
105
+ .catch(() => {});
106
+ };
107
+ window.addEventListener("mdxserve-diagram-preferences", changed);
108
+ return () => window.removeEventListener("mdxserve-diagram-preferences", changed);
109
+ }, [onClose]);
110
+ useEffect(() => {
111
+ if (!settingsReady) return;
112
+ let generation = ++revision.current;
113
+ if (!manual) setCurrent(false);
114
+ setError("");
115
+ flush.current = () => {};
116
+ let obsolete = false;
117
+ const cancel = () => {
118
+ obsolete = true;
119
+ void trpcClient.cancelDiagramConversion
120
+ .mutate({ session: session, revision: generation })
121
+ .catch(() => {});
122
+ };
123
+ if (manual) {
124
+ setStatus("Editing Mermaid locally");
125
+ return cancel;
126
+ }
127
+ if (initialSource !== undefined && text === initialSource && !image) {
128
+ setDraft({ mermaid: initialSource, assumptions: [], changes: [] });
129
+ setCurrent(policy(initialSource));
130
+ setStatus(
131
+ directSave
132
+ ? "Edit the source or describe changes, then save the diagram."
133
+ : "Edit the source or describe changes. Update the draft, then save the section.",
134
+ );
135
+ return cancel;
136
+ }
137
+ if (uploading || composing || (!text.trim() && !image)) {
138
+ setStatus(uploading ? "Preparing image…" : "Describe a diagram or drop an image.");
139
+ return cancel;
140
+ }
141
+ setStatus("Waiting for you to pause…");
142
+ let started = false;
143
+ let failed = false;
144
+ let timer: ReturnType<typeof setTimeout>;
145
+ const run = async () => {
146
+ if (obsolete || (started && !failed)) return;
147
+ clearTimeout(timer);
148
+ started = true;
149
+ failed = false;
150
+ setError("");
151
+ const attempt = ++revision.current;
152
+ generation = attempt;
153
+ try {
154
+ // Valid Mermaid stays local. The renderer performs the final insertion check.
155
+ if (!image && policy(text)) {
156
+ const { default: mermaid } = await import("mermaid");
157
+ mermaid.initialize({ startOnLoad: false, securityLevel: "strict" });
158
+ let valid = false;
159
+ try {
160
+ valid = !!(await mermaid.parse(text));
161
+ } catch {}
162
+ if (obsolete) return;
163
+ if (valid) {
164
+ setDraft({ mermaid: text, assumptions: [], changes: [] });
165
+ setCurrent(true);
166
+ setStatus("Valid Mermaid · no agent used");
167
+ return;
168
+ }
169
+ }
170
+ if (!agentEnabled) {
171
+ setError("Enter valid Mermaid, or enable an agent in Settings to fix rough input.");
172
+ return;
173
+ }
174
+ setStatus("Converting with your local agent…");
175
+
176
+ const result = await trpcClient.convertDiagram.mutate({
177
+ session: session,
178
+ revision: attempt,
179
+ text,
180
+ imageId: image?.id,
181
+ });
182
+ if (obsolete) return;
183
+ setDraft(result);
184
+ setCurrent(true);
185
+ setStatus(
186
+ `Converted with ${result.provider === "codex" ? "Codex" : "Claude Code"} · review before ${directSave ? "saving" : initialSource === undefined ? "inserting" : "updating"}`,
187
+ );
188
+ } catch (failure) {
189
+ if (obsolete) return;
190
+ setError(failure instanceof Error ? failure.message : "Conversion failed");
191
+ setStatus("Automatic conversion paused");
192
+ failed = true;
193
+ }
194
+ };
195
+ flush.current = () => {
196
+ void run();
197
+ };
198
+ timer = setTimeout(() => void run(), 1000);
199
+ return () => {
200
+ clearTimeout(timer);
201
+ cancel();
202
+ };
203
+ }, [
204
+ text,
205
+ image,
206
+ uploading,
207
+ composing,
208
+ manual,
209
+ preferenceVersion,
210
+ settingsReady,
211
+ agentEnabled,
212
+ session,
213
+ initialSource,
214
+ directSave,
215
+ ]);
216
+ const onRender = useCallback(
217
+ (source: string, valid: boolean) => setRendered(valid ? source : ""),
218
+ [],
219
+ );
220
+ async function upload(file: File) {
221
+ setError("");
222
+ if (
223
+ !["image/png", "image/jpeg", "image/webp"].includes(file.type) ||
224
+ file.size === 0 ||
225
+ file.size > 10 * 1024 * 1024
226
+ ) {
227
+ setError("Choose one PNG, JPEG or WebP image, at most 10 MiB.");
228
+ return;
229
+ }
230
+ if (!agentEnabled) {
231
+ setError("Enable an agent in Settings to convert an image.");
232
+ return;
233
+ }
234
+ setCurrent(false);
235
+ const ticket = ++uploadRevision.current;
236
+ setUploading(true);
237
+ setManual(false);
238
+ let uploadedId: string | undefined;
239
+ let adopted = false;
240
+ try {
241
+ const owner = session;
242
+ const imageId = await trpcClient.beginDiagramImageUpload.mutate({ session: owner });
243
+ uploadedId = imageId;
244
+ const bytes = new Uint8Array(await file.arrayBuffer());
245
+ for (let offset = 0, sequence = 0; offset < bytes.length; offset += 128 * 1024, sequence++) {
246
+ if (!mounted.current || ticket !== uploadRevision.current) return;
247
+ const chunk = bytes.subarray(offset, offset + 128 * 1024);
248
+ let binary = "";
249
+ for (const byte of chunk) binary += String.fromCharCode(byte);
250
+ await trpcClient.appendDiagramImageChunk.mutate({
251
+ session: owner,
252
+ imageId,
253
+ sequence,
254
+ data: btoa(binary),
255
+ });
256
+ }
257
+ await trpcClient.finishDiagramImageUpload.mutate({ session: owner, imageId });
258
+ if (mounted.current && ticket === uploadRevision.current) {
259
+ adopted = true;
260
+ setImage({ id: imageId, url: URL.createObjectURL(file), name: file.name });
261
+ }
262
+ } catch (failure) {
263
+ if (mounted.current && ticket === uploadRevision.current)
264
+ setError(failure instanceof Error ? failure.message : "Could not upload image");
265
+ } finally {
266
+ if (uploadedId && !adopted)
267
+ void trpcClient.discardDiagramImage
268
+ .mutate({ session: session, imageId: uploadedId })
269
+ .catch(() => {});
270
+ if (mounted.current && ticket === uploadRevision.current) setUploading(false);
271
+ }
272
+ }
273
+ const dirty =
274
+ text !== (initialSource ?? "") ||
275
+ !!image ||
276
+ uploading ||
277
+ (draft !== null && draft.mermaid !== (initialSource ?? ""));
278
+ return (
279
+ <Modal
280
+ open
281
+ onClose={onClose}
282
+ className="diagram-dialog diagram-workspace"
283
+ data-shake={shake ? (shake % 2 ? "left" : "right") : undefined}
284
+ initialFocus="textarea"
285
+ dismissible={!saving}
286
+ onBackdropClick={() => {
287
+ if (dirty) setShake((count) => count + 1);
288
+ else onClose();
289
+ }}
290
+ aria-label={initialSource === undefined ? "Create Mermaid diagram" : "Edit Mermaid diagram"}
291
+ onKeyDown={(event) => {
292
+ if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
293
+ event.preventDefault();
294
+ flush.current();
295
+ }
296
+ }}
297
+ onDragOver={(event) => {
298
+ event.preventDefault();
299
+ }}
300
+ onDrop={(event) => {
301
+ event.preventDefault();
302
+ if (event.dataTransfer.files.length !== 1) {
303
+ setError("Drop one image at a time. In Excalidraw, copy as PNG or export an image.");
304
+ return;
305
+ }
306
+ void upload(event.dataTransfer.files[0]);
307
+ }}
308
+ onPaste={(event) => {
309
+ const files = [...event.clipboardData.files];
310
+ if (files.length) {
311
+ event.preventDefault();
312
+ if (files.length === 1) void upload(files[0]);
313
+ else setError("Paste one image at a time.");
314
+ }
315
+ }}
316
+ >
317
+ <header className="diagram-navbar">
318
+ <div className="diagram-title">
319
+ <Network size={18} />
320
+ <h2>{initialSource === undefined ? "New diagram" : "Edit diagram"}</h2>
321
+ <span className="diagram-command">/mermaid</span>
322
+ </div>
323
+ <div className="diagram-actions">
324
+ <button
325
+ type="button"
326
+ className="diagram-icon-button"
327
+ aria-label="Settings"
328
+ title="Settings"
329
+ onClick={() => setPreferences(true)}
330
+ >
331
+ <Settings size={17} />
332
+ </button>
333
+ <button
334
+ type="button"
335
+ className="diagram-primary"
336
+ disabled={
337
+ saving ||
338
+ saveDisabled ||
339
+ !draft ||
340
+ !current ||
341
+ rendered !== draft.mermaid ||
342
+ !policy(draft.mermaid)
343
+ }
344
+ onClick={() => draft && onInsert(draft.mermaid)}
345
+ >
346
+ {saving
347
+ ? "Saving…"
348
+ : directSave
349
+ ? "Save diagram"
350
+ : initialSource === undefined
351
+ ? "Insert into draft"
352
+ : "Update in draft"}
353
+ </button>
354
+ <button
355
+ type="button"
356
+ className="diagram-icon-button"
357
+ aria-label="Close diagram dialog"
358
+ disabled={saving}
359
+ onClick={onClose}
360
+ >
361
+ <CloseIcon size={18} />
362
+ </button>
363
+ </div>
364
+ </header>
365
+ <div className="diagram-workbench" inert={saving}>
366
+ <section className="diagram-editor-pane" aria-label="Diagram source">
367
+ <div className="diagram-pane-toolbar">
368
+ <div className="diagram-tabs" role="tablist" aria-label="Editor content">
369
+ <button
370
+ type="button"
371
+ role="tab"
372
+ aria-selected={!code}
373
+ aria-controls="diagram-editor-panel"
374
+ onClick={() => setCode(false)}
375
+ >
376
+ Input
377
+ </button>
378
+ <button
379
+ type="button"
380
+ role="tab"
381
+ aria-selected={code}
382
+ aria-controls="diagram-editor-panel"
383
+ disabled={!draft}
384
+ onClick={() => setCode(true)}
385
+ >
386
+ <Braces size={14} />
387
+ Mermaid
388
+ </button>
389
+ </div>
390
+ <div className="diagram-actions">
391
+ <button
392
+ type="button"
393
+ className="diagram-icon-button"
394
+ aria-label="Choose image"
395
+ title="Attach image"
396
+ disabled={uploading}
397
+ onClick={() => fileInput.current?.click()}
398
+ >
399
+ <Paperclip size={16} />
400
+ </button>
401
+ <button
402
+ type="button"
403
+ className="diagram-run"
404
+ aria-label="Convert now"
405
+ title="Convert now (⌘/Ctrl + Enter)"
406
+ disabled={!settingsReady || uploading || (!text.trim() && !image)}
407
+ onClick={() => flush.current()}
408
+ >
409
+ <Play size={13} fill="currentColor" />
410
+ <span>Convert</span>
411
+ </button>
412
+ </div>
413
+ </div>
414
+ <div className="diagram-code-editor" role="tabpanel" id="diagram-editor-panel">
415
+ <div className="diagram-gutter" aria-hidden="true">
416
+ <div ref={gutter}>
417
+ {(code ? (draft?.mermaid ?? "") : text).split("\n").map((_, index) => (
418
+ <div key={index}>{index + 1}</div>
419
+ ))}
420
+ </div>
421
+ </div>
422
+ <textarea
423
+ key={code ? "mermaid" : "input"}
424
+ id={code ? "diagram-mermaid" : "diagram-input"}
425
+ aria-label={code ? "Generated Mermaid" : "Describe or paste rough Mermaid"}
426
+ spellCheck={false}
427
+ autoCapitalize="off"
428
+ autoCorrect="off"
429
+ wrap="off"
430
+ maxLength={code ? 65536 : 16000}
431
+ value={code ? (draft?.mermaid ?? "") : text}
432
+ placeholder={
433
+ "Describe your diagram…\n\nCustomer has many Orders\nOrder contains one or more LineItems\n\nOr drop an image here."
434
+ }
435
+ onScroll={(event) => {
436
+ if (gutter.current)
437
+ gutter.current.style.transform = `translateY(-${event.currentTarget.scrollTop}px)`;
438
+ }}
439
+ onBlur={() => {
440
+ if (!code) flush.current();
441
+ }}
442
+ onCompositionStart={() => setComposing(true)}
443
+ onCompositionEnd={() => setComposing(false)}
444
+ onChange={(event) => {
445
+ const source = event.target.value;
446
+ if (code && draft) {
447
+ setManual(true);
448
+ setDraft({ ...draft, mermaid: source });
449
+ setRendered("");
450
+ setCurrent(policy(source));
451
+ } else {
452
+ setManual(false);
453
+ setText(source);
454
+ setCurrent(false);
455
+ }
456
+ }}
457
+ />
458
+ </div>
459
+ <input
460
+ ref={fileInput}
461
+ hidden
462
+ type="file"
463
+ accept="image/png,image/jpeg,image/webp"
464
+ onChange={(event) => {
465
+ if (event.target.files?.[0]) void upload(event.target.files[0]);
466
+ event.target.value = "";
467
+ }}
468
+ />
469
+ {image && (
470
+ <details className="diagram-attachment" open>
471
+ <summary>
472
+ <Paperclip size={13} />
473
+ {image.name}
474
+ </summary>
475
+ <div>
476
+ <img src={image.url} alt="Source diagram" />
477
+ <button
478
+ type="button"
479
+ className="diagram-icon-button"
480
+ aria-label="Remove image"
481
+ onClick={() => {
482
+ setImage(null);
483
+ }}
484
+ >
485
+ <CloseIcon size={15} />
486
+ </button>
487
+ </div>
488
+ </details>
489
+ )}
490
+ <div className="diagram-pane-footer">
491
+ <span>{code ? "Mermaid · editing locally" : "Text or image"}</span>
492
+ <span>{code ? "Changes update the preview" : "PNG, JPEG, WebP · up to 10 MiB"}</span>
493
+ </div>
494
+ </section>
495
+ <section className="diagram-preview-pane" aria-label="Diagram preview">
496
+ <div className="diagram-pane-toolbar">
497
+ <span className="diagram-pane-label">
498
+ <Network size={14} />
499
+ Preview
500
+ </span>
501
+ <span className="diagram-preview-state">
502
+ {draft ? (current ? "Ready" : "Out of date") : "Waiting for input"}
503
+ </span>
504
+ </div>
505
+ <div className={"diagram-canvas" + (!current && draft ? " diagram-stale" : "")}>
506
+ {draft ? (
507
+ policy(draft.mermaid) ? (
508
+ <MermaidDiagram source={draft.mermaid} onRender={onRender} fill />
509
+ ) : (
510
+ <p role="alert">
511
+ Use ER, flowchart or sequence Mermaid without HTML, links or directives.
512
+ </p>
513
+ )
514
+ ) : (
515
+ <div className="diagram-empty">
516
+ <Network size={36} strokeWidth={1} />
517
+ <strong>Your diagram starts here</strong>
518
+ <span>Write on the left. See it take shape here.</span>
519
+ </div>
520
+ )}
521
+ </div>
522
+ {draft && (draft.assumptions.length > 0 || draft.changes.length > 0) && (
523
+ <details className="diagram-review">
524
+ <summary>
525
+ {draft.assumptions.length
526
+ ? `${draft.assumptions.length} assumptions to review`
527
+ : "Conversion notes"}
528
+ </summary>
529
+ <div>
530
+ {draft.assumptions.length > 0 && (
531
+ <ul>
532
+ {draft.assumptions.map((item, index) => (
533
+ <li key={index}>{item}</li>
534
+ ))}
535
+ </ul>
536
+ )}
537
+ {draft.changes.length > 0 && (
538
+ <>
539
+ <strong>Changes made</strong>
540
+ <ul>
541
+ {draft.changes.map((item, index) => (
542
+ <li key={index}>{item}</li>
543
+ ))}
544
+ </ul>
545
+ </>
546
+ )}
547
+ </div>
548
+ </details>
549
+ )}
550
+ <div className="diagram-pane-footer">
551
+ <span>Mermaid preview</span>
552
+ <span>Drag to pan · scroll to zoom</span>
553
+ </div>
554
+ </section>
555
+ </div>
556
+ <footer className="diagram-statusbar" data-error={!!(saveError || error)}>
557
+ <span role="status" aria-live="polite">
558
+ {saveError ||
559
+ error ||
560
+ (shake && dirty
561
+ ? "Unsaved changes — save your diagram or use Close to discard them."
562
+ : status)}
563
+ </span>
564
+ <span>{manual ? "Editing locally" : "1s pause · ⌘/Ctrl + Enter · leave editor"}</span>
565
+ </footer>
566
+ {error && (
567
+ <span className="diagram-sr-only" role="alert">
568
+ {error}
569
+ </span>
570
+ )}
571
+
572
+ <AnimatePresence>
573
+ {preferences && <DiagramPreferences onClose={() => setPreferences(false)} />}
574
+ </AnimatePresence>
575
+ </Modal>
576
+ );
577
+ }
@@ -0,0 +1,67 @@
1
+ import { useMemo, useRef, useState } from "react";
2
+ import { isTRPCClientError } from "@trpc/client";
3
+ import { trpcClient } from "../api";
4
+ import { MermaidDialog } from "./Dialog";
5
+ import { locateDiagram } from "./source";
6
+ import type { ExistingDiagram } from "./edit-context";
7
+
8
+ export function DiagramEditDialog({
9
+ source,
10
+ target,
11
+ path,
12
+ version,
13
+ startLine,
14
+ endLine,
15
+ onClose,
16
+ }: {
17
+ source: string;
18
+ target: ExistingDiagram;
19
+ path: string;
20
+ version: string;
21
+ startLine: number;
22
+ endLine: number;
23
+ onClose: () => void;
24
+ }) {
25
+ const diagram = useMemo(() => locateDiagram(source, target), [source, target]);
26
+ const [saving, setSaving] = useState(false);
27
+ const [error, setError] = useState("");
28
+ const inFlight = useRef(false);
29
+ async function save(mermaid: string) {
30
+ if (!diagram || inFlight.current) return;
31
+ inFlight.current = true;
32
+ setSaving(true);
33
+ setError("");
34
+ try {
35
+ await trpcClient.saveDocSection.mutate({
36
+ path,
37
+ version,
38
+ startLine,
39
+ endLine,
40
+ markdown: diagram.replace(mermaid),
41
+ });
42
+ onClose();
43
+ } catch (failure) {
44
+ setError(
45
+ isTRPCClientError(failure) && failure.data?.code === "CONFLICT"
46
+ ? "File changed on disk. Copy your changes, then close and reopen the diagram."
47
+ : "Couldn't save the diagram. Your changes are still here; try again.",
48
+ );
49
+ } finally {
50
+ inFlight.current = false;
51
+ setSaving(false);
52
+ }
53
+ }
54
+ return (
55
+ <MermaidDialog
56
+ initialSource={diagram?.source ?? target.source}
57
+ onClose={() => {
58
+ if (!inFlight.current) onClose();
59
+ }}
60
+ onInsert={save}
61
+ directSave
62
+ saving={saving}
63
+ saveError={diagram ? error : "Diagram changed on disk. Close and reopen it before editing."}
64
+ saveDisabled={!diagram}
65
+ />
66
+ );
67
+ }