clean up undo/redo implementation

This commit is contained in:
Brent Schroeter 2026-02-09 22:02:06 +00:00
parent cd54937573
commit be3928830a
3 changed files with 66 additions and 153 deletions

View file

@ -1,47 +0,0 @@
# Svelte + TS + Vite
This template should help get you started developing with Svelte and TypeScript in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
## Need an official Svelte framework?
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
## Technical considerations
**Why use this over SvelteKit?**
- It brings its own routing solution which might not be preferable for some users.
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
**Why include `.vscode/extensions.json`?**
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
**Why enable `allowJs` in the TS template?**
While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant.
**Why is HMR not preserving my local component state?**
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr).
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
```ts
// store.ts
// An extremely simple external store
import { writable } from 'svelte/store'
export default writable(0)
```

View file

@ -6,12 +6,13 @@ commit queue, datum editor, and field headers.
--> -->
<script lang="ts"> <script lang="ts">
import { AsyncTaskQueue, withRetry } from "attq";
import { type Datum, parse_datum_from_text } from "../datum.svelte"; import { type Datum, parse_datum_from_text } from "../datum.svelte";
import DatumEditor from "../datum-editor.svelte"; import DatumEditor from "../datum-editor.svelte";
import { BLUR_DEBOUNCE_MS } from "../datum-editor-common.svelte"; import { BLUR_DEBOUNCE_MS } from "../datum-editor-common.svelte";
import { type Row, type FieldInfo } from "../field.svelte"; import { type Row, type FieldInfo } from "../field.svelte";
import { get_empty_datum_for } from "../presentation.svelte"; import { get_empty_datum_for } from "../presentation.svelte";
import { UndoStack, type Undoable } from "./undo-stack.svelte";
import { import {
type Coords, type Coords,
coords_eq, coords_eq,
@ -21,7 +22,7 @@ commit queue, datum editor, and field headers.
import FieldAdder from "./field-adder.svelte"; import FieldAdder from "./field-adder.svelte";
import FieldHeader from "./field-header.svelte"; import FieldHeader from "./field-header.svelte";
import TableCell from "./table-cell.svelte"; import TableCell from "./table-cell.svelte";
import { AsyncTaskQueue, withRetry } from "attq"; import { invert_diff, type Undoable, UndoStack } from "./undo-stack.svelte";
type Props = { type Props = {
columns: { columns: {
@ -42,14 +43,9 @@ commit queue, datum editor, and field headers.
total_count, total_count,
}: Props = $props(); }: Props = $props();
type Selection = {
coords: Coords;
original_value: Datum;
};
type Delta = Undoable<Coords, Datum>; type Delta = Undoable<Coords, Datum>;
let selections = $state<Selection[]>([]); let selections = $state<Delta[]>([]);
let editor_value = $state<Datum | undefined>(undefined); let editor_value = $state<Datum | undefined>(undefined);
let datum_editor = $state<DatumEditor | undefined>(); let datum_editor = $state<DatumEditor | undefined>();
let focus_cursor = $state<(() => unknown) | undefined>(); let focus_cursor = $state<(() => unknown) | undefined>();
@ -95,15 +91,7 @@ commit queue, datum editor, and field headers.
// the UI and as replayed to the server side. // the UI and as replayed to the server side.
const undo_stack = new UndoStack<Delta>({ const undo_stack = new UndoStack<Delta>({
apply_diff: (diff) => { apply_diff: (diff) => {
for (const { set_cell_values(diff);
loc: { region, row_idx, field_idx },
value_updated,
} of diff) {
// TODO: Does reactivity work with a ternary on the lhs?
(region === "main" ? rows_main : rows_inserter)[row_idx].data[
field_idx
] = value_updated;
}
upload_queue.push(diff); upload_queue.push(diff);
}, },
}); });
@ -202,6 +190,16 @@ commit queue, datum editor, and field headers.
// -------- Updates and Effects -------- // // -------- Updates and Effects -------- //
function set_cell_values(diff: Delta[]) {
for (const {
loc: { region, row_idx, field_idx },
value_updated,
} of diff) {
(region === "main" ? rows_main : rows_inserter)[row_idx].data[field_idx] =
value_updated;
}
}
function set_selections(arr: Coords[]) { function set_selections(arr: Coords[]) {
selections = arr.map((coords) => { selections = arr.map((coords) => {
let cell_data: Datum | undefined; let cell_data: Datum | undefined;
@ -212,7 +210,11 @@ commit queue, datum editor, and field headers.
} else { } else {
throw new Error(`invalid region: ${coords.region}`); throw new Error(`invalid region: ${coords.region}`);
} }
return { coords, original_value: cell_data! }; return {
loc: coords,
value_initial: cell_data!,
value_updated: cell_data!,
};
}); });
if (arr.length === 1) { if (arr.length === 1) {
const [coords] = arr; const [coords] = arr;
@ -241,23 +243,22 @@ commit queue, datum editor, and field headers.
if ( if (
additive && additive &&
first_selection !== undefined && first_selection !== undefined &&
!coords_eq(new_cursor, first_selection.coords) !coords_eq(new_cursor, first_selection.loc)
) { ) {
// By convention, we keep the first selected cell at the end of the // By convention, we keep the first selected cell at the end of the
// selections array, and the current cursor at the beginning. Everything // selections array, and the current cursor at the beginning. Everything
// in the bounded box should be populated in between. // in the bounded box should be populated in between.
set_selections([ set_selections([
new_cursor, new_cursor,
...get_box(first_selection.coords, new_cursor, { ...get_box(first_selection.loc, new_cursor, {
n_fields: fields.length, n_fields: fields.length,
n_rows_main: rows_main.length, n_rows_main: rows_main.length,
n_rows_inserter: rows_inserter.length, n_rows_inserter: rows_inserter.length,
}).filter( }).filter(
(sel) => (sel) =>
!coords_eq(sel, new_cursor) && !coords_eq(sel, new_cursor) && !coords_eq(sel, first_selection.loc),
!coords_eq(sel, first_selection.coords),
), ),
first_selection.coords, first_selection.loc,
]); ]);
} else { } else {
set_selections([new_cursor]); set_selections([new_cursor]);
@ -265,28 +266,16 @@ commit queue, datum editor, and field headers.
} }
function try_sync_edit_to_cells() { function try_sync_edit_to_cells() {
if (selections.length === 0) {
console.warn("preconditions for try_sync_edit_to_cells() not met");
return;
}
// Copy value locally so that it can be used intuitively in closures. // Copy value locally so that it can be used intuitively in closures.
const editor_value_scoped = editor_value; const editor_value_scoped = editor_value;
if (editor_value_scoped === undefined) { if (editor_value_scoped !== undefined) {
return; // Editor state represents a valid cell value.
} selections = selections.map((sel) => ({
for (const sel of selections) { ...sel,
// TODO: Refactor into `set_cell_values` function or similar to avoid value_updated: editor_value_scoped,
// duplicating work with `apply_diffs()` callback of `undo_stack`. }));
if (sel.coords.region === "main") {
rows_main[sel.coords.row_idx].data[sel.coords.field_idx] =
editor_value_scoped;
} else if (sel.coords.region === "inserter") {
rows_inserter[sel.coords.row_idx].data[sel.coords.field_idx] =
editor_value_scoped;
} else {
throw new Error("Unknown region");
}
} }
set_cell_values(selections);
} }
function try_queue_delta() { function try_queue_delta() {
@ -301,9 +290,9 @@ commit queue, datum editor, and field headers.
} else { } else {
if (selections.length > 0) { if (selections.length > 0) {
undo_stack.push( undo_stack.push(
selections.map(({ coords, original_value }) => ({ selections.map(({ loc, value_initial }) => ({
loc: coords, loc,
value_initial: original_value, value_initial,
value_updated: editor_value_scoped, value_updated: editor_value_scoped,
})), })),
); );
@ -316,17 +305,9 @@ commit queue, datum editor, and field headers.
} }
function cancel_edit() { function cancel_edit() {
selections.forEach(({ coords, original_value }) => { set_cell_values(invert_diff(selections));
if (coords.region === "main") {
rows_main[coords.row_idx].data[coords.field_idx] = original_value;
} else if (coords.region === "inserter") {
rows_inserter[coords.row_idx].data[coords.field_idx] = original_value;
} else {
throw new Error("Unknown region");
}
});
// Reset editor input value // Reset editor input value
set_selections(selections.map(({ coords }) => coords)); set_selections(selections.map(({ loc }) => loc));
} }
// -------- Event Handlers -------- // // -------- Event Handlers -------- //
@ -340,16 +321,11 @@ commit queue, datum editor, and field headers.
const row_offset = const row_offset =
arrow_direction === "Down" ? 1 : arrow_direction === "Up" ? -1 : 0; arrow_direction === "Down" ? 1 : arrow_direction === "Up" ? -1 : 0;
const cursor = selections[0]; const cursor = selections[0];
const new_cursor = offset_coords( const new_cursor = offset_coords(cursor.loc, row_offset, field_offset, {
cursor.coords, n_fields: fields.length,
row_offset, n_rows_main: rows_main.length,
field_offset, n_rows_inserter: rows_inserter.length,
{ });
n_fields: fields.length,
n_rows_main: rows_main.length,
n_rows_inserter: rows_inserter.length,
},
);
if (ev.shiftKey) { if (ev.shiftKey) {
move_cursor(new_cursor, { additive: true }); move_cursor(new_cursor, { additive: true });
} else { } else {
@ -370,19 +346,19 @@ commit queue, datum editor, and field headers.
const sel = selections[0]; const sel = selections[0];
if (sel) { if (sel) {
editor_value = get_empty_datum_for( editor_value = get_empty_datum_for(
fields[sel.coords.field_idx].field.presentation, fields[sel.loc.field_idx].field.presentation,
); );
datum_editor?.focus(); datum_editor?.focus();
try_sync_edit_to_cells(); try_sync_edit_to_cells();
} }
} else if (ev.key === "Enter") { } else if (ev.key === "Enter") {
if (ev.shiftKey) { if (ev.shiftKey) {
if (selections[0]?.coords.region === "main") { if (selections[0]?.loc.region === "main") {
set_selections([ set_selections([
{ {
region: "inserter", region: "inserter",
row_idx: 0, row_idx: 0,
field_idx: selections[0]?.coords.field_idx ?? 0, field_idx: selections[0]?.loc.field_idx ?? 0,
}, },
]); ]);
} else { } else {
@ -430,17 +406,17 @@ commit queue, datum editor, and field headers.
return; return;
} }
const top_left: Coords = { const top_left: Coords = {
region: selections.some(({ coords: { region } }) => region === "main") region: selections.some(({ loc: { region } }) => region === "main")
? "main" ? "main"
: "inserter", : "inserter",
field_idx: Math.min( field_idx: Math.min(
...selections.map(({ coords: { field_idx } }) => field_idx), ...selections.map(({ loc: { field_idx } }) => field_idx),
), ),
row_idx: Math.min( row_idx: Math.min(
...(selections.some(({ coords: { region } }) => region === "main") ...(selections.some(({ loc: { region } }) => region === "main")
? selections.filter(({ coords: { region } }) => region === "main") ? selections.filter(({ loc: { region } }) => region === "main")
: selections : selections
).map(({ coords: { row_idx } }) => row_idx), ).map(({ loc: { row_idx } }) => row_idx),
), ),
}; };
const fields_to_right = fields.slice(top_left.field_idx); const fields_to_right = fields.slice(top_left.field_idx);
@ -490,22 +466,6 @@ commit queue, datum editor, and field headers.
}), }),
), ),
); );
parsed_tsv.forEach((row, i) =>
row.map((value, j) => {
const coords = offset_coords(top_left, i, j, {
n_fields: fields.length,
n_rows_main: rows_main.length,
n_rows_inserter: rows_inserter.length,
});
if (coords.region === "main") {
rows_main[coords.row_idx].data[coords.field_idx] = value;
} else if (coords.region === "inserter") {
rows_inserter[coords.row_idx].data[coords.field_idx] = value;
} else {
throw new Error("Unknown region");
}
}),
);
} }
// TODO: pasting into multiple selections // TODO: pasting into multiple selections
} }
@ -537,7 +497,7 @@ commit queue, datum editor, and field headers.
<TableCell <TableCell
coords={{ region, row_idx, field_idx }} coords={{ region, row_idx, field_idx }}
cursor={selections.length !== 0 && cursor={selections.length !== 0 &&
coords_eq(selections[0].coords, { coords_eq(selections[0].loc, {
region, region,
row_idx, row_idx,
field_idx, field_idx,
@ -552,8 +512,8 @@ commit queue, datum editor, and field headers.
onpaste={handle_cell_paste} onpaste={handle_cell_paste}
selected={selections.some( selected={selections.some(
(sel) => (sel) =>
sel.coords.region === region && sel.loc.region === region &&
coords_eq(sel.coords, { region, row_idx, field_idx }), coords_eq(sel.loc, { region, row_idx, field_idx }),
)} )}
table_region={region} table_region={region}
value={row.data[field_idx]} value={row.data[field_idx]}
@ -613,8 +573,8 @@ commit queue, datum editor, and field headers.
set_selections([ set_selections([
coords, coords,
...selections ...selections
.filter((sel) => !coords_eq(sel.coords, coords)) .filter(({ loc }) => !coords_eq(loc, coords))
.map((sel) => sel.coords), .map(({ loc }) => loc),
]); ]);
} else if (ev.shiftKey) { } else if (ev.shiftKey) {
move_cursor(coords, { additive: true }); move_cursor(coords, { additive: true });
@ -650,8 +610,8 @@ commit queue, datum editor, and field headers.
set_selections([ set_selections([
coords, coords,
...selections ...selections
.filter((sel) => !coords_eq(sel.coords, coords)) .filter(({ loc }) => !coords_eq(loc, coords))
.map((sel) => sel.coords), .map(({ loc }) => loc),
]); ]);
} else if (ev.shiftKey) { } else if (ev.shiftKey) {
move_cursor(coords, { additive: true }); move_cursor(coords, { additive: true });
@ -688,17 +648,15 @@ commit queue, datum editor, and field headers.
</form> </form>
</div> </div>
<div class="datum-editor"> <div class="datum-editor">
{#if selections.length !== 0 && selections.every(({ coords: { field_idx } }) => field_idx === selections[0]?.coords.field_idx)} {#if selections.length !== 0 && selections.every(({ loc: { field_idx } }) => field_idx === selections[0]?.loc.field_idx)}
<DatumEditor <DatumEditor
bind:this={datum_editor} bind:this={datum_editor}
bind:value={editor_value} bind:value={editor_value}
current_presentation={fields[selections[0].coords.field_idx].field current_presentation={fields[selections[0].loc.field_idx].field
.presentation} .presentation}
on_blur={() => try_queue_delta()} on_blur={try_queue_delta}
on_cancel_edit={cancel_edit} on_cancel_edit={cancel_edit}
on_change={() => { on_change={try_sync_edit_to_cells}
try_sync_edit_to_cells();
}}
on_restore_focus={handle_restore_focus} on_restore_focus={handle_restore_focus}
/> />
{/if} {/if}

View file

@ -55,7 +55,7 @@ export class UndoStack<T extends Undoable<unknown, unknown>> {
// Call `_apply_diff()` after shifting cursor, in case it recursively // Call `_apply_diff()` after shifting cursor, in case it recursively
// mutates this UndoStack. // mutates this UndoStack.
this._apply_diff(this._diffs[this._cursor + 1].map(invert)); this._apply_diff(invert_diff(this._diffs[this._cursor + 1]));
} }
} }
@ -73,10 +73,12 @@ export class UndoStack<T extends Undoable<unknown, unknown>> {
/** /**
* Returns a copy of the parameter with initial and updated values swapped. * Returns a copy of the parameter with initial and updated values swapped.
*/ */
function invert<T extends Undoable<unknown, unknown>>(undoable: T): T { export function invert_diff<T extends Undoable<unknown, unknown>>(
return { diff: T[],
): T[] {
return diff.map((undoable) => ({
loc: undoable.loc, loc: undoable.loc,
value_initial: undoable.value_updated, value_initial: undoable.value_updated,
value_updated: undoable.value_initial, value_updated: undoable.value_initial,
} as T; } as T));
} }