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} -
-
- {#each lazy_data.fields as _, field_index} - { - dragged_header = field_index; - }} - ondragover={(ev) => { - // Enable element as drop target - ev.preventDefault(); - }} - ondrop={(ev) => { - ev.preventDefault(); - if (dragged_header !== undefined) { - update_field_ordinality({ - field_index: dragged_header, - beyond_index: field_index, - }); - } - }} - onresize={(new_width_px) => { - update_field_table_width_px(field_index, new_width_px); - }} - /> - {/each} -
- -
-
-
- {#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} -
-
-
-

- Insert rows (press "shift + enter" to jump here or add a row) -

-
-
- {@render table_region({ - region: "inserter", - rows: inserter_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); - }, - })} -
- -
- {#each inserter_rows as row} - {#each lazy_data.fields as field, field_index} - - {/each} - {/each} -
-
-
- {#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} + +
+
+ {#each fields as _, field_index} + { + dragged_header = field_index; + }} + ondragover={(ev) => { + // Enable element as drop target + ev.preventDefault(); + }} + ondrop={(ev) => { + ev.preventDefault(); + if (dragged_header !== undefined) { + update_field_ordinality({ + field_index: dragged_header, + beyond_index: field_index, + }); + } + }} + onresize={(new_width_px) => { + update_field_table_width_px(field_index, new_width_px); + }} + /> + {/each} +
+ +
+
+
+ {#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} +
+
+
+

+ Insert rows (press "shift + enter" to jump here or add a row) +

+
+
+ {@render table_region({ + region: "inserter", + rows: rows_inserter, + 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); + }, + })} +
+ +
+ {#each rows_inserter as row} + {#each fields as field, field_index} + + {/each} + {/each} +
+
+
+ {#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",