@haruhimemoe/ui 0.7.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 +16 -1
- package/README.md +114 -11
- package/dist/components/basics/Disclosure.d.ts +1 -1
- package/dist/components/basics/Disclosure.js +2 -2
- package/dist/components/forms/Checkbox.d.ts +1 -1
- package/dist/components/forms/Checkbox.js +3 -1
- package/dist/components/forms/RadioGroup.js +3 -1
- 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/index.d.ts +8 -1
- package/dist/index.js +8 -1
- package/package.json +8 -3
|
@@ -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[];
|
|
@@ -0,0 +1,152 @@
|
|
|
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
|
+
"use client";
|
|
13
|
+
import { isExternalHref } from "../../utils/href.js";
|
|
14
|
+
import { HARUHIME_TOOLS } from "../shell/haruhimeTools.js";
|
|
15
|
+
const slug = (label) => label
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
18
|
+
.replace(/^-|-$/g, "") || "page";
|
|
19
|
+
const openTab = (href) => {
|
|
20
|
+
window.open(href, "_blank", "noopener");
|
|
21
|
+
};
|
|
22
|
+
const go = (href, ctx) => isExternalHref(href) ? openTab(href) : ctx.navigate(href);
|
|
23
|
+
const reducedMotion = () => typeof window.matchMedia === "function" &&
|
|
24
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
25
|
+
const navigate = (options) => {
|
|
26
|
+
const pages = (options.pages ?? []).flatMap((item) => {
|
|
27
|
+
const href = item.href;
|
|
28
|
+
if (!href)
|
|
29
|
+
return [];
|
|
30
|
+
return [
|
|
31
|
+
{
|
|
32
|
+
id: `site.go.${slug(item.label)}`,
|
|
33
|
+
title: `Go to ${item.label}`,
|
|
34
|
+
group: "Navigate",
|
|
35
|
+
keywords: [href],
|
|
36
|
+
run: (ctx) => go(href, ctx),
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
});
|
|
40
|
+
if (options.tools === false)
|
|
41
|
+
return pages;
|
|
42
|
+
const tools = HARUHIME_TOOLS.filter((tool) => tool.id !== options.tools).map((tool) => ({
|
|
43
|
+
id: `site.tool.${tool.id}`,
|
|
44
|
+
title: `Open ${tool.name}`,
|
|
45
|
+
subtitle: tool.blurb,
|
|
46
|
+
group: "Navigate",
|
|
47
|
+
run: () => openTab(tool.href),
|
|
48
|
+
}));
|
|
49
|
+
tools.push({
|
|
50
|
+
id: "site.tool.home",
|
|
51
|
+
title: "Open haruhime.moe",
|
|
52
|
+
subtitle: "all tools",
|
|
53
|
+
group: "Navigate",
|
|
54
|
+
run: () => openTab("https://www.haruhime.moe"),
|
|
55
|
+
});
|
|
56
|
+
return [...pages, ...tools];
|
|
57
|
+
};
|
|
58
|
+
const page = (options) => {
|
|
59
|
+
const list = [
|
|
60
|
+
{
|
|
61
|
+
id: "site.copy-url",
|
|
62
|
+
title: "Copy page URL",
|
|
63
|
+
group: "Page",
|
|
64
|
+
shortcut: "mod+shift+c",
|
|
65
|
+
closeOnRun: false,
|
|
66
|
+
run: (ctx) => ctx.copy(location.href),
|
|
67
|
+
},
|
|
68
|
+
{ id: "site.back", title: "Go back", group: "Page", run: () => history.back() },
|
|
69
|
+
{
|
|
70
|
+
id: "site.top",
|
|
71
|
+
title: "Scroll to top",
|
|
72
|
+
group: "Page",
|
|
73
|
+
run: () => window.scrollTo({ top: 0, behavior: reducedMotion() ? "auto" : "smooth" }),
|
|
74
|
+
},
|
|
75
|
+
{ id: "site.reload", title: "Reload page", group: "Page", run: () => location.reload() },
|
|
76
|
+
];
|
|
77
|
+
if (options.repo)
|
|
78
|
+
list.push({
|
|
79
|
+
id: "site.github",
|
|
80
|
+
title: "Open on GitHub",
|
|
81
|
+
group: "Page",
|
|
82
|
+
keywords: ["source", "repo"],
|
|
83
|
+
run: () => openTab(options.repo),
|
|
84
|
+
});
|
|
85
|
+
return list;
|
|
86
|
+
};
|
|
87
|
+
const account = (options) => {
|
|
88
|
+
const acct = options.account;
|
|
89
|
+
if (!acct)
|
|
90
|
+
return [];
|
|
91
|
+
const list = [
|
|
92
|
+
{
|
|
93
|
+
id: "site.sign-in",
|
|
94
|
+
title: "Sign in",
|
|
95
|
+
group: "Account",
|
|
96
|
+
when: () => !acct.signedIn,
|
|
97
|
+
run: (ctx) => ctx.navigate(acct.signInHref),
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
if (acct.accountHref)
|
|
101
|
+
list.push({
|
|
102
|
+
id: "site.account",
|
|
103
|
+
title: "My account",
|
|
104
|
+
group: "Account",
|
|
105
|
+
when: () => acct.signedIn,
|
|
106
|
+
run: (ctx) => ctx.navigate(acct.accountHref),
|
|
107
|
+
});
|
|
108
|
+
if (acct.signOutHref)
|
|
109
|
+
list.push({
|
|
110
|
+
id: "site.sign-out",
|
|
111
|
+
title: "Sign out",
|
|
112
|
+
group: "Account",
|
|
113
|
+
when: () => acct.signedIn,
|
|
114
|
+
run: (ctx) => ctx.navigate(acct.signOutHref),
|
|
115
|
+
});
|
|
116
|
+
return list;
|
|
117
|
+
};
|
|
118
|
+
const help = (options) => {
|
|
119
|
+
const list = [
|
|
120
|
+
{
|
|
121
|
+
id: "site.shortcuts",
|
|
122
|
+
title: "Keyboard shortcuts",
|
|
123
|
+
group: "Help",
|
|
124
|
+
shortcut: "?",
|
|
125
|
+
closeOnRun: false,
|
|
126
|
+
run: (ctx) => ctx.push({
|
|
127
|
+
title: "Keyboard shortcuts",
|
|
128
|
+
placeholder: "Filter shortcuts…",
|
|
129
|
+
commands: ctx.commands.filter((c) => c.shortcut && c.id !== "site.shortcuts"),
|
|
130
|
+
}),
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
if (options.repo)
|
|
134
|
+
list.push({
|
|
135
|
+
id: "site.report",
|
|
136
|
+
title: "Report a bug",
|
|
137
|
+
group: "Help",
|
|
138
|
+
keywords: ["issue", "feedback"],
|
|
139
|
+
run: () => openTab(`${options.repo}/issues/new`),
|
|
140
|
+
});
|
|
141
|
+
return list;
|
|
142
|
+
};
|
|
143
|
+
const BUILDERS = { navigate, page, account, help };
|
|
144
|
+
/**
|
|
145
|
+
* @function siteCommands
|
|
146
|
+
* @param options {SiteCommandsOptions} pages, tools, repo, account and which groups to include
|
|
147
|
+
* @returns {Command[]} the defaults in order: Navigate, Page, Account, Help
|
|
148
|
+
*/
|
|
149
|
+
export function siteCommands(options = {}) {
|
|
150
|
+
const include = options.include ?? ["navigate", "page", "account", "help"];
|
|
151
|
+
return include.flatMap((group) => BUILDERS[group](options));
|
|
152
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/palette/store.ts
|
|
3
|
+
* @desc The palette's state and reducer, pure: open or closed, a stack of page frames (each with
|
|
4
|
+
* its own query, active row and provider results, so popping back restores where you
|
|
5
|
+
* were), and the argument prompt in progress. Every key the palette handles is an action
|
|
6
|
+
* here, so the rules test without a DOM. CommandPalette owns it through useReducer.
|
|
7
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
8
|
+
* @created Sat Oct 3, 2026
|
|
9
|
+
* @modified Sat Oct 3, 2026
|
|
10
|
+
*/
|
|
11
|
+
import type { Command, Page } from "./types.js";
|
|
12
|
+
/** One provider's rows on a frame and where its search is. */
|
|
13
|
+
export type ProviderResult = {
|
|
14
|
+
state: "loading" | "done" | "error";
|
|
15
|
+
rows: readonly Command[];
|
|
16
|
+
};
|
|
17
|
+
/** One page on the stack. */
|
|
18
|
+
export type Frame = {
|
|
19
|
+
page: Page;
|
|
20
|
+
query: string;
|
|
21
|
+
active: number;
|
|
22
|
+
providers: Record<string, ProviderResult>;
|
|
23
|
+
};
|
|
24
|
+
/** The argument prompt in progress. */
|
|
25
|
+
export type ArgState = {
|
|
26
|
+
command: Command;
|
|
27
|
+
index: number;
|
|
28
|
+
values: Record<string, string>;
|
|
29
|
+
query: string;
|
|
30
|
+
active: number;
|
|
31
|
+
error?: string | undefined;
|
|
32
|
+
};
|
|
33
|
+
export type PaletteState = {
|
|
34
|
+
open: boolean;
|
|
35
|
+
stack: Frame[];
|
|
36
|
+
arg: ArgState | null;
|
|
37
|
+
};
|
|
38
|
+
export type PaletteAction = {
|
|
39
|
+
type: "open";
|
|
40
|
+
root: Page;
|
|
41
|
+
page?: Page | undefined;
|
|
42
|
+
} | {
|
|
43
|
+
type: "close";
|
|
44
|
+
} | {
|
|
45
|
+
type: "query";
|
|
46
|
+
value: string;
|
|
47
|
+
} | {
|
|
48
|
+
type: "active";
|
|
49
|
+
index: number;
|
|
50
|
+
count: number;
|
|
51
|
+
} | {
|
|
52
|
+
type: "move";
|
|
53
|
+
delta: Delta;
|
|
54
|
+
count: number;
|
|
55
|
+
} | {
|
|
56
|
+
type: "push";
|
|
57
|
+
page: Page;
|
|
58
|
+
} | {
|
|
59
|
+
type: "pop";
|
|
60
|
+
} | {
|
|
61
|
+
type: "beginArgs";
|
|
62
|
+
command: Command;
|
|
63
|
+
} | {
|
|
64
|
+
type: "argAccept";
|
|
65
|
+
value: string;
|
|
66
|
+
} | {
|
|
67
|
+
type: "argError";
|
|
68
|
+
message: string;
|
|
69
|
+
} | {
|
|
70
|
+
type: "provided";
|
|
71
|
+
depth: number;
|
|
72
|
+
providerId: string;
|
|
73
|
+
result: ProviderResult;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* @function initialState
|
|
77
|
+
* @returns {PaletteState} closed, empty
|
|
78
|
+
*/
|
|
79
|
+
export declare const initialState: () => PaletteState;
|
|
80
|
+
/**
|
|
81
|
+
* @function topFrame
|
|
82
|
+
* @param state {PaletteState} the state
|
|
83
|
+
* @returns {Frame | undefined} the page on top, undefined while closed
|
|
84
|
+
*/
|
|
85
|
+
export declare const topFrame: (state: PaletteState) => Frame | undefined;
|
|
86
|
+
type Delta = 1 | -1 | "home" | "end";
|
|
87
|
+
/**
|
|
88
|
+
* @function reduce
|
|
89
|
+
* @param state {PaletteState} the state before
|
|
90
|
+
* @param action {PaletteAction} what happened
|
|
91
|
+
* @returns {PaletteState} the state after; the same object when nothing changes
|
|
92
|
+
*/
|
|
93
|
+
export declare function reduce(state: PaletteState, action: PaletteAction): PaletteState;
|
|
94
|
+
export {};
|