@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 +21 -3
- package/dist/gui/controls.d.ts +31 -0
- package/dist/gui/controls.js +24 -0
- package/dist/gui/index.d.ts +1 -0
- package/dist/gui/index.js +1 -0
- package/lib/Audio.svelte +1 -1
- package/lib/MediaControls.svelte +1 -1
- package/lib/Video.svelte +1 -1
- package/lib/attachments/context-menu.ts +1 -1
- package/lib/attachments/drag-and-drop.ts +192 -201
- package/lib/attachments/selection.ts +57 -9
- package/lib/reactive/clipboard.svelte.ts +77 -0
- package/lib/reactive/index.svelte.ts +2 -0
- package/package.json +2 -1
- package/styles/list.css +1 -1
- /package/lib/{media.svelte.ts → reactive/media.svelte.ts} +0 -0
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
|
+
}
|
package/dist/gui/index.d.ts
CHANGED
package/dist/gui/index.js
CHANGED
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;
|
package/lib/MediaControls.svelte
CHANGED
|
@@ -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
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
+
const name = document.createElement('span');
|
|
19
|
+
name.className = 'drag-name';
|
|
20
|
+
name.textContent = display.name;
|
|
21
|
+
indicator.appendChild(name);
|
|
33
22
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
40
|
-
this.#targets.set(element, onDrop);
|
|
41
|
-
return () => this.#targets.delete(element);
|
|
42
|
-
}
|
|
30
|
+
document.body.appendChild(indicator);
|
|
43
31
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
118
|
-
this.dispose();
|
|
119
|
-
}
|
|
120
|
-
}
|
|
76
|
+
e.dataTransfer.setDragImage(emptyImage, 0, 0);
|
|
121
77
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
*
|
|
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
|
|
139
|
-
|
|
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
|
|
147
|
-
let
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
160
|
-
if (
|
|
161
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
169
|
-
if (e
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
174
|
-
lastY = e.clientY;
|
|
175
|
-
started = false;
|
|
176
|
-
pointerId = e.pointerId;
|
|
135
|
+
}
|
|
177
136
|
|
|
178
|
-
|
|
179
|
-
if (e
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
194
|
-
if (
|
|
195
|
-
|
|
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
|
-
|
|
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
|
|
202
|
-
if (
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (
|
|
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
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
215
|
+
function onDragLeave(e: DragEvent) {
|
|
216
|
+
if (notUpload(e)) return;
|
|
217
|
+
e.stopPropagation();
|
|
218
|
+
if (--depth <= 0) hide();
|
|
218
219
|
}
|
|
219
220
|
|
|
220
|
-
|
|
221
|
-
|
|
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('
|
|
230
|
-
element.addEventListener('
|
|
231
|
-
element.addEventListener('
|
|
232
|
-
element.addEventListener('
|
|
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
|
-
|
|
238
|
-
element.removeEventListener('
|
|
239
|
-
element.removeEventListener('
|
|
240
|
-
element.removeEventListener('
|
|
241
|
-
element.removeEventListener('
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axium/client",
|
|
3
|
-
"version": "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
|
File without changes
|