@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.
- package/LICENSE +21 -0
- package/README.md +233 -2
- package/client/App.tsx +7 -0
- package/client/CodeBlock.tsx +395 -0
- package/client/CrossFade.tsx +72 -0
- package/client/DocContext.ts +14 -0
- package/client/DocView.tsx +107 -0
- package/client/ErrorBox.tsx +23 -0
- package/client/Heading.tsx +31 -0
- package/client/HomeEmptyState.tsx +101 -0
- package/client/HomeView.tsx +78 -0
- package/client/ListingView.tsx +663 -0
- package/client/MdSection.tsx +234 -0
- package/client/MdSectionEditor.tsx +233 -0
- package/client/Mermaid.tsx +435 -0
- package/client/RenderErrorBoundary.tsx +40 -0
- package/client/Table.tsx +14 -0
- package/client/TaskCheckbox.tsx +38 -0
- package/client/api.ts +138 -0
- package/client/app.css +372 -0
- package/client/builtins/Badge.tsx +109 -0
- package/client/builtins/Button.tsx +111 -0
- package/client/builtins/Callout.tsx +97 -0
- package/client/builtins/Card.tsx +111 -0
- package/client/builtins/Chart.tsx +875 -0
- package/client/builtins/Diff.tsx +722 -0
- package/client/builtins/Dropdown.tsx +417 -0
- package/client/builtins/FileTree.tsx +87 -0
- package/client/builtins/Kbd.tsx +18 -0
- package/client/builtins/Screenshot.tsx +209 -0
- package/client/builtins/Sparkline.tsx +63 -0
- package/client/builtins/Tabs.tsx +169 -0
- package/client/builtins/Tooltip.tsx +52 -0
- package/client/builtins/chart-data.ts +133 -0
- package/client/builtins/index.ts +167 -0
- package/client/design/base/editor.css +151 -0
- package/client/design/base/prose.css +143 -0
- package/client/design/base/reset.css +79 -0
- package/client/design/tokens/colors.css +188 -0
- package/client/design/tokens/elevation.css +42 -0
- package/client/design/tokens/fonts.css +6 -0
- package/client/design/tokens/motion.css +76 -0
- package/client/design/tokens/spacing.css +34 -0
- package/client/design/tokens/typography.css +56 -0
- package/client/doc-module-cache.ts +17 -0
- package/client/editor-link.ts +27 -0
- package/client/entry.tsx +51 -0
- package/client/export-doc.ts +80 -0
- package/client/export-save.ts +96 -0
- package/client/favicon.svg +1 -0
- package/client/file-system-access.d.ts +29 -0
- package/client/format.ts +17 -0
- package/client/hooks.ts +34 -0
- package/client/lucide-icons.d.ts +9 -0
- package/client/mdx-components-base.ts +32 -0
- package/client/mdx-components.ts +18 -0
- package/client/mermaid-chart.ts +109 -0
- package/client/mermaid-direction.ts +73 -0
- package/client/motion.ts +104 -0
- package/client/platform.ts +16 -0
- package/client/route-path.ts +15 -0
- package/client/router.ts +452 -0
- package/client/shell/AppShell.tsx +401 -0
- package/client/shell/Footer.tsx +33 -0
- package/client/shell/NotFoundView.tsx +22 -0
- package/client/shell/Sidebar.tsx +169 -0
- package/client/shell/StandaloneShell.tsx +65 -0
- package/client/shell/TocRail.tsx +53 -0
- package/client/shell/TopBar.tsx +117 -0
- package/client/shell/use-doc-width.ts +61 -0
- package/client/shell/useToc.ts +77 -0
- package/client/ssr-entry.tsx +22 -0
- package/client/standalone-entry.tsx +51 -0
- package/client/state.ts +90 -0
- package/client/theme.ts +65 -0
- package/client/ui/Breadcrumb.tsx +49 -0
- package/client/ui/ConfirmDeleteDialog.tsx +113 -0
- package/client/ui/ExpandModal.tsx +342 -0
- package/client/ui/Icon.tsx +114 -0
- package/client/ui/IconButton.tsx +63 -0
- package/client/ui/Kbd.tsx +17 -0
- package/client/ui/PageNav.tsx +77 -0
- package/client/ui/ResizeHandle.tsx +201 -0
- package/client/ui/SearchDialog.tsx +187 -0
- package/client/ui/Tag.tsx +44 -0
- package/client/ui/Toast.tsx +189 -0
- package/client/ui/TocList.tsx +71 -0
- package/client/ui/icon-set.ts +102 -0
- package/client/ui/toast-count.ts +28 -0
- package/dist/cli.js +5091 -0
- package/dist/registry.json +703 -0
- package/dist/render-worker.js +145 -0
- package/package.json +113 -5
- 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
|
+
}
|