@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
package/README.md CHANGED
@@ -24,9 +24,9 @@ updates. Write JSX in an `.mdx` file and it renders too.
24
24
 
25
25
  ![The same page in the dark theme](docs/screenshots/reader-dark.jpg)
26
26
 
27
- It is built for folders of Markdown that already exist — notes, a `docs/` directory, plan
28
- files an AI agent wrote — rather than for publishing a site. There is no build step and no
29
- config file: run it, open the URL, read.
27
+ It works with folders of Markdown that already exist: notes, a `docs/` directory, or plan
28
+ files an AI agent wrote. For local use, there is no build step or config file: run it,
29
+ open the URL, and read. Use `--permissions restricted` to publish one folder as a read-only site.
30
30
 
31
31
  ## Install
32
32
 
@@ -41,6 +41,11 @@ mdxserve setup # optional: installs the writing skill for Claude Code and C
41
41
  (through `npx skills add`) and removes the MCP registration that older versions created. It is
42
42
  safe to re-run; run it again after upgrading so the installed skill matches the CLI.
43
43
 
44
+ Interactive commands check for updates in the background at most once a day and show
45
+ cached update notices on startup. Updates are never installed automatically. Disable
46
+ checks with `--no-update-notifier` or `NO_UPDATE_NOTIFIER=1`. CI, noninteractive commands,
47
+ and `--json` output stay quiet.
48
+
44
49
  ## Quick start
45
50
 
46
51
  ```bash
@@ -60,14 +65,59 @@ mdxserve roots list # what is being served right now
60
65
  mdxserve status # pid, port, URL, and the folders served
61
66
  ```
62
67
 
63
- Folders you add this way last until the server stops. `mdxserve serve` with no `-w` starts
64
- an empty server you can add folders to later.
68
+ Roots persist in `~/.mdxserve/config.json` (`$MDXSERVE_HOME/config.json` when set).
69
+ `mdxserve serve` restores them on restart; `-w` adds folders to the saved list.
70
+ You can also edit the file directly, including while the server is stopped:
71
+
72
+ ```json
73
+ {
74
+ "roots": ["~/notes", "~/work/docs"]
75
+ }
76
+ ```
77
+
78
+ The running server automatically reloads edits, updating open browsers and document access.
79
+ Relative paths resolve against the config directory; `~` resolves against your home directory.
80
+ Use `"roots": []` to unmount everything. Invalid JSON, missing directories, and invalid root
81
+ combinations leave the last working set active and log an error; fix the file to resume
82
+ reloading. Deleting the config while running also preserves the last working set until the
83
+ file is restored. An invalid config at startup reports an error. Root commands save changes
84
+ atomically and preserve unrelated settings. SQLite records only the live server's status;
85
+ the JSON file is the source of truth for which roots should exist.
86
+
87
+ | Flag | Default | Effect |
88
+ | --------------------------- | --------------- | --------------------------------------------------------- |
89
+ | `-w, --watch` | none | Serve this folder; repeat the flag for more than one |
90
+ | `-p, --port` | `4040` | Port to listen on; falls back to a free one if it's taken |
91
+ | `--host` | `127.0.0.1` | Interface to bind; determines default permission mode |
92
+ | `--dangerous-allow-network` | off | Authorize full permissions on a non-loopback interface |
93
+ | `--permissions <mode>` | interface-based | `full` on loopback, `restricted` on other interfaces |
94
+
95
+ ## Hosting a public reader
96
+
97
+ Permission mode defaults to `full` on loopback and `restricted` on other interfaces.
98
+ The effective mode is printed at startup. Use `--permissions restricted` to select the
99
+ public reader restrictions explicitly, including behind a proxy connected over loopback:
100
+
101
+ ```bash
102
+ MDXSERVE_HOME=/tmp/mdxserve-public mdxserve serve --permissions restricted -w ./site --host 0.0.0.0 -p "${PORT:-4040}"
103
+ ```
65
104
 
66
- | Flag | Default | Effect |
67
- | ------------- | ----------- | --------------------------------------------------------- |
68
- | `-w, --watch` | none | Serve this folder; repeat the flag for more than one |
69
- | `-p, --port` | `4040` | Port to listen on; falls back to a free one if it's taken |
70
- | `--host` | `127.0.0.1` | Interface to bind; `0.0.0.0` exposes it on your LAN |
105
+ Restricted mode requires one `-w` directory. It ignores saved roots
106
+ and config changes. All HTTP mutations are denied, including editing, Trash, root changes,
107
+ exports, and diagram agents, even when a reverse proxy connects over loopback. The reader,
108
+ navigation, and search remain available. A restricted instance will not reuse another server
109
+ under the same `MDXSERVE_HOME`; give it its own directory.
110
+
111
+ Treat every file in `./site` as public. Visitors can fetch MDX source and visible assets,
112
+ including imported CSS and JSX. Vite also serves the mdxserve client and dependency modules
113
+ needed by the page. Hidden files, links to hidden targets, and links outside the served root
114
+ are blocked; links to visible files inside the root still work.
115
+ Run the server with a read-only site directory and no agent credentials. At the public proxy,
116
+ limit requests per client and concurrent requests to bound repeated Vite compilation and
117
+ search work.
118
+ Full permissions on a non-loopback interface require both `--permissions full` and
119
+ `--dangerous-allow-network`. The dangerous flag alone leaves the default restricted mode
120
+ in place. Use full network access only on a trusted network.
71
121
 
72
122
  ## Writing docs
73
123
 
@@ -1,3 +1,4 @@
1
+ import { DiagramEditContext } from "./diagrams/edit-context";
1
2
  import {
2
3
  Children,
3
4
  createContext,
@@ -241,6 +242,7 @@ function CodeFrame({
241
242
  }) {
242
243
  const containerRef = useRef<HTMLDivElement>(null);
243
244
  const isMermaid = language === "mermaid";
245
+ const editDiagram = useContext(DiagramEditContext);
244
246
  const [view, setView] = useState<"diagram" | "code">("diagram");
245
247
  const [source, setSource] = useState<string | null>(null);
246
248
  const label = title ?? language;
@@ -294,6 +296,7 @@ function CodeFrame({
294
296
  return (
295
297
  <div
296
298
  ref={containerRef}
299
+ data-mermaid-editable={isMermaid ? "" : undefined}
297
300
  className="overflow-hidden rounded-lg border border-code-border bg-code-bg"
298
301
  >
299
302
  <CodeFrameHeader
@@ -302,6 +305,27 @@ function CodeFrame({
302
305
  <>
303
306
  {isMermaid ? <ViewToggle view={view} onChange={setView} toggleId={toggleId} /> : null}
304
307
  {showDiagram ? directionMenu : null}
308
+ {isMermaid && editDiagram && (
309
+ <button
310
+ type="button"
311
+ aria-label="Edit diagram"
312
+ data-print-hide
313
+ className="inline-flex items-center gap-1.5 text-[length:var(--size-xs)] text-text-subtle hover:text-text-heading"
314
+ onClick={() => {
315
+ const frame = containerRef.current;
316
+ const section = frame?.closest("[data-md-section]");
317
+ if (!frame || !section) return;
318
+ const index = [...section.querySelectorAll("[data-mermaid-editable]")].indexOf(
319
+ frame,
320
+ );
321
+ if (index >= 0) editDiagram({ index, source: getCopyText() });
322
+ }}
323
+ >
324
+ <Icon name="pencil" size={12} />
325
+ Edit
326
+ </button>
327
+ )}
328
+
305
329
  <CopyButton getText={getCopyText} />
306
330
  </>
307
331
  }
@@ -5,7 +5,13 @@ import { ErrorBox } from "./ErrorBox";
5
5
  import { RenderErrorBoundary } from "./RenderErrorBoundary";
6
6
  import type { DocModuleState } from "./doc-module-cache";
7
7
  import { ResizeHandle } from "./ui/ResizeHandle";
8
- import { DOC_MAX_WIDTH, DOC_MIN_WIDTH, docWidthAtom, openSectionAtom } from "./state";
8
+ import {
9
+ contentLayoutAtom,
10
+ DOC_MAX_WIDTH,
11
+ DOC_MIN_WIDTH,
12
+ docWidthAtom,
13
+ openSectionAtom,
14
+ } from "./state";
9
15
 
10
16
  // Each Component a re-import produces (including an HMR re-import after a
11
17
  // fix) is a distinct function identity, so this assigns it a stable, unique
@@ -45,6 +51,7 @@ export function DocView({
45
51
  onRendered?: () => void;
46
52
  }) {
47
53
  const cached = module;
54
+ const fullWidth = useAtomValue(contentLayoutAtom) === "full-width";
48
55
  const setWidth = useSetAtom(docWidthAtom);
49
56
  // While a section is being edited the page width is pinned: a drag would
50
57
  // reflow the editor under the caret, and the handles' hover strips sit
@@ -75,7 +82,7 @@ export function DocView({
75
82
  const Content = cached.Component;
76
83
  return (
77
84
  <div ref={container} className="relative">
78
- {!editing && (
85
+ {!editing && !fullWidth && (
79
86
  <>
80
87
  <ResizeHandle
81
88
  side="left"
@@ -1,5 +1,5 @@
1
1
  import { AnimatePresence, motion } from "framer-motion";
2
- import { useAtom, useSetAtom } from "jotai";
2
+ import { useAtom, useAtomValue, useSetAtom } from "jotai";
3
3
  import { useMutation } from "@tanstack/react-query";
4
4
  import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
5
5
  import { queryClient, trpc } from "./api";
@@ -9,10 +9,11 @@ import { Icon } from "./ui/Icon";
9
9
  import { ResizeHandle } from "./ui/ResizeHandle";
10
10
  import { ConfirmDeleteDialog } from "./ui/ConfirmDeleteDialog";
11
11
  import { pushToast } from "./ui/Toast";
12
- import type { ListingEntry } from "./router";
12
+ import { shellInfo, type ListingEntry } from "./router";
13
13
  import Button from "./builtins/Button";
14
14
  import Dropdown from "./builtins/Dropdown";
15
15
  import {
16
+ contentLayoutAtom,
16
17
  LISTING_MAX_WIDTH,
17
18
  LISTING_MIN_WIDTH,
18
19
  listingSortAtom,
@@ -371,6 +372,7 @@ export function ListingView({
371
372
  }) {
372
373
  const { path, rootDir, entries } = route;
373
374
  const sidebar = mode === "sidebar";
375
+ const canDelete = shellInfo.permissions !== "restricted" && !sidebar;
374
376
  const atRootTop = path === `${rootDir}/`;
375
377
  const segments = path.split("/").filter(Boolean);
376
378
  const parentSegments = segments.slice(0, -1);
@@ -389,6 +391,7 @@ export function ListingView({
389
391
  [sorted, sidebar],
390
392
  );
391
393
  const setWidth = useSetAtom(listingWidthAtom);
394
+ const fullWidth = useAtomValue(contentLayoutAtom) === "full-width";
392
395
  const container = useRef<HTMLDivElement>(null);
393
396
 
394
397
  const [selectedNames, setSelectedNames] = useState<Set<string>>(new Set());
@@ -436,14 +439,14 @@ export function ListingView({
436
439
  // consumed it, or the confirm dialog is up (it handles its own Escape).
437
440
  // Sidebar mode has no selection to clear, so skip wiring the listener.
438
441
  useEffect(() => {
439
- if (sidebar) return;
442
+ if (!canDelete) return;
440
443
  function onKeyDown(event: globalThis.KeyboardEvent) {
441
444
  if (event.defaultPrevented || confirmOpen) return;
442
445
  if (event.key === "Escape" && selectionActive) setSelectedNames(new Set());
443
446
  }
444
447
  document.addEventListener("keydown", onKeyDown);
445
448
  return () => document.removeEventListener("keydown", onKeyDown);
446
- }, [sidebar, confirmOpen, selectionActive]);
449
+ }, [canDelete, confirmOpen, selectionActive]);
447
450
 
448
451
  const trashMutation = useMutation(
449
452
  trpc.moveDocsToTrash.mutationOptions({
@@ -455,6 +458,7 @@ export function ListingView({
455
458
  // `pending` and fire a duplicate request before React re-renders.
456
459
  const pendingRef = useRef(false);
457
460
  const handleConfirm = async () => {
461
+ if (!canDelete) return;
458
462
  if (pendingRef.current) return;
459
463
  pendingRef.current = true;
460
464
  setPending(true);
@@ -497,7 +501,7 @@ export function ListingView({
497
501
 
498
502
  return (
499
503
  <div ref={container} className="relative">
500
- {sidebar ? null : (
504
+ {sidebar || fullWidth ? null : (
501
505
  <>
502
506
  <ResizeHandle
503
507
  side="left"
@@ -531,8 +535,8 @@ export function ListingView({
531
535
  ) : (
532
536
  <div className="mb-2 flex items-center justify-between gap-2">
533
537
  <div data-print-hide className="flex items-center gap-2">
534
- <SelectMenu fileEntries={fileEntries} onSelect={setSelectedNames} />
535
- {selectionActive ? (
538
+ {canDelete && <SelectMenu fileEntries={fileEntries} onSelect={setSelectedNames} />}
539
+ {canDelete && selectionActive ? (
536
540
  <>
537
541
  <span className="font-sans font-medium leading-normal text-[length:var(--size-sm)] text-text-subtle tabular-nums">
538
542
  {selected.length} selected
@@ -608,7 +612,7 @@ export function ListingView({
608
612
  />
609
613
  );
610
614
  }
611
- const selection = sidebar
615
+ const selection = !canDelete
612
616
  ? {}
613
617
  : {
614
618
  selectable: true,
@@ -647,7 +651,7 @@ export function ListingView({
647
651
  );
648
652
  })}
649
653
  </motion.div>
650
- {sidebar ? null : (
654
+ {canDelete ? (
651
655
  <ConfirmDeleteDialog
652
656
  open={confirmOpen}
653
657
  files={selected}
@@ -657,7 +661,7 @@ export function ListingView({
657
661
  }}
658
662
  onConfirm={handleConfirm}
659
663
  />
660
- )}
664
+ ) : null}
661
665
  </div>
662
666
  );
663
667
  }
@@ -0,0 +1,190 @@
1
+ import {
2
+ Children,
3
+ isValidElement,
4
+ useId,
5
+ type CSSProperties,
6
+ type ReactNode,
7
+ type TableHTMLAttributes,
8
+ } from "react";
9
+ import DataTable from "./builtins/DataTable";
10
+ import { inferColumn, parseBound, type Column, type Row } from "./data-table/model";
11
+
12
+ function elements(children: ReactNode) {
13
+ return Children.toArray(children).filter(
14
+ isValidElement<{
15
+ children?: ReactNode;
16
+ align?: string;
17
+ style?: CSSProperties;
18
+ rowSpan?: number;
19
+ colSpan?: number;
20
+ }>,
21
+ );
22
+ }
23
+
24
+ function text(children: ReactNode): string {
25
+ return Children.toArray(children)
26
+ .map((child) =>
27
+ isValidElement<{ children?: ReactNode }>(child)
28
+ ? text(child.props.children)
29
+ : typeof child === "string" || typeof child === "number"
30
+ ? String(child)
31
+ : "",
32
+ )
33
+ .join("");
34
+ }
35
+
36
+ type TableElement = ReturnType<typeof elements>[number];
37
+
38
+ function isElementOnly(children: ReactNode): boolean {
39
+ return Children.toArray(children).every(
40
+ (child) => isValidElement(child) || (typeof child === "string" && !child.trim()),
41
+ );
42
+ }
43
+
44
+ /** Rebuilt descendants only preserve children and the column-wide GFM alignment. */
45
+ function hasUnsupportedProps(element: TableElement, cell = false): boolean {
46
+ return Object.keys(element.props).some((key) => {
47
+ if (key === "children" || (cell && key === "align")) return false;
48
+ if (cell && key === "style")
49
+ return Object.keys(element.props.style ?? {}).some((property) => property !== "textAlign");
50
+ return true;
51
+ });
52
+ }
53
+
54
+ function rectangularCells(row: TableElement, cellType: "th" | "td"): TableElement[] | undefined {
55
+ if (row.type !== "tr" || hasUnsupportedProps(row) || !isElementOnly(row.props.children))
56
+ return undefined;
57
+ const cells = elements(row.props.children);
58
+ if (
59
+ cells.some(
60
+ (cell) =>
61
+ cell.type !== cellType ||
62
+ hasUnsupportedProps(cell, true) ||
63
+ (cell.props.rowSpan !== undefined && cell.props.rowSpan !== 1) ||
64
+ (cell.props.colSpan !== undefined && cell.props.colSpan !== 1),
65
+ )
66
+ )
67
+ return undefined;
68
+ return cells;
69
+ }
70
+
71
+ /** Only reconstruct a single rectangular header/body; preserve richer native structures intact. */
72
+ function tableStructure(children: ReactNode) {
73
+ if (!isElementOnly(children)) return undefined;
74
+ const sections = elements(children);
75
+ if (sections.length !== 2 || sections[0].type !== "thead" || sections[1].type !== "tbody")
76
+ return undefined;
77
+ if (
78
+ sections.some(
79
+ (section) => hasUnsupportedProps(section) || !isElementOnly(section.props.children),
80
+ )
81
+ )
82
+ return undefined;
83
+
84
+ const headerRows = elements(sections[0].props.children);
85
+ if (headerRows.length !== 1) return undefined;
86
+ const headers = rectangularCells(headerRows[0], "th");
87
+ if (!headers?.length) return undefined;
88
+
89
+ const cells: TableElement[][] = [];
90
+ for (const row of elements(sections[1].props.children)) {
91
+ const rowCells = rectangularCells(row, "td");
92
+ if (!rowCells || rowCells.length !== headers.length) return undefined;
93
+ if (
94
+ rowCells.some(
95
+ (cell, index) =>
96
+ (cell.props.style?.textAlign ?? cell.props.align) !==
97
+ (headers[index].props.style?.textAlign ?? headers[index].props.align),
98
+ )
99
+ )
100
+ return undefined;
101
+ cells.push(rowCells);
102
+ }
103
+ return { headers, cells };
104
+ }
105
+
106
+ function prepareMarkdownTable(children: ReactNode) {
107
+ const structure = tableStructure(children);
108
+ if (!structure) return undefined;
109
+ const { headers, cells } = structure;
110
+ const columns: Column[] = headers.map((cell, index) => {
111
+ const align = cell.props.style?.textAlign ?? cell.props.align;
112
+ const alignment =
113
+ align === "left" || align === "center" || align === "right" ? align : undefined;
114
+ const column = inferColumn(
115
+ `column-${index}`,
116
+ text(cell.props.children) || `Column ${index + 1}`,
117
+ cells.map((row) => text(row[index]?.props.children)),
118
+ );
119
+ // Converting units must never erase a link or an inline component.
120
+ if (
121
+ (column.type === "time" || column.type === "bytes") &&
122
+ cells.some((row) => elements(row[index]?.props.children).length > 0)
123
+ )
124
+ return { key: column.key, label: column.label, type: "text", align: alignment };
125
+ return { ...column, align: alignment };
126
+ });
127
+ const data: Row[] = cells.map((row) =>
128
+ Object.fromEntries(
129
+ columns.map((column, index) => {
130
+ const value = text(row[index]?.props.children).trim();
131
+ return [
132
+ column.key,
133
+ column.type === "text"
134
+ ? value
135
+ : value === "" || value === "—"
136
+ ? null
137
+ : (parseBound(value, column) ?? null),
138
+ ];
139
+ }),
140
+ ),
141
+ );
142
+
143
+ return { columns, data, cells, headers };
144
+ }
145
+
146
+ function renderMarkdownCell(
147
+ table: NonNullable<ReturnType<typeof prepareMarkdownTable>>,
148
+ row: number,
149
+ key: string,
150
+ ) {
151
+ const index = Number(key.slice(7));
152
+ const column = table.columns[index];
153
+
154
+ // Unit columns use DataTable's shared-unit formatter; other cells retain their original markup.
155
+ if (column.type === "time" || column.type === "bytes") return undefined;
156
+ return table.cells[row][index]?.props.children;
157
+ }
158
+
159
+ export function MarkdownTable({
160
+ children,
161
+ id,
162
+ "data-table-persist": persist,
163
+ ...tableProps
164
+ }: TableHTMLAttributes<HTMLTableElement> & { "data-table-persist"?: string }) {
165
+ const fallbackId = useId();
166
+ const table = prepareMarkdownTable(children);
167
+
168
+ if (!table)
169
+ return (
170
+ <div className="data-table">
171
+ <div className="data-table-scroll">
172
+ <table {...tableProps} id={id}>
173
+ {children}
174
+ </table>
175
+ </div>
176
+ </div>
177
+ );
178
+
179
+ return (
180
+ <DataTable
181
+ id={id ?? `markdown-${fallbackId}`}
182
+ columns={table.columns}
183
+ data={table.data}
184
+ persistState={Boolean(id) && persist !== "false"}
185
+ tableProps={{ ...tableProps, id }}
186
+ renderHeader={(key) => table.headers[Number(key.slice(7))]?.props.children}
187
+ renderCell={(row, key) => renderMarkdownCell(table, row, key)}
188
+ />
189
+ );
190
+ }
@@ -1,3 +1,5 @@
1
+ import { AnimatePresence } from "framer-motion";
2
+ import { DiagramEditContext, type ExistingDiagram } from "./diagrams/edit-context";
1
3
  import {
2
4
  lazy,
3
5
  startTransition,
@@ -12,6 +14,7 @@ import { useAtom } from "jotai";
12
14
  import { trpcClient } from "./api";
13
15
  import { DocContext } from "./DocContext";
14
16
  import { openSectionAtom } from "./state";
17
+ import { shellInfo } from "./router";
15
18
  import { IconButton } from "./ui/IconButton";
16
19
  import { pushToast } from "./ui/Toast";
17
20
 
@@ -23,6 +26,9 @@ import { pushToast } from "./ui/Toast";
23
26
  // when React commits the "edit" branch below, which SSR never reaches.
24
27
  const loadEditor = () => import("./MdSectionEditor");
25
28
  const MdSectionEditor = lazy(loadEditor);
29
+ const DiagramEditDialog = lazy(() =>
30
+ import("./diagrams/EditDialog").then((module) => ({ default: module.DiagramEditDialog })),
31
+ );
26
32
 
27
33
  // Warm the editor chunk as soon as a pointer lands on any section, so by the
28
34
  // time the user has double-clicked or reached the pencil the lazy import is
@@ -94,7 +100,8 @@ function Section({
94
100
  endLine: number;
95
101
  children: ReactNode;
96
102
  }) {
97
- const [mode, setMode] = useState<"read" | "loading" | "edit">("read");
103
+ const [mode, setMode] = useState<"read" | "loading" | "edit" | "diagram">("read");
104
+ const [initialDiagram, setInitialDiagram] = useState<ExistingDiagram | undefined>();
98
105
  const [source, setSource] = useState("");
99
106
  const [version, setVersion] = useState("");
100
107
  const [openSection, setOpenSection] = useAtom(openSectionAtom);
@@ -120,9 +127,11 @@ function Section({
120
127
  };
121
128
  }, [key, setOpenSection]);
122
129
 
123
- async function startEdit() {
130
+ async function startEdit(target?: ExistingDiagram) {
131
+ if (shellInfo.permissions === "restricted") return;
124
132
  // Double-click and the pencil can both fire while a fetch is in flight.
125
133
  if (mode !== "read") return;
134
+ setInitialDiagram(target);
126
135
  setOpenSection(key);
127
136
  setMode("loading");
128
137
  try {
@@ -145,7 +154,7 @@ function Section({
145
154
  // one go — instead of swapping in the Suspense fallback first and
146
155
  // then the editor, which read as a flash.
147
156
  startTransition(() => {
148
- setMode((current) => (current === "loading" ? "edit" : current));
157
+ setMode((current) => (current === "loading" ? (target ? "diagram" : "edit") : current));
149
158
  });
150
159
  } catch {
151
160
  pushToast({ tone: "danger", text: "Couldn't open section for editing" });
@@ -202,6 +211,7 @@ function Section({
202
211
  // word inside a code block, and svg-pan-zoom's double-click zoom on a
203
212
  // mermaid diagram (client/Mermaid.tsx `dblClickZoomEnabled`).
204
213
  function onDoubleClick(event: MouseEvent<HTMLDivElement>) {
214
+ if (shellInfo.permissions === "restricted") return;
205
215
  const target = event.target as Element | null;
206
216
  if (target?.closest("a, button, input, select, textarea, summary, pre, svg")) return;
207
217
  event.preventDefault();
@@ -209,26 +219,51 @@ function Section({
209
219
  }
210
220
 
211
221
  return (
212
- <div
213
- className="mdx-section group"
214
- data-md-section={index}
215
- onDoubleClick={onDoubleClick}
216
- onPointerEnter={preloadEditor}
222
+ <DiagramEditContext.Provider
223
+ value={
224
+ shellInfo.permissions !== "restricted" && mode === "read"
225
+ ? (target) => void startEdit(target)
226
+ : null
227
+ }
217
228
  >
218
- {children}
219
- {/* Floats over the section's top-right corner rather than in the left
229
+ <AnimatePresence>
230
+ {mode === "diagram" && initialDiagram && (
231
+ <Suspense fallback={null}>
232
+ <DiagramEditDialog
233
+ source={source}
234
+ target={initialDiagram}
235
+ path={path}
236
+ version={version}
237
+ startLine={startLine}
238
+ endLine={endLine}
239
+ onClose={finishEdit}
240
+ />
241
+ </Suspense>
242
+ )}
243
+ </AnimatePresence>
244
+ <div
245
+ className="mdx-section group"
246
+ data-md-section={index}
247
+ onDoubleClick={shellInfo.permissions === "restricted" ? undefined : onDoubleClick}
248
+ onPointerEnter={shellInfo.permissions === "restricted" ? undefined : preloadEditor}
249
+ >
250
+ {children}
251
+ {/* Floats over the section's top-right corner rather than in the left
220
252
  gutter, which is where DocView's left ResizeHandle lives — the two
221
253
  hover affordances were fighting for the same strip of pixels. */}
222
- <IconButton
223
- icon="pencil"
224
- label="Edit section"
225
- size="sm"
226
- variant="outline"
227
- data-print-hide
228
- disabled={mode === "loading"}
229
- onClick={startEdit}
230
- className="absolute -top-3 right-0 z-20 opacity-0 shadow-sm transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
231
- />
232
- </div>
254
+ {shellInfo.permissions !== "restricted" && (
255
+ <IconButton
256
+ icon="pencil"
257
+ label="Edit section"
258
+ size="sm"
259
+ variant="outline"
260
+ data-print-hide
261
+ disabled={mode === "loading"}
262
+ onClick={() => void startEdit()}
263
+ className="mdx-section-edit absolute -top-3 right-0 z-20 opacity-0 shadow-sm transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
264
+ />
265
+ )}
266
+ </div>
267
+ </DiagramEditContext.Provider>
233
268
  );
234
269
  }