@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.
- package/README.md +60 -10
- package/client/CodeBlock.tsx +24 -0
- package/client/DocView.tsx +9 -2
- package/client/ListingView.tsx +14 -10
- package/client/MarkdownTable.tsx +190 -0
- package/client/MdSection.tsx +56 -21
- package/client/MdSectionEditor.tsx +149 -2
- package/client/Mermaid.tsx +33 -7
- package/client/Settings.css +17 -0
- package/client/Settings.tsx +132 -0
- package/client/Table.tsx +2 -14
- package/client/app.css +339 -1
- package/client/block-state/README.md +39 -0
- package/client/block-state/storage.ts +73 -0
- package/client/block-state/useBlockState.ts +120 -0
- package/client/builtins/DataTable.tsx +269 -0
- package/client/builtins/chart-data.ts +22 -10
- package/client/builtins/index.ts +10 -0
- package/client/data-table/ColumnMenu.tsx +168 -0
- package/client/data-table/ColumnOptions.tsx +141 -0
- package/client/data-table/TableCell.tsx +76 -0
- package/client/data-table/TableHeaderCell.tsx +94 -0
- package/client/data-table/model.ts +194 -0
- package/client/data-table/navigation.ts +34 -0
- package/client/data-table/table-state.ts +196 -0
- package/client/data-table/useColumnMenu.ts +84 -0
- package/client/data-table/useTableState.ts +36 -0
- package/client/design/base/prose.css +4 -0
- package/client/diagrams/Dialog.tsx +577 -0
- package/client/diagrams/EditDialog.tsx +67 -0
- package/client/diagrams/Preferences.tsx +213 -0
- package/client/diagrams/edit-block.ts +93 -0
- package/client/diagrams/edit-context.ts +4 -0
- package/client/diagrams/session-id.ts +8 -0
- package/client/diagrams/slash.ts +94 -0
- package/client/diagrams/source.ts +73 -0
- package/client/diagrams/style.css +599 -0
- package/client/entry.tsx +2 -0
- package/client/mdx-components-base.ts +2 -2
- package/client/router.ts +8 -1
- package/client/shell/AppShell.tsx +38 -15
- package/client/shell/Sidebar.tsx +20 -2
- package/client/shell/StandaloneShell.tsx +19 -6
- package/client/shell/TocRail.tsx +4 -3
- package/client/shell/TopBar.tsx +17 -5
- package/client/state.ts +14 -0
- package/client/ui/ConfirmDeleteDialog.tsx +48 -91
- package/client/ui/ExpandModal.tsx +63 -104
- package/client/ui/Modal.tsx +123 -0
- package/client/ui/ResizeHandle.tsx +1 -1
- package/client/ui/SearchDialog.tsx +81 -88
- package/client/ui/modal.css +42 -0
- package/dist/cli.js +2191 -778
- package/dist/diagram-worker.js +17 -0
- package/dist/registry.json +297 -0
- package/package.json +42 -2
- 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
|
+
}
|