@karimsa/mdxserve 0.0.0-stage → 0.2.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 (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +233 -2
  3. package/client/App.tsx +7 -0
  4. package/client/CodeBlock.tsx +395 -0
  5. package/client/CrossFade.tsx +72 -0
  6. package/client/DocContext.ts +14 -0
  7. package/client/DocView.tsx +107 -0
  8. package/client/ErrorBox.tsx +23 -0
  9. package/client/Heading.tsx +31 -0
  10. package/client/HomeEmptyState.tsx +101 -0
  11. package/client/HomeView.tsx +78 -0
  12. package/client/ListingView.tsx +663 -0
  13. package/client/MdSection.tsx +234 -0
  14. package/client/MdSectionEditor.tsx +233 -0
  15. package/client/Mermaid.tsx +435 -0
  16. package/client/RenderErrorBoundary.tsx +40 -0
  17. package/client/Table.tsx +14 -0
  18. package/client/TaskCheckbox.tsx +38 -0
  19. package/client/api.ts +138 -0
  20. package/client/app.css +372 -0
  21. package/client/builtins/Badge.tsx +109 -0
  22. package/client/builtins/Button.tsx +111 -0
  23. package/client/builtins/Callout.tsx +97 -0
  24. package/client/builtins/Card.tsx +111 -0
  25. package/client/builtins/Chart.tsx +875 -0
  26. package/client/builtins/Diff.tsx +722 -0
  27. package/client/builtins/Dropdown.tsx +417 -0
  28. package/client/builtins/FileTree.tsx +87 -0
  29. package/client/builtins/Kbd.tsx +18 -0
  30. package/client/builtins/Screenshot.tsx +209 -0
  31. package/client/builtins/Sparkline.tsx +63 -0
  32. package/client/builtins/Tabs.tsx +169 -0
  33. package/client/builtins/Tooltip.tsx +52 -0
  34. package/client/builtins/chart-data.ts +133 -0
  35. package/client/builtins/index.ts +167 -0
  36. package/client/design/base/editor.css +151 -0
  37. package/client/design/base/prose.css +143 -0
  38. package/client/design/base/reset.css +79 -0
  39. package/client/design/tokens/colors.css +188 -0
  40. package/client/design/tokens/elevation.css +42 -0
  41. package/client/design/tokens/fonts.css +6 -0
  42. package/client/design/tokens/motion.css +76 -0
  43. package/client/design/tokens/spacing.css +34 -0
  44. package/client/design/tokens/typography.css +56 -0
  45. package/client/doc-module-cache.ts +17 -0
  46. package/client/editor-link.ts +27 -0
  47. package/client/entry.tsx +51 -0
  48. package/client/export-doc.ts +80 -0
  49. package/client/export-save.ts +96 -0
  50. package/client/favicon.svg +1 -0
  51. package/client/file-system-access.d.ts +29 -0
  52. package/client/format.ts +17 -0
  53. package/client/hooks.ts +34 -0
  54. package/client/lucide-icons.d.ts +9 -0
  55. package/client/mdx-components-base.ts +32 -0
  56. package/client/mdx-components.ts +18 -0
  57. package/client/mermaid-chart.ts +109 -0
  58. package/client/mermaid-direction.ts +73 -0
  59. package/client/motion.ts +104 -0
  60. package/client/platform.ts +16 -0
  61. package/client/route-path.ts +15 -0
  62. package/client/router.ts +452 -0
  63. package/client/shell/AppShell.tsx +401 -0
  64. package/client/shell/Footer.tsx +33 -0
  65. package/client/shell/NotFoundView.tsx +22 -0
  66. package/client/shell/Sidebar.tsx +169 -0
  67. package/client/shell/StandaloneShell.tsx +65 -0
  68. package/client/shell/TocRail.tsx +53 -0
  69. package/client/shell/TopBar.tsx +117 -0
  70. package/client/shell/use-doc-width.ts +61 -0
  71. package/client/shell/useToc.ts +77 -0
  72. package/client/ssr-entry.tsx +22 -0
  73. package/client/standalone-entry.tsx +51 -0
  74. package/client/state.ts +90 -0
  75. package/client/theme.ts +65 -0
  76. package/client/ui/Breadcrumb.tsx +49 -0
  77. package/client/ui/ConfirmDeleteDialog.tsx +113 -0
  78. package/client/ui/ExpandModal.tsx +342 -0
  79. package/client/ui/Icon.tsx +114 -0
  80. package/client/ui/IconButton.tsx +63 -0
  81. package/client/ui/Kbd.tsx +17 -0
  82. package/client/ui/PageNav.tsx +77 -0
  83. package/client/ui/ResizeHandle.tsx +201 -0
  84. package/client/ui/SearchDialog.tsx +187 -0
  85. package/client/ui/Tag.tsx +44 -0
  86. package/client/ui/Toast.tsx +189 -0
  87. package/client/ui/TocList.tsx +71 -0
  88. package/client/ui/icon-set.ts +102 -0
  89. package/client/ui/toast-count.ts +28 -0
  90. package/dist/cli.js +5091 -0
  91. package/dist/registry.json +703 -0
  92. package/dist/render-worker.js +145 -0
  93. package/package.json +113 -5
  94. package/skills/mdxserve/SKILL.md +178 -0
@@ -0,0 +1,234 @@
1
+ import {
2
+ lazy,
3
+ startTransition,
4
+ Suspense,
5
+ useContext,
6
+ useEffect,
7
+ useState,
8
+ type MouseEvent,
9
+ type ReactNode,
10
+ } from "react";
11
+ import { useAtom } from "jotai";
12
+ import { trpcClient } from "./api";
13
+ import { DocContext } from "./DocContext";
14
+ import { openSectionAtom } from "./state";
15
+ import { IconButton } from "./ui/IconButton";
16
+ import { pushToast } from "./ui/Toast";
17
+
18
+ // The Tiptap editor (~300 KB of ProseMirror) must never be part of a doc
19
+ // module's own bundle, and must never be evaluated by the SSR render worker
20
+ // (client/ssr-entry.tsx), which only ever renders sections in read mode. A
21
+ // module-scope `lazy()` call is safe in both places: it just builds a
22
+ // component descriptor around a loader, and the loader only actually runs
23
+ // when React commits the "edit" branch below, which SSR never reaches.
24
+ const loadEditor = () => import("./MdSectionEditor");
25
+ const MdSectionEditor = lazy(loadEditor);
26
+
27
+ // Warm the editor chunk as soon as a pointer lands on any section, so by the
28
+ // time the user has double-clicked or reached the pencil the lazy import is
29
+ // already cached and the switch into edit mode doesn't wait on the network.
30
+ let editorPreloaded = false;
31
+ function preloadEditor(): void {
32
+ if (editorPreloaded) return;
33
+ editorPreloaded = true;
34
+ void loadEditor();
35
+ }
36
+
37
+ export interface MdSectionProps {
38
+ /** 0-based order among a doc's sections, as a string (mdxJsxFlowElement attributes are always strings). */
39
+ index: string;
40
+ /** 1-based, inclusive source line numbers, as strings. */
41
+ startLine: string;
42
+ endLine: string;
43
+ children: ReactNode;
44
+ }
45
+
46
+ /**
47
+ * MDXProvider registration (see client/mdx-components.ts) for the synthetic
48
+ * `<MdSection>` element the server's remark-sections compiler plugin wraps
49
+ * around every maximal run of pure-markdown top-level nodes. In read mode
50
+ * it's invisible — same children, same DOM — until hovered, when a pencil
51
+ * appears to swap it into an inline Tiptap editor seeded from the raw file.
52
+ *
53
+ * This outer component only resolves `path` and re-keys the inner `Section`
54
+ * on `path:startLine:endLine`. That key matters after a save: Vite's HMR
55
+ * re-executes the doc module and React Fast Refresh re-renders this
56
+ * component in place with *new* startLine/endLine props (the edited range
57
+ * shifted). Without the key, Fast Refresh would preserve `Section`'s local
58
+ * state across that update and leave it holding an editor pointed at
59
+ * now-stale line numbers; keying on the range forces a clean remount back to
60
+ * read mode instead.
61
+ */
62
+ export function MdSection({ index, startLine, endLine, children }: MdSectionProps) {
63
+ const ctxPath = useContext(DocContext)?.path;
64
+ // MdSection also renders inside the SSR worker (client/ssr-entry.tsx, used
65
+ // by the `mdxserve validate` render check via renderToString), which has no
66
+ // router and so no DocContext provider — and no `location` global at all.
67
+ // Read mode never needs `path`, so falling back to "" there is fine.
68
+ const path =
69
+ ctxPath ?? (typeof location !== "undefined" ? decodeURIComponent(location.pathname) : "");
70
+
71
+ return (
72
+ <Section
73
+ key={`${path}:${startLine}:${endLine}`}
74
+ path={path}
75
+ index={index}
76
+ startLine={Number(startLine)}
77
+ endLine={Number(endLine)}
78
+ >
79
+ {children}
80
+ </Section>
81
+ );
82
+ }
83
+
84
+ function Section({
85
+ path,
86
+ index,
87
+ startLine,
88
+ endLine,
89
+ children,
90
+ }: {
91
+ path: string;
92
+ index: string;
93
+ startLine: number;
94
+ endLine: number;
95
+ children: ReactNode;
96
+ }) {
97
+ const [mode, setMode] = useState<"read" | "loading" | "edit">("read");
98
+ const [source, setSource] = useState("");
99
+ const [version, setVersion] = useState("");
100
+ const [openSection, setOpenSection] = useAtom(openSectionAtom);
101
+ const key = `${path}:${startLine}:${endLine}`;
102
+
103
+ // Only one section edits (or loads) at a time. The atom is claimed the
104
+ // moment a section starts opening, so if a different section claims it
105
+ // while this one is mid-fetch or mid-edit, this one drops back to read
106
+ // mode and discards whatever it had — no confirmation, matching the
107
+ // "explicit Save / Esc cancels, no autosave" model. Claiming up front
108
+ // (rather than when the fetch lands) is what makes a slow earlier click
109
+ // lose to a faster later one instead of clobbering it.
110
+ useEffect(() => {
111
+ if (mode !== "read" && openSection !== key) setMode("read");
112
+ }, [openSection, key, mode]);
113
+
114
+ // If this section unmounts while it owns the atom — a navigation away, or
115
+ // the HMR remount after another section's save shifted our line range —
116
+ // release it, or DocView would keep the doc's resize handles hidden.
117
+ useEffect(() => {
118
+ return () => {
119
+ setOpenSection((current) => (current === key ? null : current));
120
+ };
121
+ }, [key, setOpenSection]);
122
+
123
+ async function startEdit() {
124
+ // Double-click and the pencil can both fire while a fetch is in flight.
125
+ if (mode !== "read") return;
126
+ setOpenSection(key);
127
+ setMode("loading");
128
+ try {
129
+ // The vanilla client, not the query cache: the source must be exactly
130
+ // what is on disk at this moment, never a cached copy.
131
+ const data = await trpcClient.getDocSource.query({ path });
132
+ // Functional update so a response that lands after another section
133
+ // took the atom (the effect above has already reset us to "read")
134
+ // is dropped rather than resurrecting this section's editor.
135
+ const lines = data.text.split(/\r?\n/);
136
+ const sliced = lines.slice(startLine - 1, endLine).join("\n");
137
+ setMode((current) => {
138
+ if (current !== "loading") return current;
139
+ setSource(sliced);
140
+ setVersion(data.version);
141
+ return current;
142
+ });
143
+ // A transition, so React keeps the read-mode DOM on screen until the
144
+ // lazy editor chunk has resolved and MdSectionEditor can commit in
145
+ // one go — instead of swapping in the Suspense fallback first and
146
+ // then the editor, which read as a flash.
147
+ startTransition(() => {
148
+ setMode((current) => (current === "loading" ? "edit" : current));
149
+ });
150
+ } catch {
151
+ pushToast({ tone: "danger", text: "Couldn't open section for editing" });
152
+ setMode((current) => (current === "loading" ? "read" : current));
153
+ setOpenSection((current) => (current === key ? null : current));
154
+ }
155
+ }
156
+
157
+ // Called by MdSectionEditor on both Save-success and Cancel — this section
158
+ // is (by construction — see the effect above) still the open one whenever
159
+ // its own editor is what's calling this, so clearing unconditionally is safe.
160
+ function finishEdit() {
161
+ // Only release the atom if it is still ours: a slow save can resolve
162
+ // after another section has claimed it, and clearing unconditionally
163
+ // would close that newer editor and discard its unsaved edits.
164
+ setOpenSection((current) => (current === key ? null : current));
165
+ setMode("read");
166
+ }
167
+
168
+ // MdSectionEditor renders its own `.mdx-section[data-editing]` wrapper (it
169
+ // needs to be the direct parent of `.ProseMirror` for the prose rhythm
170
+ // rules in client/design/base/prose.css to reach it), so this branch adds
171
+ // nothing around it — a second `.mdx-section` here would double-nest and
172
+ // throw off the `:is(.mdx-prose, .mdx-section, …) > * + *` selectors.
173
+ //
174
+ // The fallback is the read-mode content itself, not a skeleton: with the
175
+ // transition in startEdit this branch normally never suspends visibly, and
176
+ // if it ever does (chunk evicted, slow network) the section simply stays
177
+ // as it was until the editor is ready.
178
+ if (mode === "edit") {
179
+ return (
180
+ <Suspense
181
+ fallback={
182
+ <div className="mdx-section" data-md-section={index}>
183
+ {children}
184
+ </div>
185
+ }
186
+ >
187
+ <MdSectionEditor
188
+ source={source}
189
+ version={version}
190
+ path={path}
191
+ startLine={startLine}
192
+ endLine={endLine}
193
+ onDone={finishEdit}
194
+ />
195
+ </Suspense>
196
+ );
197
+ }
198
+
199
+ // Double-clicking prose is the fast path into the editor. It has to stay
200
+ // out of the way where a double-click already means something: following/
201
+ // selecting a link, a button (code copy, tabs), a task checkbox, selecting a
202
+ // word inside a code block, and svg-pan-zoom's double-click zoom on a
203
+ // mermaid diagram (client/Mermaid.tsx `dblClickZoomEnabled`).
204
+ function onDoubleClick(event: MouseEvent<HTMLDivElement>) {
205
+ const target = event.target as Element | null;
206
+ if (target?.closest("a, button, input, select, textarea, summary, pre, svg")) return;
207
+ event.preventDefault();
208
+ void startEdit();
209
+ }
210
+
211
+ return (
212
+ <div
213
+ className="mdx-section group"
214
+ data-md-section={index}
215
+ onDoubleClick={onDoubleClick}
216
+ onPointerEnter={preloadEditor}
217
+ >
218
+ {children}
219
+ {/* Floats over the section's top-right corner rather than in the left
220
+ gutter, which is where DocView's left ResizeHandle lives — the two
221
+ 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>
233
+ );
234
+ }
@@ -0,0 +1,233 @@
1
+ import { useEffect, useState, type KeyboardEvent, type MouseEvent } from "react";
2
+ import type { Editor } from "@tiptap/core";
3
+ import type { Mark } from "@tiptap/pm/model";
4
+ import { EditorContent, useEditor } from "@tiptap/react";
5
+ import { StarterKit } from "@tiptap/starter-kit";
6
+ import { TaskItem, TaskList } from "@tiptap/extension-list";
7
+ import { TableKit } from "@tiptap/extension-table";
8
+ import { Image } from "@tiptap/extension-image";
9
+ import { Markdown } from "@tiptap/markdown";
10
+ import { motion } from "framer-motion";
11
+ import { isTRPCClientError } from "@trpc/client";
12
+ import { trpcClient } from "./api";
13
+ import { TRANSITIONS } from "./motion";
14
+ import { isApplePlatform } from "./platform";
15
+ import { modifiedLinkHref } from "./editor-link";
16
+ import { Kbd } from "./ui/Kbd";
17
+ import { pushToast } from "./ui/Toast";
18
+
19
+ // The two hints enter together, each springing a few px rightwards while it
20
+ // fades in, the second a beat after the first.
21
+ const hintStack = { animate: { transition: { staggerChildren: 0.05 } } };
22
+ const hint = {
23
+ initial: { opacity: 0, x: -10 },
24
+ animate: { opacity: 1, x: 0, transition: TRANSITIONS.glide },
25
+ };
26
+
27
+ export interface MdSectionEditorProps {
28
+ /** Raw markdown for exactly [startLine, endLine], sliced by MdSection.tsx. */
29
+ source: string;
30
+ /** Version token for the whole file at the moment `source` was fetched — the save's stale-write guard. */
31
+ version: string;
32
+ path: string;
33
+ startLine: number;
34
+ endLine: number;
35
+ /** Called after a successful save, or on Cancel — either way the section returns to read mode. */
36
+ onDone: () => void;
37
+ }
38
+
39
+ /**
40
+ * Markdown soft line breaks (a hard-wrapped paragraph in the source) survive
41
+ * @tiptap/markdown's parse as literal "\n" inside text nodes, and `.ProseMirror`
42
+ * renders with `white-space: pre-wrap`, so a paragraph wrapped at 80 columns
43
+ * on disk would show up in the editor broken across lines mid-sentence. Turn
44
+ * them into spaces up front — exactly what every Markdown renderer does with a
45
+ * soft break — leaving code blocks alone, where "\n" is real content. Text
46
+ * nodes are rebuilt with their own marks so bold/italic/link spans that
47
+ * happened to straddle a wrap keep their formatting. The edited paragraph is
48
+ * therefore written back as one line on save.
49
+ */
50
+ function unwrapSoftBreaks(editor: Editor): void {
51
+ if (editor.isDestroyed) return;
52
+ const { state } = editor;
53
+ const edits: { from: number; to: number; text: string; marks: readonly Mark[] }[] = [];
54
+ state.doc.descendants((node, pos, parent) => {
55
+ if (!node.isText || !node.text?.includes("\n")) return;
56
+ if (parent?.type.name === "codeBlock") return;
57
+ edits.push({
58
+ from: pos,
59
+ to: pos + node.nodeSize,
60
+ text: node.text.replace(/\n/g, " "),
61
+ marks: node.marks,
62
+ });
63
+ });
64
+ if (edits.length === 0) return;
65
+ const tr = state.tr;
66
+ // Apply back to front so earlier positions stay valid.
67
+ for (const edit of edits.reverse()) {
68
+ tr.replaceWith(edit.from, edit.to, state.schema.text(edit.text, edit.marks));
69
+ }
70
+ editor.view.dispatch(tr.setMeta("addToHistory", false));
71
+ }
72
+
73
+ /**
74
+ * The Tiptap editing surface for one `MdSection`. This is the module
75
+ * `client/MdSection.tsx` lazy-loads on click, so its ~300 KB of ProseMirror
76
+ * never ships with a doc's own bundle and is never touched by the SSR render
77
+ * worker (which only ever renders sections in read mode).
78
+ *
79
+ * `StarterKit` 3.29 bundles Link, Underline and Strike itself (confirmed in
80
+ * node_modules/@tiptap/starter-kit/src/starter-kit.ts), so no separate
81
+ * `@tiptap/extension-link` install is needed to configure `link`.
82
+ */
83
+ export default function MdSectionEditor({
84
+ source,
85
+ version,
86
+ path,
87
+ startLine,
88
+ endLine,
89
+ onDone,
90
+ }: MdSectionEditorProps) {
91
+ const [saving, setSaving] = useState(false);
92
+ // Drives the hints' entrance. Not framer's `initial`/`animate` on mount:
93
+ // this component mounts through Suspense inside a transition, and its
94
+ // first commit can happen while the subtree is still hidden, so a
95
+ // mount-time animation has already finished by the time it's on screen.
96
+ // Flipping state in the post-mount effect below starts the spring on the
97
+ // visible commit instead.
98
+ const [entered, setEntered] = useState(false);
99
+
100
+ const editor = useEditor({
101
+ extensions: [
102
+ StarterKit.configure({
103
+ heading: { levels: [1, 2, 3, 4, 5, 6] },
104
+ link: { openOnClick: false },
105
+ }),
106
+ TaskList,
107
+ TaskItem.configure({ nested: true }),
108
+ TableKit,
109
+ Image,
110
+ Markdown,
111
+ ],
112
+ content: source,
113
+ contentType: "markdown",
114
+ onCreate: ({ editor: created }) => unwrapSoftBreaks(created),
115
+ editorProps: { attributes: { class: "mdx-editor", "aria-label": "Section editor" } },
116
+ });
117
+
118
+ // Not `autofocus`: useEditor creates the editor before <EditorContent>
119
+ // below has attached its view to the DOM, so focusing at creation time is
120
+ // a no-op. This effect runs after the child's effects, i.e. once the view
121
+ // is mounted, for both the pencil and the double-click entry paths.
122
+ //
123
+ // The `isDestroyed` guard is load-bearing: this component mounts through
124
+ // React.lazy + Suspense, and useEditor can hand the first committed render
125
+ // an instance it has already torn down and is about to replace. Reading
126
+ // `.commands` on a destroyed Editor dereferences a null commandManager and
127
+ // throws; the replacement instance re-runs this effect and focuses.
128
+ useEffect(() => {
129
+ if (!editor || editor.isDestroyed) return;
130
+ editor.commands.focus("start");
131
+ setEntered(true);
132
+ }, [editor]);
133
+
134
+ async function save() {
135
+ if (!editor || saving) return;
136
+ setSaving(true);
137
+ try {
138
+ await trpcClient.saveDocSection.mutate({
139
+ path,
140
+ startLine,
141
+ endLine,
142
+ version,
143
+ markdown: editor.getMarkdown(),
144
+ });
145
+ // The write lands on disk, chokidar → Vite HMR re-executes the doc
146
+ // module, and Fast Refresh re-renders this section's MdSection
147
+ // wrapper with fresh line numbers — no need to do anything here
148
+ // beyond leaving edit mode.
149
+ onDone();
150
+ } catch (error) {
151
+ const code = isTRPCClientError(error) ? error.data?.code : undefined;
152
+ if (code === "CONFLICT") {
153
+ pushToast({ tone: "warn", text: "File changed on disk — reopen the section" });
154
+ } else if (code === "UNPROCESSABLE_CONTENT") {
155
+ console.error(error);
156
+ pushToast({ tone: "danger", text: "Couldn't save — the markdown doesn't compile" });
157
+ } else {
158
+ console.error(error);
159
+ pushToast({ tone: "danger", text: "Couldn't save section" });
160
+ }
161
+ } finally {
162
+ setSaving(false);
163
+ }
164
+ }
165
+
166
+ // Cmd/ctrl+click follows a link (client/editor-link.ts says why the
167
+ // browser's own new-tab gesture is inert inside a contenteditable). A DOM
168
+ // `click` listener rather than ProseMirror's `handleClick` prop: that one
169
+ // only fires when ProseMirror's own mousedown→mouseup tracking survives,
170
+ // and it bails on a few pixels of pointer drift. The opener is a detached
171
+ // `target=_blank` anchor rather than `window.open`, which Chrome turns into
172
+ // a popup window as soon as a feature string (even `noopener`) is passed;
173
+ // `noopener` because the target may be any site the doc links to.
174
+ function onClick(event: MouseEvent<HTMLDivElement>) {
175
+ const anchor = (event.target as Element | null)?.closest("a");
176
+ if (!anchor) return;
177
+ // The raw attribute, not `anchor.href`: an empty target (`[label]()`)
178
+ // resolves to the current page and would open a duplicate tab.
179
+ const href = modifiedLinkHref(event, anchor.getAttribute("href"));
180
+ if (!href) return;
181
+ event.preventDefault();
182
+ const opener = document.createElement("a");
183
+ opener.href = href;
184
+ opener.target = "_blank";
185
+ opener.rel = "noopener noreferrer";
186
+ opener.click();
187
+ }
188
+
189
+ function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
190
+ if ((event.metaKey || event.ctrlKey) && event.key === "s") {
191
+ event.preventDefault();
192
+ void save();
193
+ return;
194
+ }
195
+ if (event.key === "Escape") {
196
+ // Cancel discards immediately — no confirmation, matching the
197
+ // no-autosave model: nothing was ever written until Save.
198
+ event.preventDefault();
199
+ onDone();
200
+ }
201
+ }
202
+
203
+ // Editor construction is synchronous but not instant on first mount;
204
+ // render nothing rather than a half-built surface.
205
+ if (!editor) return null;
206
+
207
+ return (
208
+ <div className="mdx-section" data-editing onClick={onClick} onKeyDown={onKeyDown}>
209
+ <EditorContent editor={editor} />
210
+ {/* Keyboard is the only way out of edit mode (no buttons), so the hints
211
+ float in the right gutter beside the frame, out of the text flow —
212
+ the section keeps its read-mode box exactly. pointer-events-none so
213
+ the right ResizeHandle underneath still works. */}
214
+ <motion.div
215
+ aria-hidden="true"
216
+ className="pointer-events-none absolute top-0 left-[calc(100%+var(--space-8))] z-20 flex w-max flex-col gap-1.5 text-[length:var(--size-xs)] text-text-subtle"
217
+ variants={hintStack}
218
+ initial="initial"
219
+ animate={entered ? "animate" : "initial"}
220
+ >
221
+ <motion.span variants={hint} className="flex items-center gap-1.5">
222
+ <Kbd>{isApplePlatform() ? "⌘S" : "Ctrl S"}</Kbd> to save
223
+ </motion.span>
224
+ <motion.span variants={hint} className="flex items-center gap-1.5">
225
+ <Kbd>Esc</Kbd> to cancel
226
+ </motion.span>
227
+ <motion.span variants={hint} className="flex items-center gap-1.5">
228
+ <Kbd>{isApplePlatform() ? "⌘" : "Ctrl"}</Kbd> click to follow a link
229
+ </motion.span>
230
+ </motion.div>
231
+ </div>
232
+ );
233
+ }