@axium/client 0.29.0 → 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.
@@ -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;
@@ -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.30.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": {