svelte-p5-components 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -0
- package/dist/ActivityBar.svelte +227 -0
- package/dist/ActivityBar.svelte.d.ts +66 -0
- package/dist/ContextMenu.svelte +439 -0
- package/dist/ContextMenu.svelte.d.ts +80 -0
- package/dist/EntityToggleList.svelte +175 -21
- package/dist/EntityToggleList.svelte.d.ts +7 -0
- package/dist/SidePanel.svelte +272 -0
- package/dist/SidePanel.svelte.d.ts +59 -0
- package/dist/Sketch.svelte +20 -18
- package/dist/Sketch.svelte.d.ts +23 -12
- package/dist/Sketch.test.svelte.d.ts +1 -0
- package/dist/Sketch.test.svelte.js +102 -0
- package/dist/TimelineScrubber.svelte +223 -70
- package/dist/TimelineScrubber.svelte.d.ts +32 -8
- package/dist/TimelineScrubber.test.svelte.js +4 -3
- package/dist/TimelineTrack.svelte +483 -150
- package/dist/TimelineTrack.svelte.d.ts +33 -18
- package/dist/TimelineTrack.test.svelte.js +70 -9
- package/dist/createMediaPlayback.svelte.d.ts +60 -0
- package/dist/createMediaPlayback.svelte.js +169 -0
- package/dist/createMediaPlayback.test.d.ts +1 -0
- package/dist/createMediaPlayback.test.js +176 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +4 -0
- package/dist/sketch-types.d.ts +28 -0
- package/dist/sketch-types.js +1 -0
- package/package.json +3 -2
|
@@ -41,6 +41,13 @@
|
|
|
41
41
|
onToggle?: (id: string, visible: boolean) => void;
|
|
42
42
|
/** When provided, a native color input is shown and clicking the swatch opens it. */
|
|
43
43
|
onColorChange?: (id: string, color: string) => void;
|
|
44
|
+
/**
|
|
45
|
+
* When provided, each item gets a pencil button (shown on hover) that
|
|
46
|
+
* enters inline-edit mode. Enter/blur commits via this callback;
|
|
47
|
+
* Escape cancels. Called with the entity's current id and the new
|
|
48
|
+
* label — the consumer is responsible for updating its own state.
|
|
49
|
+
*/
|
|
50
|
+
onRename?: (id: string, nextLabel: string) => void;
|
|
44
51
|
/** Max entities to show before collapsing the remainder behind a "+N more" expander. */
|
|
45
52
|
maxVisible?: number;
|
|
46
53
|
/** Optional heading above the list. */
|
|
@@ -52,12 +59,15 @@
|
|
|
52
59
|
entities,
|
|
53
60
|
onToggle,
|
|
54
61
|
onColorChange,
|
|
62
|
+
onRename,
|
|
55
63
|
maxVisible,
|
|
56
64
|
heading,
|
|
57
65
|
class: className = ''
|
|
58
66
|
}: Props = $props();
|
|
59
67
|
|
|
60
68
|
let expanded = $state(false);
|
|
69
|
+
let editingId = $state<string | null>(null);
|
|
70
|
+
let editValue = $state('');
|
|
61
71
|
|
|
62
72
|
const visibleEntities = $derived(
|
|
63
73
|
expanded || maxVisible === undefined ? entities : entities.slice(0, maxVisible)
|
|
@@ -94,6 +104,43 @@
|
|
|
94
104
|
const target = event.currentTarget as HTMLInputElement;
|
|
95
105
|
onColorChange?.(e.id, target.value);
|
|
96
106
|
}
|
|
107
|
+
|
|
108
|
+
function startRename(e: Entity) {
|
|
109
|
+
editingId = e.id;
|
|
110
|
+
editValue = e.label;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function commitRename() {
|
|
114
|
+
if (editingId === null) return;
|
|
115
|
+
const id = editingId;
|
|
116
|
+
const next = editValue.trim();
|
|
117
|
+
editingId = null;
|
|
118
|
+
editValue = '';
|
|
119
|
+
const entity = entities.find((e) => e.id === id);
|
|
120
|
+
if (!entity) return;
|
|
121
|
+
if (next.length === 0 || next === entity.label) return;
|
|
122
|
+
onRename?.(id, next);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function cancelRename() {
|
|
126
|
+
editingId = null;
|
|
127
|
+
editValue = '';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function handleEditKeydown(event: KeyboardEvent) {
|
|
131
|
+
if (event.key === 'Enter') {
|
|
132
|
+
event.preventDefault();
|
|
133
|
+
commitRename();
|
|
134
|
+
} else if (event.key === 'Escape') {
|
|
135
|
+
event.preventDefault();
|
|
136
|
+
cancelRename();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function focusOnMount(node: HTMLInputElement) {
|
|
141
|
+
node.focus();
|
|
142
|
+
node.select();
|
|
143
|
+
}
|
|
97
144
|
</script>
|
|
98
145
|
|
|
99
146
|
<div class="entity-toggle-list {className}" data-has-groups={hasGroupBoundaries}>
|
|
@@ -128,15 +175,43 @@
|
|
|
128
175
|
aria-hidden="true"
|
|
129
176
|
></span>
|
|
130
177
|
{/if}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
178
|
+
{#if editingId === entity.id}
|
|
179
|
+
<input
|
|
180
|
+
class="entity-toggle-list__edit-input"
|
|
181
|
+
type="text"
|
|
182
|
+
bind:value={editValue}
|
|
183
|
+
onkeydown={handleEditKeydown}
|
|
184
|
+
onblur={commitRename}
|
|
185
|
+
aria-label="Rename {entity.label}"
|
|
186
|
+
use:focusOnMount
|
|
187
|
+
/>
|
|
188
|
+
{:else}
|
|
189
|
+
<button
|
|
190
|
+
type="button"
|
|
191
|
+
class="entity-toggle-list__label"
|
|
192
|
+
aria-pressed={isVisible(entity)}
|
|
193
|
+
title={isVisible(entity) ? `Hide ${entity.label}` : `Show ${entity.label}`}
|
|
194
|
+
onclick={() => handleToggle(entity)}
|
|
195
|
+
>
|
|
196
|
+
{entity.label}
|
|
197
|
+
</button>
|
|
198
|
+
{/if}
|
|
199
|
+
{#if onRename && editingId !== entity.id}
|
|
200
|
+
<button
|
|
201
|
+
type="button"
|
|
202
|
+
class="entity-toggle-list__rename"
|
|
203
|
+
aria-label="Rename {entity.label}"
|
|
204
|
+
title="Rename"
|
|
205
|
+
onclick={() => startRename(entity)}
|
|
206
|
+
>
|
|
207
|
+
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
|
|
208
|
+
<path
|
|
209
|
+
d="M14.06 9 15 9.94 5.92 19H5v-.92L14.06 9m3.6-6c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29Zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94z"
|
|
210
|
+
fill="currentColor"
|
|
211
|
+
/>
|
|
212
|
+
</svg>
|
|
213
|
+
</button>
|
|
214
|
+
{/if}
|
|
140
215
|
</div>
|
|
141
216
|
{/each}
|
|
142
217
|
</div>
|
|
@@ -169,17 +244,27 @@
|
|
|
169
244
|
flex-direction: column;
|
|
170
245
|
gap: 6px;
|
|
171
246
|
font:
|
|
172
|
-
|
|
247
|
+
13px/1.3 system-ui,
|
|
173
248
|
-apple-system,
|
|
174
249
|
Segoe UI,
|
|
175
250
|
sans-serif;
|
|
176
251
|
|
|
177
|
-
|
|
252
|
+
/* Defaults derive from currentColor so the list inherits the
|
|
253
|
+
* embedding chrome's theme. Consumers can still override any
|
|
254
|
+
* of these individually. */
|
|
255
|
+
--entity-swatch-size: 14px;
|
|
178
256
|
--entity-btn-bg: transparent;
|
|
179
|
-
--entity-btn-bg-hover:
|
|
180
|
-
--entity-btn-
|
|
181
|
-
--entity-btn-fg
|
|
182
|
-
--entity-
|
|
257
|
+
--entity-btn-bg-hover: color-mix(in srgb, currentColor 8%, transparent);
|
|
258
|
+
--entity-btn-border-hover: color-mix(in srgb, currentColor 12%, transparent);
|
|
259
|
+
--entity-btn-fg: currentColor;
|
|
260
|
+
--entity-btn-fg-hidden: color-mix(in srgb, currentColor 45%, transparent);
|
|
261
|
+
--entity-heading-fg: color-mix(in srgb, currentColor 65%, transparent);
|
|
262
|
+
--entity-swatch-border: color-mix(in srgb, currentColor 25%, transparent);
|
|
263
|
+
--entity-swatch-inset: color-mix(in srgb, currentColor 15%, transparent);
|
|
264
|
+
--entity-edit-bg: color-mix(in srgb, currentColor 4%, transparent);
|
|
265
|
+
--entity-edit-border: color-mix(in srgb, currentColor 22%, transparent);
|
|
266
|
+
--entity-rename-hover-bg: color-mix(in srgb, currentColor 8%, transparent);
|
|
267
|
+
--entity-focus-ring: color-mix(in srgb, currentColor 55%, transparent);
|
|
183
268
|
}
|
|
184
269
|
|
|
185
270
|
.entity-toggle-list__heading,
|
|
@@ -194,21 +279,27 @@
|
|
|
194
279
|
.entity-toggle-list__row {
|
|
195
280
|
display: flex;
|
|
196
281
|
flex-wrap: wrap;
|
|
197
|
-
gap:
|
|
282
|
+
gap: 6px 8px;
|
|
198
283
|
align-items: center;
|
|
199
284
|
}
|
|
200
285
|
|
|
201
286
|
.entity-toggle-list__item {
|
|
202
287
|
display: inline-flex;
|
|
203
288
|
align-items: center;
|
|
204
|
-
gap:
|
|
205
|
-
padding:
|
|
206
|
-
|
|
289
|
+
gap: 8px;
|
|
290
|
+
padding: 6px 8px 6px 10px;
|
|
291
|
+
min-height: 32px;
|
|
292
|
+
border-radius: 6px;
|
|
207
293
|
background: var(--entity-btn-bg);
|
|
294
|
+
border: 1px solid transparent;
|
|
295
|
+
transition:
|
|
296
|
+
background-color 100ms ease,
|
|
297
|
+
border-color 100ms ease;
|
|
208
298
|
}
|
|
209
299
|
|
|
210
300
|
.entity-toggle-list__item:hover {
|
|
211
301
|
background: var(--entity-btn-bg-hover);
|
|
302
|
+
border-color: var(--entity-btn-border-hover);
|
|
212
303
|
}
|
|
213
304
|
|
|
214
305
|
.entity-toggle-list__item.is-hidden .entity-toggle-list__label {
|
|
@@ -225,8 +316,9 @@
|
|
|
225
316
|
width: var(--entity-swatch-size);
|
|
226
317
|
height: var(--entity-swatch-size);
|
|
227
318
|
border-radius: 50%;
|
|
228
|
-
border: 1px solid
|
|
319
|
+
border: 1px solid var(--entity-swatch-border);
|
|
229
320
|
flex: 0 0 auto;
|
|
321
|
+
box-shadow: 0 0 0 1px var(--entity-swatch-inset) inset;
|
|
230
322
|
}
|
|
231
323
|
|
|
232
324
|
.entity-toggle-list__swatch-wrap {
|
|
@@ -247,12 +339,74 @@
|
|
|
247
339
|
.entity-toggle-list__label {
|
|
248
340
|
background: none;
|
|
249
341
|
border: none;
|
|
250
|
-
padding: 0;
|
|
342
|
+
padding: 2px 0;
|
|
251
343
|
margin: 0;
|
|
252
344
|
font: inherit;
|
|
345
|
+
font-weight: 500;
|
|
253
346
|
color: var(--entity-btn-fg);
|
|
254
347
|
cursor: pointer;
|
|
255
348
|
text-align: left;
|
|
349
|
+
line-height: 1.25;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.entity-toggle-list__label:focus-visible {
|
|
353
|
+
outline: 2px solid var(--entity-focus-ring);
|
|
354
|
+
outline-offset: 2px;
|
|
355
|
+
border-radius: 2px;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
.entity-toggle-list__edit-input {
|
|
359
|
+
background: var(--entity-edit-bg);
|
|
360
|
+
border: 1px solid var(--entity-edit-border);
|
|
361
|
+
border-radius: 4px;
|
|
362
|
+
padding: 3px 6px;
|
|
363
|
+
margin: 0;
|
|
364
|
+
font: inherit;
|
|
365
|
+
font-weight: 500;
|
|
366
|
+
color: var(--entity-btn-fg);
|
|
367
|
+
min-width: 90px;
|
|
368
|
+
max-width: 200px;
|
|
369
|
+
width: auto;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
.entity-toggle-list__edit-input:focus {
|
|
373
|
+
outline: 2px solid var(--entity-focus-ring);
|
|
374
|
+
outline-offset: 1px;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
.entity-toggle-list__rename {
|
|
378
|
+
display: inline-flex;
|
|
379
|
+
align-items: center;
|
|
380
|
+
justify-content: center;
|
|
381
|
+
width: 24px;
|
|
382
|
+
height: 24px;
|
|
383
|
+
background: none;
|
|
384
|
+
border: none;
|
|
385
|
+
padding: 0;
|
|
386
|
+
margin: 0;
|
|
387
|
+
border-radius: 4px;
|
|
388
|
+
color: var(--entity-btn-fg-hidden);
|
|
389
|
+
cursor: pointer;
|
|
390
|
+
opacity: 0;
|
|
391
|
+
transition:
|
|
392
|
+
opacity 100ms ease,
|
|
393
|
+
color 100ms ease,
|
|
394
|
+
background-color 100ms ease;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
.entity-toggle-list__item:hover .entity-toggle-list__rename,
|
|
398
|
+
.entity-toggle-list__rename:focus-visible {
|
|
399
|
+
opacity: 1;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
.entity-toggle-list__rename:hover {
|
|
403
|
+
color: var(--entity-btn-fg);
|
|
404
|
+
background: var(--entity-rename-hover-bg);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
.entity-toggle-list__rename:focus-visible {
|
|
408
|
+
outline: 2px solid var(--entity-focus-ring);
|
|
409
|
+
outline-offset: 1px;
|
|
256
410
|
}
|
|
257
411
|
|
|
258
412
|
.entity-toggle-list__expand {
|
|
@@ -22,6 +22,13 @@ interface Props {
|
|
|
22
22
|
onToggle?: (id: string, visible: boolean) => void;
|
|
23
23
|
/** When provided, a native color input is shown and clicking the swatch opens it. */
|
|
24
24
|
onColorChange?: (id: string, color: string) => void;
|
|
25
|
+
/**
|
|
26
|
+
* When provided, each item gets a pencil button (shown on hover) that
|
|
27
|
+
* enters inline-edit mode. Enter/blur commits via this callback;
|
|
28
|
+
* Escape cancels. Called with the entity's current id and the new
|
|
29
|
+
* label — the consumer is responsible for updating its own state.
|
|
30
|
+
*/
|
|
31
|
+
onRename?: (id: string, nextLabel: string) => void;
|
|
25
32
|
/** Max entities to show before collapsing the remainder behind a "+N more" expander. */
|
|
26
33
|
maxVisible?: number;
|
|
27
34
|
/** Optional heading above the list. */
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { cubicOut } from 'svelte/easing';
|
|
4
|
+
import type { TransitionConfig } from 'svelte/transition';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Slide-in drawer panel. Renders adjacent to an `ActivityBar` in a
|
|
8
|
+
* `CanvasFrame`'s `leftRail` slot (or anywhere the caller places it).
|
|
9
|
+
* The panel itself is purely presentational — the caller owns its
|
|
10
|
+
* open/closed state via the bindable `open` prop.
|
|
11
|
+
*
|
|
12
|
+
* Layout: the panel's width is bindable and mirrors pointer-drag
|
|
13
|
+
* resizes on its right edge. Width is clamped to `[minWidth, maxWidth]`.
|
|
14
|
+
* A symmetric slide+fade handles the open/close animation via Svelte's
|
|
15
|
+
* built-in transition system (no external transition dep), so the panel
|
|
16
|
+
* eases both in and out whenever `open` toggles. `prefers-reduced-motion`
|
|
17
|
+
* collapses the animation to an instant show/hide.
|
|
18
|
+
*
|
|
19
|
+
* Close affordances:
|
|
20
|
+
* - Clicking the × in the default header
|
|
21
|
+
* - Pressing Escape while the panel is open
|
|
22
|
+
* - The caller setting `open = false` programmatically
|
|
23
|
+
*
|
|
24
|
+
* Public class-name contract (stable across versions):
|
|
25
|
+
* .side-panel
|
|
26
|
+
* .side-panel--open
|
|
27
|
+
* .side-panel__header
|
|
28
|
+
* .side-panel__title
|
|
29
|
+
* .side-panel__close
|
|
30
|
+
* .side-panel__body
|
|
31
|
+
* .side-panel__resize-handle
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```svelte
|
|
35
|
+
* <SidePanel bind:open title="Filters" bind:width>
|
|
36
|
+
* <MyFilterForm />
|
|
37
|
+
* </SidePanel>
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
interface Props {
|
|
41
|
+
/** Whether the panel is visible. Bindable. */
|
|
42
|
+
open: boolean;
|
|
43
|
+
/** Title rendered in the default header. */
|
|
44
|
+
title?: string;
|
|
45
|
+
/** Panel width in px. Bindable; updated live during resize drags. */
|
|
46
|
+
width?: number;
|
|
47
|
+
/** Minimum width in px. Default: 220. */
|
|
48
|
+
minWidth?: number;
|
|
49
|
+
/** Maximum width in px. Default: 480. */
|
|
50
|
+
maxWidth?: number;
|
|
51
|
+
/** When false, the right-edge resize handle is not rendered. Default: true. */
|
|
52
|
+
resizable?: boolean;
|
|
53
|
+
/** Fired when the panel wants to close (close button, Escape). */
|
|
54
|
+
onClose?: () => void;
|
|
55
|
+
/** Optional replacement for the default header (title + close). */
|
|
56
|
+
header?: Snippet;
|
|
57
|
+
/** Panel body content. */
|
|
58
|
+
children?: Snippet;
|
|
59
|
+
class?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let {
|
|
63
|
+
open = $bindable(false),
|
|
64
|
+
title,
|
|
65
|
+
width = $bindable(280),
|
|
66
|
+
minWidth = 220,
|
|
67
|
+
maxWidth = 480,
|
|
68
|
+
resizable = true,
|
|
69
|
+
onClose,
|
|
70
|
+
header,
|
|
71
|
+
children,
|
|
72
|
+
class: className = ''
|
|
73
|
+
}: Props = $props();
|
|
74
|
+
|
|
75
|
+
let isDragging = $state(false);
|
|
76
|
+
let dragStartX = 0;
|
|
77
|
+
let dragStartWidth = 0;
|
|
78
|
+
|
|
79
|
+
// Respect prefers-reduced-motion. Detected once per mount; the slide
|
|
80
|
+
// transition is the only motion gated on this, so a single read is enough.
|
|
81
|
+
const prefersReducedMotion =
|
|
82
|
+
typeof window !== 'undefined' &&
|
|
83
|
+
typeof window.matchMedia === 'function' &&
|
|
84
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Symmetric horizontal slide + fade used for both enter and exit. Built on
|
|
88
|
+
* Svelte's built-in transition contract (no external dep): Svelte calls it
|
|
89
|
+
* for `in:` and `out:` and plays the same curve forwards/backwards, so the
|
|
90
|
+
* panel eases open and eased shut. Under reduced motion the duration drops
|
|
91
|
+
* to 0 for an instant show/hide.
|
|
92
|
+
*/
|
|
93
|
+
function slideFade(
|
|
94
|
+
_node: Element,
|
|
95
|
+
{ duration = 170, offset = -16 }: { duration?: number; offset?: number } = {}
|
|
96
|
+
): TransitionConfig {
|
|
97
|
+
return {
|
|
98
|
+
duration: prefersReducedMotion ? 0 : duration,
|
|
99
|
+
easing: cubicOut,
|
|
100
|
+
css: (t: number) => `transform: translateX(${(1 - t) * offset}px); opacity: ${t};`
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function handleClose() {
|
|
105
|
+
onClose?.();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function handleWindowKeydown(event: KeyboardEvent) {
|
|
109
|
+
if (!open) return;
|
|
110
|
+
if (event.key === 'Escape') {
|
|
111
|
+
event.stopPropagation();
|
|
112
|
+
handleClose();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function beginResize(event: PointerEvent) {
|
|
117
|
+
if (!resizable) return;
|
|
118
|
+
event.preventDefault();
|
|
119
|
+
isDragging = true;
|
|
120
|
+
dragStartX = event.clientX;
|
|
121
|
+
dragStartWidth = width;
|
|
122
|
+
document.addEventListener('pointermove', handleResizeMove);
|
|
123
|
+
document.addEventListener('pointerup', endResize);
|
|
124
|
+
document.body.style.cursor = 'col-resize';
|
|
125
|
+
document.body.style.userSelect = 'none';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function handleResizeMove(event: PointerEvent) {
|
|
129
|
+
if (!isDragging) return;
|
|
130
|
+
const delta = event.clientX - dragStartX;
|
|
131
|
+
const next = Math.min(maxWidth, Math.max(minWidth, dragStartWidth + delta));
|
|
132
|
+
width = next;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function endResize() {
|
|
136
|
+
isDragging = false;
|
|
137
|
+
document.removeEventListener('pointermove', handleResizeMove);
|
|
138
|
+
document.removeEventListener('pointerup', endResize);
|
|
139
|
+
document.body.style.cursor = '';
|
|
140
|
+
document.body.style.userSelect = '';
|
|
141
|
+
}
|
|
142
|
+
</script>
|
|
143
|
+
|
|
144
|
+
<svelte:window onkeydown={handleWindowKeydown} />
|
|
145
|
+
|
|
146
|
+
{#if open}
|
|
147
|
+
<aside
|
|
148
|
+
class="side-panel {open ? 'side-panel--open' : ''} {className}"
|
|
149
|
+
style:width="{width}px"
|
|
150
|
+
aria-hidden={!open}
|
|
151
|
+
transition:slideFade
|
|
152
|
+
>
|
|
153
|
+
{#if header}
|
|
154
|
+
{@render header()}
|
|
155
|
+
{:else}
|
|
156
|
+
<div class="side-panel__header">
|
|
157
|
+
<h2 class="side-panel__title">{title ?? ''}</h2>
|
|
158
|
+
<button
|
|
159
|
+
type="button"
|
|
160
|
+
class="side-panel__close"
|
|
161
|
+
onclick={handleClose}
|
|
162
|
+
aria-label="Close panel"
|
|
163
|
+
title="Close"
|
|
164
|
+
>
|
|
165
|
+
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
|
166
|
+
<path
|
|
167
|
+
d="M18.3 5.71 12 12.01l-6.29-6.3-1.42 1.41 6.3 6.3-6.3 6.29 1.42 1.41 6.29-6.29 6.29 6.29 1.41-1.41-6.29-6.29 6.29-6.3z"
|
|
168
|
+
fill="currentColor"
|
|
169
|
+
/>
|
|
170
|
+
</svg>
|
|
171
|
+
</button>
|
|
172
|
+
</div>
|
|
173
|
+
{/if}
|
|
174
|
+
|
|
175
|
+
<div class="side-panel__body">
|
|
176
|
+
{#if children}{@render children()}{/if}
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
{#if resizable}
|
|
180
|
+
<div
|
|
181
|
+
class="side-panel__resize-handle"
|
|
182
|
+
role="separator"
|
|
183
|
+
aria-orientation="vertical"
|
|
184
|
+
aria-label="Resize panel"
|
|
185
|
+
onpointerdown={beginResize}
|
|
186
|
+
></div>
|
|
187
|
+
{/if}
|
|
188
|
+
</aside>
|
|
189
|
+
{/if}
|
|
190
|
+
|
|
191
|
+
<style>
|
|
192
|
+
.side-panel {
|
|
193
|
+
position: relative;
|
|
194
|
+
display: flex;
|
|
195
|
+
flex-direction: column;
|
|
196
|
+
flex: 0 0 auto;
|
|
197
|
+
height: 100%;
|
|
198
|
+
background: var(--side-panel-bg, #fafafa);
|
|
199
|
+
border-right: 1px solid var(--side-panel-border, rgba(0, 0, 0, 0.1));
|
|
200
|
+
box-shadow: var(--side-panel-shadow, 2px 0 6px rgba(0, 0, 0, 0.04));
|
|
201
|
+
overflow: hidden;
|
|
202
|
+
min-height: 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.side-panel__header {
|
|
206
|
+
display: flex;
|
|
207
|
+
align-items: center;
|
|
208
|
+
justify-content: space-between;
|
|
209
|
+
padding: 10px 12px;
|
|
210
|
+
border-bottom: 1px solid var(--side-panel-border, rgba(0, 0, 0, 0.08));
|
|
211
|
+
flex: 0 0 auto;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.side-panel__title {
|
|
215
|
+
margin: 0;
|
|
216
|
+
font:
|
|
217
|
+
13px/1.3 system-ui,
|
|
218
|
+
-apple-system,
|
|
219
|
+
Segoe UI,
|
|
220
|
+
sans-serif;
|
|
221
|
+
font-weight: 600;
|
|
222
|
+
text-transform: uppercase;
|
|
223
|
+
letter-spacing: 0.04em;
|
|
224
|
+
color: var(--side-panel-title-fg, #555);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.side-panel__close {
|
|
228
|
+
display: inline-flex;
|
|
229
|
+
align-items: center;
|
|
230
|
+
justify-content: center;
|
|
231
|
+
width: 24px;
|
|
232
|
+
height: 24px;
|
|
233
|
+
padding: 0;
|
|
234
|
+
border: none;
|
|
235
|
+
background: transparent;
|
|
236
|
+
color: var(--side-panel-close-fg, #666);
|
|
237
|
+
border-radius: 4px;
|
|
238
|
+
cursor: pointer;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.side-panel__close:hover {
|
|
242
|
+
background: rgba(0, 0, 0, 0.06);
|
|
243
|
+
color: #111;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
.side-panel__close:focus-visible {
|
|
247
|
+
outline: 2px solid rgba(59, 130, 246, 0.55);
|
|
248
|
+
outline-offset: 1px;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.side-panel__body {
|
|
252
|
+
flex: 1 1 auto;
|
|
253
|
+
min-height: 0;
|
|
254
|
+
overflow: auto;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.side-panel__resize-handle {
|
|
258
|
+
position: absolute;
|
|
259
|
+
top: 0;
|
|
260
|
+
right: 0;
|
|
261
|
+
bottom: 0;
|
|
262
|
+
width: 6px;
|
|
263
|
+
cursor: col-resize;
|
|
264
|
+
background: transparent;
|
|
265
|
+
transition: background-color 120ms ease;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.side-panel__resize-handle:hover,
|
|
269
|
+
.side-panel__resize-handle:active {
|
|
270
|
+
background: rgba(59, 130, 246, 0.2);
|
|
271
|
+
}
|
|
272
|
+
</style>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
/**
|
|
3
|
+
* Slide-in drawer panel. Renders adjacent to an `ActivityBar` in a
|
|
4
|
+
* `CanvasFrame`'s `leftRail` slot (or anywhere the caller places it).
|
|
5
|
+
* The panel itself is purely presentational — the caller owns its
|
|
6
|
+
* open/closed state via the bindable `open` prop.
|
|
7
|
+
*
|
|
8
|
+
* Layout: the panel's width is bindable and mirrors pointer-drag
|
|
9
|
+
* resizes on its right edge. Width is clamped to `[minWidth, maxWidth]`.
|
|
10
|
+
* A symmetric slide+fade handles the open/close animation via Svelte's
|
|
11
|
+
* built-in transition system (no external transition dep), so the panel
|
|
12
|
+
* eases both in and out whenever `open` toggles. `prefers-reduced-motion`
|
|
13
|
+
* collapses the animation to an instant show/hide.
|
|
14
|
+
*
|
|
15
|
+
* Close affordances:
|
|
16
|
+
* - Clicking the × in the default header
|
|
17
|
+
* - Pressing Escape while the panel is open
|
|
18
|
+
* - The caller setting `open = false` programmatically
|
|
19
|
+
*
|
|
20
|
+
* Public class-name contract (stable across versions):
|
|
21
|
+
* .side-panel
|
|
22
|
+
* .side-panel--open
|
|
23
|
+
* .side-panel__header
|
|
24
|
+
* .side-panel__title
|
|
25
|
+
* .side-panel__close
|
|
26
|
+
* .side-panel__body
|
|
27
|
+
* .side-panel__resize-handle
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```svelte
|
|
31
|
+
* <SidePanel bind:open title="Filters" bind:width>
|
|
32
|
+
* <MyFilterForm />
|
|
33
|
+
* </SidePanel>
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
interface Props {
|
|
37
|
+
/** Whether the panel is visible. Bindable. */
|
|
38
|
+
open: boolean;
|
|
39
|
+
/** Title rendered in the default header. */
|
|
40
|
+
title?: string;
|
|
41
|
+
/** Panel width in px. Bindable; updated live during resize drags. */
|
|
42
|
+
width?: number;
|
|
43
|
+
/** Minimum width in px. Default: 220. */
|
|
44
|
+
minWidth?: number;
|
|
45
|
+
/** Maximum width in px. Default: 480. */
|
|
46
|
+
maxWidth?: number;
|
|
47
|
+
/** When false, the right-edge resize handle is not rendered. Default: true. */
|
|
48
|
+
resizable?: boolean;
|
|
49
|
+
/** Fired when the panel wants to close (close button, Escape). */
|
|
50
|
+
onClose?: () => void;
|
|
51
|
+
/** Optional replacement for the default header (title + close). */
|
|
52
|
+
header?: Snippet;
|
|
53
|
+
/** Panel body content. */
|
|
54
|
+
children?: Snippet;
|
|
55
|
+
class?: string;
|
|
56
|
+
}
|
|
57
|
+
declare const SidePanel: import("svelte").Component<Props, {}, "width" | "open">;
|
|
58
|
+
type SidePanel = ReturnType<typeof SidePanel>;
|
|
59
|
+
export default SidePanel;
|
package/dist/Sketch.svelte
CHANGED
|
@@ -1,16 +1,6 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import { P5Canvas, type
|
|
3
|
-
import type
|
|
4
|
-
|
|
5
|
-
interface Props {
|
|
6
|
-
sketch: SketchFn;
|
|
7
|
-
/** Apply `pixelDensity(devicePixelRatio)` on ready. Default: true */
|
|
8
|
-
hidpi?: boolean;
|
|
9
|
-
class?: string;
|
|
10
|
-
style?: string;
|
|
11
|
-
instance?: p5 | null;
|
|
12
|
-
onReady?: (instance: p5) => void;
|
|
13
|
-
}
|
|
1
|
+
<script lang="ts" generics="Ext = unknown">
|
|
2
|
+
import { P5Canvas, type ExtendedP5 } from 'svelte-p5';
|
|
3
|
+
import type { SketchProps } from './sketch-types.js';
|
|
14
4
|
|
|
15
5
|
let {
|
|
16
6
|
sketch,
|
|
@@ -18,14 +8,22 @@
|
|
|
18
8
|
class: className = '',
|
|
19
9
|
style = 'display: block; width: 100%; height: 100%; overflow: hidden;',
|
|
20
10
|
instance = $bindable(null),
|
|
21
|
-
onReady
|
|
22
|
-
|
|
11
|
+
onReady,
|
|
12
|
+
onResize
|
|
13
|
+
}: SketchProps<Ext> = $props();
|
|
23
14
|
|
|
24
15
|
let container: HTMLDivElement | null = $state(null);
|
|
25
16
|
|
|
26
|
-
function handleReady(p:
|
|
27
|
-
if (
|
|
28
|
-
|
|
17
|
+
function handleReady(p: ExtendedP5<Ext>) {
|
|
18
|
+
if (typeof window !== 'undefined') {
|
|
19
|
+
if (typeof hidpi === 'number') {
|
|
20
|
+
p.pixelDensity(hidpi);
|
|
21
|
+
} else if (hidpi) {
|
|
22
|
+
p.pixelDensity(window.devicePixelRatio);
|
|
23
|
+
} else {
|
|
24
|
+
// p5 defaults to devicePixelRatio, so opting out must be explicit.
|
|
25
|
+
p.pixelDensity(1);
|
|
26
|
+
}
|
|
29
27
|
}
|
|
30
28
|
// Size immediately to the current container dimensions.
|
|
31
29
|
if (container) {
|
|
@@ -43,7 +41,11 @@
|
|
|
43
41
|
if (!entry || !instance) return;
|
|
44
42
|
const w = Math.max(1, Math.floor(entry.contentRect.width));
|
|
45
43
|
const h = Math.max(1, Math.floor(entry.contentRect.height));
|
|
44
|
+
// Skip no-op resizes; this also swallows the observer's initial
|
|
45
|
+
// fire, which would otherwise double-report the handleReady sizing.
|
|
46
|
+
if (w === instance.width && h === instance.height) return;
|
|
46
47
|
instance.resizeCanvas(w, h);
|
|
48
|
+
onResize?.(instance, w, h);
|
|
47
49
|
});
|
|
48
50
|
ro.observe(el);
|
|
49
51
|
return () => ro.disconnect();
|