@karimsa/mdxserve 0.0.0-stage → 0.1.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 +115 -5
- package/skills/mdxserve/SKILL.md +178 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { AnimatePresence, motion } from "framer-motion";
|
|
4
|
+
import { formatModified, formatSize } from "../format";
|
|
5
|
+
import Button from "../builtins/Button";
|
|
6
|
+
import type { ListingEntry } from "../router";
|
|
7
|
+
import { Icon } from "./Icon";
|
|
8
|
+
import { VARIANTS } from "../motion";
|
|
9
|
+
|
|
10
|
+
export function ConfirmDeleteDialog({
|
|
11
|
+
open,
|
|
12
|
+
files,
|
|
13
|
+
pending,
|
|
14
|
+
onCancel,
|
|
15
|
+
onConfirm,
|
|
16
|
+
}: {
|
|
17
|
+
open: boolean;
|
|
18
|
+
/** Selected file entries, in listing order. */
|
|
19
|
+
files: ListingEntry[];
|
|
20
|
+
pending: boolean;
|
|
21
|
+
onCancel: () => void;
|
|
22
|
+
onConfirm: () => void;
|
|
23
|
+
}) {
|
|
24
|
+
// Document-level, not panel-level: while a delete is pending both buttons
|
|
25
|
+
// are disabled and the browser drops focus to <body>, so a panel onKeyDown
|
|
26
|
+
// would stop hearing Escape (e.g. after a failed request leaves the dialog
|
|
27
|
+
// open).
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!open) return;
|
|
30
|
+
function onKeyDown(event: KeyboardEvent) {
|
|
31
|
+
if (event.key === "Escape" && !pending) {
|
|
32
|
+
event.preventDefault();
|
|
33
|
+
onCancel();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
document.addEventListener("keydown", onKeyDown);
|
|
37
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
38
|
+
}, [open, pending, onCancel]);
|
|
39
|
+
|
|
40
|
+
// Portal to <body>: this renders inside the route wrapper, whose fadeRise
|
|
41
|
+
// transform would otherwise turn `fixed` into "fixed to the column" and let
|
|
42
|
+
// the scrim miss the top bar and sidebar.
|
|
43
|
+
return createPortal(
|
|
44
|
+
<AnimatePresence>
|
|
45
|
+
{open ? (
|
|
46
|
+
<motion.div
|
|
47
|
+
key="scrim"
|
|
48
|
+
onClick={pending ? undefined : onCancel}
|
|
49
|
+
{...VARIANTS.scrim}
|
|
50
|
+
className="fixed inset-0 z-[var(--z-modal)] flex justify-center bg-[var(--scrim)] pt-[10vh] backdrop-blur-sm"
|
|
51
|
+
>
|
|
52
|
+
<motion.div
|
|
53
|
+
onClick={(event) => event.stopPropagation()}
|
|
54
|
+
{...VARIANTS.pop}
|
|
55
|
+
role="alertdialog"
|
|
56
|
+
aria-modal="true"
|
|
57
|
+
aria-labelledby="confirm-delete-title"
|
|
58
|
+
className="w-[560px] max-w-[92vw] self-start overflow-hidden rounded-xl border border-border-default bg-surface-raised shadow-lg"
|
|
59
|
+
>
|
|
60
|
+
<div className="flex items-center gap-2 border-b border-border-subtle px-4 py-3">
|
|
61
|
+
<Icon name="trash-2" size="md" className="text-status-danger-fg" />
|
|
62
|
+
<div className="flex flex-col">
|
|
63
|
+
<span
|
|
64
|
+
id="confirm-delete-title"
|
|
65
|
+
className="font-sans font-semibold text-text-heading text-[length:var(--size-md)]"
|
|
66
|
+
>
|
|
67
|
+
Delete {files.length} {files.length === 1 ? "file" : "files"}?
|
|
68
|
+
</span>
|
|
69
|
+
<span className="font-sans text-[length:var(--size-sm)] text-text-muted">
|
|
70
|
+
They’ll be moved to the system Trash — this can be undone from there.
|
|
71
|
+
</span>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
<div className="max-h-72 overflow-y-auto p-2">
|
|
75
|
+
{files.map((file) => (
|
|
76
|
+
<div key={file.name} className="flex items-center gap-2 rounded-md px-2 py-1.5">
|
|
77
|
+
<Icon name="file-text" size="sm" className="shrink-0 text-text-subtle" />
|
|
78
|
+
<span className="truncate font-mono text-[length:var(--size-sm)]">
|
|
79
|
+
{file.name}
|
|
80
|
+
</span>
|
|
81
|
+
<span className="ml-auto flex shrink-0 gap-4 pl-4 font-mono text-[length:var(--size-xs)] text-text-subtle tabular-nums">
|
|
82
|
+
{typeof file.mtime === "number" ? (
|
|
83
|
+
<span>{formatModified(file.mtime)}</span>
|
|
84
|
+
) : null}
|
|
85
|
+
{typeof file.size === "number" ? (
|
|
86
|
+
<span className="w-16 text-right">{formatSize(file.size)}</span>
|
|
87
|
+
) : null}
|
|
88
|
+
</span>
|
|
89
|
+
</div>
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
<div className="flex justify-end gap-2 border-t border-border-subtle px-4 py-3">
|
|
93
|
+
{/* Start keyboard focus on the safe action. */}
|
|
94
|
+
<Button variant="secondary" size="sm" autoFocus disabled={pending} onClick={onCancel}>
|
|
95
|
+
Cancel
|
|
96
|
+
</Button>
|
|
97
|
+
<Button
|
|
98
|
+
variant="danger"
|
|
99
|
+
size="sm"
|
|
100
|
+
icon="trash-2"
|
|
101
|
+
disabled={pending}
|
|
102
|
+
onClick={onConfirm}
|
|
103
|
+
>
|
|
104
|
+
{pending ? "Moving…" : "Move to Trash"}
|
|
105
|
+
</Button>
|
|
106
|
+
</div>
|
|
107
|
+
</motion.div>
|
|
108
|
+
</motion.div>
|
|
109
|
+
) : null}
|
|
110
|
+
</AnimatePresence>,
|
|
111
|
+
document.body,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { AnimatePresence, motion, useSpring } from "framer-motion";
|
|
4
|
+
import { Icon } from "./Icon";
|
|
5
|
+
import { IconButton } from "./IconButton";
|
|
6
|
+
import { Kbd } from "./Kbd";
|
|
7
|
+
import { TRANSITIONS, VARIANTS } from "../motion";
|
|
8
|
+
|
|
9
|
+
export interface ExpandModalProps {
|
|
10
|
+
open: boolean;
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
/** Lucide icon shown before the title. */
|
|
13
|
+
icon: string;
|
|
14
|
+
title: string;
|
|
15
|
+
/** Short interaction hint after the title, e.g. "Drag to pan · scroll to zoom". */
|
|
16
|
+
hint?: ReactNode;
|
|
17
|
+
/** Controls shown at the right of the header, before the close button. */
|
|
18
|
+
actions?: ReactNode;
|
|
19
|
+
/** Fills the panel below the header; render it `h-full` to take the whole area. */
|
|
20
|
+
children: ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Explicit panel size once the reader has dragged an edge; null = the default inset. */
|
|
24
|
+
interface PanelSize {
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Edge = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
|
|
30
|
+
|
|
31
|
+
const MIN_WIDTH = 480;
|
|
32
|
+
const MIN_HEIGHT = 320;
|
|
33
|
+
/** Smallest gap kept between the panel and the viewport while resizing. */
|
|
34
|
+
const MARGIN = 16;
|
|
35
|
+
|
|
36
|
+
// Grab strips are 10px wide, straddling the edge (5px out, 5px in); corners are 16px squares.
|
|
37
|
+
// Written as literal class strings so Tailwind's scanner can see them.
|
|
38
|
+
const EDGE_HANDLES: { edge: Edge; className: string; bar?: string }[] = [
|
|
39
|
+
{
|
|
40
|
+
edge: "n",
|
|
41
|
+
className: "top-[-5px] right-4 left-4 h-[10px] cursor-ns-resize",
|
|
42
|
+
bar: "top-[4px] right-0 left-0 h-[2px]",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
edge: "s",
|
|
46
|
+
className: "bottom-[-5px] right-4 left-4 h-[10px] cursor-ns-resize",
|
|
47
|
+
bar: "bottom-[4px] right-0 left-0 h-[2px]",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
edge: "e",
|
|
51
|
+
className: "top-4 right-[-5px] bottom-4 w-[10px] cursor-ew-resize",
|
|
52
|
+
bar: "top-0 right-[4px] bottom-0 w-[2px]",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
edge: "w",
|
|
56
|
+
className: "top-4 bottom-4 left-[-5px] w-[10px] cursor-ew-resize",
|
|
57
|
+
bar: "top-0 bottom-0 left-[4px] w-[2px]",
|
|
58
|
+
},
|
|
59
|
+
{ edge: "nw", className: "top-[-5px] left-[-5px] h-4 w-4 cursor-nwse-resize" },
|
|
60
|
+
{ edge: "se", className: "right-[-5px] bottom-[-5px] h-4 w-4 cursor-nwse-resize" },
|
|
61
|
+
{ edge: "ne", className: "top-[-5px] right-[-5px] h-4 w-4 cursor-nesw-resize" },
|
|
62
|
+
{ edge: "sw", className: "bottom-[-5px] left-[-5px] h-4 w-4 cursor-nesw-resize" },
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
function clampSize(size: PanelSize, viewport: { width: number; height: number }): PanelSize {
|
|
66
|
+
// The minimum is capped by what's available: on a window narrower or shorter
|
|
67
|
+
// than the minimum the panel has to fit the viewport rather than overflow it
|
|
68
|
+
// (which would push the close control off screen).
|
|
69
|
+
const availableWidth = Math.max(0, viewport.width - MARGIN * 2);
|
|
70
|
+
const availableHeight = Math.max(0, viewport.height - MARGIN * 2);
|
|
71
|
+
const width = Math.min(Math.max(size.width, Math.min(MIN_WIDTH, availableWidth)), availableWidth);
|
|
72
|
+
const height = Math.min(
|
|
73
|
+
Math.max(size.height, Math.min(MIN_HEIGHT, availableHeight)),
|
|
74
|
+
availableHeight,
|
|
75
|
+
);
|
|
76
|
+
return { width: Math.round(width), height: Math.round(height) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A viewport coordinate, in CSS pixels — the same x/y the DOM's own rects use. */
|
|
80
|
+
interface Point {
|
|
81
|
+
x: number;
|
|
82
|
+
y: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The panel stays centred while it resizes, so moving one edge by `d` changes
|
|
87
|
+
* that dimension by `2d`. Each dimension is solved from the pointer's distance
|
|
88
|
+
* to the panel centre (minus where on the strip it was grabbed) rather than
|
|
89
|
+
* accumulated deltas, so a long drag can't drift.
|
|
90
|
+
*/
|
|
91
|
+
function resizeFromCentre(
|
|
92
|
+
edge: Edge,
|
|
93
|
+
pointer: Point,
|
|
94
|
+
centre: Point,
|
|
95
|
+
grab: Point,
|
|
96
|
+
current: PanelSize,
|
|
97
|
+
viewport: { width: number; height: number },
|
|
98
|
+
): PanelSize {
|
|
99
|
+
let { width, height } = current;
|
|
100
|
+
if (edge.includes("e")) width = 2 * (pointer.x - centre.x - grab.x);
|
|
101
|
+
if (edge.includes("w")) width = 2 * (centre.x - pointer.x - grab.x);
|
|
102
|
+
if (edge.includes("s")) height = 2 * (pointer.y - centre.y - grab.y);
|
|
103
|
+
if (edge.includes("n")) height = 2 * (centre.y - pointer.y - grab.y);
|
|
104
|
+
return clampSize({ width, height }, viewport);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Near-full-screen modal for exploring a diagram or chart at a larger scale.
|
|
109
|
+
* The panel takes the viewport minus a small inset by default; dragging any
|
|
110
|
+
* edge or corner resizes it symmetrically so it stays centred (double-click a
|
|
111
|
+
* handle, or reopen, to reset), and
|
|
112
|
+
* `children` get the whole area under a slim header (icon, title, hint, close).
|
|
113
|
+
*/
|
|
114
|
+
export function ExpandModal({
|
|
115
|
+
open,
|
|
116
|
+
onClose,
|
|
117
|
+
icon,
|
|
118
|
+
title,
|
|
119
|
+
hint,
|
|
120
|
+
actions,
|
|
121
|
+
children,
|
|
122
|
+
}: ExpandModalProps) {
|
|
123
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
124
|
+
// Whether the reader has taken over the size; the springs below hold it.
|
|
125
|
+
const [custom, setCustom] = useState(false);
|
|
126
|
+
const [dragging, setDragging] = useState<Edge | null>(null);
|
|
127
|
+
// glide, not snap (as for the sidebar/toc widths): the panel trails the
|
|
128
|
+
// cursor slightly so the spring is felt as it settles into shape.
|
|
129
|
+
const widthSpring = useSpring(0, TRANSITIONS.glide);
|
|
130
|
+
const heightSpring = useSpring(0, TRANSITIONS.glide);
|
|
131
|
+
/** Latest un-animated target, so clamping on window resize starts from the real size. */
|
|
132
|
+
const target = useRef<PanelSize | null>(null);
|
|
133
|
+
const drag = useRef<{
|
|
134
|
+
edge: Edge;
|
|
135
|
+
centre: Point;
|
|
136
|
+
/** Pointer distance past the grabbed edge at press time, so the edge doesn't snap under the cursor. */
|
|
137
|
+
grab: Point;
|
|
138
|
+
} | null>(null);
|
|
139
|
+
|
|
140
|
+
// Document-level so Escape works wherever focus landed (a pan surface or
|
|
141
|
+
// chart is a plain element and never holds focus itself). A control in the
|
|
142
|
+
// header that already consumed the key (the layout menu closing itself)
|
|
143
|
+
// marks the event default-prevented, and that Escape must not also close
|
|
144
|
+
// the modal.
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
if (!open) return;
|
|
147
|
+
function onKeyDown(event: KeyboardEvent) {
|
|
148
|
+
if (event.key === "Escape" && !event.defaultPrevented) {
|
|
149
|
+
event.preventDefault();
|
|
150
|
+
onClose();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
document.addEventListener("keydown", onKeyDown);
|
|
154
|
+
// Wheel-zooming the content must not also scroll the document behind the scrim.
|
|
155
|
+
const previousOverflow = document.body.style.overflow;
|
|
156
|
+
document.body.style.overflow = "hidden";
|
|
157
|
+
return () => {
|
|
158
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
159
|
+
document.body.style.overflow = previousOverflow;
|
|
160
|
+
// A drag interrupted by closing (Escape, scrim click, unmount) never sees
|
|
161
|
+
// pointerup, so release its global state here: otherwise text selection
|
|
162
|
+
// stays disabled page-wide and the next open treats a hover over a handle
|
|
163
|
+
// as an in-progress drag.
|
|
164
|
+
drag.current = null;
|
|
165
|
+
setDragging(null);
|
|
166
|
+
document.body.style.userSelect = "";
|
|
167
|
+
};
|
|
168
|
+
}, [open, onClose]);
|
|
169
|
+
|
|
170
|
+
// A custom size lasts for one viewing: reopening starts from the default inset.
|
|
171
|
+
const resetSize = useCallback(() => {
|
|
172
|
+
target.current = null;
|
|
173
|
+
setCustom(false);
|
|
174
|
+
}, []);
|
|
175
|
+
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
if (!open) resetSize();
|
|
178
|
+
}, [open, resetSize]);
|
|
179
|
+
|
|
180
|
+
// Keep a custom size inside the window if the window shrinks underneath it.
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
if (!custom) return;
|
|
183
|
+
function onResize() {
|
|
184
|
+
if (!target.current) return;
|
|
185
|
+
const next = clampSize(target.current, {
|
|
186
|
+
width: window.innerWidth,
|
|
187
|
+
height: window.innerHeight,
|
|
188
|
+
});
|
|
189
|
+
target.current = next;
|
|
190
|
+
widthSpring.set(next.width);
|
|
191
|
+
heightSpring.set(next.height);
|
|
192
|
+
}
|
|
193
|
+
window.addEventListener("resize", onResize);
|
|
194
|
+
return () => window.removeEventListener("resize", onResize);
|
|
195
|
+
}, [custom, widthSpring, heightSpring]);
|
|
196
|
+
|
|
197
|
+
const onHandleDown = useCallback(
|
|
198
|
+
(edge: Edge, event: React.PointerEvent<HTMLDivElement>) => {
|
|
199
|
+
const panel = panelRef.current;
|
|
200
|
+
if (!panel) return;
|
|
201
|
+
event.preventDefault();
|
|
202
|
+
const bounds = panel.getBoundingClientRect();
|
|
203
|
+
const centre = { x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2 };
|
|
204
|
+
const grab = {
|
|
205
|
+
x: edge.includes("e")
|
|
206
|
+
? event.clientX - bounds.right
|
|
207
|
+
: edge.includes("w")
|
|
208
|
+
? bounds.left - event.clientX
|
|
209
|
+
: 0,
|
|
210
|
+
y: edge.includes("s")
|
|
211
|
+
? event.clientY - bounds.bottom
|
|
212
|
+
: edge.includes("n")
|
|
213
|
+
? bounds.top - event.clientY
|
|
214
|
+
: 0,
|
|
215
|
+
};
|
|
216
|
+
drag.current = { edge, centre, grab };
|
|
217
|
+
// Land the springs on what's on screen (no animating up from 0) before
|
|
218
|
+
// the panel switches to the explicit, spring-driven size.
|
|
219
|
+
target.current = { width: bounds.width, height: bounds.height };
|
|
220
|
+
widthSpring.jump(bounds.width);
|
|
221
|
+
heightSpring.jump(bounds.height);
|
|
222
|
+
setCustom(true);
|
|
223
|
+
setDragging(edge);
|
|
224
|
+
try {
|
|
225
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
226
|
+
} catch {
|
|
227
|
+
// Not a live pointer (synthetic event); the drag still works while the cursor stays on the handle.
|
|
228
|
+
}
|
|
229
|
+
document.body.style.userSelect = "none";
|
|
230
|
+
},
|
|
231
|
+
[widthSpring, heightSpring],
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const onHandleMove = useCallback(
|
|
235
|
+
(event: React.PointerEvent<HTMLDivElement>) => {
|
|
236
|
+
const current = drag.current;
|
|
237
|
+
if (!current) return;
|
|
238
|
+
const next = resizeFromCentre(
|
|
239
|
+
current.edge,
|
|
240
|
+
{ x: event.clientX, y: event.clientY },
|
|
241
|
+
current.centre,
|
|
242
|
+
current.grab,
|
|
243
|
+
target.current ?? { width: MIN_WIDTH, height: MIN_HEIGHT },
|
|
244
|
+
{ width: window.innerWidth, height: window.innerHeight },
|
|
245
|
+
);
|
|
246
|
+
target.current = next;
|
|
247
|
+
widthSpring.set(next.width);
|
|
248
|
+
heightSpring.set(next.height);
|
|
249
|
+
},
|
|
250
|
+
[widthSpring, heightSpring],
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const onHandleUp = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
|
254
|
+
if (!drag.current) return;
|
|
255
|
+
drag.current = null;
|
|
256
|
+
setDragging(null);
|
|
257
|
+
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
258
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
259
|
+
}
|
|
260
|
+
document.body.style.userSelect = "";
|
|
261
|
+
}, []);
|
|
262
|
+
|
|
263
|
+
// The SSR entry renders the same component tree; never touch `document` at
|
|
264
|
+
// render time there (the modal is only ever opened client-side anyway).
|
|
265
|
+
if (typeof document === "undefined") return null;
|
|
266
|
+
|
|
267
|
+
// Portal to <body>: callers sit inside the route wrapper, whose fadeRise
|
|
268
|
+
// transform would otherwise turn `fixed` into "fixed to the column" and let
|
|
269
|
+
// the scrim miss the top bar and sidebar.
|
|
270
|
+
return createPortal(
|
|
271
|
+
<AnimatePresence>
|
|
272
|
+
{open ? (
|
|
273
|
+
<motion.div
|
|
274
|
+
key="scrim"
|
|
275
|
+
onClick={onClose}
|
|
276
|
+
{...VARIANTS.scrim}
|
|
277
|
+
className="fixed inset-0 z-[var(--z-modal)] flex items-center justify-center bg-[var(--scrim)] p-4 backdrop-blur-sm sm:p-8"
|
|
278
|
+
>
|
|
279
|
+
<motion.div
|
|
280
|
+
ref={panelRef}
|
|
281
|
+
onClick={(event) => event.stopPropagation()}
|
|
282
|
+
{...VARIANTS.pop}
|
|
283
|
+
role="dialog"
|
|
284
|
+
aria-modal="true"
|
|
285
|
+
aria-label={`Expanded ${title.toLowerCase()}`}
|
|
286
|
+
// Once an edge has been dragged the panel takes an explicit size and stays
|
|
287
|
+
// centred by the scrim's flex box; until then it fills the padded area
|
|
288
|
+
// (capped for very wide windows).
|
|
289
|
+
style={
|
|
290
|
+
custom
|
|
291
|
+
? { width: widthSpring, height: heightSpring, maxWidth: "none", flex: "none" }
|
|
292
|
+
: undefined
|
|
293
|
+
}
|
|
294
|
+
className="relative flex h-full w-full max-w-[1600px] flex-col rounded-xl border border-border-default bg-surface-raised shadow-lg"
|
|
295
|
+
>
|
|
296
|
+
<div className="flex shrink-0 items-center gap-2 rounded-t-xl border-b border-border-subtle px-3 py-2">
|
|
297
|
+
<Icon name={icon} size="md" className="text-text-muted" />
|
|
298
|
+
<span className="font-sans font-semibold text-text-heading text-[length:var(--size-sm)]">
|
|
299
|
+
{title}
|
|
300
|
+
</span>
|
|
301
|
+
<span className="hidden items-center gap-1.5 font-sans text-[length:var(--size-xs)] text-text-subtle sm:flex">
|
|
302
|
+
{hint ? <>{hint} · </> : null}
|
|
303
|
+
drag an edge to resize · <Kbd>esc</Kbd> to close
|
|
304
|
+
</span>
|
|
305
|
+
<div className="ml-auto flex items-center gap-2">
|
|
306
|
+
{actions}
|
|
307
|
+
<IconButton icon="x" label="Close" size="md" autoFocus onClick={onClose} />
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
<div className="min-h-0 flex-1 overflow-hidden rounded-b-xl">{children}</div>
|
|
311
|
+
{EDGE_HANDLES.map(({ edge, className, bar }) => (
|
|
312
|
+
<div
|
|
313
|
+
key={edge}
|
|
314
|
+
role="separator"
|
|
315
|
+
aria-label={`Resize ${edge} edge`}
|
|
316
|
+
className={"group absolute z-10 " + className}
|
|
317
|
+
onPointerDown={(event) => onHandleDown(edge, event)}
|
|
318
|
+
onPointerMove={onHandleMove}
|
|
319
|
+
onPointerUp={onHandleUp}
|
|
320
|
+
onPointerCancel={onHandleUp}
|
|
321
|
+
onDoubleClick={resetSize}
|
|
322
|
+
>
|
|
323
|
+
{bar ? (
|
|
324
|
+
<span
|
|
325
|
+
className={
|
|
326
|
+
"absolute rounded-full transition-opacity " +
|
|
327
|
+
bar +
|
|
328
|
+
(dragging === edge
|
|
329
|
+
? " bg-surface-accent opacity-100"
|
|
330
|
+
: " bg-border-default opacity-0 group-hover:opacity-100")
|
|
331
|
+
}
|
|
332
|
+
/>
|
|
333
|
+
) : null}
|
|
334
|
+
</div>
|
|
335
|
+
))}
|
|
336
|
+
</motion.div>
|
|
337
|
+
</motion.div>
|
|
338
|
+
) : null}
|
|
339
|
+
</AnimatePresence>,
|
|
340
|
+
document.body,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// The explicit .mjs extension matters: this module is also reachable from
|
|
2
|
+
// scripts/build-registry.ts under plain Node, whose ESM resolver (unlike
|
|
3
|
+
// Vite's) requires a real extension since lucide-react's package.json has no
|
|
4
|
+
// "exports" map for this subpath.
|
|
5
|
+
import dynamicIconImports from "lucide-react/dynamicIconImports.mjs";
|
|
6
|
+
import type { LucideIcon, LucideProps } from "lucide-react";
|
|
7
|
+
import { useEffect, useState, type CSSProperties } from "react";
|
|
8
|
+
import { staticIcons } from "./icon-set";
|
|
9
|
+
|
|
10
|
+
const SIZES = { sm: 14, md: 16, lg: 20, xl: 24 } as const;
|
|
11
|
+
|
|
12
|
+
export type IconSize = keyof typeof SIZES | number;
|
|
13
|
+
|
|
14
|
+
export interface IconProps extends Omit<LucideProps, "size" | "strokeWidth" | "ref"> {
|
|
15
|
+
/** Lucide icon name in kebab-case, e.g. "search", "chevron-right", "file-text". */
|
|
16
|
+
name: string;
|
|
17
|
+
/** 14 / 16 / 20 / 24 px, or an explicit pixel number. Default "md". */
|
|
18
|
+
size?: IconSize;
|
|
19
|
+
/** "light" dims the glyph to 75% for secondary chrome. */
|
|
20
|
+
strokeWidth?: "light";
|
|
21
|
+
style?: CSSProperties;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type DynamicIconName = keyof typeof dynamicIconImports;
|
|
25
|
+
|
|
26
|
+
function isDynamicIconName(name: string): name is DynamicIconName {
|
|
27
|
+
return Object.hasOwn(dynamicIconImports, name);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Module-level so every Icon instance sharing a name shares one load: a
|
|
31
|
+
// resolved glyph (or `null` for a name that turned out to have no default
|
|
32
|
+
// export) sits in `resolved`, an in-flight import sits in `pending` until it
|
|
33
|
+
// settles.
|
|
34
|
+
const resolved = new Map<string, LucideIcon | null>();
|
|
35
|
+
const pending = new Map<string, Promise<LucideIcon | null>>();
|
|
36
|
+
|
|
37
|
+
function loadDynamicIcon(name: DynamicIconName): Promise<LucideIcon | null> {
|
|
38
|
+
const cached = resolved.get(name);
|
|
39
|
+
if (cached !== undefined) return Promise.resolve(cached);
|
|
40
|
+
const inFlight = pending.get(name);
|
|
41
|
+
if (inFlight) return inFlight;
|
|
42
|
+
|
|
43
|
+
const promise = dynamicIconImports[name]()
|
|
44
|
+
.then((mod) => mod.default ?? null)
|
|
45
|
+
.catch(() => null)
|
|
46
|
+
.then((glyph) => {
|
|
47
|
+
resolved.set(name, glyph);
|
|
48
|
+
pending.delete(name);
|
|
49
|
+
return glyph;
|
|
50
|
+
});
|
|
51
|
+
pending.set(name, promise);
|
|
52
|
+
return promise;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Lucide (stroke 1.75) is mdxserve's icon set. Components that show icons take a
|
|
57
|
+
* Lucide *name string* so MDX authors can write `icon="rocket"`; this is the one
|
|
58
|
+
* place that turns the name into a glyph. Unknown names render nothing rather
|
|
59
|
+
* than throwing, so a typo in a doc never breaks the page.
|
|
60
|
+
*
|
|
61
|
+
* Two tiers: `staticIcons` (client/ui/icon-set.ts) covers the ~30 names
|
|
62
|
+
* mdxserve's own chrome uses, resolved synchronously with no import at all.
|
|
63
|
+
* Any other name is looked up in lucide-react's `dynamicIconImports` map and
|
|
64
|
+
* loaded on demand — this is how MDX authors can write `icon="anything-in-
|
|
65
|
+
* lucide"` without every one of lucide's 2,022 icons shipping in the bundle.
|
|
66
|
+
* While a dynamic icon is loading, a same-size empty placeholder holds its
|
|
67
|
+
* layout so nothing shifts once the glyph lands.
|
|
68
|
+
*/
|
|
69
|
+
export function Icon({ name, size = "md", strokeWidth, style, ...rest }: IconProps) {
|
|
70
|
+
const px = typeof size === "number" ? size : SIZES[size];
|
|
71
|
+
const opacity = strokeWidth === "light" ? 0.75 : undefined;
|
|
72
|
+
|
|
73
|
+
const StaticGlyph = staticIcons[name];
|
|
74
|
+
const dynamic = !StaticGlyph && isDynamicIconName(name);
|
|
75
|
+
const [DynamicGlyph, setDynamicGlyph] = useState<LucideIcon | null>(() =>
|
|
76
|
+
dynamic ? (resolved.get(name) ?? null) : null,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (!dynamic) return;
|
|
81
|
+
if (resolved.has(name)) {
|
|
82
|
+
setDynamicGlyph(resolved.get(name) ?? null);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
let cancelled = false;
|
|
86
|
+
loadDynamicIcon(name).then((glyph) => {
|
|
87
|
+
if (!cancelled) setDynamicGlyph(glyph);
|
|
88
|
+
});
|
|
89
|
+
return () => {
|
|
90
|
+
cancelled = true;
|
|
91
|
+
};
|
|
92
|
+
}, [dynamic, name]);
|
|
93
|
+
|
|
94
|
+
const Glyph = StaticGlyph ?? (dynamic ? DynamicGlyph : null);
|
|
95
|
+
|
|
96
|
+
if (!StaticGlyph && !dynamic) return null;
|
|
97
|
+
|
|
98
|
+
if (!Glyph) {
|
|
99
|
+
// Unresolved dynamic icon: still loading. Same footprint as the glyph
|
|
100
|
+
// it will become so nothing reflows once it lands.
|
|
101
|
+
return <svg aria-hidden="true" width={px} height={px} style={{ flex: "0 0 auto", ...style }} />;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<Glyph
|
|
106
|
+
aria-hidden="true"
|
|
107
|
+
data-icon={name}
|
|
108
|
+
size={px}
|
|
109
|
+
strokeWidth={1.75}
|
|
110
|
+
style={{ flex: "0 0 auto", opacity, ...style }}
|
|
111
|
+
{...rest}
|
|
112
|
+
/>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { forwardRef, type ButtonHTMLAttributes } from "react";
|
|
2
|
+
import { motion, type MotionProps } from "framer-motion";
|
|
3
|
+
import { Icon } from "./Icon";
|
|
4
|
+
import { TRANSITIONS } from "../motion";
|
|
5
|
+
|
|
6
|
+
const BOX: Record<"sm" | "md" | "lg", string> = {
|
|
7
|
+
sm: "h-[26px] w-[26px]",
|
|
8
|
+
md: "h-8 w-8",
|
|
9
|
+
lg: "h-[38px] w-[38px]",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// `motion.button` redefines a handful of DOM event props (onAnimationStart,
|
|
13
|
+
// onDrag*…) with its own signatures; omitting their DOM versions here is what
|
|
14
|
+
// lets `{...rest}` below flow straight onto it without a type clash.
|
|
15
|
+
export interface IconButtonProps extends Omit<
|
|
16
|
+
ButtonHTMLAttributes<HTMLButtonElement>,
|
|
17
|
+
keyof MotionProps
|
|
18
|
+
> {
|
|
19
|
+
/** Lucide icon name. */
|
|
20
|
+
icon: string;
|
|
21
|
+
size?: "sm" | "md" | "lg";
|
|
22
|
+
variant?: "ghost" | "outline";
|
|
23
|
+
/** Sticky pressed state, e.g. an enabled sidebar toggle. */
|
|
24
|
+
active?: boolean;
|
|
25
|
+
/** Accessible name — also used as the native tooltip. */
|
|
26
|
+
label?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Small square control for chrome: sidebar toggle, code copy, diagram zoom. */
|
|
30
|
+
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
|
|
31
|
+
{ icon, size = "md", variant = "ghost", active, label, disabled, className, ...rest },
|
|
32
|
+
ref,
|
|
33
|
+
) {
|
|
34
|
+
const bordered = variant === "outline";
|
|
35
|
+
return (
|
|
36
|
+
<motion.button
|
|
37
|
+
ref={ref}
|
|
38
|
+
type="button"
|
|
39
|
+
aria-label={label}
|
|
40
|
+
title={label}
|
|
41
|
+
disabled={disabled}
|
|
42
|
+
whileTap={disabled ? undefined : { scale: 0.92 }}
|
|
43
|
+
transition={TRANSITIONS.snap}
|
|
44
|
+
className={
|
|
45
|
+
"inline-flex items-center justify-center rounded-md cursor-pointer transition-colors " +
|
|
46
|
+
"disabled:cursor-not-allowed disabled:opacity-45 " +
|
|
47
|
+
BOX[size] +
|
|
48
|
+
" " +
|
|
49
|
+
(bordered ? "border border-border-default" : "border border-transparent") +
|
|
50
|
+
" " +
|
|
51
|
+
(active
|
|
52
|
+
? "bg-surface-active text-text-heading"
|
|
53
|
+
: bordered
|
|
54
|
+
? "bg-surface-card text-text-muted hover:bg-surface-hover"
|
|
55
|
+
: "text-text-muted hover:bg-surface-hover") +
|
|
56
|
+
(className ? " " + className : "")
|
|
57
|
+
}
|
|
58
|
+
{...rest}
|
|
59
|
+
>
|
|
60
|
+
<Icon name={icon} size={size === "sm" ? "sm" : "md"} />
|
|
61
|
+
</motion.button>
|
|
62
|
+
);
|
|
63
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { HTMLAttributes } from "react";
|
|
2
|
+
|
|
3
|
+
export interface KbdProps extends HTMLAttributes<HTMLElement> {}
|
|
4
|
+
|
|
5
|
+
/** A single keycap, e.g. inside SearchDialog's "esc" hint. */
|
|
6
|
+
export function Kbd({ className, ...rest }: KbdProps) {
|
|
7
|
+
return (
|
|
8
|
+
<kbd
|
|
9
|
+
className={
|
|
10
|
+
"inline-flex h-5 min-w-5 items-center justify-center rounded-sm border border-b-2 border-border-default " +
|
|
11
|
+
"bg-surface-card px-[5px] font-mono text-[length:var(--size-2xs)] font-medium text-text-muted" +
|
|
12
|
+
(className ? " " + className : "")
|
|
13
|
+
}
|
|
14
|
+
{...rest}
|
|
15
|
+
/>
|
|
16
|
+
);
|
|
17
|
+
}
|