@haruhimemoe/ui 0.6.0 → 0.8.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/CHANGELOG.md +29 -1
- package/README.md +143 -33
- package/dist/components/basics/Disclosure.d.ts +1 -1
- package/dist/components/basics/Disclosure.js +2 -2
- package/dist/components/filters/RangeSlider.d.ts +1 -1
- package/dist/components/filters/RangeSlider.js +6 -6
- package/dist/components/filters/chipStyles.d.ts +2 -2
- package/dist/components/filters/chipStyles.js +3 -2
- package/dist/components/forms/CharCounter.d.ts +4 -2
- package/dist/components/forms/CharCounter.js +5 -3
- package/dist/components/forms/Checkbox.d.ts +1 -1
- package/dist/components/forms/Checkbox.js +3 -1
- package/dist/components/forms/FieldFrame.d.ts +1 -1
- package/dist/components/forms/FieldFrame.js +1 -1
- package/dist/components/forms/RadioGroup.d.ts +1 -1
- package/dist/components/forms/RadioGroup.js +4 -2
- package/dist/components/forms/ReportDisclosure.d.ts +1 -1
- package/dist/components/forms/ReportDisclosure.js +10 -6
- package/dist/components/forms/fieldStyles.d.ts +5 -6
- package/dist/components/forms/fieldStyles.js +6 -7
- package/dist/components/osu/BeatmapStats.d.ts +1 -1
- package/dist/components/osu/BeatmapStats.js +2 -1
- package/dist/components/osu/starColors.d.ts +14 -4
- package/dist/components/osu/starColors.js +39 -2
- package/dist/components/palette/CommandPalette.d.ts +21 -0
- package/dist/components/palette/CommandPalette.js +315 -0
- package/dist/components/palette/CommandPaletteButton.d.ts +21 -0
- package/dist/components/palette/CommandPaletteButton.js +26 -0
- package/dist/components/palette/PaletteFooter.d.ts +21 -0
- package/dist/components/palette/PaletteFooter.js +10 -0
- package/dist/components/palette/PaletteInput.d.ts +30 -0
- package/dist/components/palette/PaletteInput.js +23 -0
- package/dist/components/palette/PaletteList.d.ts +29 -0
- package/dist/components/palette/PaletteList.js +44 -0
- package/dist/components/palette/PaletteRow.d.ts +30 -0
- package/dist/components/palette/PaletteRow.js +33 -0
- package/dist/components/palette/calc.d.ts +28 -0
- package/dist/components/palette/calc.js +226 -0
- package/dist/components/palette/fuzzy.d.ts +51 -0
- package/dist/components/palette/fuzzy.js +138 -0
- package/dist/components/palette/hotkeys.d.ts +57 -0
- package/dist/components/palette/hotkeys.js +111 -0
- package/dist/components/palette/paletteEvents.d.ts +22 -0
- package/dist/components/palette/paletteEvents.js +22 -0
- package/dist/components/palette/platform.d.ts +13 -0
- package/dist/components/palette/platform.js +21 -0
- package/dist/components/palette/recents.d.ts +48 -0
- package/dist/components/palette/recents.js +85 -0
- package/dist/components/palette/rows.d.ts +63 -0
- package/dist/components/palette/rows.js +136 -0
- package/dist/components/palette/siteCommands.d.ts +38 -0
- package/dist/components/palette/siteCommands.js +152 -0
- package/dist/components/palette/store.d.ts +94 -0
- package/dist/components/palette/store.js +135 -0
- package/dist/components/palette/types.d.ts +102 -0
- package/dist/components/palette/types.js +11 -0
- package/dist/components/palette/useProviderSearch.d.ts +28 -0
- package/dist/components/palette/useProviderSearch.js +68 -0
- package/dist/components/shell/HeaderMenu.d.ts +1 -1
- package/dist/components/shell/HeaderMenu.js +2 -2
- package/dist/components/shell/NavItem.d.ts +1 -1
- package/dist/components/shell/NavItem.js +2 -2
- package/dist/components/shell/SiteFooter.d.ts +9 -4
- package/dist/components/shell/SiteFooter.js +4 -2
- package/dist/components/tables/Table.d.ts +5 -3
- package/dist/components/tables/Table.js +15 -2
- package/dist/index.d.ts +8 -1
- package/dist/index.js +8 -1
- package/dist/theme.css +4 -4
- package/package.json +8 -3
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/palette/hotkeys.ts
|
|
3
|
+
* @desc Shortcut text ("mod+k", "?", a chord "g p") parsed, matched against keydown events and
|
|
4
|
+
* shown as kbd parts. `mod` is Command on a Mac (platform.ts decides) and Control
|
|
5
|
+
* elsewhere. A chord is two bare keys in a row; CommandPalette times it. Editable targets
|
|
6
|
+
* (fields, contenteditable, a dialog that isn't the palette) never fire command shortcuts.
|
|
7
|
+
* Pure: no directive, so server-safe files (PaletteRow) can import it.
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Sat Oct 3, 2026
|
|
10
|
+
* @modified Sat Oct 3, 2026
|
|
11
|
+
*/
|
|
12
|
+
/** A key with modifiers. `key` is lower-case, compared to `event.key`. */
|
|
13
|
+
export type Combo = {
|
|
14
|
+
key: string;
|
|
15
|
+
mod: boolean;
|
|
16
|
+
shift: boolean;
|
|
17
|
+
alt: boolean;
|
|
18
|
+
ctrl: boolean;
|
|
19
|
+
meta: boolean;
|
|
20
|
+
};
|
|
21
|
+
/** A combo, or a chord of two bare keys pressed in turn. */
|
|
22
|
+
export type Shortcut = {
|
|
23
|
+
kind: "combo";
|
|
24
|
+
combo: Combo;
|
|
25
|
+
} | {
|
|
26
|
+
kind: "chord";
|
|
27
|
+
keys: readonly [string, string];
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* @function parseShortcut
|
|
31
|
+
* @param text {string} "mod+shift+c", "?" or "g p"
|
|
32
|
+
* @returns {Shortcut | null} the parsed shortcut, or null for text that isn't one
|
|
33
|
+
*/
|
|
34
|
+
export declare function parseShortcut(text: string): Shortcut | null;
|
|
35
|
+
/**
|
|
36
|
+
* @function matchesCombo
|
|
37
|
+
* @param event {KeyboardEvent} a keydown
|
|
38
|
+
* @param combo {Combo} the parsed combo
|
|
39
|
+
* @param mac {boolean} whether `mod` means Meta
|
|
40
|
+
* @returns {boolean} true when the key and the exact modifier set match. Shift is not required
|
|
41
|
+
* for a symbol key such as "?", which needs it on most layouts.
|
|
42
|
+
*/
|
|
43
|
+
export declare function matchesCombo(event: KeyboardEvent, combo: Combo, mac: boolean): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* @function isEditableTarget
|
|
46
|
+
* @param target {EventTarget | null} the keydown's target
|
|
47
|
+
* @returns {boolean} true inside an input, textarea, select, contenteditable, or a `<dialog>`
|
|
48
|
+
* without `data-palette`
|
|
49
|
+
*/
|
|
50
|
+
export declare function isEditableTarget(target: EventTarget | null): boolean;
|
|
51
|
+
/**
|
|
52
|
+
* @function displayShortcut
|
|
53
|
+
* @param shortcut {Shortcut} a parsed shortcut
|
|
54
|
+
* @param mac {boolean} symbols (⌘ ⇧ ⌥ ⌃) on a Mac, words elsewhere
|
|
55
|
+
* @returns {string[]} one entry per `<kbd>`, in order
|
|
56
|
+
*/
|
|
57
|
+
export declare function displayShortcut(shortcut: Shortcut, mac: boolean): string[];
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/palette/hotkeys.ts
|
|
3
|
+
* @desc Shortcut text ("mod+k", "?", a chord "g p") parsed, matched against keydown events and
|
|
4
|
+
* shown as kbd parts. `mod` is Command on a Mac (platform.ts decides) and Control
|
|
5
|
+
* elsewhere. A chord is two bare keys in a row; CommandPalette times it. Editable targets
|
|
6
|
+
* (fields, contenteditable, a dialog that isn't the palette) never fire command shortcuts.
|
|
7
|
+
* Pure: no directive, so server-safe files (PaletteRow) can import it.
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Sat Oct 3, 2026
|
|
10
|
+
* @modified Sat Oct 3, 2026
|
|
11
|
+
*/
|
|
12
|
+
const MODIFIERS = new Set(["mod", "shift", "alt", "ctrl", "meta"]);
|
|
13
|
+
/**
|
|
14
|
+
* @function parseShortcut
|
|
15
|
+
* @param text {string} "mod+shift+c", "?" or "g p"
|
|
16
|
+
* @returns {Shortcut | null} the parsed shortcut, or null for text that isn't one
|
|
17
|
+
*/
|
|
18
|
+
export function parseShortcut(text) {
|
|
19
|
+
const trimmed = text.trim().toLowerCase();
|
|
20
|
+
if (trimmed.length === 0)
|
|
21
|
+
return null;
|
|
22
|
+
const chord = trimmed.split(/\s+/);
|
|
23
|
+
if (chord.length === 2 && chord.every((k) => k.length === 1)) {
|
|
24
|
+
return { kind: "chord", keys: [chord[0], chord[1]] };
|
|
25
|
+
}
|
|
26
|
+
if (chord.length !== 1)
|
|
27
|
+
return null;
|
|
28
|
+
const parts = trimmed.split("+");
|
|
29
|
+
const key = parts.pop();
|
|
30
|
+
if (!key || parts.some((part) => !MODIFIERS.has(part)))
|
|
31
|
+
return null;
|
|
32
|
+
const has = (name) => parts.includes(name);
|
|
33
|
+
return {
|
|
34
|
+
kind: "combo",
|
|
35
|
+
combo: {
|
|
36
|
+
key,
|
|
37
|
+
mod: has("mod"),
|
|
38
|
+
shift: has("shift"),
|
|
39
|
+
alt: has("alt"),
|
|
40
|
+
ctrl: has("ctrl"),
|
|
41
|
+
meta: has("meta"),
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const isLetterOrDigit = (key) => /^[a-z0-9]$/i.test(key);
|
|
46
|
+
/**
|
|
47
|
+
* @function matchesCombo
|
|
48
|
+
* @param event {KeyboardEvent} a keydown
|
|
49
|
+
* @param combo {Combo} the parsed combo
|
|
50
|
+
* @param mac {boolean} whether `mod` means Meta
|
|
51
|
+
* @returns {boolean} true when the key and the exact modifier set match. Shift is not required
|
|
52
|
+
* for a symbol key such as "?", which needs it on most layouts.
|
|
53
|
+
*/
|
|
54
|
+
export function matchesCombo(event, combo, mac) {
|
|
55
|
+
if (event.key.toLowerCase() !== combo.key)
|
|
56
|
+
return false;
|
|
57
|
+
const wantMeta = combo.meta || (combo.mod && mac);
|
|
58
|
+
const wantCtrl = combo.ctrl || (combo.mod && !mac);
|
|
59
|
+
if (event.metaKey !== wantMeta || event.ctrlKey !== wantCtrl || event.altKey !== combo.alt)
|
|
60
|
+
return false;
|
|
61
|
+
if (combo.key.length === 1 && !isLetterOrDigit(combo.key) && !combo.shift)
|
|
62
|
+
return true;
|
|
63
|
+
return event.shiftKey === combo.shift;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* @function isEditableTarget
|
|
67
|
+
* @param target {EventTarget | null} the keydown's target
|
|
68
|
+
* @returns {boolean} true inside an input, textarea, select, contenteditable, or a `<dialog>`
|
|
69
|
+
* without `data-palette`
|
|
70
|
+
*/
|
|
71
|
+
export function isEditableTarget(target) {
|
|
72
|
+
if (!(target instanceof Element))
|
|
73
|
+
return false;
|
|
74
|
+
const tag = target.tagName;
|
|
75
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT")
|
|
76
|
+
return true;
|
|
77
|
+
if (target instanceof HTMLElement && target.isContentEditable)
|
|
78
|
+
return true;
|
|
79
|
+
const dialog = target.closest("dialog");
|
|
80
|
+
return dialog !== null && !dialog.hasAttribute("data-palette");
|
|
81
|
+
}
|
|
82
|
+
const KEY_NAMES = {
|
|
83
|
+
arrowup: "↑",
|
|
84
|
+
arrowdown: "↓",
|
|
85
|
+
enter: "↵",
|
|
86
|
+
escape: "Esc",
|
|
87
|
+
backspace: "⌫",
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* @function displayShortcut
|
|
91
|
+
* @param shortcut {Shortcut} a parsed shortcut
|
|
92
|
+
* @param mac {boolean} symbols (⌘ ⇧ ⌥ ⌃) on a Mac, words elsewhere
|
|
93
|
+
* @returns {string[]} one entry per `<kbd>`, in order
|
|
94
|
+
*/
|
|
95
|
+
export function displayShortcut(shortcut, mac) {
|
|
96
|
+
const name = (key) => KEY_NAMES[key] ?? key.toUpperCase();
|
|
97
|
+
if (shortcut.kind === "chord")
|
|
98
|
+
return shortcut.keys.map(name);
|
|
99
|
+
const { combo } = shortcut;
|
|
100
|
+
const parts = [];
|
|
101
|
+
if (combo.ctrl || (combo.mod && !mac))
|
|
102
|
+
parts.push(mac ? "⌃" : "Ctrl");
|
|
103
|
+
if (combo.alt)
|
|
104
|
+
parts.push(mac ? "⌥" : "Alt");
|
|
105
|
+
if (combo.shift)
|
|
106
|
+
parts.push(mac ? "⇧" : "Shift");
|
|
107
|
+
if (combo.meta || (combo.mod && mac))
|
|
108
|
+
parts.push(mac ? "⌘" : "Win");
|
|
109
|
+
parts.push(name(combo.key));
|
|
110
|
+
return parts;
|
|
111
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/palette/paletteEvents.ts
|
|
3
|
+
* @desc How anything opens the mounted CommandPalette without a context provider: a CustomEvent
|
|
4
|
+
* on window that the palette listens for. `CommandPaletteButton` and app code call
|
|
5
|
+
* `openCommandPalette`, optionally with a page to open onto.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Sat Oct 3, 2026
|
|
8
|
+
* @modified Sat Oct 3, 2026
|
|
9
|
+
*/
|
|
10
|
+
import type { Page } from "./types.js";
|
|
11
|
+
/** The event name CommandPalette listens for on window. */
|
|
12
|
+
export declare const PALETTE_EVENT = "haruhime:palette";
|
|
13
|
+
/** The event's detail. */
|
|
14
|
+
export type PaletteEventDetail = {
|
|
15
|
+
page?: Page | undefined;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* @function openCommandPalette
|
|
19
|
+
* @param page {Page} a page to open straight onto, above the root
|
|
20
|
+
* @returns {void}
|
|
21
|
+
*/
|
|
22
|
+
export declare function openCommandPalette(page?: Page): void;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/palette/paletteEvents.ts
|
|
3
|
+
* @desc How anything opens the mounted CommandPalette without a context provider: a CustomEvent
|
|
4
|
+
* on window that the palette listens for. `CommandPaletteButton` and app code call
|
|
5
|
+
* `openCommandPalette`, optionally with a page to open onto.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Sat Oct 3, 2026
|
|
8
|
+
* @modified Sat Oct 3, 2026
|
|
9
|
+
*/
|
|
10
|
+
"use client";
|
|
11
|
+
/** The event name CommandPalette listens for on window. */
|
|
12
|
+
export const PALETTE_EVENT = "haruhime:palette";
|
|
13
|
+
/**
|
|
14
|
+
* @function openCommandPalette
|
|
15
|
+
* @param page {Page} a page to open straight onto, above the root
|
|
16
|
+
* @returns {void}
|
|
17
|
+
*/
|
|
18
|
+
export function openCommandPalette(page) {
|
|
19
|
+
if (typeof window === "undefined")
|
|
20
|
+
return;
|
|
21
|
+
window.dispatchEvent(new CustomEvent(PALETTE_EVENT, { detail: { page } }));
|
|
22
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/palette/platform.ts
|
|
3
|
+
* @desc Whether this is a Mac, so `mod` means Command and hints say ⌘. Client-only (reads
|
|
4
|
+
* navigator); the palette and its button call it after mount.
|
|
5
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
6
|
+
* @created Sat Oct 3, 2026
|
|
7
|
+
* @modified Sat Oct 3, 2026
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* @function isMac
|
|
11
|
+
* @returns {boolean} true on macOS, from `userAgentData.platform` or `navigator.platform`
|
|
12
|
+
*/
|
|
13
|
+
export declare function isMac(): boolean;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/palette/platform.ts
|
|
3
|
+
* @desc Whether this is a Mac, so `mod` means Command and hints say ⌘. Client-only (reads
|
|
4
|
+
* navigator); the palette and its button call it after mount.
|
|
5
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
6
|
+
* @created Sat Oct 3, 2026
|
|
7
|
+
* @modified Sat Oct 3, 2026
|
|
8
|
+
*/
|
|
9
|
+
"use client";
|
|
10
|
+
/**
|
|
11
|
+
* @function isMac
|
|
12
|
+
* @returns {boolean} true on macOS, from `userAgentData.platform` or `navigator.platform`
|
|
13
|
+
*/
|
|
14
|
+
export function isMac() {
|
|
15
|
+
if (typeof navigator === "undefined")
|
|
16
|
+
return false;
|
|
17
|
+
const data = navigator.userAgentData;
|
|
18
|
+
if (data?.platform)
|
|
19
|
+
return data.platform === "macOS";
|
|
20
|
+
return /^Mac/.test(navigator.platform ?? "");
|
|
21
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/palette/recents.ts
|
|
3
|
+
* @desc Which commands get used, in localStorage under "haruhime:palette:<key>": run count and
|
|
4
|
+
* last-use time per command id, pruned to the 50 most recent. Every read and write is
|
|
5
|
+
* guarded, so a blocked or full store means no recents, never an error.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Sat Oct 3, 2026
|
|
8
|
+
* @modified Sat Oct 3, 2026
|
|
9
|
+
*/
|
|
10
|
+
/** Per command id: how many runs and when the last one was (ms since epoch). */
|
|
11
|
+
export type Recents = Record<string, {
|
|
12
|
+
n: number;
|
|
13
|
+
t: number;
|
|
14
|
+
}>;
|
|
15
|
+
/**
|
|
16
|
+
* @function storageKeyFor
|
|
17
|
+
* @param key {string} the app's namespace (CommandPalette's `storageKey`)
|
|
18
|
+
* @returns {string} the localStorage key
|
|
19
|
+
*/
|
|
20
|
+
export declare const storageKeyFor: (key: string) => string;
|
|
21
|
+
/**
|
|
22
|
+
* @function readRecents
|
|
23
|
+
* @param key {string} the app's namespace
|
|
24
|
+
* @returns {Recents} the stored recents, dropping entries that aren't `{ n, t }`; empty when
|
|
25
|
+
* storage is missing, throws, or holds something that isn't JSON
|
|
26
|
+
*/
|
|
27
|
+
export declare function readRecents(key: string): Recents;
|
|
28
|
+
/**
|
|
29
|
+
* @function recordRecent
|
|
30
|
+
* @param key {string} the app's namespace
|
|
31
|
+
* @param id {string} the command that ran
|
|
32
|
+
* @param now {number} the time, default Date.now()
|
|
33
|
+
* @returns {Recents} the recents after the write (empty when storage is unavailable)
|
|
34
|
+
*/
|
|
35
|
+
export declare function recordRecent(key: string, id: string, now?: number): Recents;
|
|
36
|
+
/**
|
|
37
|
+
* @function recentIds
|
|
38
|
+
* @param recents {Recents} the stored recents
|
|
39
|
+
* @param limit {number} how many, default 5
|
|
40
|
+
* @returns {string[]} ids by last use, newest first
|
|
41
|
+
*/
|
|
42
|
+
export declare const recentIds: (recents: Recents, limit?: number) => string[];
|
|
43
|
+
/**
|
|
44
|
+
* @function boostFrom
|
|
45
|
+
* @param recents {Recents} the stored recents
|
|
46
|
+
* @returns {(id: string) => number} the run count per id, 0 for unknown ids (rankResults' boost)
|
|
47
|
+
*/
|
|
48
|
+
export declare const boostFrom: (recents: Recents) => (id: string) => number;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/palette/recents.ts
|
|
3
|
+
* @desc Which commands get used, in localStorage under "haruhime:palette:<key>": run count and
|
|
4
|
+
* last-use time per command id, pruned to the 50 most recent. Every read and write is
|
|
5
|
+
* guarded, so a blocked or full store means no recents, never an error.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Sat Oct 3, 2026
|
|
8
|
+
* @modified Sat Oct 3, 2026
|
|
9
|
+
*/
|
|
10
|
+
const LIMIT = 50;
|
|
11
|
+
/**
|
|
12
|
+
* @function storageKeyFor
|
|
13
|
+
* @param key {string} the app's namespace (CommandPalette's `storageKey`)
|
|
14
|
+
* @returns {string} the localStorage key
|
|
15
|
+
*/
|
|
16
|
+
export const storageKeyFor = (key) => `haruhime:palette:${key}`;
|
|
17
|
+
const isEntry = (value) => typeof value === "object" &&
|
|
18
|
+
value !== null &&
|
|
19
|
+
typeof value.n === "number" &&
|
|
20
|
+
typeof value.t === "number";
|
|
21
|
+
/**
|
|
22
|
+
* @function readRecents
|
|
23
|
+
* @param key {string} the app's namespace
|
|
24
|
+
* @returns {Recents} the stored recents, dropping entries that aren't `{ n, t }`; empty when
|
|
25
|
+
* storage is missing, throws, or holds something that isn't JSON
|
|
26
|
+
*/
|
|
27
|
+
export function readRecents(key) {
|
|
28
|
+
try {
|
|
29
|
+
const raw = localStorage.getItem(storageKeyFor(key));
|
|
30
|
+
if (!raw)
|
|
31
|
+
return {};
|
|
32
|
+
const parsed = JSON.parse(raw);
|
|
33
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
34
|
+
return {};
|
|
35
|
+
return Object.fromEntries(Object.entries(parsed).filter(([, value]) => isEntry(value)));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* @function recordRecent
|
|
43
|
+
* @param key {string} the app's namespace
|
|
44
|
+
* @param id {string} the command that ran
|
|
45
|
+
* @param now {number} the time, default Date.now()
|
|
46
|
+
* @returns {Recents} the recents after the write (empty when storage is unavailable)
|
|
47
|
+
*/
|
|
48
|
+
export function recordRecent(key, id, now = Date.now()) {
|
|
49
|
+
// Storage that can't be read is treated as absent: nothing is written over it either.
|
|
50
|
+
try {
|
|
51
|
+
localStorage.getItem(storageKeyFor(key));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
const recents = readRecents(key);
|
|
57
|
+
const entry = recents[id];
|
|
58
|
+
recents[id] = { n: (entry?.n ?? 0) + 1, t: now };
|
|
59
|
+
const kept = Object.fromEntries(Object.entries(recents)
|
|
60
|
+
.sort(([, a], [, b]) => b.t - a.t)
|
|
61
|
+
.slice(0, LIMIT));
|
|
62
|
+
try {
|
|
63
|
+
localStorage.setItem(storageKeyFor(key), JSON.stringify(kept));
|
|
64
|
+
return kept;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return {};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* @function recentIds
|
|
72
|
+
* @param recents {Recents} the stored recents
|
|
73
|
+
* @param limit {number} how many, default 5
|
|
74
|
+
* @returns {string[]} ids by last use, newest first
|
|
75
|
+
*/
|
|
76
|
+
export const recentIds = (recents, limit = 5) => Object.entries(recents)
|
|
77
|
+
.sort(([, a], [, b]) => b.t - a.t)
|
|
78
|
+
.slice(0, limit)
|
|
79
|
+
.map(([id]) => id);
|
|
80
|
+
/**
|
|
81
|
+
* @function boostFrom
|
|
82
|
+
* @param recents {Recents} the stored recents
|
|
83
|
+
* @returns {(id: string) => number} the run count per id, 0 for unknown ids (rankResults' boost)
|
|
84
|
+
*/
|
|
85
|
+
export const boostFrom = (recents) => (id) => recents[id]?.n ?? 0;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/palette/rows.ts
|
|
3
|
+
* @desc What the list shows for a state, pure: option rows (a command under a group heading,
|
|
4
|
+
* with the title's matched spans) and hint rows (not selectable: provider states, no
|
|
5
|
+
* matches). Empty query: Recent, then every group in declaration order. A query: the
|
|
6
|
+
* calculator row at the root, the ranked static matches, then each provider's rows or its
|
|
7
|
+
* state. In a choice prompt: the choices, fuzzy-filtered.
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Sat Oct 3, 2026
|
|
10
|
+
* @modified Sat Oct 3, 2026
|
|
11
|
+
*/
|
|
12
|
+
import { type FuzzyMatch } from "./fuzzy.js";
|
|
13
|
+
import type { ArgState, ProviderResult } from "./store.js";
|
|
14
|
+
import type { Command, PaletteContext, Provider } from "./types.js";
|
|
15
|
+
/** One list entry. Options are selectable; hints are text. */
|
|
16
|
+
export type Row = {
|
|
17
|
+
kind: "option";
|
|
18
|
+
id: string;
|
|
19
|
+
command: Command;
|
|
20
|
+
ranges: FuzzyMatch["ranges"];
|
|
21
|
+
group: string;
|
|
22
|
+
} | {
|
|
23
|
+
kind: "hint";
|
|
24
|
+
id: string;
|
|
25
|
+
text: string;
|
|
26
|
+
};
|
|
27
|
+
/** What buildRows needs from the palette. */
|
|
28
|
+
export type RowsInput = {
|
|
29
|
+
query: string;
|
|
30
|
+
commands: readonly Command[];
|
|
31
|
+
providers: readonly Provider[];
|
|
32
|
+
providerResults: Record<string, ProviderResult>;
|
|
33
|
+
recentIds: readonly string[];
|
|
34
|
+
boost: (id: string) => number;
|
|
35
|
+
isRoot: boolean;
|
|
36
|
+
calculator: boolean;
|
|
37
|
+
arg: ArgState | null;
|
|
38
|
+
ctx: PaletteContext;
|
|
39
|
+
};
|
|
40
|
+
/** The id of the calculator's row and command. */
|
|
41
|
+
export declare const CALC_ID = "calc";
|
|
42
|
+
/**
|
|
43
|
+
* @function buildRows
|
|
44
|
+
* @param input {RowsInput} the query, commands, providers and their results, recents, and the
|
|
45
|
+
* argument prompt in progress
|
|
46
|
+
* @returns {Row[]} the list, top to bottom
|
|
47
|
+
*/
|
|
48
|
+
export declare function buildRows(input: RowsInput): Row[];
|
|
49
|
+
/**
|
|
50
|
+
* @function optionCount
|
|
51
|
+
* @param rows {readonly Row[]} the list
|
|
52
|
+
* @returns {number} how many rows can be selected
|
|
53
|
+
*/
|
|
54
|
+
export declare const optionCount: (rows: readonly Row[]) => number;
|
|
55
|
+
/**
|
|
56
|
+
* @function optionAt
|
|
57
|
+
* @param rows {readonly Row[]} the list
|
|
58
|
+
* @param index {number} the active index, counting options only
|
|
59
|
+
* @returns {Extract<Row, { kind: "option" }> | undefined} that option
|
|
60
|
+
*/
|
|
61
|
+
export declare const optionAt: (rows: readonly Row[], index: number) => Extract<Row, {
|
|
62
|
+
kind: "option";
|
|
63
|
+
}> | undefined;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/palette/rows.ts
|
|
3
|
+
* @desc What the list shows for a state, pure: option rows (a command under a group heading,
|
|
4
|
+
* with the title's matched spans) and hint rows (not selectable: provider states, no
|
|
5
|
+
* matches). Empty query: Recent, then every group in declaration order. A query: the
|
|
6
|
+
* calculator row at the root, the ranked static matches, then each provider's rows or its
|
|
7
|
+
* state. In a choice prompt: the choices, fuzzy-filtered.
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Sat Oct 3, 2026
|
|
10
|
+
* @modified Sat Oct 3, 2026
|
|
11
|
+
*/
|
|
12
|
+
import { evaluate, formatResult, isBareNumber } from "./calc.js";
|
|
13
|
+
import { rankResults } from "./fuzzy.js";
|
|
14
|
+
/** The id of the calculator's row and command. */
|
|
15
|
+
export const CALC_ID = "calc";
|
|
16
|
+
const DEFAULT_GROUP = "Commands";
|
|
17
|
+
const DEFAULT_MIN = 2;
|
|
18
|
+
const option = (command, group, ranges = [], prefix = "") => ({
|
|
19
|
+
kind: "option",
|
|
20
|
+
id: `${prefix}${group}/${command.id}`,
|
|
21
|
+
command,
|
|
22
|
+
ranges,
|
|
23
|
+
group,
|
|
24
|
+
});
|
|
25
|
+
const hint = (text, scope = "") => ({
|
|
26
|
+
kind: "hint",
|
|
27
|
+
id: `hint:${scope}${text}`,
|
|
28
|
+
text,
|
|
29
|
+
});
|
|
30
|
+
const visible = (commands, ctx) => commands.filter((command) => {
|
|
31
|
+
if (!command.when)
|
|
32
|
+
return true;
|
|
33
|
+
try {
|
|
34
|
+
return command.when(ctx);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
console.error(`CommandPalette: "${command.id}" when() threw`, error);
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
const choiceRows = (arg) => {
|
|
42
|
+
const spec = arg.command.args?.[arg.index];
|
|
43
|
+
if (spec?.type !== "choice")
|
|
44
|
+
return [];
|
|
45
|
+
const group = spec.label;
|
|
46
|
+
const rows = (spec.choices ?? []).map((choice) => ({
|
|
47
|
+
id: choice.value,
|
|
48
|
+
title: choice.label,
|
|
49
|
+
subtitle: choice.subtitle,
|
|
50
|
+
}));
|
|
51
|
+
if (arg.query.trim() === "")
|
|
52
|
+
return rows.map((row) => option(row, group));
|
|
53
|
+
return rankResults(arg.query, rows, () => 0).map((scored) => option(scored.command, group, scored.ranges));
|
|
54
|
+
};
|
|
55
|
+
// A bare word ("e", "pi") is a search, not a sum, even though the calculator knows it.
|
|
56
|
+
const BARE_WORD = /^[a-z]+$/i;
|
|
57
|
+
const calcRow = (query) => {
|
|
58
|
+
if (isBareNumber(query) || BARE_WORD.test(query))
|
|
59
|
+
return null;
|
|
60
|
+
const value = evaluate(query);
|
|
61
|
+
if (value === null)
|
|
62
|
+
return null;
|
|
63
|
+
const text = formatResult(value);
|
|
64
|
+
const command = {
|
|
65
|
+
id: CALC_ID,
|
|
66
|
+
title: `= ${text}`,
|
|
67
|
+
subtitle: "Enter to copy",
|
|
68
|
+
closeOnRun: false,
|
|
69
|
+
run: (ctx) => ctx.copy(text),
|
|
70
|
+
};
|
|
71
|
+
return option(command, "Calculator");
|
|
72
|
+
};
|
|
73
|
+
const providerRows = (input) => input.providers.flatMap((provider) => {
|
|
74
|
+
const group = provider.group ?? "Results";
|
|
75
|
+
const min = provider.minLength ?? DEFAULT_MIN;
|
|
76
|
+
const scope = `${provider.id}:`;
|
|
77
|
+
if (input.query.trim().length < min)
|
|
78
|
+
return [hint(`Type ${min} characters to search`, scope)];
|
|
79
|
+
const result = input.providerResults[provider.id];
|
|
80
|
+
const rows = (result?.rows ?? []).map((command) => option(command, group, [], scope));
|
|
81
|
+
// Searching: the last rows stay under the hint, so the list doesn't flash per keystroke.
|
|
82
|
+
if (!result || result.state === "loading")
|
|
83
|
+
return [hint("Searching…", scope), ...rows];
|
|
84
|
+
if (result.state === "error")
|
|
85
|
+
return [hint("Couldn't search, try again", scope)];
|
|
86
|
+
if (rows.length === 0)
|
|
87
|
+
return [hint(`No results for “${input.query.trim()}”`, scope)];
|
|
88
|
+
return rows;
|
|
89
|
+
});
|
|
90
|
+
/**
|
|
91
|
+
* @function buildRows
|
|
92
|
+
* @param input {RowsInput} the query, commands, providers and their results, recents, and the
|
|
93
|
+
* argument prompt in progress
|
|
94
|
+
* @returns {Row[]} the list, top to bottom
|
|
95
|
+
*/
|
|
96
|
+
export function buildRows(input) {
|
|
97
|
+
if (input.arg)
|
|
98
|
+
return choiceRows(input.arg);
|
|
99
|
+
const commands = visible(input.commands, input.ctx);
|
|
100
|
+
const query = input.query.trim();
|
|
101
|
+
if (query === "") {
|
|
102
|
+
const recent = input.recentIds
|
|
103
|
+
.map((id) => commands.find((command) => command.id === id))
|
|
104
|
+
.filter((command) => command !== undefined)
|
|
105
|
+
.map((command) => option(command, "Recent", [], "recent:"));
|
|
106
|
+
return [
|
|
107
|
+
...recent,
|
|
108
|
+
...commands.map((command) => option(command, command.group ?? DEFAULT_GROUP)),
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
const rows = [];
|
|
112
|
+
const calc = input.isRoot && input.calculator ? calcRow(query) : null;
|
|
113
|
+
if (calc)
|
|
114
|
+
rows.push(calc);
|
|
115
|
+
for (const scored of rankResults(query, commands, input.boost)) {
|
|
116
|
+
rows.push(option(scored.command, scored.command.group ?? DEFAULT_GROUP, scored.ranges));
|
|
117
|
+
}
|
|
118
|
+
rows.push(...providerRows(input));
|
|
119
|
+
if (!rows.some((row) => row.kind === "option") && input.providers.length === 0) {
|
|
120
|
+
rows.push(hint("No matching commands"));
|
|
121
|
+
}
|
|
122
|
+
return rows;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* @function optionCount
|
|
126
|
+
* @param rows {readonly Row[]} the list
|
|
127
|
+
* @returns {number} how many rows can be selected
|
|
128
|
+
*/
|
|
129
|
+
export const optionCount = (rows) => rows.filter((row) => row.kind === "option").length;
|
|
130
|
+
/**
|
|
131
|
+
* @function optionAt
|
|
132
|
+
* @param rows {readonly Row[]} the list
|
|
133
|
+
* @param index {number} the active index, counting options only
|
|
134
|
+
* @returns {Extract<Row, { kind: "option" }> | undefined} that option
|
|
135
|
+
*/
|
|
136
|
+
export const optionAt = (rows, index) => rows.filter((row) => row.kind === "option")[index];
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/palette/siteCommands.ts
|
|
3
|
+
* @desc The default command set every tool gets: "Go to <page>" from the nav items, "Open
|
|
4
|
+
* <tool>" from HARUHIME_TOOLS, page actions (copy URL, back, top, reload, GitHub), account
|
|
5
|
+
* (sign in, my account, sign out by signedIn), and help (the shortcuts page, report a bug).
|
|
6
|
+
* Ids are stable ("site.copy-url") so recents survive relabeling. Client-only: it opens
|
|
7
|
+
* tabs and scrolls.
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Sat Oct 3, 2026
|
|
10
|
+
* @modified Sat Oct 3, 2026
|
|
11
|
+
*/
|
|
12
|
+
import { type HaruhimeToolId } from "../shell/haruhimeTools.js";
|
|
13
|
+
import type { SiteLinkItem } from "../shell/links.js";
|
|
14
|
+
import type { Command } from "./types.js";
|
|
15
|
+
/** Which default groups to build and what they need. */
|
|
16
|
+
export type SiteCommandsOptions = {
|
|
17
|
+
/** The nav items the app passes to SiteHeader; one "Go to <label>" per linked item. */
|
|
18
|
+
pages?: readonly SiteLinkItem[] | undefined;
|
|
19
|
+
/** "Open <tool>" for every other live tool plus haruhime.moe. The current tool is skipped. false leaves them out. */
|
|
20
|
+
tools?: HaruhimeToolId | false | undefined;
|
|
21
|
+
/** The app's repo URL: "Open on GitHub" and "Report a bug". */
|
|
22
|
+
repo?: string | undefined;
|
|
23
|
+
account?: {
|
|
24
|
+
signedIn: boolean;
|
|
25
|
+
signInHref: string;
|
|
26
|
+
accountHref?: string | undefined;
|
|
27
|
+
/** Navigated, not fetched (next-kit's sign-out route). */
|
|
28
|
+
signOutHref?: string | undefined;
|
|
29
|
+
} | undefined;
|
|
30
|
+
/** Default: every group. */
|
|
31
|
+
include?: readonly ("navigate" | "page" | "account" | "help")[] | undefined;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* @function siteCommands
|
|
35
|
+
* @param options {SiteCommandsOptions} pages, tools, repo, account and which groups to include
|
|
36
|
+
* @returns {Command[]} the defaults in order: Navigate, Page, Account, Help
|
|
37
|
+
*/
|
|
38
|
+
export declare function siteCommands(options?: SiteCommandsOptions): Command[];
|