feat(shell,api,keys): firefly v1 shell over the api with cookie auth, period, palette and keymap

This commit is contained in:
hh committed 2026-10-16 02:21:45 +00:00
1 parent ae4de7911b
commit c74dac3e00
37 files changed
+58163 -79

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
export interface Binding {
group?: string;
hidden?: boolean;
input?: boolean;
keys: string;
label: string;
run: (event: KeyboardEvent) => void;
}
const SEQUENCE_MS = 1200;
const EDITABLE =
"input, textarea, select, [contenteditable=''], [contenteditable='true']";
function token(event: KeyboardEvent): string {
const parts: string[] = [];
if (event.ctrlKey || event.metaKey) {
parts.push("mod");
}
if (event.altKey) {
parts.push("alt");
}
const key = event.key === " " ? "Space" : event.key;
if (event.shiftKey && key.length > 1) {
parts.push("shift");
}
parts.push(key.length === 1 && parts.length > 0 ? key.toLowerCase() : key);
return parts.join("+");
}
export function isEditable(target: EventTarget | null): boolean {
return target instanceof Element && target.closest(EDITABLE) !== null;
}
class Keys {
layers = $state<Binding[][]>([]);
pending = $state("");
sheetOpen = $state(false);
private timer: ReturnType<typeof setTimeout> | undefined;
all = $derived(this.layers.flat());
register(bindings: Binding[]): () => void {
const layer = bindings;
this.layers = [...this.layers, layer];
return () => {
this.layers = this.layers.filter((l) => l !== layer);
};
}
private find(combo: string, editable: boolean): Binding | undefined {
for (let i = this.layers.length - 1; i >= 0; i -= 1) {
const match = this.layers[i].find(
(b) => b.keys === combo && (!editable || b.input)
);
if (match) {
return match;
}
}
}
private isPrefix(combo: string): boolean {
return this.all.some((b) => b.keys.startsWith(`${combo} `));
}
private clear() {
this.pending = "";
clearTimeout(this.timer);
}
handle = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.isComposing) {
return;
}
if (["Shift", "Control", "Meta", "Alt"].includes(event.key)) {
return;
}
const editable = isEditable(event.target);
const current = token(event);
const combo = this.pending ? `${this.pending} ${current}` : current;
const binding =
this.find(combo, editable) ??
(this.pending ? this.find(current, editable) : undefined);
if (binding) {
event.preventDefault();
this.clear();
binding.run(event);
return;
}
if (!editable && this.isPrefix(current)) {
event.preventDefault();
this.clear();
this.pending = current;
this.timer = setTimeout(() => this.clear(), SEQUENCE_MS);
return;
}
this.clear();
};
}
export const keys = new Keys();
export function useKeys(bindings: () => Binding[]) {
$effect(() => keys.register(bindings()));
}
const APPLE = /Mac|iPhone|iPad/;
const PC_NAMES: Record<string, string> = {
alt: "Alt",
Enter: "↵",
Escape: "Esc",
mod: "Ctrl",
shift: "Shift",
};
const MAC_NAMES: Record<string, string> = { ...PC_NAMES, alt: "⌥", mod: "⌘" };
export function prettyKeys(combo: string): string[] {
const mac =
typeof navigator !== "undefined" && APPLE.test(navigator.platform);
return combo.split(" ").map((part) =>
part
.split("+")
.map((piece) => (mac ? MAC_NAMES : PC_NAMES)[piece] ?? piece)
.join(mac ? "" : "+")
);
}