@deckbuilderkit/editor 0.0.0-stage → 0.1.1

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 (56) hide show
  1. package/package.json +53 -4
  2. package/src/ArchiveView.tsx +111 -0
  3. package/src/CodeEditor.tsx +265 -0
  4. package/src/CodeView.tsx +67 -0
  5. package/src/DiffView.tsx +139 -0
  6. package/src/EnvView.tsx +42 -0
  7. package/src/FilePreview.tsx +108 -0
  8. package/src/FontView.tsx +48 -0
  9. package/src/HarView.tsx +70 -0
  10. package/src/HexView.tsx +28 -0
  11. package/src/HtmlPreview.tsx +23 -0
  12. package/src/MarkdownEditor.tsx +90 -0
  13. package/src/MarkdownToc.tsx +135 -0
  14. package/src/MarkdownView.tsx +84 -0
  15. package/src/MediaView.tsx +17 -0
  16. package/src/NotebookView.tsx +74 -0
  17. package/src/PdfView.tsx +289 -0
  18. package/src/RequestEditor.tsx +419 -0
  19. package/src/ResponseView.tsx +89 -0
  20. package/src/SqlGraph.tsx +322 -0
  21. package/src/SqlSteps.tsx +487 -0
  22. package/src/SqliteView.tsx +68 -0
  23. package/src/SvgView.tsx +231 -0
  24. package/src/TableView.tsx +85 -0
  25. package/src/VarSuggest.tsx +125 -0
  26. package/src/curl-file.ts +70 -0
  27. package/src/editorOptions.ts +83 -0
  28. package/src/editorOptionsContext.ts +4 -0
  29. package/src/fileKinds.ts +69 -0
  30. package/src/http-client.ts +9 -0
  31. package/src/http-import.ts +594 -0
  32. package/src/http.ts +251 -0
  33. package/src/import-warning.ts +8 -0
  34. package/src/index.ts +56 -0
  35. package/src/languages.ts +107 -0
  36. package/src/markdown.ts +123 -0
  37. package/src/markdownLive.ts +289 -0
  38. package/src/options.ts +3 -0
  39. package/src/pdf-url.d.ts +4 -0
  40. package/src/sql/format.ts +637 -0
  41. package/src/sql/formatOptions.ts +71 -0
  42. package/src/sql/lineage.ts +365 -0
  43. package/src/sql/lint.ts +951 -0
  44. package/src/sql/markerHighlight.ts +50 -0
  45. package/src/sql/markers.ts +138 -0
  46. package/src/sql/outline.ts +662 -0
  47. package/src/sql/statements.ts +83 -0
  48. package/src/sql/tokens.ts +156 -0
  49. package/src/sql/xmlQuery.ts +249 -0
  50. package/src/sqlProblems.ts +40 -0
  51. package/src/table.ts +89 -0
  52. package/src/text-save.ts +28 -0
  53. package/src/theme.ts +86 -0
  54. package/src/toc.ts +11 -0
  55. package/src/variables.ts +123 -0
  56. package/README.md +0 -3
@@ -0,0 +1,108 @@
1
+ import { type HighlightTerm, Icon, JsonTree, linesOf, LogView, PanelBanner, PanelLoading, PanelNotice, t } from '@deckbuilderkit/ui';
2
+ import { lazy, Suspense, useMemo, useState } from 'react';
3
+ import { type ArchiveContent, ArchiveView } from './ArchiveView';
4
+ import { CodeView } from './CodeView';
5
+ import { EnvView } from './EnvView';
6
+ import { HarView } from './HarView';
7
+ import { HexView } from './HexView';
8
+ import { HtmlPreview } from './HtmlPreview';
9
+ import { langOfFile, previewKind } from './languages';
10
+ import { MarkdownView } from './MarkdownView';
11
+ import { MediaView } from './MediaView';
12
+ import { NotebookView } from './NotebookView';
13
+ import { type SqliteContent, SqliteView } from './SqliteView';
14
+ import type { ImageInfo } from './SvgView';
15
+ import { TableView } from './TableView';
16
+
17
+ export type { ImageInfo } from './SvgView';
18
+ const SvgView = lazy(() => import('./SvgView').then((m) => ({ default: m.SvgView })));
19
+ const PdfView = lazy(() => import('./PdfView').then((m) => ({ default: m.PdfView })));
20
+ const FontView = lazy(() => import('./FontView').then((m) => ({ default: m.FontView })));
21
+
22
+ /** `/api/fs/read` gives text or `{ binary }`; images come as a URL (a blob URL of `/api/fs/raw`). */
23
+ export type PreviewContent = { text: string; truncated?: boolean } | { binary: true } | { url: string; source?: string } | { pdf: Uint8Array } | { archive: ArchiveContent }
24
+ | { bytes: Uint8Array } | { sqlite: SqliteContent; load(table: string, offset: number): Promise<SqliteContent> };
25
+
26
+ /** Shows a file by its extension: rendered markdown, a JSON tree, highlighted code, a log, an image or plain text. */
27
+ export function FilePreview({ name, content, resolveImage, viewHtml, onImageInfo, toc = true }: {
28
+ name: string;
29
+ content: PreviewContent;
30
+ resolveImage?(src: string): string | null;
31
+ /** URL the sandboxed frame of an HTML page loads, so its scripts and relative files work; without it the text is shown with scripts blocked. */
32
+ viewHtml?(): Promise<string>;
33
+ /** An image's size and zoom, for a status bar. */
34
+ onImageInfo?(info: ImageInfo): void;
35
+ /** Show the heading rail on rendered markdown; default on. */
36
+ toc?: boolean;
37
+ }) {
38
+ const kind = previewKind(name);
39
+
40
+ if ('binary' in content) return <PanelNotice icon={<Icon name="cube" size={20} />} title={t('preview.binary')} />;
41
+ if ('archive' in content) return <ArchiveView archive={content.archive} />;
42
+ if ('pdf' in content) return <Suspense fallback={<PanelLoading />}><PdfView data={content.pdf} /></Suspense>;
43
+ if ('sqlite' in content) return <SqliteView content={content.sqlite} load={content.load} />;
44
+ if ('bytes' in content) {
45
+ return kind === 'font'
46
+ ? <Suspense fallback={<PanelLoading />}><FontView data={content.bytes} /></Suspense>
47
+ : <HexView bytes={content.bytes} />;
48
+ }
49
+ if ('url' in content) {
50
+ if (kind === 'audio' || kind === 'video') return <MediaView url={content.url} video={kind === 'video'} />;
51
+ if (kind !== 'svg' && kind !== 'image') return <PanelNotice icon={<Icon name="cube" size={20} />} title={t('preview.none')} message={t('preview.noneMessage')} />;
52
+ return <Suspense fallback={<PanelLoading />}><SvgView url={content.url} source={kind === 'svg' ? content.source : undefined} name={name} onInfo={onImageInfo} /></Suspense>;
53
+ }
54
+ return (
55
+ <div className="file-preview">
56
+ {content.truncated && <PanelBanner tone="warn" icon="alert">{t('preview.truncated')}</PanelBanner>}
57
+ {kind === 'markdown' ? <MarkdownPreview text={content.text} resolveImage={resolveImage} toc={toc} />
58
+ : kind === 'table' ? <TableView name={name} text={content.text} />
59
+ : kind === 'notebook' ? <NotebookView text={content.text} />
60
+ : kind === 'har' ? <HarView text={content.text} />
61
+ : kind === 'env' ? <EnvView text={content.text} />
62
+ : kind === 'html' ? <div className="file-preview-body"><HtmlPreview name={name} text={content.text} viewHtml={viewHtml} /></div>
63
+ : kind === 'json' ? <JsonPreview text={content.text} />
64
+ : kind === 'log' ? <LogPreview text={content.text} />
65
+ : <div className="file-preview-body"><CodeView value={content.text} lang={kind === 'code' ? langOfFile(name) : 'text'} lineNumbers /></div>}
66
+ </div>
67
+ );
68
+ }
69
+
70
+ function MarkdownPreview({ text, resolveImage, toc }: { text: string; resolveImage?(src: string): string | null; toc: boolean }) {
71
+ const [source, setSource] = useState(false);
72
+ return (
73
+ <>
74
+ <div className="file-preview-bar">
75
+ <button className="btn btn-ghost btn-sm" onClick={() => setSource(!source)} aria-pressed={source}>
76
+ {source ? t('preview.rendered') : t('preview.source')}
77
+ </button>
78
+ </div>
79
+ <div className="file-preview-body">
80
+ {source ? <CodeView value={text} lang="markdown" wrap lineNumbers /> : <MarkdownView markdown={text} resolveImage={resolveImage} toc={toc} />}
81
+ </div>
82
+ </>
83
+ );
84
+ }
85
+
86
+ /** A read-only LogView of the text: no follow, no bookmarks. */
87
+ function LogPreview({ text }: { text: string }) {
88
+ const lines = useMemo(() => linesOf({ from: 0, to: 0, text }, 0, null), [text]);
89
+ const [highlights, setHighlights] = useState<HighlightTerm[]>([]);
90
+ return <div className="file-preview-body file-preview-log"><LogView lines={lines} highlights={highlights} onHighlightsChange={setHighlights} /></div>;
91
+ }
92
+
93
+ function JsonPreview({ text }: { text: string }) {
94
+ const parsed = useMemo(() => {
95
+ try {
96
+ return { value: JSON.parse(text) as unknown };
97
+ } catch (err) {
98
+ return { error: (err as Error).message };
99
+ }
100
+ }, [text]);
101
+ if ('value' in parsed) return <div className="file-preview-body"><JsonTree value={parsed.value} /></div>;
102
+ return (
103
+ <>
104
+ <PanelBanner tone="err">{t('preview.jsonError', { message: parsed.error })}</PanelBanner>
105
+ <div className="file-preview-body"><CodeView value={text} lang="json" lineNumbers /></div>
106
+ </>
107
+ );
108
+ }
@@ -0,0 +1,48 @@
1
+ import { PanelBanner, t } from '@deckbuilderkit/ui';
2
+ import { useEffect, useState } from 'react';
3
+
4
+ const SIZES = [12, 16, 24, 36, 56];
5
+ const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ\nabcdefghijklmnopqrstuvwxyz\n0123456789 !?&@#$%(){}[]';
6
+
7
+ let counter = 0;
8
+
9
+ /** A font file shown at several sizes with editable sample text. */
10
+ export function FontView({ data }: { data: Uint8Array }) {
11
+ const [family, setFamily] = useState<string>();
12
+ const [failed, setFailed] = useState(false);
13
+ const [sample, setSample] = useState(() => t('preview.fontDefault'));
14
+
15
+ useEffect(() => {
16
+ let alive = true;
17
+ const name = `deckbuilder-preview-${++counter}`;
18
+ const face = new FontFace(name, data.slice().buffer);
19
+ setFamily(undefined);
20
+ setFailed(false);
21
+ face.load().then(
22
+ (loaded) => {
23
+ if (!alive) return;
24
+ document.fonts.add(loaded);
25
+ setFamily(name);
26
+ },
27
+ () => alive && setFailed(true),
28
+ );
29
+ return () => {
30
+ alive = false;
31
+ document.fonts.delete(face);
32
+ };
33
+ }, [data]);
34
+
35
+ if (failed) return <PanelBanner tone="err" icon="alert">{t('preview.fontError')}</PanelBanner>;
36
+ if (!family) return null;
37
+ return (
38
+ <div className="file-preview">
39
+ <div className="file-preview-bar archive-bar">
40
+ <input className="input font-sample-input" value={sample} onChange={(e) => setSample(e.target.value)} placeholder={t('preview.fontSample')} aria-label={t('preview.fontSample')} />
41
+ </div>
42
+ <div className="file-preview-body font-view" style={{ fontFamily: `"${family}", sans-serif` }}>
43
+ <pre className="font-alphabet">{ALPHABET}</pre>
44
+ {SIZES.map((px) => <div key={px} className="font-line" style={{ fontSize: px }}><span className="font-size">{px}</span>{sample}</div>)}
45
+ </div>
46
+ </div>
47
+ );
48
+ }
@@ -0,0 +1,70 @@
1
+ import { formatBytes, PanelBanner, t } from '@deckbuilderkit/ui';
2
+ import { useMemo, useState } from 'react';
3
+ import { DataGrid } from './TableView';
4
+ import { compareCells } from './table';
5
+
6
+ interface HarEntry {
7
+ startedDateTime?: string;
8
+ time?: number;
9
+ request?: { method?: string; url?: string };
10
+ response?: { status?: number; content?: { size?: number; mimeType?: string }; bodySize?: number };
11
+ }
12
+
13
+ /** A browser HAR capture as a request table with filter and sort. */
14
+ export function HarView({ text }: { text: string }) {
15
+ const parsed = useMemo(() => {
16
+ try {
17
+ const entries = (JSON.parse(text) as { log?: { entries?: HarEntry[] } }).log?.entries;
18
+ if (!Array.isArray(entries)) return { error: 'log.entries' };
19
+ const rows = entries.map((e) => {
20
+ const size = e.response?.content?.size ?? e.response?.bodySize ?? 0;
21
+ return [
22
+ e.request?.method ?? '',
23
+ e.request?.url ?? '',
24
+ String(e.response?.status ?? ''),
25
+ (e.response?.content?.mimeType ?? '').split(';')[0]!,
26
+ String(Math.max(0, size)),
27
+ String(Math.round(e.time ?? 0)),
28
+ ];
29
+ });
30
+ return { rows, bytes: rows.reduce((sum, r) => sum + Number(r[4]), 0) };
31
+ } catch (err) {
32
+ return { error: (err as Error).message };
33
+ }
34
+ }, [text]);
35
+ const [filter, setFilter] = useState('');
36
+ const [sort, setSort] = useState<{ column: number; desc: boolean } | null>(null);
37
+
38
+ const rows = useMemo(() => {
39
+ if ('error' in parsed) return [];
40
+ const q = filter.trim().toLowerCase();
41
+ let out = q ? parsed.rows.filter((r) => r.some((c) => c.toLowerCase().includes(q))) : parsed.rows;
42
+ if (sort) {
43
+ const sign = sort.desc ? -1 : 1;
44
+ out = [...out].sort((a, b) => sign * compareCells(a[sort.column]!, b[sort.column]!));
45
+ }
46
+ return out;
47
+ }, [parsed, filter, sort]);
48
+
49
+ if ('error' in parsed) return <PanelBanner tone="err">{t('preview.harInvalid', { message: parsed.error ?? '' })}</PanelBanner>;
50
+
51
+ const shown = rows.slice(0, 2000).map((r) => [r[0]!, r[1]!, r[2]!, r[3]!, formatBytes(Number(r[4])), `${r[5]} ms`]);
52
+ const onSort = (column: number) =>
53
+ setSort((s) => (s?.column !== column ? { column, desc: false } : s.desc ? null : { column, desc: true }));
54
+ return (
55
+ <div className="file-preview">
56
+ <div className="file-preview-bar archive-bar">
57
+ <span className="archive-summary">{t('preview.harSummary', { count: parsed.rows.length, size: formatBytes(parsed.bytes) })}</span>
58
+ <input className="input archive-filter" value={filter} onChange={(e) => setFilter(e.target.value)} placeholder={t('preview.filter')} aria-label={t('preview.filter')} />
59
+ </div>
60
+ <div className="file-preview-body">
61
+ <DataGrid
62
+ columns={[t('preview.col.method'), t('preview.col.url'), t('preview.col.status'), t('preview.col.type'), t('preview.col.size'), t('preview.col.time')]}
63
+ rows={shown}
64
+ sort={sort}
65
+ onSort={onSort}
66
+ />
67
+ </div>
68
+ </div>
69
+ );
70
+ }
@@ -0,0 +1,28 @@
1
+ import { formatBytes, PanelBanner, t } from '@deckbuilderkit/ui';
2
+ import { useMemo } from 'react';
3
+ import { hexRows } from './table';
4
+
5
+ /** Bytes the hex view asks for; a longer file shows only its start. */
6
+ export const HEX_LIMIT = 64 * 1024;
7
+
8
+ /** A hex dump: offset, 16 bytes in hex and their printable characters. */
9
+ export function HexView({ bytes }: { bytes: Uint8Array }) {
10
+ const rows = useMemo(() => hexRows(bytes), [bytes]);
11
+ return (
12
+ <div className="file-preview">
13
+ {bytes.length >= HEX_LIMIT && <PanelBanner tone="warn" icon="alert">{t('preview.hexCapped', { size: formatBytes(HEX_LIMIT) })}</PanelBanner>}
14
+ <div className="file-preview-bar archive-bar"><span className="archive-summary">{formatBytes(bytes.length)}</span></div>
15
+ <div className="file-preview-body">
16
+ <pre className="hex-view">
17
+ {rows.map((r) => (
18
+ <div key={r.offset} className="hex-row">
19
+ <span className="hex-offset">{r.offset}</span>
20
+ <span className="hex-bytes">{r.hex}</span>
21
+ <span className="hex-ascii">{r.ascii}</span>
22
+ </div>
23
+ ))}
24
+ </pre>
25
+ </div>
26
+ </div>
27
+ );
28
+ }
@@ -0,0 +1,23 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ /**
4
+ * An HTML file in a sandboxed frame. With `viewHtml` the frame loads the page from the server, so its scripts
5
+ * run (the app's CSP blocks them in `srcDoc`) and its relative files resolve; the frame has no same-origin access to the app.
6
+ */
7
+ export function HtmlPreview({ name, text, viewHtml }: { name: string; text: string; viewHtml?(): Promise<string> }) {
8
+ const [url, setUrl] = useState<string>();
9
+
10
+ useEffect(() => {
11
+ setUrl(undefined);
12
+ if (!viewHtml) return;
13
+ let live = true;
14
+ viewHtml().then((u) => live && setUrl(u), () => {});
15
+ return () => {
16
+ live = false;
17
+ };
18
+ }, [viewHtml, text]);
19
+
20
+ return url
21
+ ? <iframe className="file-preview-html" title={name} sandbox="allow-scripts" src={url} />
22
+ : <iframe className="file-preview-html" title={name} sandbox="" srcDoc={viewHtml ? '' : text} />;
23
+ }
@@ -0,0 +1,90 @@
1
+ import { SplitView } from '@deckbuilderkit/ui';
2
+ import { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { CodeEditor, type SelectionInfo } from './CodeEditor';
4
+ import { markdownLive } from './markdownLive';
5
+ import { MarkdownView } from './MarkdownView';
6
+
7
+ export type MarkdownMode = 'live' | 'edit' | 'split' | 'preview';
8
+
9
+ export const MARKDOWN_MODES: readonly MarkdownMode[] = ['live', 'edit', 'split', 'preview'];
10
+
11
+ /** Where the text column sits in the Live and Preview views; `full` fits it to the width. */
12
+ export type MarkdownLayout = 'left' | 'center' | 'full';
13
+
14
+ export const MARKDOWN_LAYOUTS: readonly MarkdownLayout[] = ['left', 'center', 'full'];
15
+
16
+ const LAYOUT_KEY = 'deckbuilder.markdownLayout';
17
+
18
+ function readLayout(): MarkdownLayout {
19
+ try {
20
+ const v = localStorage.getItem(LAYOUT_KEY);
21
+ return MARKDOWN_LAYOUTS.find((l) => l === v) ?? 'center';
22
+ } catch {
23
+ return 'center';
24
+ }
25
+ }
26
+
27
+ const layoutListeners = new Set<(l: MarkdownLayout) => void>();
28
+
29
+ /** The markdown text column layout, kept in localStorage and shared by every editor of the page. */
30
+ export function useMarkdownLayout(): [MarkdownLayout, (l: MarkdownLayout) => void] {
31
+ const [layout, setState] = useState<MarkdownLayout>(readLayout);
32
+ useEffect(() => {
33
+ layoutListeners.add(setState);
34
+ return () => void layoutListeners.delete(setState);
35
+ }, []);
36
+ const set = (l: MarkdownLayout) => {
37
+ try { localStorage.setItem(LAYOUT_KEY, l); } catch { /* storage unavailable */ }
38
+ for (const fn of layoutListeners) fn(l);
39
+ };
40
+ return [layout, set];
41
+ }
42
+
43
+ export interface MarkdownEditorProps {
44
+ value: string;
45
+ onChange(value: string): void;
46
+ /** The view; the host shows the switch (and the layout choice) itself. */
47
+ mode: MarkdownMode;
48
+ layout?: MarkdownLayout;
49
+ resolveImage?(src: string): string | null;
50
+ autoFocus?: boolean;
51
+ /** Wrap long lines in the editor; default on. */
52
+ wrap?: boolean;
53
+ onSelection?(info: SelectionInfo): void;
54
+ /** Show the heading rail in the Preview view; default on. */
55
+ toc?: boolean;
56
+ }
57
+
58
+ /** A markdown editor in one of four views: Live (rendered while editing), Source, Split (source and preview) or Preview. */
59
+ export function MarkdownEditor({ value, onChange, mode, layout = 'center', resolveImage, autoFocus, wrap = true, onSelection, toc = true }: MarkdownEditorProps) {
60
+ const deferred = useDeferredValue(value);
61
+ const preview = useRef<HTMLDivElement>(null);
62
+ const liveExt = useMemo(() => markdownLive(resolveImage), [resolveImage]);
63
+ const [sizes, setSizes] = useState([0.5, 0.5]);
64
+
65
+ // Proportional: the preview sits at the same fraction of its range as the editor.
66
+ const syncScroll = (fraction: number) => {
67
+ const el = preview.current;
68
+ if (el) el.scrollTop = fraction * (el.scrollHeight - el.clientHeight);
69
+ };
70
+
71
+ const editor = (
72
+ <CodeEditor key="editor" value={value} lang="markdown" onChange={onChange} height="100%" autoFocus={autoFocus} wrap={wrap}
73
+ onScroll={mode === 'split' ? syncScroll : undefined} extensions={mode === 'live' ? liveExt : undefined} onSelection={onSelection} />
74
+ );
75
+ const rendered = (
76
+ <div key="preview" ref={preview} className="md-editor-preview">
77
+ <MarkdownView markdown={deferred} resolveImage={resolveImage} toc={toc && mode === 'preview'} />
78
+ </div>
79
+ );
80
+
81
+ return (
82
+ <div className="md-editor" data-layout={layout} data-mode={mode}>
83
+ <div className="md-editor-body">
84
+ {mode === 'edit' || mode === 'live' ? editor
85
+ : mode === 'preview' ? rendered
86
+ : <SplitView direction="row" sizes={sizes} defaultSizes={[0.5, 0.5]} min={[200, 200]} onResize={setSizes}>{editor}{rendered}</SplitView>}
87
+ </div>
88
+ </div>
89
+ );
90
+ }
@@ -0,0 +1,135 @@
1
+ import { rovingIndex, t } from '@deckbuilderkit/ui';
2
+ import { type KeyboardEvent, type RefObject, useEffect, useRef, useState } from 'react';
3
+ import { activeHeading } from './toc';
4
+
5
+ /** A rendered h1–h3, kept by reference: the sanitizer strips ids, so there is nothing to link to. */
6
+ export interface TocHeading {
7
+ el: HTMLElement;
8
+ level: number;
9
+ text: string;
10
+ }
11
+
12
+ /** Tick width per heading level (h1, h2, h3). */
13
+ const TICK = [20, 14, 8];
14
+ /** Vertical distance between ticks; matches `.md-toc-tick` height plus margin. */
15
+ const GAP = 8;
16
+ /** A heading this close below the container's top already counts as the one being read. */
17
+ const SPY_OFFSET = 16;
18
+ /** Where a jumped-to heading lands below the container's top. */
19
+ const JUMP_OFFSET = 8;
20
+
21
+ /** The nearest ancestor that scrolls vertically. */
22
+ function scrollParent(el: HTMLElement | null): HTMLElement | null {
23
+ for (let p = el?.parentElement; p; p = p.parentElement) {
24
+ const { overflowY } = getComputedStyle(p);
25
+ if (overflowY === 'auto' || overflowY === 'scroll') return p;
26
+ }
27
+ return null;
28
+ }
29
+
30
+ /** A rail of heading ticks pinned to the left of the rendered markdown, with scroll-spy; a click lists the headings. */
31
+ export function MarkdownToc({ headings, content }: { headings: TocHeading[]; content: RefObject<HTMLElement | null> }) {
32
+ const root = useRef<HTMLDivElement>(null);
33
+ const rail = useRef<HTMLButtonElement>(null);
34
+ const panel = useRef<HTMLElement>(null);
35
+ const [active, setActive] = useState(0);
36
+ const [open, setOpen] = useState(false);
37
+ const shown = headings.length >= 2;
38
+
39
+ useEffect(() => {
40
+ const box = scrollParent(root.current);
41
+ if (!box || !shown) return;
42
+ let frame = 0;
43
+ const update = () => {
44
+ frame = 0;
45
+ const top = box.getBoundingClientRect().top;
46
+ const tops = headings.map((h) => h.el.getBoundingClientRect().top - top);
47
+ const atBottom = box.scrollHeight > box.clientHeight && box.scrollTop + box.clientHeight >= box.scrollHeight - 1;
48
+ setActive(Math.max(0, activeHeading(tops, SPY_OFFSET, atBottom)));
49
+ };
50
+ const schedule = () => {
51
+ if (!frame) frame = requestAnimationFrame(update);
52
+ };
53
+ update();
54
+ box.addEventListener('scroll', schedule, { passive: true });
55
+ const resize = new ResizeObserver(schedule);
56
+ resize.observe(box);
57
+ if (content.current) resize.observe(content.current);
58
+ return () => {
59
+ box.removeEventListener('scroll', schedule);
60
+ resize.disconnect();
61
+ cancelAnimationFrame(frame);
62
+ };
63
+ }, [headings, content, shown]);
64
+
65
+ useEffect(() => {
66
+ if (!open) return;
67
+ const list = panel.current;
68
+ const item = list?.querySelector<HTMLButtonElement>('.md-toc-item.active');
69
+ if (list && item) {
70
+ list.scrollTop = item.offsetTop - (list.clientHeight - item.offsetHeight) / 2;
71
+ item.focus({ preventScroll: true });
72
+ }
73
+ const onDown = (e: PointerEvent) => {
74
+ if (!root.current?.contains(e.target as Node)) setOpen(false);
75
+ };
76
+ document.addEventListener('pointerdown', onDown);
77
+ return () => document.removeEventListener('pointerdown', onDown);
78
+ }, [open]);
79
+
80
+ if (!shown) return null;
81
+
82
+ const close = () => {
83
+ setOpen(false);
84
+ rail.current?.focus({ preventScroll: true });
85
+ };
86
+
87
+ const jump = (h: TocHeading) => {
88
+ close();
89
+ const box = scrollParent(root.current);
90
+ if (!box) return;
91
+ const top = box.scrollTop + h.el.getBoundingClientRect().top - box.getBoundingClientRect().top - JUMP_OFFSET;
92
+ box.scrollTo({ top, behavior: 'smooth' });
93
+ };
94
+
95
+ const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
96
+ if (!open) return;
97
+ if (e.key === 'Escape') {
98
+ e.preventDefault();
99
+ e.stopPropagation();
100
+ close();
101
+ return;
102
+ }
103
+ const items = [...(panel.current?.querySelectorAll<HTMLButtonElement>('.md-toc-item') ?? [])];
104
+ const next = rovingIndex(e.key, items.indexOf(document.activeElement as HTMLButtonElement), items.length, 10);
105
+ if (next === null) return;
106
+ e.preventDefault();
107
+ items[next]?.focus();
108
+ };
109
+
110
+ return (
111
+ <div ref={root} className="md-toc" onKeyDown={onKeyDown}>
112
+ <button ref={rail} type="button" className="md-toc-rail" aria-expanded={open} aria-label={t('preview.toc')} title={t('preview.toc')}
113
+ onClick={() => setOpen(!open)}>
114
+ {headings.map((h, i) => (
115
+ <span key={i} className={'md-toc-tick' + (i === active ? ' active' : '')} style={{ width: TICK[h.level - 1] }} />
116
+ ))}
117
+ <span className="md-toc-indicator" style={{ transform: `translateY(${active * GAP}px)` }} />
118
+ </button>
119
+ {open && (
120
+ <nav ref={panel} className="md-toc-panel" aria-label={t('preview.toc')}>
121
+ <ul>
122
+ {headings.map((h, i) => (
123
+ <li key={i}>
124
+ <button type="button" className={'md-toc-item' + (i === active ? ' active' : '')} aria-current={i === active ? 'location' : undefined}
125
+ style={{ paddingLeft: 10 + (h.level - 1) * 12 }} onClick={() => jump(h)}>
126
+ {h.text}
127
+ </button>
128
+ </li>
129
+ ))}
130
+ </ul>
131
+ </nav>
132
+ )}
133
+ </div>
134
+ );
135
+ }
@@ -0,0 +1,84 @@
1
+ import { copyText, t } from '@deckbuilderkit/ui';
2
+ import { classHighlighter, highlightTree } from '@lezer/highlight';
3
+ import { type MouseEvent, useLayoutEffect, useMemo, useRef, useState } from 'react';
4
+ import { langOfFile, type Parser, parserOf } from './languages';
5
+ import { renderMarkdown } from './markdown';
6
+ import { MarkdownToc, type TocHeading } from './MarkdownToc';
7
+
8
+ /** Replaces a code element's text with `tok-*` spans (classHighlighter); styled by `.markdown .tok-*`. */
9
+ function highlightCode(code: HTMLElement, parser: Parser) {
10
+ const text = code.textContent ?? '';
11
+ const out = document.createDocumentFragment();
12
+ let pos = 0;
13
+ highlightTree(parser.parse(text), classHighlighter, (from, to, classes) => {
14
+ if (from > pos) out.append(text.slice(pos, from));
15
+ const span = document.createElement('span');
16
+ span.className = classes;
17
+ span.textContent = text.slice(from, to);
18
+ out.append(span);
19
+ pos = to;
20
+ });
21
+ if (pos < text.length) out.append(text.slice(pos));
22
+ code.replaceChildren(out);
23
+ }
24
+
25
+ /**
26
+ * Rendered, sanitized markdown; code blocks highlighted when their fence names a file extension with a parser.
27
+ * `toc` adds a heading rail (MarkdownToc) that tracks the nearest scrolling ancestor.
28
+ */
29
+ export function MarkdownView({ markdown, resolveImage, toc }: { markdown: string; resolveImage?(src: string): string | null; toc?: boolean }) {
30
+ const host = useRef<HTMLDivElement>(null);
31
+ const [headings, setHeadings] = useState<TocHeading[]>([]);
32
+ const html = useMemo(
33
+ () => renderMarkdown(markdown, { resolveImage, blockedLabel: t('preview.remoteImageBlocked') }),
34
+ [markdown, resolveImage],
35
+ );
36
+
37
+ useLayoutEffect(() => {
38
+ for (const code of host.current?.querySelectorAll<HTMLElement>('pre > code[class*="language-"]') ?? []) {
39
+ const fence = /language-(\w+)/.exec(code.className)?.[1];
40
+ const parser = fence ? parserOf(fence.toLowerCase() === 'shell' ? 'shell' : langOfFile('x.' + fence)) : null;
41
+ if (parser) highlightCode(code, parser);
42
+ }
43
+ for (const pre of host.current?.querySelectorAll<HTMLElement>('pre') ?? []) {
44
+ const button = document.createElement('button');
45
+ button.type = 'button';
46
+ button.className = 'md-code-copy';
47
+ button.textContent = t('output.copyAll');
48
+ const wrap = document.createElement('div');
49
+ wrap.className = 'md-code';
50
+ pre.replaceWith(wrap);
51
+ wrap.append(pre, button);
52
+ }
53
+ }, [html]);
54
+
55
+ useLayoutEffect(() => {
56
+ if (!toc) return;
57
+ setHeadings([...(host.current?.querySelectorAll<HTMLElement>('h1, h2, h3') ?? [])]
58
+ .map((el) => ({ el, level: Number(el.tagName[1]), text: el.textContent?.trim() ?? '' })));
59
+ }, [html, toc]);
60
+
61
+ const onClick = async (e: MouseEvent<HTMLDivElement>) => {
62
+ const button = e.target as HTMLElement;
63
+ if (button.closest('a[href^="#"]')) {
64
+ e.preventDefault();
65
+ return;
66
+ }
67
+ if (!button.classList.contains('md-code-copy')) return;
68
+ const code = button.parentElement?.querySelector('code') ?? button.parentElement;
69
+ if (!code || !(await copyText(code.textContent ?? ''))) return;
70
+ button.textContent = t('common.copied');
71
+ setTimeout(() => {
72
+ button.textContent = t('output.copyAll');
73
+ }, 1500);
74
+ };
75
+
76
+ const view = <div ref={host} className="markdown" onClick={onClick} dangerouslySetInnerHTML={{ __html: html }} />;
77
+ if (!toc) return view;
78
+ return (
79
+ <div className={'md-toc-wrap' + (headings.length >= 2 ? ' has-toc' : '')}>
80
+ <MarkdownToc headings={headings} content={host} />
81
+ {view}
82
+ </div>
83
+ );
84
+ }
@@ -0,0 +1,17 @@
1
+ import { PanelBanner, t } from '@deckbuilderkit/ui';
2
+ import { useState } from 'react';
3
+
4
+ /** An audio or video file played from a blob URL. */
5
+ export function MediaView({ url, video }: { url: string; video: boolean }) {
6
+ const [failed, setFailed] = useState(false);
7
+ return (
8
+ <div className="file-preview">
9
+ {failed && <PanelBanner tone="err" icon="alert">{t('preview.mediaError')}</PanelBanner>}
10
+ <div className="file-preview-body file-preview-image">
11
+ {video
12
+ ? <video className="media-video" src={url} controls onError={() => setFailed(true)} />
13
+ : <audio className="media-audio" src={url} controls onError={() => setFailed(true)} />}
14
+ </div>
15
+ </div>
16
+ );
17
+ }