@axium/client 0.28.1 → 0.30.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 +59 -9
- package/assets/theme.css +2 -0
- 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/AccessControlDialog.svelte +1 -1
- package/lib/Audio.svelte +1 -1
- package/lib/FormDialog.svelte +1 -1
- package/lib/MediaControls.svelte +1 -1
- package/lib/Video.svelte +1 -1
- package/lib/attachments/context-menu.ts +123 -0
- package/lib/attachments/drag-and-drop.ts +257 -0
- package/lib/attachments/gestures.ts +36 -0
- package/lib/attachments/index.ts +37 -0
- package/lib/attachments/selection.ts +138 -0
- package/lib/reactive/clipboard.svelte.ts +77 -0
- package/lib/reactive/index.svelte.ts +2 -0
- package/package.json +3 -2
- package/styles/list.css +9 -0
- package/lib/attachments.ts +0 -158
- /package/lib/{media.svelte.ts → reactive/media.svelte.ts} +0 -0
package/assets/styles.css
CHANGED
|
@@ -50,7 +50,7 @@ button {
|
|
|
50
50
|
cursor: pointer;
|
|
51
51
|
|
|
52
52
|
&:hover {
|
|
53
|
-
background-color:
|
|
53
|
+
background-color: var(--bg-accent);
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
@@ -145,7 +145,7 @@ details {
|
|
|
145
145
|
dialog {
|
|
146
146
|
border-radius: 1em;
|
|
147
147
|
background: var(--bg-menu);
|
|
148
|
-
border:
|
|
148
|
+
border: var(--border-elevated);
|
|
149
149
|
padding: 1em;
|
|
150
150
|
max-width: calc(100% - 2em);
|
|
151
151
|
word-wrap: normal;
|
|
@@ -177,11 +177,11 @@ progress::-webkit-progress-bar {
|
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
progress::-webkit-progress-value {
|
|
180
|
-
background-color:
|
|
180
|
+
background-color: var(--fg-normal);
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
progress::-moz-progress-bar {
|
|
184
|
-
background-color:
|
|
184
|
+
background-color: var(--fg-normal);
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
::selection,
|
|
@@ -233,7 +233,7 @@ div.success {
|
|
|
233
233
|
|
|
234
234
|
.danger {
|
|
235
235
|
border: var(--border-error);
|
|
236
|
-
background-color: hsl(
|
|
236
|
+
background-color: hsl(from var(--bg-strong) 0 s l);
|
|
237
237
|
color: hsl(0 33 var(--fg-light));
|
|
238
238
|
--fill: hsl(0 33 var(--fg-light));
|
|
239
239
|
accent-color: hsl(0 33 var(--fg-light));
|
|
@@ -252,8 +252,8 @@ div.success {
|
|
|
252
252
|
}
|
|
253
253
|
|
|
254
254
|
:popover-open {
|
|
255
|
-
border:
|
|
256
|
-
background-color:
|
|
255
|
+
border: var(--border-elevated);
|
|
256
|
+
background-color: var(--bg-elevated);
|
|
257
257
|
border-radius: 0.5em;
|
|
258
258
|
padding: 0.25em;
|
|
259
259
|
margin: 0;
|
|
@@ -275,6 +275,14 @@ div.success {
|
|
|
275
275
|
cursor: pointer;
|
|
276
276
|
}
|
|
277
277
|
}
|
|
278
|
+
|
|
279
|
+
.menu-header {
|
|
280
|
+
padding: 0.5em 0.75em;
|
|
281
|
+
font-size: 0.85em;
|
|
282
|
+
font-weight: bold;
|
|
283
|
+
color: var(--fg-normal);
|
|
284
|
+
user-select: none;
|
|
285
|
+
}
|
|
278
286
|
}
|
|
279
287
|
|
|
280
288
|
.icon-text {
|
|
@@ -283,6 +291,47 @@ div.success {
|
|
|
283
291
|
gap: 1em;
|
|
284
292
|
}
|
|
285
293
|
|
|
294
|
+
.drag-indicator {
|
|
295
|
+
position: fixed;
|
|
296
|
+
z-index: 100;
|
|
297
|
+
display: inline-flex;
|
|
298
|
+
align-items: center;
|
|
299
|
+
gap: 0.5em;
|
|
300
|
+
pointer-events: none;
|
|
301
|
+
padding: 0.5em 0.75em;
|
|
302
|
+
border-radius: 0.5em;
|
|
303
|
+
background-color: var(--bg-elevated);
|
|
304
|
+
border: var(--border-elevated);
|
|
305
|
+
transform: translate(0.75em, 0.75em);
|
|
306
|
+
max-width: 20em;
|
|
307
|
+
white-space: nowrap;
|
|
308
|
+
|
|
309
|
+
> :not(.drag-name) {
|
|
310
|
+
flex-shrink: 0;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.drag-name {
|
|
314
|
+
overflow: hidden;
|
|
315
|
+
text-overflow: ellipsis;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
.drag-count {
|
|
319
|
+
font-weight: bold;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
.drag-over {
|
|
324
|
+
outline: 2px solid var(--fg-accent);
|
|
325
|
+
outline-offset: -2px;
|
|
326
|
+
border-radius: 0.5em;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
body.dragging-items,
|
|
330
|
+
body.dragging-items * {
|
|
331
|
+
cursor: grabbing !important;
|
|
332
|
+
user-select: none;
|
|
333
|
+
}
|
|
334
|
+
|
|
286
335
|
a:has(button, div) {
|
|
287
336
|
display: contents;
|
|
288
337
|
}
|
|
@@ -290,7 +339,6 @@ a:has(button, div) {
|
|
|
290
339
|
.version {
|
|
291
340
|
font-family: monospace;
|
|
292
341
|
font-size: 0.9em;
|
|
293
|
-
color: #aaa;
|
|
294
342
|
}
|
|
295
343
|
|
|
296
344
|
h1,
|
|
@@ -300,13 +348,15 @@ h4,
|
|
|
300
348
|
h5,
|
|
301
349
|
h6 {
|
|
302
350
|
.version {
|
|
351
|
+
opacity: 80%;
|
|
303
352
|
margin-left: 1em;
|
|
304
353
|
}
|
|
305
354
|
}
|
|
306
355
|
|
|
307
356
|
.version::before {
|
|
308
357
|
content: 'v';
|
|
309
|
-
color:
|
|
358
|
+
color: var(--fg-disabled);
|
|
359
|
+
opacity: 80%;
|
|
310
360
|
}
|
|
311
361
|
|
|
312
362
|
.inline-button {
|
package/assets/theme.css
CHANGED
|
@@ -35,6 +35,7 @@ html {
|
|
|
35
35
|
--bg-accent: hsl(var(--hue) 15 calc(var(--bg-light) + (var(--light-step) * 2)));
|
|
36
36
|
--bg-alt: hsl(var(--hue) 5 calc(var(--bg-light) + var(--light-step)));
|
|
37
37
|
--bg-strong: hsl(var(--hue) 20 calc(var(--bg-light) + var(--light-step)));
|
|
38
|
+
--bg-elevated: hsl(from var(--bg-menu) h s l / 95%);
|
|
38
39
|
|
|
39
40
|
--bg-error: hsl(0 40 var(--bg-light-status));
|
|
40
41
|
--bg-warning: hsl(60 40 var(--bg-light-status));
|
|
@@ -52,6 +53,7 @@ html {
|
|
|
52
53
|
--border-accent: 1px solid hsl(var(--hue) 10 calc(var(--bg-light) + (var(--light-step) * 3)));
|
|
53
54
|
--border-alt: 1px solid var(--bg-alt);
|
|
54
55
|
--border-strong: 1px solid hsl(var(--hue) 20 calc(var(--bg-light) + (var(--light-step) * 3)));
|
|
56
|
+
--border-elevated: 1px solid hsl(var(--hue) 10 calc(var(--fg-light) - var(--light-step)));
|
|
55
57
|
|
|
56
58
|
--border-error: 1px solid hsl(0 50 var(--fg-light));
|
|
57
59
|
--border-warning: 1px solid hsl(60 50 var(--fg-light));
|
|
@@ -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
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import Discovery, * as discovery from './Discovery.svelte';
|
|
7
7
|
import Icon from './Icon.svelte';
|
|
8
8
|
import UserCard from './UserCard.svelte';
|
|
9
|
-
import { closeOnBackGesture } from './attachments.js';
|
|
9
|
+
import { closeOnBackGesture } from './attachments/gestures.js';
|
|
10
10
|
import { toast, toastStatus } from './toast.js';
|
|
11
11
|
|
|
12
12
|
interface Props extends HTMLDialogAttributes {
|
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/FormDialog.svelte
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { text } from '@axium/client';
|
|
3
3
|
import type { HTMLDialogAttributes } from 'svelte/elements';
|
|
4
|
-
import { closeOnBackGesture } from './attachments.js';
|
|
4
|
+
import { closeOnBackGesture } from './attachments/gestures.js';
|
|
5
5
|
|
|
6
6
|
let {
|
|
7
7
|
children,
|
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;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { mount, unmount } from 'svelte';
|
|
2
|
+
import type { Attachment } from 'svelte/attachments';
|
|
3
|
+
import Icon from '../Icon.svelte';
|
|
4
|
+
|
|
5
|
+
export interface ContextMenuItem {
|
|
6
|
+
/** Icon name */
|
|
7
|
+
i?: string;
|
|
8
|
+
text: string;
|
|
9
|
+
danger?: boolean;
|
|
10
|
+
action(): unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A non-interactive label, e.g. to describe what it acts on. */
|
|
14
|
+
export interface ContextMenuHeader {
|
|
15
|
+
header: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ContextMenuEntry = ContextMenuItem | ContextMenuHeader | false | null | undefined;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Attach a context menu to an element with the given actions.
|
|
22
|
+
*
|
|
23
|
+
* Pass a function instead of a fixed list to have the menu rebuilt every time it opens — useful when
|
|
24
|
+
* the available actions or their labels depend on state that changes after the menu is attached (e.g. a
|
|
25
|
+
* selection).
|
|
26
|
+
*/
|
|
27
|
+
export function contextMenu(...menuItems: ContextMenuEntry[] | [() => ContextMenuEntry[]]): Attachment<HTMLElement> {
|
|
28
|
+
return function _attachContextMenu(element: HTMLElement) {
|
|
29
|
+
const menu = document.createElement('div');
|
|
30
|
+
menu.popover = 'auto';
|
|
31
|
+
menu.className = 'context-menu';
|
|
32
|
+
|
|
33
|
+
const mountedIcons = new Set<Record<string, any>>();
|
|
34
|
+
|
|
35
|
+
function build(items: ContextMenuEntry[]) {
|
|
36
|
+
for (const item of items) {
|
|
37
|
+
if (!item) continue;
|
|
38
|
+
|
|
39
|
+
if ('header' in item) {
|
|
40
|
+
const header = document.createElement('div');
|
|
41
|
+
header.classList.add('menu-header');
|
|
42
|
+
header.appendChild(document.createTextNode(item.header));
|
|
43
|
+
menu.appendChild(header);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const div = document.createElement('div');
|
|
48
|
+
div.classList.add('icon-text', 'menu-item');
|
|
49
|
+
if (item.danger) div.classList.add('danger');
|
|
50
|
+
|
|
51
|
+
div.onclick = e => {
|
|
52
|
+
e.stopPropagation();
|
|
53
|
+
menu.hidePopover();
|
|
54
|
+
item.action();
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
if (item.i) mountedIcons.add(mount<any, {}>(Icon, { target: div, props: { i: item.i } }));
|
|
58
|
+
|
|
59
|
+
div.appendChild(document.createTextNode(item.text));
|
|
60
|
+
|
|
61
|
+
menu.appendChild(div);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (typeof menuItems[0] != 'function') build(menuItems as ContextMenuEntry[]);
|
|
66
|
+
|
|
67
|
+
document.body.appendChild(menu);
|
|
68
|
+
|
|
69
|
+
let _forcePopover = false;
|
|
70
|
+
|
|
71
|
+
element.oncontextmenu = (e: MouseEvent) => {
|
|
72
|
+
for (let node = e.target as HTMLElement | null; node && node !== element; node = node.parentElement) {
|
|
73
|
+
if (node instanceof HTMLDialogElement || (node.popover && node !== menu)) return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
e.stopPropagation();
|
|
78
|
+
|
|
79
|
+
if (typeof menuItems[0] == 'function') {
|
|
80
|
+
for (const icon of mountedIcons) unmount(icon);
|
|
81
|
+
mountedIcons.clear();
|
|
82
|
+
menu.replaceChildren();
|
|
83
|
+
build(menuItems[0]());
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
menu.showPopover();
|
|
87
|
+
_forcePopover = true;
|
|
88
|
+
|
|
89
|
+
const x = e.clientX;
|
|
90
|
+
const y = e.clientY;
|
|
91
|
+
|
|
92
|
+
menu.style.left = x + 'px';
|
|
93
|
+
menu.style.top = y + 'px';
|
|
94
|
+
|
|
95
|
+
const rect = menu.getBoundingClientRect();
|
|
96
|
+
if (rect.right > window.innerWidth) {
|
|
97
|
+
menu.style.left = '';
|
|
98
|
+
menu.style.right = window.innerWidth - x + 'px';
|
|
99
|
+
}
|
|
100
|
+
if (rect.bottom > window.innerHeight) {
|
|
101
|
+
menu.style.top = '';
|
|
102
|
+
menu.style.bottom = window.innerHeight - y + 'px';
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Workaround for https://github.com/whatwg/html/issues/10905
|
|
108
|
+
* @todo Remove when the problem is fixed.
|
|
109
|
+
*/
|
|
110
|
+
element.onpointerup = (e: PointerEvent) => {
|
|
111
|
+
if (!_forcePopover) return;
|
|
112
|
+
e.stopPropagation();
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
menu.togglePopover();
|
|
115
|
+
_forcePopover = false;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
return function _disposeContextMenu() {
|
|
119
|
+
for (const icon of mountedIcons) unmount(icon);
|
|
120
|
+
menu.remove();
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { mount, unmount } from 'svelte';
|
|
2
|
+
import type { Attachment } from 'svelte/attachments';
|
|
3
|
+
import Icon from '../Icon.svelte';
|
|
4
|
+
import { SvelteSet } from 'svelte/reactivity';
|
|
5
|
+
import type { Selection } from './selection.js';
|
|
6
|
+
|
|
7
|
+
/** A grabbable item, used to populate the floating drag indicator. */
|
|
8
|
+
export interface Item {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
icon?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
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>;
|
|
28
|
+
|
|
29
|
+
/** Registered drop targets, keyed by their element. */
|
|
30
|
+
readonly #targets = new Map<HTMLElement, (ids: string[]) => unknown>();
|
|
31
|
+
|
|
32
|
+
#activeTarget?: HTMLElement;
|
|
33
|
+
|
|
34
|
+
/** Whether a drag is currently in progress. */
|
|
35
|
+
get active(): boolean {
|
|
36
|
+
return this.size > 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
_registerTarget(element: HTMLElement, onDrop: (ids: string[]) => unknown): () => void {
|
|
40
|
+
this.#targets.set(element, onDrop);
|
|
41
|
+
return () => this.#targets.delete(element);
|
|
42
|
+
}
|
|
43
|
+
|
|
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
|
+
}
|
|
64
|
+
|
|
65
|
+
document.body.appendChild(indicator);
|
|
66
|
+
this.#indicator = indicator;
|
|
67
|
+
document.body.classList.add('dragging-items');
|
|
68
|
+
}
|
|
69
|
+
|
|
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
|
+
}
|
|
84
|
+
|
|
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
|
+
}
|
|
90
|
+
|
|
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();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Abort the drag without dropping. */
|
|
102
|
+
cancel(): void {
|
|
103
|
+
this.dispose();
|
|
104
|
+
}
|
|
105
|
+
|
|
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
|
+
}
|
|
116
|
+
|
|
117
|
+
[Symbol.dispose]() {
|
|
118
|
+
this.dispose();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The default drag-and-drop controller.
|
|
124
|
+
*/
|
|
125
|
+
const defaultController = new Controller();
|
|
126
|
+
|
|
127
|
+
export { defaultController as controller };
|
|
128
|
+
|
|
129
|
+
/** How long, in milliseconds, a touch must be held before it starts a drag (rather than scrolling). */
|
|
130
|
+
const HOLD_DELAY = 400;
|
|
131
|
+
|
|
132
|
+
/**
|
|
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.
|
|
137
|
+
*/
|
|
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);
|
|
145
|
+
|
|
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;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function start() {
|
|
160
|
+
if (started || pointerId === undefined) return;
|
|
161
|
+
started = true;
|
|
162
|
+
|
|
163
|
+
controller.begin(selection, item);
|
|
164
|
+
element.setPointerCapture(pointerId);
|
|
165
|
+
controller.move(lastX, lastY);
|
|
166
|
+
}
|
|
167
|
+
|
|
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;
|
|
172
|
+
}
|
|
173
|
+
lastX = e.clientX;
|
|
174
|
+
lastY = e.clientY;
|
|
175
|
+
started = false;
|
|
176
|
+
pointerId = e.pointerId;
|
|
177
|
+
|
|
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);
|
|
180
|
+
}
|
|
181
|
+
|
|
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();
|
|
187
|
+
|
|
188
|
+
lastX = e.clientX;
|
|
189
|
+
lastY = e.clientY;
|
|
190
|
+
start();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function onPointerMove(e: PointerEvent) {
|
|
194
|
+
if (pointerId === undefined || e.pointerId !== pointerId || !started) return;
|
|
195
|
+
lastX = e.clientX;
|
|
196
|
+
lastY = e.clientY;
|
|
197
|
+
e.preventDefault();
|
|
198
|
+
controller.move(e.clientX, e.clientY);
|
|
199
|
+
}
|
|
200
|
+
|
|
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;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function onPointerCancel() {
|
|
215
|
+
_clearTimer();
|
|
216
|
+
if (started) controller.cancel();
|
|
217
|
+
started = false;
|
|
218
|
+
}
|
|
219
|
+
|
|
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;
|
|
225
|
+
e.preventDefault();
|
|
226
|
+
e.stopPropagation();
|
|
227
|
+
}
|
|
228
|
+
|
|
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
|
+
|
|
236
|
+
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 });
|
|
243
|
+
};
|
|
244
|
+
};
|
|
245
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Attachment } from 'svelte/attachments';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Enabled the use of back gestures to close dialogs
|
|
5
|
+
*/
|
|
6
|
+
export function closeOnBackGesture(element: HTMLDialogElement) {
|
|
7
|
+
if (!globalThis.history) {
|
|
8
|
+
throw new Error('Can not attach back gesture handling because the History API is unavailable');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const showModal = element.showModal.bind(element);
|
|
12
|
+
|
|
13
|
+
let closedByBack = false,
|
|
14
|
+
historyKey = Math.random().toString(16).slice(2);
|
|
15
|
+
|
|
16
|
+
element.showModal = () => {
|
|
17
|
+
closedByBack = false;
|
|
18
|
+
/* popstate's state will be the "upcoming" state.
|
|
19
|
+
i.e, the state after having pushed 1 then 2 would be 1 */
|
|
20
|
+
history.replaceState(historyKey, '');
|
|
21
|
+
history.pushState(null, '');
|
|
22
|
+
showModal();
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
element.addEventListener('close', () => {
|
|
26
|
+
if (closedByBack) return;
|
|
27
|
+
history.replaceState(null, '');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
window.addEventListener('popstate', e => {
|
|
31
|
+
if (e.state != historyKey) return;
|
|
32
|
+
closedByBack = true;
|
|
33
|
+
element.close();
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
closeOnBackGesture satisfies Attachment<HTMLDialogElement>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Svelte attachments
|
|
3
|
+
* @see https://svelte.dev/docs/svelte/@attach
|
|
4
|
+
*/
|
|
5
|
+
import type { Attachment } from 'svelte/attachments';
|
|
6
|
+
|
|
7
|
+
export * from './context-menu.js';
|
|
8
|
+
export * as drag from './drag-and-drop.js';
|
|
9
|
+
export * from './gestures.js';
|
|
10
|
+
export * from './selection.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Dynamically resize `<textarea>`s based on content.
|
|
14
|
+
* @todo Remove this once `field-sizing` works everywhere
|
|
15
|
+
*/
|
|
16
|
+
export function dynamicRows(max: number = 40, min: number = 3): Attachment<HTMLTextAreaElement> {
|
|
17
|
+
return function _attachDynamicRows(element: HTMLTextAreaElement) {
|
|
18
|
+
element.style.resize = 'none';
|
|
19
|
+
element.style.fieldSizing = 'content';
|
|
20
|
+
element.style.height = 'max-content';
|
|
21
|
+
element.style.overflowY = 'scroll';
|
|
22
|
+
|
|
23
|
+
function update() {
|
|
24
|
+
if (!element.value) return;
|
|
25
|
+
element.rows = Math.max(Math.min(element.value.split('\n').length, max), min);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!navigator.userAgent.includes('Firefox')) return;
|
|
29
|
+
update();
|
|
30
|
+
element.addEventListener('input', update);
|
|
31
|
+
element.addEventListener('keyup', update);
|
|
32
|
+
return () => {
|
|
33
|
+
element.removeEventListener('input', update);
|
|
34
|
+
element.removeEventListener('keyup', update);
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { controls } from '@axium/client/gui';
|
|
2
|
+
import type { Attachment } from 'svelte/attachments';
|
|
3
|
+
import { SvelteSet } from 'svelte/reactivity';
|
|
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
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Manages a reusable, list-wide selection across items rendered with the {@link selectable} attachment.
|
|
10
|
+
*
|
|
11
|
+
* Click semantics (matching common file managers):
|
|
12
|
+
* - Plain click: select only the clicked item, clearing the rest.
|
|
13
|
+
* - Ctrl/Cmd click: toggle the clicked item's selection.
|
|
14
|
+
* - Shift click: select the range between the last-clicked item and the clicked item, clearing everything outside it.
|
|
15
|
+
*/
|
|
16
|
+
export class Selection extends SvelteSet<string> {
|
|
17
|
+
/** The ordered list of ids, set by the caller so ranges (shift-click) can be computed. */
|
|
18
|
+
#order: string[] = [];
|
|
19
|
+
|
|
20
|
+
/** The id of the last item interacted with, used as the anchor for shift-click ranges. */
|
|
21
|
+
#anchor?: string;
|
|
22
|
+
|
|
23
|
+
constructor(
|
|
24
|
+
/** Keyboard controls dispatched by {@link handleKeyboard}. */
|
|
25
|
+
protected readonly controls: SelectionControl[] = []
|
|
26
|
+
) {
|
|
27
|
+
super();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Update the ordered list of item ids. Call this whenever the rendered order changes
|
|
32
|
+
* (e.g. after sorting or loading a new directory).
|
|
33
|
+
*/
|
|
34
|
+
setOrder(ids: string[]): void {
|
|
35
|
+
this.#order = ids;
|
|
36
|
+
for (const id of this) if (!ids.includes(id)) this.delete(id);
|
|
37
|
+
if (this.#anchor && !ids.includes(this.#anchor)) this.#anchor = undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Clear the entire selection. */
|
|
41
|
+
clear(): void {
|
|
42
|
+
super.clear();
|
|
43
|
+
this.#anchor = undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
add(id: string): this {
|
|
47
|
+
super.add(id);
|
|
48
|
+
this.#anchor = id;
|
|
49
|
+
return this;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Apply selection behavior for a click on `id` with the given modifier keys. */
|
|
53
|
+
handleClick(id: string, e: { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }): void {
|
|
54
|
+
if (e.shiftKey && this.#anchor) {
|
|
55
|
+
const start = this.#order.indexOf(this.#anchor);
|
|
56
|
+
const end = this.#order.indexOf(id);
|
|
57
|
+
if (start != -1 && end != -1) {
|
|
58
|
+
const [lo, hi] = start < end ? [start, end] : [end, start];
|
|
59
|
+
const range = new Set(this.#order.slice(lo, hi + 1));
|
|
60
|
+
super.clear();
|
|
61
|
+
for (const rangeId of range) super.add(rangeId);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (e.ctrlKey || e.metaKey) {
|
|
67
|
+
if (this.has(id)) this.delete(id);
|
|
68
|
+
else this.add(id);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
super.clear();
|
|
73
|
+
this.add(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;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Wire an element into a {@link Selection} as the item identified by `id`.
|
|
90
|
+
*
|
|
91
|
+
* Reflects selection state via the `selected` class and handles select-on-click (with shift/ctrl/meta
|
|
92
|
+
* modifiers). The handler runs in the capture phase so it can be combined with other click handlers on
|
|
93
|
+
* the element that perform navigation/preview only when the click was not a selection gesture.
|
|
94
|
+
*/
|
|
95
|
+
export function selectable(selection: Selection, id: string): Attachment<HTMLElement> {
|
|
96
|
+
return function _attachSelectable(element: HTMLElement) {
|
|
97
|
+
function onclick(e: MouseEvent) {
|
|
98
|
+
for (let node = e.target as HTMLElement | null; node && node != element; node = node.parentElement)
|
|
99
|
+
if (node.hasAttribute('data-no-select')) return;
|
|
100
|
+
|
|
101
|
+
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
e.stopPropagation();
|
|
104
|
+
}
|
|
105
|
+
selection.handleClick(id, e);
|
|
106
|
+
}
|
|
107
|
+
|
|
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
|
+
}
|
|
134
|
+
|
|
135
|
+
document.addEventListener('keydown', onkeydown);
|
|
136
|
+
return () => document.removeEventListener('keydown', onkeydown);
|
|
137
|
+
};
|
|
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.30.0",
|
|
4
4
|
"author": "James Prevett <jp@jamespre.dev>",
|
|
5
5
|
"funding": {
|
|
6
6
|
"type": "individual",
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
"./components": "./lib/index.js",
|
|
39
39
|
"./components/*": "./lib/*.svelte",
|
|
40
40
|
"./toast": "./lib/toast.js",
|
|
41
|
-
"./attachments": "./lib/attachments.js",
|
|
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
|
@@ -45,6 +45,7 @@ div.list-header {
|
|
|
45
45
|
overflow: hidden;
|
|
46
46
|
text-wrap: nowrap;
|
|
47
47
|
flex: 0 0 auto;
|
|
48
|
+
user-select: none;
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
.list-item:not(.list-header, :first-child) {
|
|
@@ -52,9 +53,17 @@ div.list-header {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
.list-item:not(.list-header):hover {
|
|
56
|
+
background-color: var(--bg-strong);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.list-item.selected {
|
|
55
60
|
background-color: var(--bg-alt);
|
|
56
61
|
}
|
|
57
62
|
|
|
63
|
+
.list-item.dragging {
|
|
64
|
+
opacity: 0.5;
|
|
65
|
+
}
|
|
66
|
+
|
|
58
67
|
p.list-empty {
|
|
59
68
|
text-align: center;
|
|
60
69
|
color: #888;
|
package/lib/attachments.ts
DELETED
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Svelte attachments
|
|
3
|
-
* @see https://svelte.dev/docs/svelte/@attach
|
|
4
|
-
*/
|
|
5
|
-
import { mount, unmount } from 'svelte';
|
|
6
|
-
import type { Attachment } from 'svelte/attachments';
|
|
7
|
-
import Icon from './Icon.svelte';
|
|
8
|
-
|
|
9
|
-
export interface ContextMenuItem {
|
|
10
|
-
/** Icon name */
|
|
11
|
-
i?: string;
|
|
12
|
-
text: string;
|
|
13
|
-
danger?: boolean;
|
|
14
|
-
action(): unknown;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Attach a context menu to an element with the given actions
|
|
19
|
-
*/
|
|
20
|
-
export function contextMenu(...menuItems: (ContextMenuItem | false | null | undefined)[]): Attachment<HTMLElement> {
|
|
21
|
-
return function _attachContextMenu(element: HTMLElement) {
|
|
22
|
-
const menu = document.createElement('div');
|
|
23
|
-
menu.popover = 'auto';
|
|
24
|
-
menu.className = 'context-menu';
|
|
25
|
-
|
|
26
|
-
const mountedIcons = new Set<Record<string, any>>();
|
|
27
|
-
|
|
28
|
-
for (const item of menuItems) {
|
|
29
|
-
if (!item) continue;
|
|
30
|
-
const div = document.createElement('div');
|
|
31
|
-
div.classList.add('icon-text', 'menu-item');
|
|
32
|
-
if (item.danger) div.classList.add('danger');
|
|
33
|
-
|
|
34
|
-
div.onclick = e => {
|
|
35
|
-
e.stopPropagation();
|
|
36
|
-
menu.hidePopover();
|
|
37
|
-
item.action();
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
if (item.i) mountedIcons.add(mount<any, {}>(Icon, { target: div, props: { i: item.i } }));
|
|
41
|
-
|
|
42
|
-
div.appendChild(document.createTextNode(item.text));
|
|
43
|
-
|
|
44
|
-
menu.appendChild(div);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
document.body.appendChild(menu);
|
|
48
|
-
|
|
49
|
-
let _forcePopover = false;
|
|
50
|
-
|
|
51
|
-
element.oncontextmenu = (e: MouseEvent) => {
|
|
52
|
-
for (let node = e.target as HTMLElement | null; node && node !== element; node = node.parentElement) {
|
|
53
|
-
if (node instanceof HTMLDialogElement || (node.popover && node !== menu)) return;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
e.preventDefault();
|
|
57
|
-
e.stopPropagation();
|
|
58
|
-
|
|
59
|
-
menu.showPopover();
|
|
60
|
-
_forcePopover = true;
|
|
61
|
-
|
|
62
|
-
const x = e.clientX;
|
|
63
|
-
const y = e.clientY;
|
|
64
|
-
|
|
65
|
-
menu.style.left = x + 'px';
|
|
66
|
-
menu.style.top = y + 'px';
|
|
67
|
-
|
|
68
|
-
const rect = menu.getBoundingClientRect();
|
|
69
|
-
if (rect.right > window.innerWidth) {
|
|
70
|
-
menu.style.left = '';
|
|
71
|
-
menu.style.right = window.innerWidth - x + 'px';
|
|
72
|
-
}
|
|
73
|
-
if (rect.bottom > window.innerHeight) {
|
|
74
|
-
menu.style.top = '';
|
|
75
|
-
menu.style.bottom = window.innerHeight - y + 'px';
|
|
76
|
-
}
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Workaround for https://github.com/whatwg/html/issues/10905
|
|
81
|
-
* @todo Remove when the problem is fixed.
|
|
82
|
-
*/
|
|
83
|
-
element.onpointerup = (e: PointerEvent) => {
|
|
84
|
-
if (!_forcePopover) return;
|
|
85
|
-
e.stopPropagation();
|
|
86
|
-
e.preventDefault();
|
|
87
|
-
menu.togglePopover();
|
|
88
|
-
_forcePopover = false;
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
return function _disposeContextMenu() {
|
|
92
|
-
for (const icon of mountedIcons) unmount(icon);
|
|
93
|
-
menu.remove();
|
|
94
|
-
};
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Dynamically resize `<textarea>`s based on content.
|
|
100
|
-
* @todo Remove this once `field-sizing` works everywhere
|
|
101
|
-
*/
|
|
102
|
-
export function dynamicRows(max: number = 40, min: number = 3): Attachment<HTMLTextAreaElement> {
|
|
103
|
-
return function _attachDynamicRows(element: HTMLTextAreaElement) {
|
|
104
|
-
element.style.resize = 'none';
|
|
105
|
-
element.style.fieldSizing = 'content';
|
|
106
|
-
element.style.height = 'max-content';
|
|
107
|
-
element.style.overflowY = 'scroll';
|
|
108
|
-
|
|
109
|
-
function update() {
|
|
110
|
-
if (!element.value) return;
|
|
111
|
-
element.rows = Math.max(Math.min(element.value.split('\n').length, max), min);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if (!navigator.userAgent.includes('Firefox')) return;
|
|
115
|
-
update();
|
|
116
|
-
element.addEventListener('input', update);
|
|
117
|
-
element.addEventListener('keyup', update);
|
|
118
|
-
return () => {
|
|
119
|
-
element.removeEventListener('input', update);
|
|
120
|
-
element.removeEventListener('keyup', update);
|
|
121
|
-
};
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Enabled the use of back gestures to close dialogs
|
|
127
|
-
*/
|
|
128
|
-
export function closeOnBackGesture(element: HTMLDialogElement) {
|
|
129
|
-
if (!globalThis.history) {
|
|
130
|
-
throw new Error('Can not attach back gesture handling because the History API is unavailable');
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const showModal = element.showModal.bind(element);
|
|
134
|
-
|
|
135
|
-
let closedByBack = false,
|
|
136
|
-
historyKey = Math.random().toString(16).slice(2);
|
|
137
|
-
|
|
138
|
-
element.showModal = () => {
|
|
139
|
-
closedByBack = false;
|
|
140
|
-
/* popstate's state will be the "upcoming" state.
|
|
141
|
-
i.e, the state after having pushed 1 then 2 would be 1 */
|
|
142
|
-
history.replaceState(historyKey, '');
|
|
143
|
-
history.pushState(null, '');
|
|
144
|
-
showModal();
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
element.addEventListener('close', () => {
|
|
148
|
-
if (closedByBack) return;
|
|
149
|
-
history.replaceState(null, '');
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
window.addEventListener('popstate', e => {
|
|
153
|
-
if (e.state != historyKey) return;
|
|
154
|
-
closedByBack = true;
|
|
155
|
-
element.close();
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
closeOnBackGesture satisfies Attachment<HTMLDialogElement>;
|
|
File without changes
|