feat(shell,api,keys): firefly v1 shell over the api with cookie auth, period, palette and keymap
This commit is contained in:
1 parent
ae4de7911b
commit
c74dac3e00
37 files changed
+58163
-79
No files matched your search
@@ -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 ? "" : "+")
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user