@kupcad/ui 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 (45) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +281 -2
  3. package/package.json +65 -3
  4. package/src/ConsolePanel.svelte +139 -0
  5. package/src/ExportMenu.svelte +36 -0
  6. package/src/ParamField.svelte +390 -0
  7. package/src/ParamsPanel.svelte +216 -0
  8. package/src/PreviewPanel.svelte +372 -0
  9. package/src/ShortcutsDialog.svelte +170 -0
  10. package/src/Splitter.svelte +148 -0
  11. package/src/Viewer.svelte +175 -0
  12. package/src/ViewerPane.svelte +745 -0
  13. package/src/base64.js +16 -0
  14. package/src/console.js +78 -0
  15. package/src/controls.css +183 -0
  16. package/src/index.js +40 -0
  17. package/src/measures.js +52 -0
  18. package/src/params.js +348 -0
  19. package/src/range.js +19 -0
  20. package/src/recent.js +44 -0
  21. package/src/run-status.js +78 -0
  22. package/src/settings.js +82 -0
  23. package/src/shortcuts.js +59 -0
  24. package/src/theme.js +131 -0
  25. package/src/tokens.css +70 -0
  26. package/src/values.svelte.js +13 -0
  27. package/src/view-settings.js +81 -0
  28. package/src/viewer/appearance.js +90 -0
  29. package/src/viewer/camera.js +100 -0
  30. package/src/viewer/dispose.js +30 -0
  31. package/src/viewer/environment.js +145 -0
  32. package/src/viewer/faces.js +311 -0
  33. package/src/viewer/hover-mark.js +125 -0
  34. package/src/viewer/index.js +7 -0
  35. package/src/viewer/model.js +293 -0
  36. package/src/viewer/motion.js +96 -0
  37. package/src/viewer/section.js +166 -0
  38. package/src/viewer/testing.js +90 -0
  39. package/src/viewer/theme.js +32 -0
  40. package/src/viewer/types.d.ts +136 -0
  41. package/src/viewer/view-cube.js +314 -0
  42. package/src/viewer/viewer.js +1027 -0
  43. package/src/viewer/wheel.js +55 -0
  44. package/src/viewport-socket.js +64 -0
  45. package/src/vscode.css +49 -0
@@ -0,0 +1,372 @@
1
+ <!--
2
+ @component
3
+ A language server's preview: the model of the document the host says it shows, as the
4
+ server's viewport socket sends it, with its parameters beside it. The panel's values go to the
5
+ host (a ViewerHost: the VS Code extension, the desktop app), which gives them to the server;
6
+ the frame that comes back is of a run with them. Why the run failed and what it warned of are
7
+ listed under the model: one with a line, clicked, is shown in the script's editor. The
8
+ document's presets are above its parameters: one applied, or the panel's values saved as one.
9
+ The console under the model keeps each run (what it printed, its warnings and its error) and
10
+ the host's log, as OpenSCAD's does. The parameters' column and the console are resized by the
11
+ bars beside them, and the column hidden by the toolbar's Parameters button (for the model's
12
+ width); their sizes, the column and the view's panel (the Panel button's) hidden or not, and
13
+ the console folded or not are kept in the host's `settings`, and follow them as they are set;
14
+ how the view looks too (the pane's view.* keys). A host's own shortcuts (`shortcuts`) are
15
+ listed before the viewer's in the dialog the pane's bottom bar opens (`shortcutsOpen`).
16
+ -->
17
+ <script>
18
+ import { onMount, untrack } from 'svelte';
19
+ import { errorText } from '@kupcad/protocol';
20
+ import ConsolePanel from './ConsolePanel.svelte';
21
+ import ExportMenu from './ExportMenu.svelte';
22
+ import { appendLines, linesOfLog, linesOfRun } from './console.js';
23
+ import { plainValues } from './values.svelte.js';
24
+ import ParamsPanel from './ParamsPanel.svelte';
25
+ import { memorySettings, sizeSetting } from './settings.js';
26
+ import Splitter from './Splitter.svelte';
27
+ import ViewerPane from './ViewerPane.svelte';
28
+ import { toBase64 } from './base64.js';
29
+ import { problemsOf, runStatusOf } from './run-status.js';
30
+ import { connectViewport } from './viewport-socket.js';
31
+
32
+ /** @import { ExportFormat, ParamValues, Preview, ShownDocument, ViewerHost } from '@kupcad/protocol/types' */
33
+ /** @import { ViewerOptions } from './viewer/types.js' */
34
+ /** @import { ConsoleLine } from './console.js' */
35
+ /** @import { Problem } from './run-status.js' */
36
+ /** @import { Settings } from './settings.js' */
37
+ /** @import { ShortcutGroup } from './shortcuts.js' */
38
+
39
+ /**
40
+ * @typedef {object} Props
41
+ * @property {ViewerHost} host
42
+ * @property {string | null} viewportUrl the language server's socket, once it is up
43
+ * @property {typeof connectViewport} [connect]
44
+ * @property {ViewerOptions} [viewerOptions] what the viewer is made with
45
+ * @property {boolean} [presets] whether the host keeps presets, which the panel then offers
46
+ * @property {boolean} [console] whether the console shows under the model
47
+ * @property {Settings} [settings] where the panes' sizes, and how the view looks, are kept; by
48
+ * default the page's memory
49
+ * @property {readonly ShortcutGroup[]} [shortcuts] the host's own, listed before the viewer's
50
+ * @property {boolean} [shortcutsOpen] whether the shortcuts' dialog shows; bindable
51
+ */
52
+
53
+ /** @type {Props} */
54
+ let {
55
+ host,
56
+ viewportUrl,
57
+ connect = connectViewport,
58
+ viewerOptions = {},
59
+ presets: offersPresets = true,
60
+ console: showsConsole = true,
61
+ settings = memorySettings(),
62
+ shortcuts = [],
63
+ shortcutsOpen = $bindable(false),
64
+ } = $props();
65
+
66
+ /**
67
+ * The settings' keys: the parameters' width and whether they show, whether the view's panel
68
+ * shows, the console's height and whether it is open.
69
+ */
70
+ const KEYS = {
71
+ params: 'preview.params',
72
+ paramsOpen: 'preview.paramsOpen',
73
+ panelOpen: 'preview.panelOpen',
74
+ console: 'preview.console',
75
+ open: 'preview.consoleOpen',
76
+ };
77
+ const id = $props.id();
78
+ // As the settings have them; a size dragged is the pane's own until the drag is done
79
+ let paramsWidth = $derived(sizeSetting(settings, KEYS.params, 288));
80
+ const paramsOpen = $derived(settings.get(KEYS.paramsOpen) !== false);
81
+ const panelOpen = $derived(settings.get(KEYS.panelOpen) !== false);
82
+ let consoleHeight = $derived(sizeSetting(settings, KEYS.console, 144));
83
+ const consoleOpen = $derived(settings.get(KEYS.open) !== false);
84
+
85
+ let shown = $state(/** @type {ShownDocument | null} */ (null));
86
+ let preview = $state(/** @type {Preview | null} */ (null));
87
+ let connected = $state(false);
88
+ let overrides = $state(/** @type {ParamValues} */ ({}));
89
+ let presets = $state(/** @type {string[]} */ ([]));
90
+ /** Why the viewer can't show the frame's model, until the next frame. */
91
+ let unshown = $state('');
92
+ let consoleLines = $state(/** @type {ConsoleLine[]} */ ([]));
93
+ /** The header of the run the console has last: a frame sent again is the same run. */
94
+ let lastRun = '';
95
+
96
+ const header = $derived(preview?.header ?? null);
97
+ /** How the shown document's run went, for the pane's status line. */
98
+ const run = $derived(shown ? runStatusOf(header) : null);
99
+ const problems = $derived(problemsOf(header));
100
+ const parameters = $derived(header?.params.schema.parameters ?? []);
101
+ const status = $derived(statusOf());
102
+
103
+ function statusOf() {
104
+ if (!viewportUrl) return 'Waiting for the KupCAD language server…';
105
+ if (!connected) return 'Connecting to the language server…';
106
+ if (!shown) return 'Open a .kup file to see its model.';
107
+ if (!preview) return `Running ${shown.name}…`;
108
+ if (unshown) return `The model can't be shown: ${unshown}`;
109
+ return null;
110
+ }
111
+
112
+ onMount(() => {
113
+ const stopListening = host.listen((message) => {
114
+ // The host holds the values and presets of every document: it says the shown one's
115
+ // with each document, and again when they change
116
+ if (message.type === 'log') {
117
+ consoleLines = appendLines(consoleLines, linesOfLog(message.text));
118
+ } else if (message.type === 'document') {
119
+ if (message.document?.uri !== shown?.uri) preview = null;
120
+ shown = message.document;
121
+ overrides = {};
122
+ presets = [];
123
+ } else if (message.uri !== shown?.uri) {
124
+ return;
125
+ } else if (message.type === 'overrides') {
126
+ overrides = message.values;
127
+ } else {
128
+ presets = message.names;
129
+ }
130
+ });
131
+ return stopListening;
132
+ });
133
+
134
+ // The socket of the server up now: one started again (it ended) has another, and the panel
135
+ // keeps what it shows and its console across it
136
+ $effect(() => {
137
+ const url = viewportUrl;
138
+ if (!url) return;
139
+ const disconnect = untrack(() =>
140
+ connect(url, {
141
+ open: () => host.post({ type: 'ready' }),
142
+ state: (open) => (connected = open),
143
+ frame: (next) => {
144
+ // The socket sends every viewer the frame of the document the server shows: one of
145
+ // another (late, or with none shown here) isn't this page's
146
+ if (!shown || next.header.document !== shown.uri) return;
147
+ preview = next;
148
+ unshown = '';
149
+ const thisRun = JSON.stringify(next.header);
150
+ if (thisRun !== lastRun) {
151
+ lastRun = thisRun;
152
+ consoleLines = appendLines(consoleLines, linesOfRun(next.header, shown.name));
153
+ }
154
+ },
155
+ problem: (message) => host.post({ type: 'problem', message }),
156
+ }),
157
+ );
158
+ return () => {
159
+ disconnect();
160
+ connected = false;
161
+ };
162
+ });
163
+
164
+ /** @param {ParamValues} values */
165
+ function changed(values) {
166
+ if (shown) host.post({ type: 'setParams', uri: shown.uri, values });
167
+ }
168
+
169
+ /** @param {Event & { currentTarget: HTMLSelectElement }} event */
170
+ function applyPreset(event) {
171
+ const name = event.currentTarget.value;
172
+ event.currentTarget.value = '';
173
+ if (shown && name) host.post({ type: 'applyPreset', uri: shown.uri, name });
174
+ }
175
+
176
+ function savePreset() {
177
+ if (shown) host.post({ type: 'savePreset', uri: shown.uri, values: plainValues(overrides) });
178
+ }
179
+
180
+ /**
181
+ * Asks the host to export the shown document in `format`, with its values.
182
+ * @param {ExportFormat} format
183
+ */
184
+ function exportShown(format) {
185
+ if (shown) host.post({ type: 'export', uri: shown.uri, format });
186
+ }
187
+
188
+ /**
189
+ * Asks the host to show the script where `problem` was, in its editor.
190
+ * @param {Problem} problem
191
+ */
192
+ function revealProblem(problem) {
193
+ if (!shown || problem.line === null) return;
194
+ host.post({ type: 'reveal', uri: shown.uri, line: problem.line, column: problem.column ?? 1 });
195
+ }
196
+
197
+ /**
198
+ * Shows the script where a console line names, in its editor.
199
+ * @param {ConsoleLine} line
200
+ */
201
+ function revealLine(line) {
202
+ if (!shown || line.line === null) return;
203
+ host.post({ type: 'reveal', uri: shown.uri, line: line.line, column: line.column ?? 1 });
204
+ }
205
+
206
+ /** @param {Blob} image */
207
+ async function screenshot(image) {
208
+ host.post({ type: 'screenshot', png: toBase64(new Uint8Array(await image.arrayBuffer())) });
209
+ }
210
+
211
+ /** @param {unknown} error the viewer's: a model it can't show */
212
+ function failed(error) {
213
+ unshown = errorText(error);
214
+ host.post({ type: 'problem', message: unshown });
215
+ }
216
+ </script>
217
+
218
+ <div class="kc-preview">
219
+ <section class="view">
220
+ <div class="model">
221
+ <ViewerPane
222
+ model={preview?.model ?? null}
223
+ subject={shown?.uri ?? null}
224
+ options={viewerOptions}
225
+ bind:panel={() => panelOpen, (open) => settings.set(KEYS.panelOpen, open)}
226
+ {settings}
227
+ {shortcuts}
228
+ bind:shortcutsOpen
229
+ status={run}
230
+ {problems}
231
+ onproblem={revealProblem}
232
+ onscreenshot={screenshot}
233
+ onerror={failed}
234
+ >
235
+ {#snippet actions()}
236
+ {#if shown}
237
+ <ExportMenu kind={preview?.model.kind ?? 'none'} onexport={exportShown} />
238
+ {/if}
239
+ {/snippet}
240
+ {#snippet toggles()}
241
+ {#if header && parameters.length > 0}
242
+ <button
243
+ type="button"
244
+ class="kc-control"
245
+ aria-controls="{id}-params"
246
+ aria-expanded={paramsOpen}
247
+ onclick={() => settings.set(KEYS.paramsOpen, !paramsOpen)}
248
+ >
249
+ Parameters
250
+ </button>
251
+ {/if}
252
+ {/snippet}
253
+ </ViewerPane>
254
+ {#if status}
255
+ <p class="notice">{status}</p>
256
+ {/if}
257
+ </div>
258
+ {#if showsConsole}
259
+ {#if consoleOpen}
260
+ <Splitter
261
+ between="rows"
262
+ pane="after"
263
+ bind:size={consoleHeight}
264
+ min={48}
265
+ label="Resize the console"
266
+ onchange={(size) => settings.set(KEYS.console, size)}
267
+ />
268
+ {/if}
269
+ <ConsolePanel
270
+ lines={consoleLines}
271
+ height={consoleHeight}
272
+ bind:open={() => consoleOpen, (open) => settings.set(KEYS.open, open)}
273
+ onreveal={revealLine}
274
+ onclear={() => (consoleLines = [])}
275
+ />
276
+ {/if}
277
+ </section>
278
+ {#if header && parameters.length > 0}
279
+ {#if paramsOpen}
280
+ <Splitter
281
+ pane="after"
282
+ bind:size={paramsWidth}
283
+ min={200}
284
+ max={640}
285
+ label="Resize the parameters"
286
+ onchange={(size) => settings.set(KEYS.params, size)}
287
+ />
288
+ {/if}
289
+ <!-- Hidden, not gone: what its fields hold stays, and the toggle has it to control -->
290
+ <aside
291
+ class="params"
292
+ id="{id}-params"
293
+ aria-label="Parameters"
294
+ hidden={!paramsOpen}
295
+ style:width="{paramsWidth}px"
296
+ >
297
+ {#if offersPresets}
298
+ <div class="presets">
299
+ <select
300
+ class="kc-control"
301
+ aria-label="Preset"
302
+ disabled={presets.length === 0}
303
+ onchange={applyPreset}
304
+ >
305
+ <option value="" selected disabled>
306
+ {presets.length > 0 ? 'Apply a preset…' : 'No presets'}
307
+ </option>
308
+ {#each presets as name (name)}
309
+ <option value={name}>{name}</option>
310
+ {/each}
311
+ </select>
312
+ <button type="button" class="kc-control" onclick={savePreset}>Save as preset…</button>
313
+ </div>
314
+ {/if}
315
+ <ParamsPanel
316
+ schema={header.params.schema}
317
+ values={header.params.values}
318
+ error={header.error}
319
+ bind:overrides
320
+ onchange={changed}
321
+ />
322
+ </aside>
323
+ {/if}
324
+ </div>
325
+
326
+ <style>
327
+ .kc-preview {
328
+ display: flex;
329
+ height: 100%;
330
+ background: var(--kc-bg);
331
+ font-family: var(--kc-font);
332
+ font-size: var(--kc-font-size);
333
+ color: var(--kc-fg);
334
+ }
335
+ .view {
336
+ display: flex;
337
+ flex-direction: column;
338
+ flex: 1;
339
+ min-width: 0;
340
+ }
341
+ .model {
342
+ position: relative;
343
+ flex: 1;
344
+ min-height: 0;
345
+ }
346
+ /* No space at its top: the parameters' bar, held there as they scroll, has its own */
347
+ .params {
348
+ flex: none;
349
+ overflow: auto;
350
+ padding: 0 var(--kc-gap) var(--kc-gap);
351
+ border-left: 1px solid var(--kc-border);
352
+ }
353
+ .presets {
354
+ display: flex;
355
+ gap: 4px;
356
+ /* The column gives no space at its top */
357
+ padding-top: var(--kc-gap);
358
+ margin-bottom: var(--kc-gap);
359
+ }
360
+ .presets select {
361
+ flex: 1;
362
+ min-width: 0;
363
+ }
364
+ .notice {
365
+ position: absolute;
366
+ inset: 50% 0 auto;
367
+ margin: 0;
368
+ text-align: center;
369
+ color: var(--kc-muted);
370
+ pointer-events: none;
371
+ }
372
+ </style>
@@ -0,0 +1,170 @@
1
+ <!--
2
+ @component
3
+ The keys and the pointer's moves a person can't see on the screen, by group (a host's own, the
4
+ viewer's: `VIEWER_SHORTCUTS`), in a modal dialog while `open` (bindable). Escape or its close
5
+ button closes it, and the focus goes back to what had it when it opened.
6
+ -->
7
+ <script>
8
+ /** @import { ShortcutGroup } from './shortcuts.js' */
9
+
10
+ /**
11
+ * @typedef {object} Props
12
+ * @property {readonly ShortcutGroup[]} groups
13
+ * @property {boolean} [open] whether it shows; bindable
14
+ */
15
+
16
+ /** @type {Props} */
17
+ let { groups, open = $bindable(false) } = $props();
18
+
19
+ const id = $props.id();
20
+ /** @type {HTMLDialogElement} */
21
+ let dialog;
22
+ /** What had the focus when it opened, given it back when it closes. */
23
+ let before = /** @type {HTMLElement | null} */ (null);
24
+
25
+ $effect(() => {
26
+ if (open && !dialog.open) {
27
+ const active = document.activeElement;
28
+ before = active instanceof HTMLElement ? active : null;
29
+ dialog.showModal();
30
+ } else if (!open && dialog.open) {
31
+ dialog.close();
32
+ }
33
+ });
34
+
35
+ /** It closed: by Escape, its button, or `open` set false. */
36
+ function closed() {
37
+ open = false;
38
+ before?.focus();
39
+ before = null;
40
+ }
41
+
42
+ /** @param {KeyboardEvent} event */
43
+ function keydown(event) {
44
+ // A browser's modal dialog closes on Escape by itself; a webview's may not
45
+ if (event.key !== 'Escape') return;
46
+ event.preventDefault();
47
+ dialog.close();
48
+ }
49
+ </script>
50
+
51
+ <dialog
52
+ class="kc-shortcuts"
53
+ aria-labelledby="{id}-title"
54
+ bind:this={dialog}
55
+ onclose={closed}
56
+ onkeydown={keydown}
57
+ >
58
+ <header>
59
+ <h2 id="{id}-title">Keyboard shortcuts</h2>
60
+ <button type="button" class="close" aria-label="Close" onclick={() => dialog.close()}>
61
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" /></svg>
62
+ </button>
63
+ </header>
64
+ {#each groups as group, index (index)}
65
+ <section aria-labelledby="{id}-group-{index}">
66
+ <h3 id="{id}-group-{index}">{group.title}</h3>
67
+ {#if group.note}<p class="note">{group.note}</p>{/if}
68
+ <dl>
69
+ {#each group.keys as [keys, what], at (at)}
70
+ <div class="row">
71
+ <dt><kbd>{keys}</kbd></dt>
72
+ <dd>{what}</dd>
73
+ </div>
74
+ {/each}
75
+ </dl>
76
+ </section>
77
+ {/each}
78
+ </dialog>
79
+
80
+ <style>
81
+ .kc-shortcuts {
82
+ width: min(32em, calc(100vw - 32px));
83
+ max-height: calc(100vh - 64px);
84
+ padding: 16px 20px;
85
+ font-family: var(--kc-font);
86
+ font-size: var(--kc-font-size);
87
+ color: var(--kc-fg);
88
+ background: var(--kc-bg);
89
+ border: 1px solid var(--kc-border);
90
+ border-radius: calc(var(--kc-radius) * 2);
91
+ }
92
+ .kc-shortcuts::backdrop {
93
+ background: rgb(0 0 0 / 0.35);
94
+ }
95
+ header {
96
+ display: flex;
97
+ align-items: center;
98
+ justify-content: space-between;
99
+ margin-bottom: 8px;
100
+ }
101
+ h2 {
102
+ margin: 0;
103
+ font-size: 1.25em;
104
+ }
105
+ h3 {
106
+ margin: 12px 0 4px;
107
+ font-size: 1em;
108
+ }
109
+ .close {
110
+ display: grid;
111
+ place-items: center;
112
+ width: 28px;
113
+ height: 28px;
114
+ padding: 0;
115
+ color: var(--kc-muted);
116
+ background: none;
117
+ border: none;
118
+ border-radius: var(--kc-radius);
119
+ cursor: pointer;
120
+ }
121
+ .close:hover {
122
+ color: var(--kc-fg);
123
+ background: var(--kc-hover);
124
+ }
125
+ .close:focus-visible {
126
+ outline: 2px solid var(--kc-focus);
127
+ outline-offset: -2px;
128
+ }
129
+ .close svg {
130
+ width: 16px;
131
+ height: 16px;
132
+ fill: none;
133
+ stroke: currentColor;
134
+ stroke-width: 1.75;
135
+ stroke-linecap: round;
136
+ }
137
+ .note {
138
+ margin: 0 0 4px;
139
+ color: var(--kc-muted);
140
+ }
141
+ /* The group's keys in a column as wide as its widest, what each does beside it: a row is
142
+ its two cells, a line under each */
143
+ dl {
144
+ display: grid;
145
+ grid-template-columns: max-content 1fr;
146
+ margin: 0;
147
+ }
148
+ .row {
149
+ display: contents;
150
+ }
151
+ dt,
152
+ dd {
153
+ margin: 0;
154
+ padding: 3px 0;
155
+ border-bottom: 1px solid var(--kc-border);
156
+ }
157
+ dt {
158
+ padding-right: 16px;
159
+ }
160
+ kbd {
161
+ font-family: var(--kc-code-font, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
162
+ font-size: 0.92em;
163
+ padding: 1px 5px;
164
+ color: var(--kc-section-fg);
165
+ background: var(--kc-section-bg);
166
+ border: 1px solid var(--kc-border);
167
+ border-radius: var(--kc-radius);
168
+ white-space: nowrap;
169
+ }
170
+ </style>
@@ -0,0 +1,148 @@
1
+ <!--
2
+ @component
3
+ The bar between two panes, which resizes one of them: dragged, or with the arrow keys (by
4
+ 16 px, 64 with Shift; Home and End for its least and most), and double-clicked back to the
5
+ size it started with. `size` (bindable) is the pane's width for a bar between columns, its
6
+ height for one between rows, in CSS pixels, kept between `min` and `max`; the pane is the one
7
+ before the bar or after it (`pane`). `onchange` gets the size once a drag or a key is done,
8
+ for the host to keep.
9
+ -->
10
+ <script>
11
+ import { untrack } from 'svelte';
12
+
13
+ /**
14
+ * @typedef {object} Props
15
+ * @property {number} size the pane's size; bindable
16
+ * @property {'columns' | 'rows'} [between] what the bar is between
17
+ * @property {'before' | 'after'} [pane] which pane it sizes: the one before it (left, above)
18
+ * or after it
19
+ * @property {number} [min]
20
+ * @property {number} [max]
21
+ * @property {string} label what it resizes, for a screen reader
22
+ * @property {(size: number) => void} [onchange]
23
+ */
24
+
25
+ /** @type {Props} */
26
+ let {
27
+ size = $bindable(),
28
+ between = 'columns',
29
+ pane = 'before',
30
+ min = 120,
31
+ max = Number.POSITIVE_INFINITY,
32
+ label,
33
+ onchange,
34
+ } = $props();
35
+
36
+ /** A key's step, and with Shift. */
37
+ const STEP = 16;
38
+ const LONG_STEP = 64;
39
+ const first = untrack(() => size);
40
+ /** Where a drag started: the pointer, and the size then. */
41
+ let drag = $state(/** @type {{ at: number, from: number } | null} */ (null));
42
+
43
+ const clamp = (/** @type {number} */ value) =>
44
+ Math.round(Math.min(Math.max(value, min), Math.max(min, max)));
45
+ /** +1 when moving right (or down) grows the pane, -1 when it shrinks it. */
46
+ const sign = $derived(pane === 'before' ? 1 : -1);
47
+ const along = (/** @type {PointerEvent} */ event) =>
48
+ between === 'columns' ? event.clientX : event.clientY;
49
+
50
+ /** @param {number} next */
51
+ function settle(next) {
52
+ size = clamp(next);
53
+ onchange?.(size);
54
+ }
55
+
56
+ /** @param {PointerEvent & { currentTarget: HTMLElement }} event */
57
+ function down(event) {
58
+ if (event.button !== 0) return;
59
+ event.preventDefault();
60
+ event.currentTarget.setPointerCapture(event.pointerId);
61
+ drag = { at: along(event), from: size };
62
+ }
63
+
64
+ /** @param {PointerEvent} event */
65
+ function move(event) {
66
+ if (drag) size = clamp(drag.from + sign * (along(event) - drag.at));
67
+ }
68
+
69
+ function up() {
70
+ if (!drag) return;
71
+ drag = null;
72
+ onchange?.(size);
73
+ }
74
+
75
+ /** @param {KeyboardEvent} event */
76
+ function key(event) {
77
+ const step = event.shiftKey ? LONG_STEP : STEP;
78
+ const [less, more] =
79
+ between === 'columns' ? ['ArrowLeft', 'ArrowRight'] : ['ArrowUp', 'ArrowDown'];
80
+ if (event.key === less) settle(size - sign * step);
81
+ else if (event.key === more) settle(size + sign * step);
82
+ else if (event.key === 'Home') settle(min);
83
+ else if (event.key === 'End' && Number.isFinite(max)) settle(max);
84
+ else return;
85
+ event.preventDefault();
86
+ }
87
+ </script>
88
+
89
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions (a focusable separator is ARIA's splitter, a widget) -->
90
+ <div
91
+ class="kc-splitter {between}"
92
+ class:dragging={drag !== null}
93
+ role="separator"
94
+ tabindex="0"
95
+ aria-label={label}
96
+ aria-orientation={between === 'columns' ? 'vertical' : 'horizontal'}
97
+ aria-valuenow={size}
98
+ aria-valuemin={min}
99
+ aria-valuemax={Number.isFinite(max) ? max : undefined}
100
+ onpointerdown={down}
101
+ onpointermove={move}
102
+ onpointerup={up}
103
+ onpointercancel={up}
104
+ onkeydown={key}
105
+ ondblclick={() => settle(first)}
106
+ ></div>
107
+
108
+ <style>
109
+ /* No room of its own: the panes keep their borders, and the bar lies over the one between
110
+ them, a grip wider than it, which shows its line when used */
111
+ .kc-splitter {
112
+ position: relative;
113
+ flex: none;
114
+ z-index: 1;
115
+ touch-action: none;
116
+ outline: none;
117
+ }
118
+ .columns {
119
+ width: 0;
120
+ cursor: col-resize;
121
+ }
122
+ .rows {
123
+ height: 0;
124
+ cursor: row-resize;
125
+ }
126
+ .kc-splitter::before,
127
+ .kc-splitter::after {
128
+ content: '';
129
+ position: absolute;
130
+ }
131
+ .columns::after {
132
+ inset: 0 -4px;
133
+ }
134
+ .rows::after {
135
+ inset: -4px 0;
136
+ }
137
+ .columns::before {
138
+ inset: 0 -1px;
139
+ }
140
+ .rows::before {
141
+ inset: -1px 0;
142
+ }
143
+ .kc-splitter:hover::before,
144
+ .kc-splitter:focus-visible::before,
145
+ .dragging::before {
146
+ background: var(--kc-accent);
147
+ }
148
+ </style>