feat(keys): vim keys in any layout, z motions, no Option chords
Keys on a non-Latin layout map to their physical US position, so j, k, gg and / work on the Russian layout. Lists get z z, z t, z b, and the first row scrolls the page to the top. The editor drops Alt chords for Esc then a letter, reorder in groups, piggy banks and subscriptions moves to J and K.
This commit is contained in:
1 parent
63a1971900
commit
a1e7489ad4
9 files changed
+132
-94
No files matched your search
@@ -52,15 +52,15 @@
|
|||||||
withdrawal: "withdrawal",
|
withdrawal: "withdrawal",
|
||||||
};
|
};
|
||||||
const FIELDS: [string, string, string][] = [
|
const FIELDS: [string, string, string][] = [
|
||||||
["alt+d", "description", "description"],
|
["d", "description", "description"],
|
||||||
["alt+f", "source", "source account"],
|
["f", "source", "source account"],
|
||||||
["alt+t", "destination", "destination account"],
|
["t", "destination", "destination account"],
|
||||||
["alt+w", "date", "date"],
|
["w", "date", "date"],
|
||||||
["alt+m", "amount", "amount"],
|
["m", "amount", "amount"],
|
||||||
["alt+k", "category", "category"],
|
["c", "category", "category"],
|
||||||
["alt+b", "budget", "budget"],
|
["b", "budget", "budget"],
|
||||||
["alt+g", "tags", "tags"],
|
["#", "tags", "tags"],
|
||||||
["alt+r", "notes", "notes"],
|
["r", "notes", "notes"],
|
||||||
];
|
];
|
||||||
const INLINE = new Set([
|
const INLINE = new Set([
|
||||||
"description",
|
"description",
|
||||||
@@ -104,6 +104,7 @@
|
|||||||
const originalType = start.type;
|
const originalType = start.type;
|
||||||
|
|
||||||
let form = $state<Form>(start);
|
let form = $state<Form>(start);
|
||||||
|
let lastSplit = start.splits[0].key;
|
||||||
let saved = $state(untrack(() => group));
|
let saved = $state(untrack(() => group));
|
||||||
let snapshot = JSON.stringify(start);
|
let snapshot = JSON.stringify(start);
|
||||||
let errors = $state<Errors>({});
|
let errors = $state<Errors>({});
|
||||||
@@ -204,9 +205,17 @@
|
|||||||
tick().then(() => focusField("description", false));
|
tick().then(() => focusField("description", false));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function splitOf(target: EventTarget | null): number | null {
|
||||||
|
const holder =
|
||||||
|
target instanceof Element ? target.closest("[data-split]") : null;
|
||||||
|
return holder ? Number(holder.getAttribute("data-split")) : null;
|
||||||
|
}
|
||||||
|
|
||||||
function currentKey(): number {
|
function currentKey(): number {
|
||||||
const holder = document.activeElement?.closest("[data-split]");
|
const key = splitOf(document.activeElement) ?? lastSplit;
|
||||||
return Number(holder?.getAttribute("data-split") ?? form.splits[0].key);
|
return form.splits.some((split) => split.key === key)
|
||||||
|
? key
|
||||||
|
: form.splits[0].key;
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusField(field: string, select = true) {
|
function focusField(field: string, select = true) {
|
||||||
@@ -452,29 +461,25 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: "Editor",
|
group: "Editor",
|
||||||
input: true,
|
keys: "X",
|
||||||
keys: "mod+alt+Backspace",
|
|
||||||
label: "Remove the current split",
|
label: "Remove the current split",
|
||||||
run: () => removeSplit(),
|
run: () => removeSplit(),
|
||||||
},
|
},
|
||||||
...TX_TYPES.map((type, index) => ({
|
...TX_TYPES.map((type, index) => ({
|
||||||
group: "Editor",
|
group: "Editor",
|
||||||
input: true,
|
keys: `${index + 1}`,
|
||||||
keys: `alt+${index + 1}`,
|
|
||||||
label: `Make it a ${type}`,
|
label: `Make it a ${type}`,
|
||||||
run: () => setType(type),
|
run: () => setType(type),
|
||||||
})),
|
})),
|
||||||
...FIELDS.map(([keys, field, label]) => ({
|
...FIELDS.map(([keys, field, label]) => ({
|
||||||
group: "Editor",
|
group: "Editor",
|
||||||
input: true,
|
|
||||||
keys,
|
keys,
|
||||||
label: `Go to ${label}`,
|
label: `Go to ${label}`,
|
||||||
run: () => focusField(field),
|
run: () => focusField(field),
|
||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
group: "Editor",
|
group: "Editor",
|
||||||
input: true,
|
keys: "T",
|
||||||
keys: "alt+i",
|
|
||||||
label: "Go to the split transaction title",
|
label: "Go to the split transaction title",
|
||||||
run: () => document.getElementById("group-title")?.focus(),
|
run: () => document.getElementById("group-title")?.focus(),
|
||||||
},
|
},
|
||||||
@@ -520,7 +525,11 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4" bind:this={root}>
|
<div
|
||||||
|
class="flex flex-col gap-4"
|
||||||
|
onfocusin={(event) => (lastSplit = splitOf(event.target) ?? lastSplit)}
|
||||||
|
bind:this={root}
|
||||||
|
>
|
||||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
<div
|
<div
|
||||||
aria-label="Transaction type"
|
aria-label="Transaction type"
|
||||||
@@ -540,9 +549,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{TYPE_LABEL[type]}
|
{TYPE_LABEL[type]}
|
||||||
<span class="kbd hidden lg:inline-flex"
|
<span class="kbd hidden lg:inline-flex">{index + 1}</span>
|
||||||
>{prettyKeys(`alt+${index + 1}`)[0]}</span
|
|
||||||
>
|
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -614,7 +621,7 @@
|
|||||||
<Field
|
<Field
|
||||||
error={errors.group_title?.[0]}
|
error={errors.group_title?.[0]}
|
||||||
for="group-title"
|
for="group-title"
|
||||||
keys="alt+i"
|
keys="T"
|
||||||
label="Description of the split transaction"
|
label="Description of the split transaction"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
import Field from "#lib/components/inputs/Field.svelte";
|
import Field from "#lib/components/inputs/Field.svelte";
|
||||||
import { INPUT } from "#lib/components/inputs/styles.js";
|
import { INPUT } from "#lib/components/inputs/styles.js";
|
||||||
import TagsInput from "#lib/components/inputs/TagsInput.svelte";
|
import TagsInput from "#lib/components/inputs/TagsInput.svelte";
|
||||||
import { prettyKeys } from "#lib/keys.svelte.js";
|
|
||||||
import { session } from "#lib/session.svelte.js";
|
import { session } from "#lib/session.svelte.js";
|
||||||
import AccountField from "./AccountField.svelte";
|
import AccountField from "./AccountField.svelte";
|
||||||
import type { Catalog } from "./catalog.svelte.js";
|
import type { Catalog } from "./catalog.svelte.js";
|
||||||
@@ -205,9 +204,7 @@
|
|||||||
>
|
>
|
||||||
<Trash class="size-3.5" />
|
<Trash class="size-3.5" />
|
||||||
Remove split
|
Remove split
|
||||||
<span class="kbd hidden lg:inline-flex"
|
<span class="kbd hidden lg:inline-flex">X</span>
|
||||||
>{prettyKeys("mod+alt+Backspace")[0]}</span
|
|
||||||
>
|
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -216,7 +213,7 @@
|
|||||||
<Field
|
<Field
|
||||||
error={err("description")}
|
error={err("description")}
|
||||||
for={id("description")}
|
for={id("description")}
|
||||||
keys={keyHint("alt+d")}
|
keys={keyHint("d")}
|
||||||
label="Description"
|
label="Description"
|
||||||
>
|
>
|
||||||
<AutocompleteInput
|
<AutocompleteInput
|
||||||
@@ -229,10 +226,10 @@
|
|||||||
bind:value={split.description}
|
bind:value={split.description}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
{@render account("source", "Source account", "alt+f")}
|
{@render account("source", "Source account", "f")}
|
||||||
{@render account("destination", "Destination account", "alt+t")}
|
{@render account("destination", "Destination account", "t")}
|
||||||
{#if first}
|
{#if first}
|
||||||
<Field error={err("date")} for={id("date")} keys="alt+w" label="Date">
|
<Field error={err("date")} for={id("date")} keys="w" label="Date">
|
||||||
<DateInput
|
<DateInput
|
||||||
id={id("date")}
|
id={id("date")}
|
||||||
invalid={Boolean(err("date"))}
|
invalid={Boolean(err("date"))}
|
||||||
@@ -245,7 +242,7 @@
|
|||||||
<Field
|
<Field
|
||||||
error={err("amount")}
|
error={err("amount")}
|
||||||
for={id("amount")}
|
for={id("amount")}
|
||||||
keys={keyHint("alt+m")}
|
keys={keyHint("m")}
|
||||||
label="Amount"
|
label="Amount"
|
||||||
>
|
>
|
||||||
<AmountInput
|
<AmountInput
|
||||||
@@ -328,7 +325,7 @@
|
|||||||
<Field
|
<Field
|
||||||
error={err("budget_id")}
|
error={err("budget_id")}
|
||||||
for={id("budget")}
|
for={id("budget")}
|
||||||
keys={keyHint("alt+b")}
|
keys={keyHint("b")}
|
||||||
label="Budget"
|
label="Budget"
|
||||||
>
|
>
|
||||||
<RefField
|
<RefField
|
||||||
@@ -343,7 +340,7 @@
|
|||||||
<Field
|
<Field
|
||||||
error={err("category_name")}
|
error={err("category_name")}
|
||||||
for={id("category")}
|
for={id("category")}
|
||||||
keys={keyHint("alt+k")}
|
keys={keyHint("c")}
|
||||||
label="Category"
|
label="Category"
|
||||||
>
|
>
|
||||||
<AutocompleteInput
|
<AutocompleteInput
|
||||||
@@ -385,7 +382,7 @@
|
|||||||
<Field
|
<Field
|
||||||
error={err("tags")}
|
error={err("tags")}
|
||||||
for={id("tags")}
|
for={id("tags")}
|
||||||
keys={keyHint("alt+g")}
|
keys={keyHint("#")}
|
||||||
label="Tags"
|
label="Tags"
|
||||||
>
|
>
|
||||||
<TagsInput
|
<TagsInput
|
||||||
@@ -409,7 +406,7 @@
|
|||||||
<Field
|
<Field
|
||||||
error={err("notes")}
|
error={err("notes")}
|
||||||
for={id("notes")}
|
for={id("notes")}
|
||||||
keys={keyHint("alt+r")}
|
keys={keyHint("r")}
|
||||||
label="Notes"
|
label="Notes"
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Box {body} class={className} {footer} {title} {tools}>
|
<Box {body} class={className} {footer} {title} {tools}>
|
||||||
<div class="scroll-mt-24" {id}>
|
<div class="scroll-mt-12" {id}>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p class="px-3 py-3 text-cladd-fg-soft">Loading</p>
|
<p class="px-3 py-3 text-cladd-fg-soft">Loading</p>
|
||||||
{:else if empty}
|
{:else if empty}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if drill}
|
{#if drill}
|
||||||
<div
|
<div
|
||||||
class="cladd-surface cladd-outline scroll-mt-24 overflow-hidden rounded-cladd-lg"
|
class="cladd-surface cladd-outline scroll-mt-12 overflow-hidden rounded-cladd-lg"
|
||||||
id="drill-{drill.box}"
|
id="drill-{drill.box}"
|
||||||
>
|
>
|
||||||
<DrillPanel {drill} />
|
<DrillPanel {drill} />
|
||||||
|
|||||||
+48
-5
@@ -13,14 +13,57 @@ const SEQUENCE_MS = 1200;
|
|||||||
const EDITABLE =
|
const EDITABLE =
|
||||||
"input, textarea, select, [contenteditable=''], [contenteditable='true']";
|
"input, textarea, select, [contenteditable=''], [contenteditable='true']";
|
||||||
|
|
||||||
|
const LATIN_LETTER = /^[a-z]$/i;
|
||||||
|
const ANY_LETTER = /^\p{L}$/u;
|
||||||
|
const US_KEYS: Record<string, [string, string]> = {
|
||||||
|
Backquote: ["`", "~"],
|
||||||
|
Backslash: ["\\", "|"],
|
||||||
|
BracketLeft: ["[", "{"],
|
||||||
|
BracketRight: ["]", "}"],
|
||||||
|
Comma: [",", "<"],
|
||||||
|
Digit0: ["0", ")"],
|
||||||
|
Digit1: ["1", "!"],
|
||||||
|
Digit2: ["2", "@"],
|
||||||
|
Digit3: ["3", "#"],
|
||||||
|
Digit4: ["4", "$"],
|
||||||
|
Digit5: ["5", "%"],
|
||||||
|
Digit6: ["6", "^"],
|
||||||
|
Digit7: ["7", "&"],
|
||||||
|
Digit8: ["8", "*"],
|
||||||
|
Digit9: ["9", "("],
|
||||||
|
Equal: ["=", "+"],
|
||||||
|
Minus: ["-", "_"],
|
||||||
|
Period: [".", ">"],
|
||||||
|
Quote: ["'", '"'],
|
||||||
|
Semicolon: [";", ":"],
|
||||||
|
Slash: ["/", "?"],
|
||||||
|
};
|
||||||
|
|
||||||
|
let foreignLayout = false;
|
||||||
|
|
||||||
|
function physicalKey({ code, shiftKey }: KeyboardEvent): string | undefined {
|
||||||
|
if (code.startsWith("Key")) {
|
||||||
|
const letter = code.slice(3);
|
||||||
|
return shiftKey ? letter : letter.toLowerCase();
|
||||||
|
}
|
||||||
|
return US_KEYS[code]?.[shiftKey ? 1 : 0];
|
||||||
|
}
|
||||||
|
|
||||||
function baseKey(event: KeyboardEvent): string {
|
function baseKey(event: KeyboardEvent): string {
|
||||||
if (event.altKey && event.code.startsWith("Key")) {
|
const { key } = event;
|
||||||
return event.code.slice(3).toLowerCase();
|
if (key === " ") {
|
||||||
|
return "Space";
|
||||||
}
|
}
|
||||||
if (event.altKey && event.code.startsWith("Digit")) {
|
if (key.length !== 1) {
|
||||||
return event.code.slice(5);
|
return key;
|
||||||
}
|
}
|
||||||
return event.key === " " ? "Space" : event.key;
|
const foreign = !LATIN_LETTER.test(key) && ANY_LETTER.test(key);
|
||||||
|
const plain = !(event.altKey || event.ctrlKey || event.metaKey);
|
||||||
|
if (plain && (foreign || LATIN_LETTER.test(key))) {
|
||||||
|
foreignLayout = foreign;
|
||||||
|
}
|
||||||
|
const positional = event.altKey || foreign || (plain && foreignLayout);
|
||||||
|
return (positional && physicalKey(event)) || key;
|
||||||
}
|
}
|
||||||
|
|
||||||
function token(event: KeyboardEvent): string {
|
function token(event: KeyboardEvent): string {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { type Binding, useKeys } from "#lib/keys.svelte.js";
|
import { type Binding, useKeys } from "#lib/keys.svelte.js";
|
||||||
|
|
||||||
|
const TOP_REACH = 0.6;
|
||||||
|
|
||||||
interface Options<T> {
|
interface Options<T> {
|
||||||
edit?: (item: T) => void;
|
edit?: (item: T) => void;
|
||||||
extra?: () => Binding[];
|
extra?: () => Binding[];
|
||||||
@@ -56,9 +58,22 @@ export class ListNav<T> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.cursor = Math.max(0, Math.min(count - 1, to));
|
this.cursor = Math.max(0, Math.min(count - 1, to));
|
||||||
document
|
this.reveal("nearest");
|
||||||
.getElementById(this.rowId(this.cursor))
|
}
|
||||||
?.scrollIntoView({ block: "nearest" });
|
|
||||||
|
reveal(block: ScrollLogicalPosition) {
|
||||||
|
const row = document.getElementById(this.rowId(this.cursor));
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nearTop =
|
||||||
|
row.getBoundingClientRect().top + window.scrollY <
|
||||||
|
window.innerHeight * TOP_REACH;
|
||||||
|
if (this.cursor === 0 && block === "nearest" && nearTop) {
|
||||||
|
window.scrollTo({ top: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
row.scrollIntoView({ block });
|
||||||
}
|
}
|
||||||
|
|
||||||
toggle(item: T | undefined = this.current) {
|
toggle(item: T | undefined = this.current) {
|
||||||
@@ -164,6 +179,24 @@ export class ListNav<T> {
|
|||||||
label: "Last row",
|
label: "Last row",
|
||||||
run: () => this.move(this.items.length - 1),
|
run: () => this.move(this.items.length - 1),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
group: "List",
|
||||||
|
keys: "z z",
|
||||||
|
label: "Scroll row to the middle",
|
||||||
|
run: () => this.reveal("center"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "List",
|
||||||
|
keys: "z t",
|
||||||
|
label: "Scroll row to the top",
|
||||||
|
run: () => this.reveal("start"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "List",
|
||||||
|
keys: "z b",
|
||||||
|
label: "Scroll row to the bottom",
|
||||||
|
run: () => this.reveal("end"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
group: "List",
|
group: "List",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
|
|||||||
@@ -139,27 +139,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: "Groups",
|
group: "Groups",
|
||||||
keys: "alt+j",
|
keys: "J",
|
||||||
label: "Move group down",
|
label: "Move group down",
|
||||||
run: () => move(1),
|
run: () => move(1),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: "Groups",
|
group: "Groups",
|
||||||
keys: "alt+k",
|
keys: "K",
|
||||||
label: "Move group up",
|
|
||||||
run: () => move(-1),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: "Groups",
|
|
||||||
hidden: true,
|
|
||||||
keys: "alt+∆",
|
|
||||||
label: "Move group down",
|
|
||||||
run: () => move(1),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: "Groups",
|
|
||||||
hidden: true,
|
|
||||||
keys: "alt+˚",
|
|
||||||
label: "Move group up",
|
label: "Move group up",
|
||||||
run: () => move(-1),
|
run: () => move(-1),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -132,27 +132,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: "Piggy banks",
|
group: "Piggy banks",
|
||||||
keys: "alt+j",
|
keys: "J",
|
||||||
label: "Move down in group",
|
label: "Move down in group",
|
||||||
run: () => move(1),
|
run: () => move(1),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: "Piggy banks",
|
group: "Piggy banks",
|
||||||
keys: "alt+k",
|
keys: "K",
|
||||||
label: "Move up in group",
|
|
||||||
run: () => move(-1),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: "Piggy banks",
|
|
||||||
hidden: true,
|
|
||||||
keys: "alt+∆",
|
|
||||||
label: "Move down in group",
|
|
||||||
run: () => move(1),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: "Piggy banks",
|
|
||||||
hidden: true,
|
|
||||||
keys: "alt+˚",
|
|
||||||
label: "Move up in group",
|
label: "Move up in group",
|
||||||
run: () => move(-1),
|
run: () => move(-1),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -181,27 +181,13 @@
|
|||||||
extra: () => [
|
extra: () => [
|
||||||
{
|
{
|
||||||
group: "Subscriptions",
|
group: "Subscriptions",
|
||||||
keys: "alt+j",
|
keys: "J",
|
||||||
label: "Move down in group",
|
label: "Move down in group",
|
||||||
run: () => move(1),
|
run: () => move(1),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: "Subscriptions",
|
group: "Subscriptions",
|
||||||
keys: "alt+k",
|
keys: "K",
|
||||||
label: "Move up in group",
|
|
||||||
run: () => move(-1),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: "Subscriptions",
|
|
||||||
hidden: true,
|
|
||||||
keys: "alt+∆",
|
|
||||||
label: "Move down in group",
|
|
||||||
run: () => move(1),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: "Subscriptions",
|
|
||||||
hidden: true,
|
|
||||||
keys: "alt+˚",
|
|
||||||
label: "Move up in group",
|
label: "Move up in group",
|
||||||
run: () => move(-1),
|
run: () => move(-1),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user