diff --git a/deno.lock b/deno.lock
index 1c44806..2084d45 100644
--- a/deno.lock
+++ b/deno.lock
@@ -10,16 +10,17 @@
"jsr:@std/uuid@*": "1.0.9",
"jsr:@std/uuid@^1.0.9": "1.0.9",
"npm:@date-fns/utc@^2.1.1": "2.1.1",
- "npm:@deno/vite-plugin@^1.0.5": "1.0.5_vite@7.1.2__picomatch@4.0.3_sass-embedded@1.91.0",
- "npm:@sveltejs/vite-plugin-svelte@^6.1.1": "6.1.1_svelte@5.38.1__acorn@8.15.0_vite@7.1.2__picomatch@4.0.3_sass-embedded@1.91.0",
+ "npm:@deno/vite-plugin@^1.0.5": "1.0.5_vite@7.1.2__picomatch@4.0.3",
+ "npm:@sveltejs/vite-plugin-svelte@^6.1.1": "6.1.1_svelte@5.38.1__acorn@8.15.0_vite@7.1.2__picomatch@4.0.3",
"npm:@tsconfig/svelte@^5.0.4": "5.0.4",
+ "npm:attq@0.2": "0.2.0",
"npm:date-fns@^4.1.0": "4.1.0",
"npm:svelte-check@^4.3.1": "4.3.1_svelte@5.38.1__acorn@8.15.0_typescript@5.8.3",
"npm:svelte-language-server@~0.17.19": "0.17.19_prettier@3.3.3_svelte@4.2.20_typescript@5.9.2",
"npm:svelte@^5.37.3": "5.38.1_acorn@8.15.0",
"npm:typescript@~5.8.3": "5.8.3",
"npm:uuid@^11.1.0": "11.1.0",
- "npm:vite@^7.1.1": "7.1.2_picomatch@4.0.3_sass-embedded@1.91.0",
+ "npm:vite@^7.1.1": "7.1.2_picomatch@4.0.3",
"npm:zod@^4.0.17": "4.0.17"
},
"jsr": {
@@ -69,7 +70,7 @@
"@date-fns/utc@2.1.1": {
"integrity": "sha512-SlJDfG6RPeEX8wEVv6ZB3kak4MmbtyiI2qX/5zuKdordbrhB/iaJ58GVMZgJ6P1sJaM1gMgENFYYeg1JWrCFrA=="
},
- "@deno/vite-plugin@1.0.5_vite@7.1.2__picomatch@4.0.3_sass-embedded@1.91.0": {
+ "@deno/vite-plugin@1.0.5_vite@7.1.2__picomatch@4.0.3": {
"integrity": "sha512-tLja5n4dyMhcze1NzvSs2iiriBymfBlDCZIrjMTxb9O2ru0gvmV6mn5oBD2teNw5Sd92cj3YJzKwsAs8tMJXlg==",
"dependencies": [
"vite"
@@ -443,7 +444,7 @@
"acorn"
]
},
- "@sveltejs/vite-plugin-svelte-inspector@5.0.0_@sveltejs+vite-plugin-svelte@6.1.1__svelte@5.38.1___acorn@8.15.0__vite@7.1.2___picomatch@4.0.3_svelte@5.38.1__acorn@8.15.0_vite@7.1.2__picomatch@4.0.3_sass-embedded@1.91.0": {
+ "@sveltejs/vite-plugin-svelte-inspector@5.0.0_@sveltejs+vite-plugin-svelte@6.1.1__svelte@5.38.1___acorn@8.15.0__vite@7.1.2___picomatch@4.0.3_svelte@5.38.1__acorn@8.15.0_vite@7.1.2__picomatch@4.0.3": {
"integrity": "sha512-iwQ8Z4ET6ZFSt/gC+tVfcsSBHwsqc6RumSaiLUkAurW3BCpJam65cmHw0oOlDMTO0u+PZi9hilBRYN+LZNHTUQ==",
"dependencies": [
"@sveltejs/vite-plugin-svelte",
@@ -452,7 +453,7 @@
"vite"
]
},
- "@sveltejs/vite-plugin-svelte@6.1.1_svelte@5.38.1__acorn@8.15.0_vite@7.1.2__picomatch@4.0.3_sass-embedded@1.91.0": {
+ "@sveltejs/vite-plugin-svelte@6.1.1_svelte@5.38.1__acorn@8.15.0_vite@7.1.2__picomatch@4.0.3": {
"integrity": "sha512-vB0Vq47Js7C11L2JrwhncIAoDNkdKDPI500SjLSb34X48dDcsSH5JpLl0cHT0sfO997BrzAS6PKjiZEey/S0VQ==",
"dependencies": [
"@sveltejs/vite-plugin-svelte-inspector",
@@ -492,6 +493,9 @@
"aria-query@5.3.2": {
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="
},
+ "attq@0.2.0": {
+ "integrity": "sha512-I6QcR5BrweKK2HuzFovudxHFNRjcLjYM3HyE+9ufdPxBivU6ZL4TUwSO7C59odsz6HRqp1J0VXnta7FjwSwQEw=="
+ },
"axobject-query@4.1.0": {
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="
},
@@ -613,10 +617,10 @@
"fdir@6.4.6_picomatch@4.0.3": {
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
"dependencies": [
- "picomatch@4.0.3"
+ "picomatch"
],
"optionalPeers": [
- "picomatch@4.0.3"
+ "picomatch"
]
},
"fill-range@7.1.1": {
@@ -687,8 +691,7 @@
"micromatch@4.0.8": {
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dependencies": [
- "braces",
- "picomatch@2.3.1"
+ "braces"
]
},
"mri@1.2.0": {
@@ -729,9 +732,6 @@
"picocolors@1.1.1": {
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
- "picomatch@2.3.1": {
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="
- },
"picomatch@4.0.3": {
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="
},
@@ -1050,7 +1050,7 @@
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"dependencies": [
"fdir",
- "picomatch@4.0.3"
+ "picomatch"
]
},
"to-regex-range@5.0.1": {
@@ -1083,12 +1083,12 @@
"varint@6.0.0": {
"integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg=="
},
- "vite@7.1.2_picomatch@4.0.3_sass-embedded@1.91.0": {
+ "vite@7.1.2_picomatch@4.0.3": {
"integrity": "sha512-J0SQBPlQiEXAF7tajiH+rUooJPo0l8KQgyg4/aMunNtrOa7bwuZJsJbDWzeljqQpgftxuq5yNJxQ91O9ts29UQ==",
"dependencies": [
"esbuild",
"fdir",
- "picomatch@4.0.3",
+ "picomatch",
"postcss",
"rollup",
"sass-embedded",
@@ -1102,7 +1102,7 @@
],
"bin": true
},
- "vitefu@1.1.1_vite@7.1.2__picomatch@4.0.3_sass-embedded@1.91.0": {
+ "vitefu@1.1.1_vite@7.1.2__picomatch@4.0.3": {
"integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
"dependencies": [
"vite"
@@ -1180,6 +1180,7 @@
"npm:@deno/vite-plugin@^1.0.5",
"npm:@sveltejs/vite-plugin-svelte@^6.1.1",
"npm:@tsconfig/svelte@^5.0.4",
+ "npm:attq@0.2",
"npm:date-fns@^4.1.0",
"npm:svelte-check@^4.3.1",
"npm:svelte-language-server@~0.17.19",
diff --git a/package.json b/package.json
index f408c25..0c6c968 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
"@date-fns/utc": "^2.1.1",
"@deno/vite-plugin": "^1.0.5",
"@sveltejs/vite-plugin-svelte": "^6.1.1",
+ "attq": "^0.2.0",
"date-fns": "^4.1.0",
"svelte-language-server": "^0.17.19",
"uuid": "^11.1.0",
diff --git a/svelte/src/table-viewer.webc.svelte b/svelte/src/table-viewer.webc.svelte
deleted file mode 100644
index c9f7ba1..0000000
--- a/svelte/src/table-viewer.webc.svelte
+++ /dev/null
@@ -1,776 +0,0 @@
-
-
-
-
-{#snippet table_region({
- region,
- rows,
- on_cell_click,
-}: {
- region: "main" | "inserter";
- rows: Row[];
- on_cell_click(ev: MouseEvent, coords: Coords): void;
-})}
- {#if lazy_data}
- {#each rows as row, row_idx}
-
- {#each lazy_data.fields as field, field_idx}
-
{
- focus_cursor = focus;
- }}
- ondblclick={() => datum_editor?.focus()}
- onkeydown={(ev) => handle_cell_keydown(ev)}
- onmousedown={on_cell_click}
- onpaste={handle_cell_paste}
- selected={selections.some(
- (sel) =>
- sel.coords.region === region &&
- coords_eq(sel.coords, { region, row_idx, field_idx }),
- )}
- table_region={region}
- value={row.data[field_idx]}
- />
- {/each}
-
- {/each}
- {/if}
-{/snippet}
-
-
- {#if lazy_data}
-
-
-
- {#if subfilter && subfilter !== "null"}
-
- {/if}
-
- {@render table_region({
- region: "main",
- rows: lazy_data.rows,
- on_cell_click: (ev: MouseEvent, coords: Coords) => {
- // Must wait out `BLUR_DEBOUNCE_MS` before switching selection to
- // avoid committing updates to the wrong cells. Refer to docs for
- // `BLUR_DEBOUNCE_MS`.
- setTimeout(() => {
- if (ev.metaKey || ev.ctrlKey) {
- set_selections([
- coords,
- ...selections
- .filter((sel) => !coords_eq(sel.coords, coords))
- .map((sel) => sel.coords),
- ]);
- } else if (ev.shiftKey) {
- move_cursor(coords, { additive: true });
- } else {
- move_cursor(coords);
- }
- }, BLUR_DEBOUNCE_MS + 1);
- },
- })}
-
-
- {lazy_data.count} records total
- {#if lazy_data.count > lazy_data.rows.length}
- ({lazy_data.count - lazy_data.rows.length} hidden; use filters to narrow
- your search)
- {/if}
-
-
-
-
-
- {#if selections.length !== 0 && selections.every(({ coords: { field_idx } }) => field_idx === selections[0]?.coords.field_idx)}
- try_queue_delta()}
- on_cancel_edit={cancel_edit}
- on_change={() => {
- try_sync_edit_to_cells();
- }}
- on_restore_focus={handle_restore_focus}
- />
- {/if}
-
- {/if}
-
diff --git a/svelte/src/coords.svelte.ts b/svelte/src/table-viewer.webc/coords.svelte.ts
similarity index 100%
rename from svelte/src/coords.svelte.ts
rename to svelte/src/table-viewer.webc/coords.svelte.ts
diff --git a/svelte/src/field-adder.svelte b/svelte/src/table-viewer.webc/field-adder.svelte
similarity index 98%
rename from svelte/src/field-adder.svelte
rename to svelte/src/table-viewer.webc/field-adder.svelte
index a4f47ea..2db65c4 100644
--- a/svelte/src/field-adder.svelte
+++ b/svelte/src/table-viewer.webc/field-adder.svelte
@@ -16,11 +16,11 @@ submission.
incompatible with the current presentation configuration.-->
+
+{#snippet table_region({
+ region,
+ rows,
+ on_cell_click,
+}: {
+ region: "main" | "inserter";
+ rows: Row[];
+ on_cell_click(ev: MouseEvent, coords: Coords): void;
+})}
+ {#each rows as row, row_idx}
+
+ {#each fields as field, field_idx}
+
{
+ focus_cursor = focus;
+ }}
+ ondblclick={() => datum_editor?.focus()}
+ onkeydown={(ev) => handle_cell_keydown(ev)}
+ onmousedown={on_cell_click}
+ onpaste={handle_cell_paste}
+ selected={selections.some(
+ (sel) =>
+ sel.coords.region === region &&
+ coords_eq(sel.coords, { region, row_idx, field_idx }),
+ )}
+ table_region={region}
+ value={row.data[field_idx]}
+ />
+ {/each}
+
+ {/each}
+{/snippet}
+
+
+
+
+ {#if subfilter_active}
+
+ {/if}
+
+ {@render table_region({
+ region: "main",
+ rows: rows_main,
+ on_cell_click: (ev: MouseEvent, coords: Coords) => {
+ // Must wait out `BLUR_DEBOUNCE_MS` before switching selection to
+ // avoid committing updates to the wrong cells. Refer to docs for
+ // `BLUR_DEBOUNCE_MS`.
+ setTimeout(() => {
+ if (ev.metaKey || ev.ctrlKey) {
+ set_selections([
+ coords,
+ ...selections
+ .filter((sel) => !coords_eq(sel.coords, coords))
+ .map((sel) => sel.coords),
+ ]);
+ } else if (ev.shiftKey) {
+ move_cursor(coords, { additive: true });
+ } else {
+ move_cursor(coords);
+ }
+ }, BLUR_DEBOUNCE_MS + 1);
+ },
+ })}
+
+
+ {total_count} records total
+ {#if total_count > rows_main.length}
+ ({total_count - rows_main.length} hidden; use filters to narrow your search)
+ {/if}
+
+
+
+
+
+ {#if selections.length !== 0 && selections.every(({ coords: { field_idx } }) => field_idx === selections[0]?.coords.field_idx)}
+ try_queue_delta()}
+ on_cancel_edit={cancel_edit}
+ on_change={() => {
+ try_sync_edit_to_cells();
+ }}
+ on_restore_focus={handle_restore_focus}
+ />
+ {/if}
+
diff --git a/svelte/src/table-viewer.webc/index.svelte b/svelte/src/table-viewer.webc/index.svelte
new file mode 100644
index 0000000..077f525
--- /dev/null
+++ b/svelte/src/table-viewer.webc/index.svelte
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+ {#if lazy_data}
+
+ {/if}
+
diff --git a/svelte/src/table-cell.svelte b/svelte/src/table-viewer.webc/table-cell.svelte
similarity index 98%
rename from svelte/src/table-cell.svelte
rename to svelte/src/table-viewer.webc/table-cell.svelte
index f3b1200..43775be 100644
--- a/svelte/src/table-cell.svelte
+++ b/svelte/src/table-viewer.webc/table-cell.svelte
@@ -2,9 +2,9 @@
import { utc } from "@date-fns/utc";
import { format as format_date } from "date-fns";
+ import { type Datum } from "../datum.svelte";
+ import { type FieldInfo } from "../field.svelte";
import { type Coords } from "./coords.svelte";
- import { type Datum } from "./datum.svelte";
- import { type FieldInfo } from "./field.svelte";
type Props = {
coords: Coords;
diff --git a/svelte/src/table-viewer.webc/undo-stack.svelte.ts b/svelte/src/table-viewer.webc/undo-stack.svelte.ts
new file mode 100644
index 0000000..d7a7ebc
--- /dev/null
+++ b/svelte/src/table-viewer.webc/undo-stack.svelte.ts
@@ -0,0 +1,82 @@
+export type Undoable = {
+ loc: L;
+ value_initial: T;
+ value_updated: T;
+};
+
+export class UndoStack> {
+ /**
+ * All undo- and redo-able diffs, in order.
+ */
+ private _diffs: T[][] = [];
+
+ /**
+ * Index of last delta currently represented in application state. Decrements
+ * when undoing; increments when pushing or redoing. -1 when stack is empty.
+ */
+ private _cursor: number = -1;
+
+ private readonly _apply_diff: (diff: T[]) => unknown;
+
+ /**
+ * NOTE: `apply_diff()` is permitted to mutate the array it receives as a
+ * parameter, but otherwise `UndoStack` expects that the deltas it receives
+ * and provides will not be tampered with. Mutating arrays after providing
+ * them to `.push()` or mutating the individual array items supplied to
+ * `apply_diff()`, for example, is considered undefined behavior.
+ */
+ constructor(
+ { apply_diff }: { apply_diff(deltas: T[]): unknown },
+ ) {
+ // Always shallow copy array so that callback may perform some mutations.
+ this._apply_diff = (diff: T[]) => apply_diff([...diff]);
+ }
+
+ /**
+ * Pushes a batch of deltas to the end of the undo stack, and clears the redo
+ * stack. `apply_diff()` will be immediately called with a copy of `deltas`.
+ */
+ push(diff: T[]): undefined {
+ // Clear redo stack. `Array.splice()` does nothing if start index > array
+ // length, and `this._cursor` is always >= -1, so no conditional needed.
+ this._diffs.splice(this._cursor + 1);
+
+ this._diffs.push(diff);
+ this._cursor += 1;
+
+ // Call `_apply_diff()` after shifting cursor, in case it recursively
+ // mutates this UndoStack.
+ this._apply_diff(diff);
+ }
+
+ undo(): undefined {
+ if (this._cursor > -1) {
+ this._cursor -= 1;
+
+ // Call `_apply_diff()` after shifting cursor, in case it recursively
+ // mutates this UndoStack.
+ this._apply_diff(this._diffs[this._cursor + 1].map(invert));
+ }
+ }
+
+ redo(): undefined {
+ if (this._diffs.length > this._cursor + 1) {
+ this._cursor += 1;
+
+ // Call `_apply_diff()` after shifting cursor, in case it recursively
+ // mutates this UndoStack.
+ this._apply_diff(this._diffs[this._cursor]);
+ }
+ }
+}
+
+/**
+ * Returns a copy of the parameter with initial and updated values swapped.
+ */
+function invert>(undoable: T): T {
+ return {
+ loc: undoable.loc,
+ value_initial: undoable.value_updated,
+ value_updated: undoable.value_initial,
+ } as T;
+}
diff --git a/svelte/vite.config.ts b/svelte/vite.config.ts
index 72d7bf7..4fe36bc 100644
--- a/svelte/vite.config.ts
+++ b/svelte/vite.config.ts
@@ -7,11 +7,20 @@ export default defineConfig({
plugins: [svelte()],
build: {
rollupOptions: {
- input: [
+ input: Object.fromEntries([
...Deno.readDirSync("./src")
- .filter(({ name }) => name.endsWith(".webc.svelte"))
- .map(({ name }) => path.join("./src", name)),
- ],
+ .filter(({ isFile, name }) => isFile && name.endsWith(".webc.svelte"))
+ .map((
+ { name },
+ ) => [name.replace(/\.svelte$/, ""), path.join("./src", name)]),
+ ...Deno.readDirSync("./src")
+ .filter(({ isDirectory, name }) =>
+ isDirectory && name.endsWith(".webc")
+ )
+ .map((
+ { name },
+ ) => [name, path.join("./src", name, "index.svelte")]),
+ ]),
output: {
dir: path.fromFileUrl(new URL("../js_dist", import.meta.url)),
entryFileNames: "[name].mjs",