@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,231 @@
1
+ import { PanelBanner, t } from '@deckbuilderkit/ui';
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { CodeView } from './CodeView';
4
+
5
+
6
+ const NAMESPACES: Record<string, string> = {
7
+ inkscape: 'http://www.inkscape.org/namespaces/inkscape',
8
+ sodipodi: 'http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd',
9
+ xlink: 'http://www.w3.org/1999/xlink',
10
+ dc: 'http://purl.org/dc/elements/1.1/',
11
+ cc: 'http://creativecommons.org/ns#',
12
+ rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
13
+ };
14
+
15
+ /** Declares the well-known prefixes a file uses without declaring them (some generators forget `xmlns:inkscape`), so the strict XML parser accepts it. */
16
+ export function declareNamespaces(source: string): string {
17
+ const root = /<svg[^>]*>/.exec(source);
18
+ if (!root) return source;
19
+ const missing = Object.keys(NAMESPACES).filter((p) => !root[0].includes(`xmlns:${p}=`) && (source.includes(`<${p}:`) || source.includes(` ${p}:`)));
20
+ if (missing.length === 0) return source;
21
+ const decls = missing.map((p) => ` xmlns:${p}="${NAMESPACES[p]}"`).join('');
22
+ return source.replace(/<svg/, `<svg${decls}`);
23
+ }
24
+
25
+ interface Layer {
26
+ id: number;
27
+ label: string;
28
+ }
29
+
30
+ interface Parsed {
31
+ doc?: Document;
32
+ layers: Layer[];
33
+ error?: string;
34
+ }
35
+
36
+ const isLayer = (el: Element) => el.getAttributeNS(NAMESPACES.inkscape!, 'groupmode') === 'layer';
37
+
38
+ function parseSvg(source: string): Parsed {
39
+ const doc = new DOMParser().parseFromString(declareNamespaces(source), 'image/svg+xml');
40
+ const err = doc.querySelector('parsererror');
41
+ if (err) return { layers: [], error: err.textContent?.trim().slice(0, 200) ?? 'parse error' };
42
+ const layers = [...doc.querySelectorAll('g')].filter(isLayer).map((g, id) => ({ id, label: g.getAttributeNS(NAMESPACES.inkscape!, 'label') || g.id || `Layer ${id + 1}` }));
43
+ return { doc, layers };
44
+ }
45
+
46
+ const MIN_ZOOM = 0.05;
47
+ const MAX_ZOOM = 40;
48
+
49
+ /** What an image view shows, for a status bar: natural size in px (0 until loaded) and zoom (1 = 100 %). */
50
+ export interface ImageInfo {
51
+ width: number;
52
+ height: number;
53
+ zoom: number;
54
+ }
55
+
56
+ /** Pointer travel (px) below which a press counts as a click, not a pan. */
57
+ const CLICK_SLOP = 4;
58
+
59
+ /**
60
+ * An SVG drawn by an `<img>` (its scripts never run) with zoom, drag to pan, a background switch and its source.
61
+ * Without `source` it shows any image this way (PNG, JPEG, ...). It opens fitted to the view (never enlarged);
62
+ * a click toggles fit and 100 %; the wheel and +/- (0 fits) zoom while the pointer or focus is on the image.
63
+ */
64
+ export function SvgView({ url, source, name, onInfo }: { url: string; source?: string; name: string; onInfo?(info: ImageInfo): void }) {
65
+ const [view, setView] = useState({ zoom: 1, x: 0, y: 0 });
66
+ const [fit, setFit] = useState(true);
67
+ const [natural, setNatural] = useState({ w: 0, h: 0 });
68
+ const [box, setBox] = useState({ w: 0, h: 0 });
69
+ const [light, setLight] = useState(true);
70
+ const [showSource, setShowSource] = useState(false);
71
+ const drag = useRef<{ x: number; y: number; moved: number }>();
72
+ const stage = useRef<HTMLDivElement>(null);
73
+ const [hidden, setHidden] = useState<Set<number>>(new Set());
74
+ const [showLayers, setShowLayers] = useState(false);
75
+ const parsed = useMemo(() => (source === undefined ? undefined : parseSvg(source)), [source]);
76
+ const [shown, setShown] = useState(url);
77
+
78
+ useEffect(() => setHidden(new Set()), [source]);
79
+ // The file as the page shows it: namespaces repaired, hidden layers display:none, drawn by an <img> so its scripts never run.
80
+ useEffect(() => {
81
+ if (!parsed?.doc) {
82
+ setShown(url);
83
+ return;
84
+ }
85
+ const copy = parsed.doc.cloneNode(true) as Document;
86
+ [...copy.querySelectorAll('g')].filter(isLayer).forEach((g, i) => {
87
+ if (hidden.has(i)) g.setAttribute('style', `${g.getAttribute('style') ?? ''};display:none`);
88
+ });
89
+ const blob = URL.createObjectURL(new Blob([new XMLSerializer().serializeToString(copy)], { type: 'image/svg+xml' }));
90
+ setShown(blob);
91
+ return () => URL.revokeObjectURL(blob);
92
+ }, [parsed, hidden, url]);
93
+
94
+ // An SVG without its own size takes the size of the view.
95
+ const size = natural.w > 0 && natural.h > 0 ? natural : box;
96
+ const fitZoom = size.w > 0 && box.w > 0 ? Math.min(1, box.w / size.w, box.h / size.h) : 1;
97
+ const shownView = fit ? { zoom: fitZoom, x: 0, y: 0 } : view;
98
+
99
+ const zoomAt = (factor: number, cx: number, cy: number) => {
100
+ const from = shownView;
101
+ const zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, from.zoom * factor));
102
+ const k = zoom / from.zoom;
103
+ setFit(false);
104
+ setView({ zoom, x: cx - (cx - from.x) * k, y: cy - (cy - from.y) * k });
105
+ };
106
+ const actual = () => {
107
+ setFit(false);
108
+ setView({ zoom: 1, x: 0, y: 0 });
109
+ };
110
+ const toggle = () => (fit ? actual() : setFit(true));
111
+
112
+ useEffect(() => {
113
+ onInfo?.({ width: natural.w, height: natural.h, zoom: shownView.zoom });
114
+ }, [natural.w, natural.h, shownView.zoom]);
115
+
116
+ useEffect(() => {
117
+ const el = stage.current;
118
+ if (!el) return;
119
+ const observer = new ResizeObserver(() => setBox({ w: el.clientWidth, h: el.clientHeight }));
120
+ observer.observe(el);
121
+ return () => observer.disconnect();
122
+ }, [showSource]);
123
+
124
+ // A native listener: React's wheel listeners are passive, so they can't stop the page from scrolling or zooming (Ctrl+wheel in a browser tab).
125
+ const zoomRef = useRef(zoomAt);
126
+ zoomRef.current = zoomAt;
127
+ useEffect(() => {
128
+ const el = stage.current;
129
+ if (!el) return;
130
+ const onWheel = (e: WheelEvent) => {
131
+ e.preventDefault();
132
+ const r = el.getBoundingClientRect();
133
+ zoomRef.current(e.deltaY < 0 ? 1.15 : 1 / 1.15, e.clientX - r.left - r.width / 2, e.clientY - r.top - r.height / 2);
134
+ };
135
+ el.addEventListener('wheel', onWheel, { passive: false });
136
+ return () => el.removeEventListener('wheel', onWheel);
137
+ }, [showSource]);
138
+
139
+ return (
140
+ <div className="file-preview">
141
+ <div className="file-preview-bar svg-bar">
142
+ <button className="btn btn-ghost btn-sm" onClick={() => zoomAt(1 / 1.25, 0, 0)} aria-label={t('pdf.zoomOut')} title={t('pdf.zoomOut')}>−</button>
143
+ <span className="svg-zoom">{Math.round(shownView.zoom * 100)}%</span>
144
+ <button className="btn btn-ghost btn-sm" onClick={() => zoomAt(1.25, 0, 0)} aria-label={t('pdf.zoomIn')} title={t('pdf.zoomIn')}>+</button>
145
+ <button className="btn btn-ghost btn-sm" onClick={toggle}>{fit ? '100%' : t('svg.actual')}</button>
146
+ <button className="btn btn-ghost btn-sm" onClick={() => setLight(!light)} aria-pressed={light}>{light ? t('svg.bgLight') : t('svg.bgDark')}</button>
147
+ {parsed && parsed.layers.length > 0 && (
148
+ <button className="btn btn-ghost btn-sm" onClick={() => setShowLayers(!showLayers)} aria-pressed={showLayers}>
149
+ {t('pdf.layers')} ({parsed.layers.length})
150
+ </button>
151
+ )}
152
+ {source !== undefined && (
153
+ <button className="btn btn-ghost btn-sm" onClick={() => setShowSource(!showSource)} aria-pressed={showSource}>
154
+ {showSource ? t('svg.image') : t('svg.source')}
155
+ </button>
156
+ )}
157
+ </div>
158
+ {parsed?.error && <PanelBanner tone="err" icon="alert">{t('svg.invalid', { message: parsed.error })}</PanelBanner>}
159
+ <div className="file-preview-body svg-body">
160
+ {showSource && source !== undefined ? (
161
+ <CodeView value={source} lang="text" wrap lineNumbers />
162
+ ) : (
163
+ <div
164
+ ref={stage}
165
+ className={`svg-stage${light ? ' svg-light' : ''}`}
166
+ tabIndex={0}
167
+ onPointerDown={(e) => {
168
+ drag.current = { x: e.clientX, y: e.clientY, moved: 0 };
169
+ e.currentTarget.setPointerCapture(e.pointerId);
170
+ }}
171
+ onPointerMove={(e) => {
172
+ const d = drag.current;
173
+ if (!d) return;
174
+ const dx = e.clientX - d.x;
175
+ const dy = e.clientY - d.y;
176
+ drag.current = { x: e.clientX, y: e.clientY, moved: d.moved + Math.abs(dx) + Math.abs(dy) };
177
+ if (drag.current.moved < CLICK_SLOP) return;
178
+ const from = shownView;
179
+ setFit(false);
180
+ setView({ ...from, x: from.x + dx, y: from.y + dy });
181
+ }}
182
+ onPointerUp={() => {
183
+ if (drag.current && drag.current.moved < CLICK_SLOP) toggle();
184
+ drag.current = undefined;
185
+ }}
186
+ onKeyDown={(e) => {
187
+ if (e.ctrlKey || e.altKey || e.metaKey) return;
188
+ if (e.key === '+' || e.key === '=') zoomAt(1.25, 0, 0);
189
+ else if (e.key === '-') zoomAt(1 / 1.25, 0, 0);
190
+ else if (e.key === '0') setFit(true);
191
+ else return;
192
+ e.preventDefault();
193
+ }}
194
+ >
195
+ <img
196
+ src={shown}
197
+ alt={name}
198
+ draggable={false}
199
+ onLoad={(e) => setNatural({ w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })}
200
+ style={{
201
+ width: size.w || undefined,
202
+ height: size.h || undefined,
203
+ transform: `translate(-50%, -50%) translate(${shownView.x}px, ${shownView.y}px) scale(${shownView.zoom})`,
204
+ }}
205
+ />
206
+ </div>
207
+ )}
208
+ {showLayers && !showSource && parsed && (
209
+ <div className="pdf-layers">
210
+ {parsed.layers.map((layer) => (
211
+ <label key={layer.id} className="pdf-layer">
212
+ <input
213
+ type="checkbox"
214
+ checked={!hidden.has(layer.id)}
215
+ onChange={() =>
216
+ setHidden((h) => {
217
+ const next = new Set(h);
218
+ if (!next.delete(layer.id)) next.add(layer.id);
219
+ return next;
220
+ })
221
+ }
222
+ />
223
+ <span>{layer.label}</span>
224
+ </label>
225
+ ))}
226
+ </div>
227
+ )}
228
+ </div>
229
+ </div>
230
+ );
231
+ }
@@ -0,0 +1,85 @@
1
+ import { PanelBanner, t } from '@deckbuilderkit/ui';
2
+ import { type ReactNode, useMemo, useState } from 'react';
3
+ import { compareCells, delimiterOf, parseDelimited } from './table';
4
+
5
+ /** Most rows drawn at once; the filter and the sort still cover all of them. */
6
+ export const TABLE_ROW_LIMIT = 2000;
7
+
8
+ type Cell = string | number | null;
9
+
10
+ /** A header row and cells, with an optional row-number column and sortable headers. */
11
+ export function DataGrid({ columns, rows, onSort, sort }: {
12
+ columns: ReactNode[];
13
+ rows: Cell[][];
14
+ sort?: { column: number; desc: boolean } | null;
15
+ onSort?(column: number): void;
16
+ }) {
17
+ return (
18
+ <table className="data-grid">
19
+ <thead>
20
+ <tr>
21
+ <th className="data-grid-n">#</th>
22
+ {columns.map((c, i) => (
23
+ <th key={i} onClick={onSort ? () => onSort(i) : undefined} className={onSort ? 'sortable' : undefined}>
24
+ {c}{sort?.column === i && <span aria-hidden> {sort.desc ? '▼' : '▲'}</span>}
25
+ </th>
26
+ ))}
27
+ </tr>
28
+ </thead>
29
+ <tbody>
30
+ {rows.map((row, r) => (
31
+ <tr key={r}>
32
+ <td className="data-grid-n">{r + 1}</td>
33
+ {columns.map((_, c) => (
34
+ <td key={c} className={row[c] === null || row[c] === undefined ? 'data-grid-null' : undefined} title={String(row[c] ?? '')}>{row[c] ?? 'NULL'}</td>
35
+ ))}
36
+ </tr>
37
+ ))}
38
+ </tbody>
39
+ </table>
40
+ );
41
+ }
42
+
43
+ /** A CSV or TSV file as a table: the first line is the header; filter on any cell, sort by a header. */
44
+ export function TableView({ name, text, truncated }: { name: string; text: string; truncated?: boolean }) {
45
+ const parsed = useMemo(() => {
46
+ const all = parseDelimited(text, delimiterOf(name, text));
47
+ const width = all.reduce((max, r) => Math.max(max, r.length), 0);
48
+ const header = (all[0] ?? []).slice();
49
+ while (header.length < width) header.push('');
50
+ return { header, body: all.slice(1) };
51
+ }, [name, text]);
52
+ const [filter, setFilter] = useState('');
53
+ const [sort, setSort] = useState<{ column: number; desc: boolean } | null>(null);
54
+
55
+ const rows = useMemo(() => {
56
+ const q = filter.trim().toLowerCase();
57
+ let out = q ? parsed.body.filter((r) => r.some((c) => c.toLowerCase().includes(q))) : parsed.body;
58
+ if (sort) {
59
+ const sign = sort.desc ? -1 : 1;
60
+ out = [...out].sort((a, b) => sign * compareCells(a[sort.column] ?? '', b[sort.column] ?? ''));
61
+ }
62
+ return out;
63
+ }, [parsed, filter, sort]);
64
+
65
+ const onSort = (column: number) =>
66
+ setSort((s) => (s?.column !== column ? { column, desc: false } : s.desc ? null : { column, desc: true }));
67
+
68
+ return (
69
+ <div className="file-preview">
70
+ {truncated && <PanelBanner tone="warn" icon="alert">{t('preview.truncated')}</PanelBanner>}
71
+ <div className="file-preview-bar archive-bar">
72
+ <span className="archive-summary">
73
+ {t('preview.rows', { shown: rows.length, total: parsed.body.length })} · {t('preview.columns', { count: parsed.header.length })}
74
+ </span>
75
+ <input className="input archive-filter" value={filter} onChange={(e) => setFilter(e.target.value)} placeholder={t('preview.filter')} aria-label={t('preview.filter')} />
76
+ </div>
77
+ {rows.length > TABLE_ROW_LIMIT && <PanelBanner tone="warn" icon="alert">{t('preview.rowsCapped', { max: TABLE_ROW_LIMIT })}</PanelBanner>}
78
+ <div className="file-preview-body">
79
+ {parsed.header.length === 0
80
+ ? <div className="archive-empty">{t('preview.empty')}</div>
81
+ : <DataGrid columns={parsed.header} rows={rows.slice(0, TABLE_ROW_LIMIT)} sort={sort} onSort={onSort} />}
82
+ </div>
83
+ </div>
84
+ );
85
+ }
@@ -0,0 +1,125 @@
1
+ import type { RestEnvVar } from '@deckbuilderkit/server-core/protocol';
2
+ import { type InputHTMLAttributes, type KeyboardEvent, type ReactNode, type RefObject, useLayoutEffect, useMemo, useRef, useState } from 'react';
3
+ import { filterVariableOptions, insertVariable, type VariableOption, variableOptions, variableQueryAt } from './variables';
4
+
5
+ export interface VarSuggest {
6
+ /** Re-reads the text before the caret; call on change, select and click. */
7
+ update(el: HTMLInputElement): void;
8
+ /** Handles ↑/↓/Enter/Tab/Esc while the list is open; true when it took the key. */
9
+ keyDown(e: KeyboardEvent<HTMLInputElement>): boolean;
10
+ close(): void;
11
+ /** The dropdown, or null when closed; render it inside a positioned wrapper. */
12
+ list: ReactNode;
13
+ }
14
+
15
+ interface Open {
16
+ caret: number;
17
+ items: VariableOption[];
18
+ index: number;
19
+ }
20
+
21
+ /** A `{{name}}` suggestion list for a plain input; without `variables` it never opens. */
22
+ export function useVarSuggest(variables: readonly RestEnvVar[] | undefined, input: RefObject<HTMLInputElement | null>, onChange: (value: string) => void): VarSuggest {
23
+ const options = useMemo(() => (variables ? variableOptions(variables) : []), [variables]);
24
+ const [open, setOpen] = useState<Open | null>(null);
25
+ const pendingCaret = useRef<number | null>(null);
26
+ const listRef = useRef<HTMLUListElement>(null);
27
+
28
+ useLayoutEffect(() => {
29
+ const el = input.current;
30
+ if (pendingCaret.current !== null && el) {
31
+ el.setSelectionRange(pendingCaret.current, pendingCaret.current);
32
+ pendingCaret.current = null;
33
+ }
34
+ listRef.current?.querySelector('.active')?.scrollIntoView({ block: 'nearest' });
35
+ });
36
+
37
+ const close = () => setOpen(null);
38
+ const update = (el: HTMLInputElement) => {
39
+ if (!variables || el.selectionStart === null || el.selectionStart !== el.selectionEnd) return close();
40
+ const q = variableQueryAt(el.value, el.selectionStart);
41
+ const items = q ? filterVariableOptions(options, q.partial) : [];
42
+ if (items.length === 0) return close();
43
+ setOpen((prev) => ({ caret: el.selectionStart!, items, index: prev && prev.index < items.length ? prev.index : 0 }));
44
+ };
45
+ const pick = (name: string) => {
46
+ const el = input.current;
47
+ if (!el || !open) return;
48
+ const r = insertVariable(el.value, open.caret, name);
49
+ pendingCaret.current = r.caret;
50
+ setOpen(null);
51
+ onChange(r.text);
52
+ };
53
+ const keyDown = (e: KeyboardEvent<HTMLInputElement>) => {
54
+ if (!open) return false;
55
+ const n = open.items.length;
56
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
57
+ setOpen({ ...open, index: (open.index + (e.key === 'ArrowDown' ? 1 : n - 1)) % n });
58
+ } else if (e.key === 'Enter' || e.key === 'Tab') {
59
+ pick(open.items[open.index]!.name);
60
+ } else if (e.key === 'Escape') {
61
+ close();
62
+ } else {
63
+ return false;
64
+ }
65
+ e.preventDefault();
66
+ e.stopPropagation();
67
+ return true;
68
+ };
69
+ const list = open && (
70
+ <ul ref={listRef} className="menu var-suggest" role="listbox">
71
+ {open.items.map((o, i) => (
72
+ <li
73
+ key={o.name}
74
+ role="option"
75
+ aria-selected={i === open.index}
76
+ className={`menu-item${i === open.index ? ' active' : ''}`}
77
+ title={o.detail}
78
+ onMouseDown={(e) => {
79
+ e.preventDefault();
80
+ pick(o.name);
81
+ }}
82
+ >
83
+ <span className="menu-label">{o.name}</span>
84
+ <span className="var-suggest-detail menu-hint">{o.detail}</span>
85
+ </li>
86
+ ))}
87
+ </ul>
88
+ );
89
+ return { update, keyDown, close, list };
90
+ }
91
+
92
+ export type VarInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange'> & {
93
+ value: string;
94
+ onValueChange(value: string): void;
95
+ variables?: readonly RestEnvVar[];
96
+ };
97
+
98
+ /** An input that suggests `{{name}}` after `{{`; without `variables` a plain input. */
99
+ export function VarInput({ value, onValueChange, variables, onKeyDown, onBlur, ...rest }: VarInputProps) {
100
+ const input = useRef<HTMLInputElement>(null);
101
+ const s = useVarSuggest(variables, input, onValueChange);
102
+ if (!variables) return <input value={value} onChange={(e) => onValueChange(e.target.value)} onKeyDown={onKeyDown} onBlur={onBlur} {...rest} />;
103
+ return (
104
+ <span className="var-suggest-wrap">
105
+ <input
106
+ ref={input}
107
+ value={value}
108
+ {...rest}
109
+ onChange={(e) => {
110
+ onValueChange(e.target.value);
111
+ s.update(e.target);
112
+ }}
113
+ onKeyDown={(e) => {
114
+ if (!s.keyDown(e)) onKeyDown?.(e);
115
+ }}
116
+ onSelect={(e) => s.update(e.currentTarget)}
117
+ onBlur={(e) => {
118
+ s.close();
119
+ onBlur?.(e);
120
+ }}
121
+ />
122
+ {s.list}
123
+ </span>
124
+ );
125
+ }
@@ -0,0 +1,70 @@
1
+ export interface CurlCommand {
2
+ from: number;
3
+ to: number;
4
+ text: string;
5
+ }
6
+
7
+ const START = /^\s*(?:\$\s+)?curl(?:\.exe)?(?=\s|$)/;
8
+
9
+ function scanQuote(line: string, open: string | null): string | null {
10
+ let q = open;
11
+ for (let i = 0; i < line.length; i++) {
12
+ const c = line[i];
13
+ if (q === "'") {
14
+ if (c === "'") q = null;
15
+ } else if (q === '"') {
16
+ if (c === '\\' || c === '^') i++;
17
+ else if (c === '"') q = null;
18
+ } else if (c === '\\' || c === '^') {
19
+ i++;
20
+ } else if (c === "'" || c === '"') {
21
+ q = c;
22
+ }
23
+ }
24
+ return q;
25
+ }
26
+
27
+ export function curlCommands(text: string): CurlCommand[] {
28
+ const out: CurlCommand[] = [];
29
+ let start = -1;
30
+ let end = 0;
31
+ let quote: string | null = null;
32
+ let continued = false;
33
+ let pos = 0;
34
+ while (pos <= text.length) {
35
+ let nl = text.indexOf('\n', pos);
36
+ if (nl < 0) nl = text.length;
37
+ let lineEnd = nl;
38
+ if (lineEnd > pos && text[lineEnd - 1] === '\r') lineEnd--;
39
+ const line = text.slice(pos, lineEnd);
40
+ const trimmed = line.trim();
41
+ if (start >= 0 && !quote && !continued) {
42
+ out.push({ from: start, to: end, text: text.slice(start, end) });
43
+ start = -1;
44
+ }
45
+ if (start < 0) {
46
+ if (trimmed !== '' && !trimmed.startsWith('#') && START.test(line)) {
47
+ start = pos;
48
+ quote = scanQuote(line, null);
49
+ continued = !quote && /[\\^]$/.test(line.trimEnd());
50
+ end = lineEnd;
51
+ }
52
+ } else {
53
+ quote = scanQuote(line, quote);
54
+ continued = !quote && /[\\^]$/.test(line.trimEnd());
55
+ end = lineEnd;
56
+ }
57
+ pos = nl + 1;
58
+ }
59
+ if (start >= 0) out.push({ from: start, to: end, text: text.slice(start, end) });
60
+ return out;
61
+ }
62
+
63
+ export function curlAt(text: string, offset: number): CurlCommand | null {
64
+ let above: CurlCommand | null = null;
65
+ for (const c of curlCommands(text)) {
66
+ if (offset >= c.from && offset <= c.to) return c;
67
+ if (c.to < offset) above = c;
68
+ }
69
+ return above;
70
+ }
@@ -0,0 +1,83 @@
1
+ import { closeBrackets } from '@codemirror/autocomplete';
2
+ import { bracketMatching, indentUnit } from '@codemirror/language';
3
+ import { EditorState, type Extension } from '@codemirror/state';
4
+ import { Decoration, type DecorationSet, drawSelection, EditorView, highlightActiveLine, highlightActiveLineGutter, highlightWhitespace, lineNumbers, ViewPlugin, type ViewUpdate } from '@codemirror/view';
5
+ import { useContext } from 'react';
6
+ import { EditorOptionsContext } from './editorOptionsContext';
7
+
8
+ /** Plain-data editor preferences; every field is optional and defaults to the built-in behavior. */
9
+ export interface EditorOptions {
10
+ tabSize?: number;
11
+ insertSpaces?: boolean;
12
+ /** Initial wrapping for editors that don't set `wrap` themselves. */
13
+ wordWrap?: boolean;
14
+ lineNumbers?: boolean;
15
+ highlightActiveLine?: boolean;
16
+ renderWhitespace?: 'none' | 'selection' | 'all';
17
+ bracketMatching?: boolean;
18
+ autoCloseBrackets?: boolean;
19
+ cursorStyle?: 'line' | 'block' | 'underline';
20
+ cursorBlink?: boolean;
21
+ }
22
+
23
+ export const DEFAULT_EDITOR_OPTIONS: Required<EditorOptions> = {
24
+ tabSize: 4, insertSpaces: true, wordWrap: false, lineNumbers: true, highlightActiveLine: true, renderWhitespace: 'none',
25
+ bracketMatching: true, autoCloseBrackets: true, cursorStyle: 'line', cursorBlink: true,
26
+ };
27
+
28
+ /** The indent unit for the options: `tabSize` spaces, or a tab character. */
29
+ export const indentUnitOf = (o: Required<EditorOptions>): string => (o.insertSpaces ? ' '.repeat(o.tabSize) : '\t');
30
+
31
+ const cursorTheme = (style: 'block' | 'underline') => EditorView.theme({
32
+ '.cm-cursor, .cm-dropCursor': style === 'block'
33
+ ? { borderLeft: 'none', backgroundColor: 'color-mix(in srgb, var(--accent) 60%, transparent)', width: '0.6em' }
34
+ : { borderLeft: 'none', borderBottom: '2px solid var(--accent)', width: '0.6em' },
35
+ });
36
+
37
+ const wsMark = Decoration.mark({ class: 'cm-ws-mark' });
38
+
39
+ function selectionWhitespace(view: EditorView): DecorationSet {
40
+ const ranges = [];
41
+ for (const r of view.state.selection.ranges) {
42
+ if (r.empty) continue;
43
+ const text = view.state.sliceDoc(r.from, r.to);
44
+ for (let i = 0; i < text.length; i++) if (text[i] === ' ' || text[i] === ' ') ranges.push(wsMark.range(r.from + i, r.from + i + 1));
45
+ }
46
+ return Decoration.set(ranges, true);
47
+ }
48
+
49
+ const selectionWhitespaceExt = [
50
+ ViewPlugin.fromClass(class {
51
+ decorations: DecorationSet;
52
+ constructor(view: EditorView) {
53
+ this.decorations = selectionWhitespace(view);
54
+ }
55
+ update(u: ViewUpdate) {
56
+ if (u.selectionSet || u.docChanged) this.decorations = selectionWhitespace(u.view);
57
+ }
58
+ }, { decorations: (v) => v.decorations }),
59
+ EditorView.baseTheme({ '.cm-ws-mark': { backgroundImage: 'radial-gradient(circle at 50% 55%, var(--text-3) 12%, transparent 14%)' } }),
60
+ ];
61
+
62
+ /** Maps the options (defaults filled in) to CodeMirror extensions. */
63
+ export function editorOptionsExtension(options: EditorOptions | undefined): Extension {
64
+ const o = { ...DEFAULT_EDITOR_OPTIONS, ...options };
65
+ const indentSet = options?.tabSize !== undefined || options?.insertSpaces !== undefined;
66
+ return [
67
+ EditorState.tabSize.of(o.tabSize),
68
+ indentSet ? indentUnit.of(indentUnitOf(o)) : [],
69
+ o.lineNumbers ? lineNumbers() : [],
70
+ o.highlightActiveLine ? [highlightActiveLine(), highlightActiveLineGutter()] : [],
71
+ o.renderWhitespace === 'all' ? highlightWhitespace() : [],
72
+ o.renderWhitespace === 'selection' ? selectionWhitespaceExt : [],
73
+ o.bracketMatching ? bracketMatching() : [],
74
+ o.autoCloseBrackets ? closeBrackets() : [],
75
+ drawSelection({ cursorBlinkRate: o.cursorBlink ? 1200 : 0 }),
76
+ o.cursorStyle === 'line' ? [] : cursorTheme(o.cursorStyle),
77
+ ];
78
+ }
79
+
80
+ export { EditorOptionsContext };
81
+
82
+ /** The app-wide editor options, as provided above the editors. */
83
+ export const useEditorOptions = (): EditorOptions | undefined => useContext(EditorOptionsContext);
@@ -0,0 +1,4 @@
1
+ import { createContext } from 'react';
2
+ import type { EditorOptions } from './editorOptions';
3
+
4
+ export const EditorOptionsContext = createContext<EditorOptions | undefined>(undefined);
@@ -0,0 +1,69 @@
1
+ import type { Lang } from './languages';
2
+ import { effectiveName, LOG_NAME } from '@deckbuilderkit/ui/file-associations';
3
+
4
+ const extOf = (name: string) => {
5
+ const dot = name.lastIndexOf('.');
6
+ return dot < 0 ? '' : name.slice(dot + 1).toLowerCase();
7
+ };
8
+
9
+ const LANG_BY_EXT: Record<string, Lang> = {
10
+ sql: 'sql', pls: 'sql', pkb: 'sql', pks: 'sql', json: 'json', jsonc: 'json', java: 'java', sh: 'shell', bash: 'shell', curl: 'shell', http: 'shell',
11
+ md: 'markdown', markdown: 'markdown',
12
+ xml: 'xml', xsd: 'xml', xsl: 'xml', xslt: 'xml', wsdl: 'xml', pom: 'xml', plist: 'xml', csproj: 'xml', fxml: 'xml',
13
+ html: 'html', htm: 'html', xhtml: 'html',
14
+ js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript', ts: 'typescript', mts: 'typescript', cts: 'typescript', tsx: 'typescript',
15
+ css: 'css', py: 'python', yaml: 'yaml', yml: 'yaml', toml: 'toml', properties: 'properties', ini: 'properties', cfg: 'properties', conf: 'properties',
16
+ rs: 'rust', go: 'go', c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', hpp: 'cpp', cs: 'csharp', kt: 'kotlin', kts: 'kotlin',
17
+ diff: 'diff', patch: 'diff', dockerfile: 'dockerfile',
18
+ };
19
+
20
+ /** The editor language for a file name, by extension. */
21
+ export function langOfFile(name: string): Lang {
22
+ return langOfEffective(effectiveName(name));
23
+ }
24
+
25
+ function langOfEffective(name: string): Lang {
26
+ const lower = name.toLowerCase();
27
+ if (lower === 'dockerfile' || lower.startsWith('dockerfile.')) return 'dockerfile';
28
+ if (lower === '.env' || lower.startsWith('.env.')) return 'properties';
29
+ return LANG_BY_EXT[extOf(name)] ?? 'text';
30
+ }
31
+
32
+ export type PreviewKind =
33
+ | 'markdown' | 'json' | 'html' | 'code' | 'image' | 'svg' | 'pdf' | 'archive' | 'log' | 'text'
34
+ | 'audio' | 'video' | 'table' | 'font' | 'notebook' | 'har' | 'sqlite' | 'hex' | 'env';
35
+
36
+ const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'ico']);
37
+ const ARCHIVE_EXTS = new Set([
38
+ 'zip', '7z', 'rar', 'tar', 'gz', 'tgz', 'bz2', 'tbz2', 'xz', 'txz', 'lzma', 'zst', 'iso', 'cab', 'jar', 'war', 'whl', 'nupkg', 'apk',
39
+ 'docx', 'xlsx', 'pptx', 'odt', 'ods', 'odp', 'epub',
40
+ ]);
41
+ const AUDIO_EXTS = new Set(['mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac', 'opus']);
42
+ const VIDEO_EXTS = new Set(['mp4', 'm4v', 'webm', 'mov', 'mkv', 'avi']);
43
+ const FONT_EXTS = new Set(['ttf', 'otf', 'woff', 'woff2']);
44
+ const HEX_EXTS = new Set(['class', 'exe', 'dll', 'so', 'dylib', 'bin', 'msi', 'o', 'obj', 'dat', 'pyc']);
45
+ const SQLITE_EXTS = new Set(['sqlite', 'sqlite3', 'db', 'db3']);
46
+
47
+ /** How FilePreview shows a file: images need its bytes (`/api/fs/raw`), the rest its text. */
48
+ export function previewKind(original: string): PreviewKind {
49
+ const name = effectiveName(original);
50
+ if (LOG_NAME.test(name)) return 'log';
51
+ const lower = name.toLowerCase();
52
+ if (lower === '.env' || lower.startsWith('.env.') || lower.endsWith('.env')) return 'env';
53
+ const ext = extOf(name);
54
+ if (ext === 'csv' || ext === 'tsv') return 'table';
55
+ if (ext === 'ipynb') return 'notebook';
56
+ if (ext === 'har') return 'har';
57
+ if (AUDIO_EXTS.has(ext)) return 'audio';
58
+ if (VIDEO_EXTS.has(ext)) return 'video';
59
+ if (FONT_EXTS.has(ext)) return 'font';
60
+ if (HEX_EXTS.has(ext)) return 'hex';
61
+ if (SQLITE_EXTS.has(ext)) return 'sqlite';
62
+ if (IMAGE_EXTS.has(ext)) return 'image';
63
+ if (ext === 'svg') return 'svg';
64
+ if (ext === 'pdf') return 'pdf';
65
+ if (ARCHIVE_EXTS.has(ext)) return 'archive';
66
+ const lang = langOfEffective(name);
67
+ if (lang === 'markdown' || lang === 'json' || lang === 'html') return lang;
68
+ return lang === 'text' ? 'text' : 'code';
69
+ }