clean up undo/redo implementation

This commit is contained in:
Brent Schroeter 2026-02-09 22:02:06 +00:00
parent cd54937573
commit 2419880af3
4 changed files with 66 additions and 154 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

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -6,12 +6,13 @@ commit queue, datum editor, and field headers.
-->
<script lang="ts">
import { AsyncTaskQueue, withRetry } from "attq";
import { type Datum, parse_datum_from_text } from "../datum.svelte";
import DatumEditor from "../datum-editor.svelte";
import { BLUR_DEBOUNCE_MS } from "../datum-editor-common.svelte";
import { type Row, type FieldInfo } from "../field.svelte";
import { get_empty_datum_for } from "../presentation.svelte";
import { UndoStack, type Undoable } from "./undo-stack.svelte";
import {
type Coords,
coords_eq,
@ -21,7 +22,7 @@ commit queue, datum editor, and field headers.
import FieldAdder from "./field-adder.svelte";
import FieldHeader from "./field-header.svelte";
import TableCell from "./table-cell.svelte";
import { AsyncTaskQueue, withRetry } from "attq";
import { invert_diff, type Undoable, UndoStack } from "./undo-stack.svelte";
type Props = {
columns: {
@ -42,14 +43,9 @@ commit queue, datum editor, and field headers.
total_count,
}: Props = $props();
type Selection = {
coords: Coords;
original_value: Datum;
};
type Delta = Undoable<Coords, Datum>;
let selections = $state<Selection[]>([]);
let selections = $state<Delta[]>([]);
let editor_value = $state<Datum | undefined>(undefined);
let datum_editor = $state<DatumEditor | 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.
const undo_stack = new UndoStack<Delta>({
apply_diff: (diff) => {
for (const {
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;
}
set_cell_values(diff);
upload_queue.push(diff);
},
});
@ -202,6 +190,16 @@ commit queue, datum editor, and field headers.
// -------- 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[]) {
selections = arr.map((coords) => {
let cell_data: Datum | undefined;
@ -212,7 +210,11 @@ commit queue, datum editor, and field headers.
} else {
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) {
const [coords] = arr;
@ -241,23 +243,22 @@ commit queue, datum editor, and field headers.
if (
additive &&
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
// selections array, and the current cursor at the beginning. Everything
// in the bounded box should be populated in between.
set_selections([
new_cursor,
...get_box(first_selection.coords, new_cursor, {
...get_box(first_selection.loc, new_cursor, {
n_fields: fields.length,
n_rows_main: rows_main.length,
n_rows_inserter: rows_inserter.length,
}).filter(
(sel) =>
!coords_eq(sel, new_cursor) &&
!coords_eq(sel, first_selection.coords),
!coords_eq(sel, new_cursor) && !coords_eq(sel, first_selection.loc),
),
first_selection.coords,
first_selection.loc,
]);
} else {
set_selections([new_cursor]);
@ -265,28 +266,16 @@ commit queue, datum editor, and field headers.
}
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.
const editor_value_scoped = editor_value;
if (editor_value_scoped === undefined) {
return;
}
for (const sel of selections) {
// TODO: Refactor into `set_cell_values` function or similar to avoid
// 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");
}
if (editor_value_scoped !== undefined) {
// Editor state represents a valid cell value.
selections = selections.map((sel) => ({
...sel,
value_updated: editor_value_scoped,
}));
}
set_cell_values(selections);
}
function try_queue_delta() {
@ -301,9 +290,9 @@ commit queue, datum editor, and field headers.
} else {
if (selections.length > 0) {
undo_stack.push(
selections.map(({ coords, original_value }) => ({
loc: coords,
value_initial: original_value,
selections.map(({ loc, value_initial }) => ({
loc,
value_initial,
value_updated: editor_value_scoped,
})),
);
@ -316,17 +305,9 @@ commit queue, datum editor, and field headers.
}
function cancel_edit() {
selections.forEach(({ coords, original_value }) => {
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");
}
});
set_cell_values(invert_diff(selections));
// Reset editor input value
set_selections(selections.map(({ coords }) => coords));
set_selections(selections.map(({ loc }) => loc));
}
// -------- Event Handlers -------- //
@ -340,16 +321,11 @@ commit queue, datum editor, and field headers.
const row_offset =
arrow_direction === "Down" ? 1 : arrow_direction === "Up" ? -1 : 0;
const cursor = selections[0];
const new_cursor = offset_coords(
cursor.coords,
row_offset,
field_offset,
{
n_fields: fields.length,
n_rows_main: rows_main.length,
n_rows_inserter: rows_inserter.length,
},
);
const new_cursor = offset_coords(cursor.loc, row_offset, field_offset, {
n_fields: fields.length,
n_rows_main: rows_main.length,
n_rows_inserter: rows_inserter.length,
});
if (ev.shiftKey) {
move_cursor(new_cursor, { additive: true });
} else {
@ -370,19 +346,19 @@ commit queue, datum editor, and field headers.
const sel = selections[0];
if (sel) {
editor_value = get_empty_datum_for(
fields[sel.coords.field_idx].field.presentation,
fields[sel.loc.field_idx].field.presentation,
);
datum_editor?.focus();
try_sync_edit_to_cells();
}
} else if (ev.key === "Enter") {
if (ev.shiftKey) {
if (selections[0]?.coords.region === "main") {
if (selections[0]?.loc.region === "main") {
set_selections([
{
region: "inserter",
row_idx: 0,
field_idx: selections[0]?.coords.field_idx ?? 0,
field_idx: selections[0]?.loc.field_idx ?? 0,
},
]);
} else {
@ -430,17 +406,17 @@ commit queue, datum editor, and field headers.
return;
}
const top_left: Coords = {
region: selections.some(({ coords: { region } }) => region === "main")
region: selections.some(({ loc: { region } }) => region === "main")
? "main"
: "inserter",
field_idx: Math.min(
...selections.map(({ coords: { field_idx } }) => field_idx),
...selections.map(({ loc: { field_idx } }) => field_idx),
),
row_idx: Math.min(
...(selections.some(({ coords: { region } }) => region === "main")
? selections.filter(({ coords: { region } }) => region === "main")
...(selections.some(({ loc: { region } }) => region === "main")
? selections.filter(({ loc: { region } }) => region === "main")
: selections
).map(({ coords: { row_idx } }) => row_idx),
).map(({ loc: { row_idx } }) => row_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
}
@ -537,7 +497,7 @@ commit queue, datum editor, and field headers.
<TableCell
coords={{ region, row_idx, field_idx }}
cursor={selections.length !== 0 &&
coords_eq(selections[0].coords, {
coords_eq(selections[0].loc, {
region,
row_idx,
field_idx,
@ -552,8 +512,8 @@ commit queue, datum editor, and field headers.
onpaste={handle_cell_paste}
selected={selections.some(
(sel) =>
sel.coords.region === region &&
coords_eq(sel.coords, { region, row_idx, field_idx }),
sel.loc.region === region &&
coords_eq(sel.loc, { region, row_idx, field_idx }),
)}
table_region={region}
value={row.data[field_idx]}
@ -613,8 +573,8 @@ commit queue, datum editor, and field headers.
set_selections([
coords,
...selections
.filter((sel) => !coords_eq(sel.coords, coords))
.map((sel) => sel.coords),
.filter(({ loc }) => !coords_eq(loc, coords))
.map(({ loc }) => loc),
]);
} else if (ev.shiftKey) {
move_cursor(coords, { additive: true });
@ -650,8 +610,8 @@ commit queue, datum editor, and field headers.
set_selections([
coords,
...selections
.filter((sel) => !coords_eq(sel.coords, coords))
.map((sel) => sel.coords),
.filter(({ loc }) => !coords_eq(loc, coords))
.map(({ loc }) => loc),
]);
} else if (ev.shiftKey) {
move_cursor(coords, { additive: true });
@ -688,17 +648,15 @@ commit queue, datum editor, and field headers.
</form>
</div>
<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
bind:this={datum_editor}
bind:value={editor_value}
current_presentation={fields[selections[0].coords.field_idx].field
current_presentation={fields[selections[0].loc.field_idx].field
.presentation}
on_blur={() => try_queue_delta()}
on_blur={try_queue_delta}
on_cancel_edit={cancel_edit}
on_change={() => {
try_sync_edit_to_cells();
}}
on_change={try_sync_edit_to_cells}
on_restore_focus={handle_restore_focus}
/>
{/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
// 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.
*/
function invert<T extends Undoable<unknown, unknown>>(undoable: T): T {
return {
export function invert_diff<T extends Undoable<unknown, unknown>>(
diff: T[],
): T[] {
return diff.map((undoable) => ({
loc: undoable.loc,
value_initial: undoable.value_updated,
value_updated: undoable.value_initial,
} as T;
} as T));
}