@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.
- package/LICENSE +661 -0
- package/README.md +281 -2
- package/package.json +65 -3
- package/src/ConsolePanel.svelte +139 -0
- package/src/ExportMenu.svelte +36 -0
- package/src/ParamField.svelte +390 -0
- package/src/ParamsPanel.svelte +216 -0
- package/src/PreviewPanel.svelte +372 -0
- package/src/ShortcutsDialog.svelte +170 -0
- package/src/Splitter.svelte +148 -0
- package/src/Viewer.svelte +175 -0
- package/src/ViewerPane.svelte +745 -0
- package/src/base64.js +16 -0
- package/src/console.js +78 -0
- package/src/controls.css +183 -0
- package/src/index.js +40 -0
- package/src/measures.js +52 -0
- package/src/params.js +348 -0
- package/src/range.js +19 -0
- package/src/recent.js +44 -0
- package/src/run-status.js +78 -0
- package/src/settings.js +82 -0
- package/src/shortcuts.js +59 -0
- package/src/theme.js +131 -0
- package/src/tokens.css +70 -0
- package/src/values.svelte.js +13 -0
- package/src/view-settings.js +81 -0
- package/src/viewer/appearance.js +90 -0
- package/src/viewer/camera.js +100 -0
- package/src/viewer/dispose.js +30 -0
- package/src/viewer/environment.js +145 -0
- package/src/viewer/faces.js +311 -0
- package/src/viewer/hover-mark.js +125 -0
- package/src/viewer/index.js +7 -0
- package/src/viewer/model.js +293 -0
- package/src/viewer/motion.js +96 -0
- package/src/viewer/section.js +166 -0
- package/src/viewer/testing.js +90 -0
- package/src/viewer/theme.js +32 -0
- package/src/viewer/types.d.ts +136 -0
- package/src/viewer/view-cube.js +314 -0
- package/src/viewer/viewer.js +1027 -0
- package/src/viewer/wheel.js +55 -0
- package/src/viewport-socket.js +64 -0
- 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>
|