@axium/client 0.29.0 → 0.31.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/assets/styles.css CHANGED
@@ -293,16 +293,17 @@ div.success {
293
293
 
294
294
  .drag-indicator {
295
295
  position: fixed;
296
+ top: 0;
297
+ left: 0;
296
298
  z-index: 100;
299
+ pointer-events: none;
297
300
  display: inline-flex;
298
301
  align-items: center;
299
302
  gap: 0.5em;
300
- pointer-events: none;
301
303
  padding: 0.5em 0.75em;
302
304
  border-radius: 0.5em;
303
305
  background-color: var(--bg-elevated);
304
306
  border: var(--border-elevated);
305
- transform: translate(0.75em, 0.75em);
306
307
  max-width: 20em;
307
308
  white-space: nowrap;
308
309
 
@@ -326,9 +327,26 @@ div.success {
326
327
  border-radius: 0.5em;
327
328
  }
328
329
 
330
+ .drag-upload-indicator {
331
+ position: fixed;
332
+ bottom: 2em;
333
+ left: 50%;
334
+ translate: -50% 0;
335
+ z-index: 100;
336
+ pointer-events: none;
337
+ display: inline-flex;
338
+ align-items: center;
339
+ gap: 0.5em;
340
+ padding: 1em 2em;
341
+ border-radius: 1em;
342
+ background-color: var(--bg-elevated);
343
+ border: var(--border-accent);
344
+ font-weight: bold;
345
+ white-space: nowrap;
346
+ }
347
+
329
348
  body.dragging-items,
330
349
  body.dragging-items * {
331
- cursor: grabbing !important;
332
350
  user-select: none;
333
351
  }
334
352
 
@@ -0,0 +1,31 @@
1
+ export interface TriggerModifiers {
2
+ ctrl?: boolean;
3
+ shift?: boolean;
4
+ alt?: boolean;
5
+ meta?: boolean;
6
+ }
7
+ export interface MouseTrigger extends TriggerModifiers {
8
+ button: number;
9
+ }
10
+ /** @see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/location */
11
+ export declare const enum KeyLocation {
12
+ Standard = 0,
13
+ Left = 1,
14
+ Right = 2,
15
+ Numpad = 3
16
+ }
17
+ export interface KeyboardTrigger extends TriggerModifiers {
18
+ key: string;
19
+ location?: KeyLocation;
20
+ }
21
+ export type Trigger = KeyboardTrigger | MouseTrigger;
22
+ export interface WithAction<TData extends unknown[]> {
23
+ action(...args: TData): unknown;
24
+ }
25
+ export interface KeyboardHandler<TData extends unknown[]> extends KeyboardTrigger, WithAction<TData> {
26
+ }
27
+ export interface MouseHandler<TData extends unknown[]> extends MouseTrigger, WithAction<TData> {
28
+ }
29
+ export type Handler<TData extends unknown[]> = KeyboardHandler<TData> | MouseHandler<TData>;
30
+ export declare function isMatch(trigger: Trigger, event: KeyboardEvent | MouseEvent): boolean;
31
+ export declare function find<T extends Trigger>(triggers: T[], event: KeyboardEvent | MouseEvent): T | undefined;
@@ -0,0 +1,24 @@
1
+ /** @see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/location */
2
+ export var KeyLocation;
3
+ (function (KeyLocation) {
4
+ KeyLocation[KeyLocation["Standard"] = 0] = "Standard";
5
+ KeyLocation[KeyLocation["Left"] = 1] = "Left";
6
+ KeyLocation[KeyLocation["Right"] = 2] = "Right";
7
+ KeyLocation[KeyLocation["Numpad"] = 3] = "Numpad";
8
+ })(KeyLocation || (KeyLocation = {}));
9
+ export function isMatch(trigger, event) {
10
+ if (('ctrl' in trigger && trigger.ctrl !== event.ctrlKey) ||
11
+ ('shift' in trigger && trigger.shift !== event.shiftKey) ||
12
+ ('alt' in trigger && trigger.alt !== event.altKey) ||
13
+ ('meta' in trigger && trigger.meta !== event.metaKey))
14
+ return false;
15
+ return 'key' in trigger
16
+ ? 'key' in event && event.key === trigger.key && (!('location' in trigger) || trigger.location === event.location)
17
+ : 'button' in event && event.button === trigger.button;
18
+ }
19
+ export function find(triggers, event) {
20
+ for (const trigger of triggers) {
21
+ if (isMatch(trigger, event))
22
+ return trigger;
23
+ }
24
+ }
@@ -1,2 +1,3 @@
1
1
  export * from './animate.js';
2
2
  export * from './clipboard.js';
3
+ export * as controls from './controls.js';
package/dist/gui/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export * from './animate.js';
2
2
  export * from './clipboard.js';
3
+ export * as controls from './controls.js';
package/lib/Audio.svelte CHANGED
@@ -3,7 +3,7 @@
3
3
  import type { Snippet } from 'svelte';
4
4
  import Icon from './Icon.svelte';
5
5
  import MediaControls from './MediaControls.svelte';
6
- import { getMetadata, MediaState, type MediaPicture, type MediaProps } from './media.svelte.js';
6
+ import { getMetadata, MediaState, type MediaPicture, type MediaProps } from './reactive/media.svelte.js';
7
7
 
8
8
  interface Props extends MediaProps {
9
9
  cover?: boolean;
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { formatDuration } from '@axium/core';
3
3
  import Icon from './Icon.svelte';
4
- import type { MediaState } from './media.svelte.js';
4
+ import type { MediaState } from './reactive/media.svelte.js';
5
5
  import type { Snippet } from 'svelte';
6
6
 
7
7
  interface Props {
package/lib/Video.svelte CHANGED
@@ -2,7 +2,7 @@
2
2
  import type { Snippet } from 'svelte';
3
3
  import Icon from './Icon.svelte';
4
4
  import MediaControls from './MediaControls.svelte';
5
- import { MediaState, type MediaProps } from './media.svelte.js';
5
+ import { MediaState, type MediaProps } from './reactive/media.svelte.js';
6
6
 
7
7
  interface Props extends MediaProps {
8
8
  extraControls?: Snippet;
@@ -54,7 +54,7 @@ export function contextMenu(...menuItems: ContextMenuEntry[] | [() => ContextMen
54
54
  item.action();
55
55
  };
56
56
 
57
- if (item.i) mountedIcons.add(mount<any, {}>(Icon, { target: div, props: { i: item.i } }));
57
+ if (item.i) mountedIcons.add(mount(Icon, { target: div, props: { i: item.i } }));
58
58
 
59
59
  div.appendChild(document.createTextNode(item.text));
60
60
 
@@ -1,257 +1,248 @@
1
- import { mount, unmount } from 'svelte';
1
+ import { mount, unmount, type Component } from 'svelte';
2
2
  import type { Attachment } from 'svelte/attachments';
3
3
  import Icon from '../Icon.svelte';
4
- import { SvelteSet } from 'svelte/reactivity';
5
- import type { Selection } from './selection.js';
6
4
 
7
- /** A grabbable item, used to populate the floating drag indicator. */
8
- export interface Item {
9
- id: string;
5
+ const mimeType = 'text/x-axium-drag';
6
+
7
+ export interface Display {
10
8
  name: string;
11
9
  icon?: string;
12
10
  }
13
11
 
14
- /**
15
- * Coordinates a press-and-hold drag of one or more things onto a {@link target}.
16
- *
17
- * Things are made grabbable with the {@link source} attachment,
18
- * and targets accept them with {@link target}.
19
- *
20
- * While a drag is in progress a floating indicator follows the pointer showing
21
- * the grabbed item's name and icon, with a bold `+ N` when several are grabbed.
22
- *
23
- * The grabbed elements expose the `dragging` class for styling.
24
- */
25
- export class Controller extends SvelteSet<string> implements Disposable {
26
- #indicator?: HTMLElement;
27
- #indicatorIcon?: Record<string, any>;
12
+ function createIndicator(display: Display, extraItems: number): HTMLElement {
13
+ const indicator = document.createElement('div');
14
+ indicator.className = 'drag-indicator';
28
15
 
29
- /** Registered drop targets, keyed by their element. */
30
- readonly #targets = new Map<HTMLElement, (ids: string[]) => unknown>();
16
+ const icon = display.icon && mount(Icon, { target: indicator, props: { i: display.icon } });
31
17
 
32
- #activeTarget?: HTMLElement;
18
+ const name = document.createElement('span');
19
+ name.className = 'drag-name';
20
+ name.textContent = display.name;
21
+ indicator.appendChild(name);
33
22
 
34
- /** Whether a drag is currently in progress. */
35
- get active(): boolean {
36
- return this.size > 0;
23
+ if (extraItems) {
24
+ const extra = document.createElement('b');
25
+ extra.className = 'drag-count';
26
+ extra.textContent = `+ ${extraItems}`;
27
+ indicator.appendChild(extra);
37
28
  }
38
29
 
39
- _registerTarget(element: HTMLElement, onDrop: (ids: string[]) => unknown): () => void {
40
- this.#targets.set(element, onDrop);
41
- return () => this.#targets.delete(element);
42
- }
30
+ document.body.appendChild(indicator);
43
31
 
44
- /** Begin dragging the given ids, showing a floating indicator labelled for `lead`. */
45
- begin(ids: string[], lead: Item): void {
46
- if (!ids.length) return;
47
- for (const id of ids) this.add(id);
48
-
49
- const indicator = document.createElement('div');
50
- indicator.className = 'drag-indicator';
51
- if (lead.icon) this.#indicatorIcon = mount<any, {}>(Icon, { target: indicator, props: { i: lead.icon } });
52
-
53
- const name = document.createElement('span');
54
- name.className = 'drag-name';
55
- name.textContent = lead.name;
56
- indicator.appendChild(name);
57
-
58
- if (ids.length > 1) {
59
- const extra = document.createElement('b');
60
- extra.className = 'drag-count';
61
- extra.textContent = `+ ${ids.length - 1}`;
62
- indicator.appendChild(extra);
63
- }
32
+ const remove = indicator.remove.bind(indicator);
33
+ return Object.assign(indicator, {
34
+ remove() {
35
+ if (icon) unmount(icon);
36
+ remove();
37
+ },
38
+ });
39
+ }
64
40
 
65
- document.body.appendChild(indicator);
66
- this.#indicator = indicator;
67
- document.body.classList.add('dragging-items');
68
- }
41
+ const emptyImage = new Image();
42
+ emptyImage.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
69
43
 
70
- /** Move the floating indicator and update which drop target is under the pointer. */
71
- move(x: number, y: number): void {
72
- if (!this.#indicator) return;
73
- this.#indicator.style.left = x + 'px';
74
- this.#indicator.style.top = y + 'px';
75
-
76
- // Find the drop target under the pointer (ignoring the indicator itself, which is pointer-events: none).
77
- let target: HTMLElement | undefined;
78
- for (let node = document.elementFromPoint(x, y) as HTMLElement | null; node; node = node.parentElement) {
79
- if (this.#targets.has(node)) {
80
- target = node;
81
- break;
82
- }
83
- }
44
+ export let isActive = false;
84
45
 
85
- if (target == this.#activeTarget) return;
86
- this.#activeTarget?.classList.remove('drag-over');
87
- this.#activeTarget = target;
88
- this.#activeTarget?.classList.add('drag-over');
89
- }
46
+ /**
47
+ * Make an element a draggable source.
48
+ *
49
+ * Grabs either the given selection (when non-empty) or just this item.
50
+ */
51
+ export function source<T = string>(type: string, selection: Iterable<T>, thisValue: T, display: Display): Attachment<HTMLElement> {
52
+ const sel = Array.from(selection);
53
+ if (!sel.length) sel.push(thisValue);
54
+
55
+ return function _attachDraggable(element: HTMLElement) {
56
+ element.draggable = true;
90
57
 
91
- /** Finish the drag, dropping onto the active target (if any), and clean up. */
92
- end(): void {
93
- const onDrop = this.#activeTarget && this.#targets.get(this.#activeTarget);
94
- try {
95
- if (onDrop && this.size) onDrop([...this]);
96
- } finally {
97
- this.dispose();
58
+ let indicator: HTMLElement;
59
+
60
+ function onDragOver(e: DragEvent) {
61
+ indicator.style.translate = `${e.clientX}px ${e.clientY}px`;
98
62
  }
99
- }
100
63
 
101
- /** Abort the drag without dropping. */
102
- cancel(): void {
103
- this.dispose();
104
- }
64
+ function onDragStart(e: DragEvent) {
65
+ for (let node = e.target as HTMLElement | null; node && node !== element; node = node.parentElement) {
66
+ if (node.hasAttribute('data-no-select')) {
67
+ e.preventDefault();
68
+ return;
69
+ }
70
+ }
105
71
 
106
- dispose(): void {
107
- this.clear();
108
- this.#activeTarget?.classList.remove('drag-over');
109
- this.#activeTarget = undefined;
110
- if (this.#indicatorIcon) unmount(this.#indicatorIcon);
111
- this.#indicatorIcon = undefined;
112
- this.#indicator?.remove();
113
- this.#indicator = undefined;
114
- document.body.classList.remove('dragging-items');
115
- }
72
+ if (!e.dataTransfer) return;
73
+ e.dataTransfer.setData(`${mimeType}+${type.toLowerCase()}`, JSON.stringify(sel));
74
+ e.dataTransfer.effectAllowed = 'move';
116
75
 
117
- [Symbol.dispose]() {
118
- this.dispose();
119
- }
120
- }
76
+ e.dataTransfer.setDragImage(emptyImage, 0, 0);
121
77
 
122
- /**
123
- * The default drag-and-drop controller.
124
- */
125
- const defaultController = new Controller();
78
+ isActive = true;
79
+ indicator = createIndicator(display, sel.length - 1);
80
+ indicator.style.translate = `${e.clientX}px ${e.clientY}px`;
81
+ document.body.classList.add('dragging-items');
82
+ document.addEventListener('dragover', onDragOver);
83
+ }
126
84
 
127
- export { defaultController as controller };
85
+ function onDragEnd() {
86
+ isActive = false;
87
+ document.removeEventListener('dragover', onDragOver);
88
+ indicator.remove();
89
+ document.body.classList.remove('dragging-items');
90
+ }
128
91
 
129
- /** How long, in milliseconds, a touch must be held before it starts a drag (rather than scrolling). */
130
- const HOLD_DELAY = 400;
92
+ element.addEventListener('dragstart', onDragStart);
93
+ element.addEventListener('dragend', onDragEnd);
94
+
95
+ return () => {
96
+ document.removeEventListener('dragover', onDragOver);
97
+ element.removeEventListener('dragstart', onDragStart);
98
+ element.removeEventListener('dragend', onDragEnd);
99
+ };
100
+ };
101
+ }
131
102
 
132
103
  /**
133
- * Make an element a draggable source for a {@link Controller}.
134
- *
135
- * Pressing and dragging the pointer out of the item grabs either the current {@link Selection} (when
136
- * this item is part of a non-empty selection) or just this item, then follows the pointer until release.
104
+ * Register an element as a drop target for drags of the given kind.
137
105
  */
138
- export function source(
139
- item: Item,
140
- selectedIds: Iterable<string> = [item.id],
141
- controller: Controller = defaultController
142
- ): Attachment<HTMLElement> {
143
- const selection = Array.from(selectedIds);
144
- if (!selection.length) selection.push(item.id);
106
+ export function target<T = string>(type: string, onDrop: (items: T[]) => unknown, exclude?: T): Attachment<HTMLElement> {
107
+ const mime = `${mimeType}+${type.toLowerCase()}`;
145
108
 
146
- return function _attachDraggable(element: HTMLElement) {
147
- let lastX = 0,
148
- lastY = 0,
149
- started = false,
150
- pointerId: number | undefined,
151
- holdTimer: number | undefined;
152
-
153
- function _clearTimer() {
154
- clearTimeout(holdTimer);
155
- holdTimer = undefined;
156
- pointerId = undefined;
109
+ return function _attachDropTarget(element: HTMLElement) {
110
+ let depth = 0;
111
+
112
+ function ignore(e: DragEvent): boolean {
113
+ return !e.dataTransfer?.types.includes(mime);
157
114
  }
158
115
 
159
- function start() {
160
- if (started || pointerId === undefined) return;
161
- started = true;
116
+ function onDragEnter(e: DragEvent) {
117
+ if (ignore(e)) return;
118
+ e.preventDefault();
119
+ if (depth++ === 0) element.classList.add('drag-over');
120
+ }
162
121
 
163
- controller.begin(selection, item);
164
- element.setPointerCapture(pointerId);
165
- controller.move(lastX, lastY);
122
+ function onDragOver(e: DragEvent) {
123
+ if (ignore(e)) return;
124
+ // Required for the element to be a valid drop target.
125
+ e.preventDefault();
126
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
166
127
  }
167
128
 
168
- function onPointerDown(e: PointerEvent) {
169
- if (e.button !== 0) return;
170
- for (let node = e.target as HTMLElement | null; node && node !== element; node = node.parentElement) {
171
- if (node.hasAttribute('data-no-select')) return;
129
+ function onDragLeave(e: DragEvent) {
130
+ if (ignore(e)) return;
131
+ if (--depth <= 0) {
132
+ depth = 0;
133
+ element.classList.remove('drag-over');
172
134
  }
173
- lastX = e.clientX;
174
- lastY = e.clientY;
175
- started = false;
176
- pointerId = e.pointerId;
135
+ }
177
136
 
178
- // Touch presses scroll the list, so require a deliberate press-and-hold before grabbing.
179
- if (e.pointerType === 'touch') holdTimer = setTimeout(start, HOLD_DELAY);
137
+ function onDropEvent(e: DragEvent) {
138
+ if (ignore(e)) return;
139
+ e.preventDefault();
140
+ depth = 0;
141
+ element.classList.remove('drag-over');
142
+
143
+ let items: T[];
144
+ try {
145
+ items = JSON.parse(e.dataTransfer!.getData(mime));
146
+ } catch {
147
+ return;
148
+ }
149
+ if (!Array.isArray(items)) return;
150
+ const index = exclude ? items.indexOf(exclude) : -1;
151
+ if (index !== -1) items.splice(index, 1);
152
+ if (items.length) onDrop(items);
180
153
  }
181
154
 
182
- // The drag begins once the pointer leaves the item it was pressed on.
183
- function onPointerLeave(e: PointerEvent) {
184
- if (pointerId === undefined || e.pointerId !== pointerId || started) return;
185
- // A touch that leaves the item before the hold fires is a scroll, not a drag.
186
- if (holdTimer !== undefined) return _clearTimer();
155
+ element.addEventListener('dragenter', onDragEnter);
156
+ element.addEventListener('dragover', onDragOver);
157
+ element.addEventListener('dragleave', onDragLeave);
158
+ element.addEventListener('drop', onDropEvent);
187
159
 
188
- lastX = e.clientX;
189
- lastY = e.clientY;
190
- start();
160
+ return () => {
161
+ element.removeEventListener('dragenter', onDragEnter);
162
+ element.removeEventListener('dragover', onDragOver);
163
+ element.removeEventListener('dragleave', onDragLeave);
164
+ element.removeEventListener('drop', onDropEvent);
165
+ };
166
+ };
167
+ }
168
+
169
+ function notUpload(e: DragEvent): boolean {
170
+ return !e.dataTransfer?.types.includes('Files');
171
+ }
172
+
173
+ /**
174
+ * Register an element as a target for files dragged in from the system file manager.
175
+ */
176
+ export function uploadTarget(label: string, onDrop: (entries: FileSystemEntry[]) => unknown): Attachment<HTMLElement> {
177
+ return function _attachUploadTarget(element: HTMLElement) {
178
+ let depth = 0,
179
+ indicator: HTMLElement | undefined,
180
+ icon: {} | undefined;
181
+
182
+ function hide() {
183
+ depth = 0;
184
+ element.classList.remove('drag-over');
185
+ if (icon) unmount(icon);
186
+ indicator?.remove();
191
187
  }
192
188
 
193
- function onPointerMove(e: PointerEvent) {
194
- if (pointerId === undefined || e.pointerId !== pointerId || !started) return;
195
- lastX = e.clientX;
196
- lastY = e.clientY;
189
+ function onDragEnter(e: DragEvent) {
190
+ if (notUpload(e)) return;
191
+ // Stop drag events from bubbling so the innermost upload target wins
197
192
  e.preventDefault();
198
- controller.move(e.clientX, e.clientY);
193
+ e.stopPropagation();
194
+ if (depth++ !== 0) return;
195
+ element.classList.add('drag-over');
196
+
197
+ indicator = document.createElement('div');
198
+ indicator.className = 'drag-upload-indicator';
199
+ icon = mount(Icon, { target: indicator, props: { i: 'upload' } });
200
+
201
+ const text = document.createElement('span');
202
+ text.textContent = label;
203
+ indicator.appendChild(text);
204
+
205
+ document.body.appendChild(indicator);
199
206
  }
200
207
 
201
- function onPointerUp(e: PointerEvent) {
202
- if (pointerId === undefined || e.pointerId !== pointerId) return;
203
- if (element.hasPointerCapture(pointerId)) element.releasePointerCapture(pointerId);
204
- _clearTimer();
205
- if (started) {
206
- e.preventDefault();
207
- e.stopPropagation();
208
- suppressClick = true;
209
- controller.end();
210
- }
211
- started = false;
208
+ function onDragOver(e: DragEvent) {
209
+ if (notUpload(e)) return;
210
+ e.preventDefault();
211
+ e.stopPropagation();
212
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
212
213
  }
213
214
 
214
- function onPointerCancel() {
215
- _clearTimer();
216
- if (started) controller.cancel();
217
- started = false;
215
+ function onDragLeave(e: DragEvent) {
216
+ if (notUpload(e)) return;
217
+ e.stopPropagation();
218
+ if (--depth <= 0) hide();
218
219
  }
219
220
 
220
- // A drag may be followed by a synthetic click; swallow it so it doesn't re-select or open the item.
221
- let suppressClick = false;
222
- function onClick(e: MouseEvent) {
223
- if (!suppressClick) return;
224
- suppressClick = false;
221
+ function onDropEvent(e: DragEvent) {
222
+ if (notUpload(e)) return;
225
223
  e.preventDefault();
226
224
  e.stopPropagation();
225
+ hide();
226
+
227
+ const entries: FileSystemEntry[] = [];
228
+ for (const item of e.dataTransfer!.items) {
229
+ const entry = item.webkitGetAsEntry();
230
+ if (entry) entries.push(entry);
231
+ }
232
+ if (entries.length) onDrop(entries);
227
233
  }
228
234
 
229
- element.addEventListener('pointerdown', onPointerDown);
230
- element.addEventListener('pointerleave', onPointerLeave);
231
- element.addEventListener('pointermove', onPointerMove);
232
- element.addEventListener('pointerup', onPointerUp);
233
- element.addEventListener('pointercancel', onPointerCancel);
234
- element.addEventListener('click', onClick, { capture: true });
235
+ element.addEventListener('dragenter', onDragEnter);
236
+ element.addEventListener('dragover', onDragOver);
237
+ element.addEventListener('dragleave', onDragLeave);
238
+ element.addEventListener('drop', onDropEvent);
235
239
 
236
240
  return () => {
237
- element.removeEventListener('pointerdown', onPointerDown);
238
- element.removeEventListener('pointerleave', onPointerLeave);
239
- element.removeEventListener('pointermove', onPointerMove);
240
- element.removeEventListener('pointerup', onPointerUp);
241
- element.removeEventListener('pointercancel', onPointerCancel);
242
- element.removeEventListener('click', onClick, { capture: true });
241
+ hide();
242
+ element.removeEventListener('dragenter', onDragEnter);
243
+ element.removeEventListener('dragover', onDragOver);
244
+ element.removeEventListener('dragleave', onDragLeave);
245
+ element.removeEventListener('drop', onDropEvent);
243
246
  };
244
247
  };
245
248
  }
246
-
247
- /**
248
- * Register an element as a drop target for a {@link DragController}.
249
- *
250
- * When grabbed items are released over the element, `onDrop` is called with the dragged ids. While a
251
- * drag hovers the element it carries the `drag-over` class.
252
- */
253
- export function target(onDrop: (ids: string[]) => unknown, controller: Controller = defaultController): Attachment<HTMLElement> {
254
- return function _attachDropTarget(element: HTMLElement) {
255
- return controller._registerTarget(element, onDrop);
256
- };
257
- }
@@ -1,6 +1,10 @@
1
+ import { controls } from '@axium/client/gui';
1
2
  import type { Attachment } from 'svelte/attachments';
2
3
  import { SvelteSet } from 'svelte/reactivity';
3
4
 
5
+ /** A keyboard control bound to a {@link Selection}, e.g. `F2` to rename or `Ctrl+C` to copy. */
6
+ export type SelectionControl = controls.KeyboardHandler<[selection: Selection]>;
7
+
4
8
  /**
5
9
  * Manages a reusable, list-wide selection across items rendered with the {@link selectable} attachment.
6
10
  *
@@ -16,6 +20,13 @@ export class Selection extends SvelteSet<string> {
16
20
  /** The id of the last item interacted with, used as the anchor for shift-click ranges. */
17
21
  #anchor?: string;
18
22
 
23
+ constructor(
24
+ /** Keyboard controls dispatched by {@link handleKeyboard}. */
25
+ protected readonly controls: SelectionControl[] = []
26
+ ) {
27
+ super();
28
+ }
29
+
19
30
  /**
20
31
  * Update the ordered list of item ids. Call this whenever the rendered order changes
21
32
  * (e.g. after sorting or loading a new directory).
@@ -32,6 +43,12 @@ export class Selection extends SvelteSet<string> {
32
43
  this.#anchor = undefined;
33
44
  }
34
45
 
46
+ add(id: string): this {
47
+ super.add(id);
48
+ this.#anchor = id;
49
+ return this;
50
+ }
51
+
35
52
  /** Apply selection behavior for a click on `id` with the given modifier keys. */
36
53
  handleClick(id: string, e: { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }): void {
37
54
  if (e.shiftKey && this.#anchor) {
@@ -41,7 +58,7 @@ export class Selection extends SvelteSet<string> {
41
58
  const [lo, hi] = start < end ? [start, end] : [end, start];
42
59
  const range = new Set(this.#order.slice(lo, hi + 1));
43
60
  super.clear();
44
- for (const rangeId of range) this.add(rangeId);
61
+ for (const rangeId of range) super.add(rangeId);
45
62
  return;
46
63
  }
47
64
  }
@@ -49,13 +66,22 @@ export class Selection extends SvelteSet<string> {
49
66
  if (e.ctrlKey || e.metaKey) {
50
67
  if (this.has(id)) this.delete(id);
51
68
  else this.add(id);
52
- this.#anchor = id;
53
69
  return;
54
70
  }
55
71
 
56
72
  super.clear();
57
73
  this.add(id);
58
- this.#anchor = id;
74
+ }
75
+
76
+ /**
77
+ * Dispatch a keyboard event to a matching control, if any. Returns whether a control handled it.
78
+ */
79
+ handleKeyboard(e: KeyboardEvent): boolean {
80
+ const control = controls.find(this.controls, e);
81
+ if (!control) return false;
82
+
83
+ control.action(this);
84
+ return true;
59
85
  }
60
86
  }
61
87
 
@@ -69,10 +95,8 @@ export class Selection extends SvelteSet<string> {
69
95
  export function selectable(selection: Selection, id: string): Attachment<HTMLElement> {
70
96
  return function _attachSelectable(element: HTMLElement) {
71
97
  function onclick(e: MouseEvent) {
72
- // Let interactive descendants (action buttons, etc.) opt out of triggering selection.
73
- for (let node = e.target as HTMLElement | null; node && node != element; node = node.parentElement) {
98
+ for (let node = e.target as HTMLElement | null; node && node != element; node = node.parentElement)
74
99
  if (node.hasAttribute('data-no-select')) return;
75
- }
76
100
 
77
101
  if (e.shiftKey || e.ctrlKey || e.metaKey) {
78
102
  e.preventDefault();
@@ -82,9 +106,33 @@ export function selectable(selection: Selection, id: string): Attachment<HTMLEle
82
106
  }
83
107
 
84
108
  element.addEventListener('click', onclick, { capture: true });
109
+ return () => element.removeEventListener('click', onclick, { capture: true });
110
+ };
111
+ }
112
+
113
+ /** Elements that consume keyboard input themselves, and so should suppress selection controls. */
114
+ function isTypingTarget(target: EventTarget | null): boolean {
115
+ if (!(target instanceof HTMLElement)) return false;
116
+ return target.isContentEditable || target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement;
117
+ }
118
+
119
+ /**
120
+ * Register a {@link Selection}'s keyboard controls (e.g. `F2` to rename) on the document.
121
+ *
122
+ * This is separate from {@link selectable} so the controls work regardless of which element is focused.
123
+ * Controls are ignored while the user is typing in an input, textarea, or other editable element.
124
+ */
125
+ export function selectionControls(selection: Selection): Attachment {
126
+ return function _attachSelectionControls() {
127
+ function onkeydown(e: KeyboardEvent) {
128
+ if (isTypingTarget(e.target)) return;
129
+ if (selection.handleKeyboard(e)) {
130
+ e.preventDefault();
131
+ e.stopPropagation();
132
+ }
133
+ }
85
134
 
86
- return () => {
87
- element.removeEventListener('click', onclick, { capture: true });
88
- };
135
+ document.addEventListener('keydown', onkeydown);
136
+ return () => document.removeEventListener('keydown', onkeydown);
89
137
  };
90
138
  }
@@ -0,0 +1,77 @@
1
+ import { SvelteSet } from 'svelte/reactivity';
2
+
3
+ interface StoredClipboard<T extends string | boolean | bigint | number> {
4
+ isCopy: boolean;
5
+ values: T[];
6
+ }
7
+
8
+ /**
9
+ * A reactive, cross-tab clipboard for "cut" and "copy" of values.
10
+ *
11
+ * State is mirrored to `localStorage` so it is shared across all tabs of the origin.
12
+ */
13
+ export class SyncedClipboard<T extends string | boolean | bigint | number> extends SvelteSet<T> {
14
+ /** The current clipboard mode, or `undefined` when the clipboard is empty. Reactive. */
15
+ isCopy = $state<boolean>();
16
+
17
+ constructor(protected readonly key = 'clipboard') {
18
+ super();
19
+
20
+ this.load();
21
+
22
+ addEventListener('storage', e => {
23
+ if (e.key == this.key) this.load();
24
+ });
25
+ }
26
+
27
+ /** Whether the clipboard currently holds anything. */
28
+ get active(): boolean {
29
+ return typeof this.isCopy === 'boolean' && this.size > 0;
30
+ }
31
+
32
+ /** Place the given values on the clipboard to be moved on paste. */
33
+ cut(values: Iterable<T>): void {
34
+ this.isCopy = false;
35
+ this.set([...values]);
36
+ }
37
+
38
+ /** Place the given values on the clipboard to be duplicated on paste. */
39
+ copy(values: Iterable<T>): void {
40
+ this.isCopy = true;
41
+ this.set([...values]);
42
+ }
43
+
44
+ /** Empty the clipboard. */
45
+ clear(): void {
46
+ this.isCopy = undefined;
47
+ super.clear();
48
+ if (typeof localStorage != 'undefined') localStorage.removeItem(this.key);
49
+ }
50
+
51
+ protected set(values: T[]): void {
52
+ super.clear();
53
+ for (const value of values) this.add(value);
54
+ if (typeof localStorage != 'undefined')
55
+ localStorage.setItem(this.key, JSON.stringify({ isCopy: this.isCopy!, values } satisfies StoredClipboard<T>));
56
+ }
57
+
58
+ protected load(): void {
59
+ if (typeof localStorage == 'undefined') return;
60
+
61
+ const raw = localStorage.getItem(this.key);
62
+ if (!raw) {
63
+ this.isCopy = undefined;
64
+ super.clear();
65
+ return;
66
+ }
67
+
68
+ try {
69
+ const { isCopy, values } = JSON.parse(raw) as StoredClipboard<T>;
70
+ this.isCopy = isCopy;
71
+ for (const value of this) if (!values.includes(value)) this.delete(value);
72
+ for (const value of values) this.add(value);
73
+ } catch {
74
+ this.clear();
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,2 @@
1
+ export * from './clipboard.svelte.js';
2
+ export * from './media.svelte.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axium/client",
3
- "version": "0.29.0",
3
+ "version": "0.31.0",
4
4
  "author": "James Prevett <jp@jamespre.dev>",
5
5
  "funding": {
6
6
  "type": "individual",
@@ -39,6 +39,7 @@
39
39
  "./components/*": "./lib/*.svelte",
40
40
  "./toast": "./lib/toast.js",
41
41
  "./attachments": "./lib/attachments/index.js",
42
+ "./reactive": "./lib/reactive/index.svelte.js",
42
43
  "./styles/*": "./styles/*.css"
43
44
  },
44
45
  "scripts": {
package/styles/list.css CHANGED
@@ -60,7 +60,7 @@ div.list-header {
60
60
  background-color: var(--bg-alt);
61
61
  }
62
62
 
63
- .list-item.dragging {
63
+ .list-item.dimmed {
64
64
  opacity: 0.5;
65
65
  }
66
66