forked from 2sys/phonograph
implement front-end undo/redo key bindings
This commit is contained in:
parent
4ce999dc46
commit
cd54937573
11 changed files with 904 additions and 804 deletions
35
deno.lock
generated
35
deno.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,776 +0,0 @@
|
|||
<svelte:options
|
||||
customElement={{
|
||||
props: {
|
||||
columns: { type: "Array" },
|
||||
subfilter: { type: "String" },
|
||||
},
|
||||
shadow: "none",
|
||||
tag: "table-viewer",
|
||||
}}
|
||||
/>
|
||||
|
||||
<script lang="ts">
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
type Coords,
|
||||
coords_eq,
|
||||
get_box,
|
||||
offset_coords,
|
||||
} from "./coords.svelte";
|
||||
import {
|
||||
type Datum,
|
||||
datum_schema,
|
||||
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, field_info_schema } from "./field.svelte";
|
||||
import FieldAdder from "./field-adder.svelte";
|
||||
import FieldHeader from "./field-header.svelte";
|
||||
import { get_empty_datum_for } from "./presentation.svelte";
|
||||
import TableCell from "./table-cell.svelte";
|
||||
|
||||
type Props = {
|
||||
columns?: {
|
||||
name: string;
|
||||
regtype: string;
|
||||
}[];
|
||||
subfilter?: string;
|
||||
};
|
||||
|
||||
let { columns = [], subfilter = "null" }: Props = $props();
|
||||
|
||||
type CellDelta = {
|
||||
// Assumes that primary keys are immutable and that rows are only added or
|
||||
// removed upon a refresh.
|
||||
coords: Coords;
|
||||
value_initial: Datum;
|
||||
value_updated: Datum;
|
||||
};
|
||||
|
||||
type Delta = {
|
||||
cells: CellDelta[];
|
||||
};
|
||||
|
||||
type LazyData = {
|
||||
count: number;
|
||||
rows: Row[];
|
||||
fields: FieldInfo[];
|
||||
};
|
||||
|
||||
type Selection = {
|
||||
coords: Coords;
|
||||
original_value: Datum;
|
||||
};
|
||||
|
||||
let selections = $state<Selection[]>([]);
|
||||
// While the datum editor is focused and while updated values are being pushed
|
||||
// to the server, other actions such as changing the set of selected cells are
|
||||
// restricted.
|
||||
let editor_value = $state<Datum | undefined>(undefined);
|
||||
let deltas = $state<{
|
||||
commit_queued: Delta[];
|
||||
commit_pending: Delta[];
|
||||
committed: Delta[];
|
||||
revert_queued: Delta[];
|
||||
revert_pending: Delta[];
|
||||
reverted: Delta[];
|
||||
}>({
|
||||
commit_queued: [],
|
||||
commit_pending: [],
|
||||
committed: [],
|
||||
revert_queued: [],
|
||||
revert_pending: [],
|
||||
reverted: [],
|
||||
});
|
||||
let datum_editor = $state<DatumEditor | undefined>();
|
||||
let focus_cursor = $state<(() => unknown) | undefined>();
|
||||
let inserter_rows = $state<Row[]>([]);
|
||||
let lazy_data = $state<LazyData | undefined>();
|
||||
let dragged_header = $state<number | undefined>();
|
||||
|
||||
// -------- Helper Functions -------- //
|
||||
|
||||
function range(from: number, until: number): number[] {
|
||||
const arr: number[] = [];
|
||||
for (let n = from; n < until; n += 1) {
|
||||
arr.push(n);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
function arrow_key_direction(
|
||||
key: string,
|
||||
): "Down" | "Left" | "Right" | "Up" | undefined {
|
||||
if (key === "ArrowDown") {
|
||||
return "Down";
|
||||
} else if (key === "ArrowLeft") {
|
||||
return "Left";
|
||||
} else if (key === "ArrowRight") {
|
||||
return "Right";
|
||||
} else if (key === "ArrowUp") {
|
||||
return "Up";
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function update_field_ordinality({
|
||||
field_index,
|
||||
beyond_index,
|
||||
}: {
|
||||
field_index: number;
|
||||
beyond_index: number;
|
||||
}) {
|
||||
if (!lazy_data) {
|
||||
console.warn("preconditions for update_field_ordinality() not met");
|
||||
return;
|
||||
}
|
||||
let target_ordinality: number | undefined;
|
||||
const ordinality_near = lazy_data.fields[beyond_index].field.ordinality;
|
||||
if (beyond_index > field_index) {
|
||||
// Field is moving towards the end.
|
||||
const ordinality_far =
|
||||
lazy_data.fields[beyond_index + 1]?.field.ordinality;
|
||||
if (ordinality_far) {
|
||||
target_ordinality = (ordinality_near + ordinality_far) / 2;
|
||||
} else {
|
||||
target_ordinality = ordinality_near + 1;
|
||||
}
|
||||
} else if (beyond_index < field_index) {
|
||||
// Field is moving towards the start.
|
||||
const ordinality_far =
|
||||
lazy_data.fields[beyond_index - 1]?.field.ordinality;
|
||||
if (ordinality_far) {
|
||||
target_ordinality = (ordinality_near + ordinality_far) / 2;
|
||||
} else {
|
||||
// Avoid setting ordinality <= 0.
|
||||
target_ordinality = ordinality_near / 2;
|
||||
}
|
||||
} else {
|
||||
// No movement.
|
||||
return;
|
||||
}
|
||||
|
||||
// Imperatively submit HTML form.
|
||||
const form = document.createElement("form");
|
||||
form.setAttribute("action", "update-field-ordinality");
|
||||
form.setAttribute("method", "post");
|
||||
form.style.display = "none";
|
||||
const field_id_input = document.createElement("input");
|
||||
field_id_input.type = "hidden";
|
||||
field_id_input.name = "field_id";
|
||||
field_id_input.value = lazy_data.fields[field_index].field.id;
|
||||
const ordinality_input = document.createElement("input");
|
||||
ordinality_input.name = "ordinality";
|
||||
ordinality_input.type = "hidden";
|
||||
ordinality_input.value = `${target_ordinality}`;
|
||||
form.appendChild(field_id_input);
|
||||
form.appendChild(ordinality_input);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function update_field_table_width_px(
|
||||
field_index: number,
|
||||
new_width_px: number,
|
||||
) {
|
||||
if (lazy_data?.fields[field_index]) {
|
||||
lazy_data.fields[field_index].field.table_width_px = new_width_px;
|
||||
fetch("update-field-table-width-px", {
|
||||
method: "post",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: `field_id=${encodeURIComponent(lazy_data.fields[field_index].field.id)}&table_width_px=${new_width_px}`,
|
||||
}).catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
// -------- Updates and Effects -------- //
|
||||
|
||||
function set_selections(arr: Coords[]) {
|
||||
selections = arr.map((coords) => {
|
||||
let cell_data: Datum | undefined;
|
||||
if (coords.region === "main") {
|
||||
cell_data = lazy_data?.rows[coords.row_idx].data[coords.field_idx];
|
||||
} else if (coords.region === "inserter") {
|
||||
cell_data = inserter_rows[coords.row_idx].data[coords.field_idx];
|
||||
} else {
|
||||
throw new Error(`invalid region: ${coords.region}`);
|
||||
}
|
||||
return { coords, original_value: cell_data! };
|
||||
});
|
||||
if (arr.length === 1) {
|
||||
const [coords] = arr;
|
||||
let cell_data: Datum | undefined;
|
||||
if (coords.region === "main") {
|
||||
cell_data = lazy_data?.rows[coords.row_idx].data[coords.field_idx];
|
||||
} else if (coords.region === "inserter") {
|
||||
cell_data = inserter_rows[coords.row_idx].data[coords.field_idx];
|
||||
}
|
||||
editor_value = cell_data;
|
||||
} else {
|
||||
editor_value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function move_cursor(
|
||||
new_cursor: Coords,
|
||||
{ additive }: { additive?: boolean } = {},
|
||||
) {
|
||||
if (!lazy_data || selections.length === 0) {
|
||||
console.warn("move_cursor() preconditions not met");
|
||||
return;
|
||||
}
|
||||
|
||||
const first_selection = selections[selections.length - 1];
|
||||
if (
|
||||
additive &&
|
||||
first_selection !== undefined &&
|
||||
!coords_eq(new_cursor, first_selection.coords)
|
||||
) {
|
||||
// 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, {
|
||||
n_fields: lazy_data.fields.length,
|
||||
n_rows_main: lazy_data.rows.length,
|
||||
n_rows_inserter: inserter_rows.length,
|
||||
}).filter(
|
||||
(sel) =>
|
||||
!coords_eq(sel, new_cursor) &&
|
||||
!coords_eq(sel, first_selection.coords),
|
||||
),
|
||||
first_selection.coords,
|
||||
]);
|
||||
} else {
|
||||
set_selections([new_cursor]);
|
||||
}
|
||||
}
|
||||
|
||||
function try_sync_edit_to_cells() {
|
||||
if (!lazy_data || selections.length === 0) {
|
||||
console.warn("preconditions for try_sync_edit_to_cells() not met");
|
||||
return;
|
||||
}
|
||||
if (editor_value === undefined) {
|
||||
return;
|
||||
}
|
||||
for (const sel of selections) {
|
||||
if (sel.coords.region === "main") {
|
||||
lazy_data.rows[sel.coords.row_idx].data[sel.coords.field_idx] =
|
||||
editor_value;
|
||||
} else if (sel.coords.region === "inserter") {
|
||||
inserter_rows[sel.coords.row_idx].data[sel.coords.field_idx] =
|
||||
editor_value;
|
||||
} else {
|
||||
throw new Error("Unknown region");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function try_queue_delta() {
|
||||
// Copy `editor_value` so that it can be used intuitively within closures.
|
||||
const editor_value_scoped = editor_value;
|
||||
if (editor_value_scoped === undefined) {
|
||||
console.debug("not a valid cell value");
|
||||
cancel_edit();
|
||||
} else {
|
||||
if (selections.length > 0) {
|
||||
deltas.commit_queued = [
|
||||
...deltas.commit_queued,
|
||||
{
|
||||
cells: selections.map((sel) => ({
|
||||
coords: sel.coords,
|
||||
value_initial: sel.original_value,
|
||||
value_updated: editor_value_scoped,
|
||||
})),
|
||||
},
|
||||
];
|
||||
console.debug("Commit queue:", deltas.commit_queued);
|
||||
selections = selections.map((sel) => ({
|
||||
...sel,
|
||||
original_value: editor_value_scoped,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function commit_delta(delta: Delta) {
|
||||
// Copy `lazy_data` so that it can be used intuitively within closures.
|
||||
const lazy_data_scoped = lazy_data;
|
||||
if (!lazy_data_scoped) {
|
||||
console.warn("sync_delta() preconditions not met");
|
||||
return;
|
||||
}
|
||||
deltas.commit_pending = [...deltas.commit_pending, delta];
|
||||
const resp = await fetch("update-values", {
|
||||
method: "post",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
cells: delta.cells
|
||||
.filter(({ coords: { region } }) => region === "main")
|
||||
.map((cell) => ({
|
||||
pkey: JSON.parse(
|
||||
lazy_data_scoped.rows[cell.coords.row_idx].key as string,
|
||||
),
|
||||
column: lazy_data_scoped.fields[cell.coords.field_idx].field.name,
|
||||
value: cell.value_updated,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
if (resp.status >= 200 && resp.status < 300) {
|
||||
deltas.commit_pending = deltas.commit_pending.filter((x) => x !== delta);
|
||||
deltas.committed = [...deltas.committed, delta];
|
||||
} else {
|
||||
// TODO display feedback to user
|
||||
console.error(resp);
|
||||
console.error(await resp.text());
|
||||
}
|
||||
}
|
||||
|
||||
function tick_delta_queue() {
|
||||
const front_of_queue: Delta | undefined = deltas.commit_queued[0];
|
||||
if (front_of_queue) {
|
||||
deltas.commit_queued = deltas.commit_queued.filter(
|
||||
(x) => x !== front_of_queue,
|
||||
);
|
||||
commit_delta(front_of_queue).catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
function cancel_edit() {
|
||||
selections.forEach(({ coords, original_value }) => {
|
||||
if (coords.region === "main") {
|
||||
if (lazy_data) {
|
||||
lazy_data.rows[coords.row_idx].data[coords.field_idx] =
|
||||
original_value;
|
||||
}
|
||||
} else if (coords.region === "inserter") {
|
||||
inserter_rows[coords.row_idx].data[coords.field_idx] = original_value;
|
||||
} else {
|
||||
throw new Error("Unknown region");
|
||||
}
|
||||
});
|
||||
// Reset editor input value
|
||||
set_selections(selections.map(({ coords }) => coords));
|
||||
}
|
||||
|
||||
// -------- Event Handlers -------- //
|
||||
|
||||
function handle_cell_keydown(ev: KeyboardEvent) {
|
||||
if (!lazy_data) {
|
||||
console.warn("preconditions for handle_table_keydown() not met");
|
||||
return;
|
||||
}
|
||||
const arrow_direction = arrow_key_direction(ev.key);
|
||||
if (arrow_direction) {
|
||||
ev.preventDefault();
|
||||
const field_offset =
|
||||
arrow_direction === "Right" ? 1 : arrow_direction === "Left" ? -1 : 0;
|
||||
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: lazy_data.fields.length,
|
||||
n_rows_main: lazy_data.rows.length,
|
||||
n_rows_inserter: inserter_rows.length,
|
||||
},
|
||||
);
|
||||
if (ev.shiftKey) {
|
||||
move_cursor(new_cursor, { additive: true });
|
||||
} else {
|
||||
move_cursor(new_cursor);
|
||||
}
|
||||
} else if (
|
||||
!ev.altKey &&
|
||||
!ev.ctrlKey &&
|
||||
!ev.metaKey &&
|
||||
/^([a-zA-Z0-9`~!@#$%^&*()_=+[\]{}\\|;:'",<.>/?-]|Backspace)$/.test(ev.key)
|
||||
) {
|
||||
const sel = selections[0];
|
||||
if (sel) {
|
||||
editor_value = get_empty_datum_for(
|
||||
lazy_data.fields[sel.coords.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") {
|
||||
set_selections([
|
||||
{
|
||||
region: "inserter",
|
||||
row_idx: 0,
|
||||
field_idx: selections[0]?.coords.field_idx ?? 0,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
inserter_rows = [
|
||||
...inserter_rows,
|
||||
{
|
||||
key: inserter_rows.length,
|
||||
data: lazy_data.fields.map(({ field: { presentation } }) =>
|
||||
get_empty_datum_for(presentation),
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
} else {
|
||||
datum_editor?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handle_restore_focus() {
|
||||
focus_cursor?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* When the user pastes a TSV formatted string into the table, it gets
|
||||
* projected onto the selected cells, with TSV values aligned to the top-left
|
||||
* of the current selection (that is, uppermost row of any selected cell, and
|
||||
* leftmost column of any selected cell, even if these cells are distinct), and
|
||||
* repeated down and to the right to fill all selections.
|
||||
*
|
||||
* If only one cell is selected, the one copy of the full TSV is pasted into
|
||||
* the cells in the down-and/or-to-the-right directions. If the TSV overflows
|
||||
* the available columns, extra columns are truncated. If it overflows the
|
||||
* available rows, additional rows are added to the bottom of the inserter
|
||||
* region and filled as needed.
|
||||
*/
|
||||
function handle_cell_paste(ev: ClipboardEvent) {
|
||||
if (!lazy_data || selections.length === 0) {
|
||||
console.warn("preconditions for handle_cell_paste() not met");
|
||||
return;
|
||||
}
|
||||
const paste_text = ev.clipboardData?.getData("text");
|
||||
if (paste_text === undefined) {
|
||||
console.warn("paste text is undefined");
|
||||
return;
|
||||
}
|
||||
const top_left: Coords = {
|
||||
region: selections.some(({ coords: { region } }) => region === "main")
|
||||
? "main"
|
||||
: "inserter",
|
||||
field_idx: Math.min(
|
||||
...selections.map(({ coords: { field_idx } }) => field_idx),
|
||||
),
|
||||
row_idx: Math.min(
|
||||
...(selections.some(({ coords: { region } }) => region === "main")
|
||||
? selections.filter(({ coords: { region } }) => region === "main")
|
||||
: selections
|
||||
).map(({ coords: { row_idx } }) => row_idx),
|
||||
),
|
||||
};
|
||||
const fields = lazy_data.fields.slice(top_left.field_idx);
|
||||
const parsed_tsv: Datum[][] = paste_text.split("\n").map((line) => {
|
||||
const raw_values = line.split("\t");
|
||||
return fields.map(({ field: { presentation } }, i) =>
|
||||
parse_datum_from_text(presentation, raw_values[i] ?? ""),
|
||||
);
|
||||
});
|
||||
if (selections.length === 1) {
|
||||
const bottom_left = offset_coords(top_left, parsed_tsv.length - 1, 0, {
|
||||
n_fields: lazy_data.fields.length,
|
||||
n_rows_main: lazy_data.rows.length,
|
||||
// Ensure that result won't be clamped.
|
||||
n_rows_inserter: inserter_rows.length + parsed_tsv.length,
|
||||
});
|
||||
if (
|
||||
bottom_left.region === "inserter" &&
|
||||
bottom_left.row_idx >= inserter_rows.length
|
||||
) {
|
||||
inserter_rows = [
|
||||
...inserter_rows,
|
||||
// TypeScript lacks a built-in range function or operator.
|
||||
...range(inserter_rows.length, bottom_left.row_idx + 1).map((i) => ({
|
||||
key: i,
|
||||
data: lazy_data!.fields.map(({ field: { presentation } }) =>
|
||||
get_empty_datum_for(presentation),
|
||||
),
|
||||
})),
|
||||
];
|
||||
}
|
||||
deltas.commit_queued = [
|
||||
...deltas.commit_queued,
|
||||
{
|
||||
cells: parsed_tsv.flatMap((row, i) =>
|
||||
row.map((value, j) => {
|
||||
const coords = offset_coords(top_left, i, j, {
|
||||
n_fields: lazy_data!.fields.length,
|
||||
n_rows_main: lazy_data!.rows.length,
|
||||
n_rows_inserter: inserter_rows.length,
|
||||
});
|
||||
return {
|
||||
coords,
|
||||
value_initial: (coords.region === "main"
|
||||
? lazy_data!.rows
|
||||
: inserter_rows)[coords.row_idx].data[coords.field_idx],
|
||||
value_updated: value,
|
||||
};
|
||||
}),
|
||||
),
|
||||
},
|
||||
];
|
||||
parsed_tsv.forEach((row, i) =>
|
||||
row.map((value, j) => {
|
||||
const coords = offset_coords(top_left, i, j, {
|
||||
n_fields: lazy_data!.fields.length,
|
||||
n_rows_main: lazy_data!.rows.length,
|
||||
n_rows_inserter: inserter_rows.length,
|
||||
});
|
||||
if (coords.region === "main") {
|
||||
lazy_data!.rows[coords.row_idx].data[coords.field_idx] = value;
|
||||
} else if (coords.region === "inserter") {
|
||||
inserter_rows[coords.row_idx].data[coords.field_idx] = value;
|
||||
} else {
|
||||
throw new Error("Unknown region");
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
// TODO: pasting into multiple selections
|
||||
}
|
||||
|
||||
// -------- Initial API Fetch -------- //
|
||||
|
||||
(async function () {
|
||||
const get_data_response_schema = z.object({
|
||||
count: z.number().int(),
|
||||
rows: z.array(
|
||||
z.object({
|
||||
pkey: z.string(),
|
||||
data: z.array(datum_schema),
|
||||
}),
|
||||
),
|
||||
fields: z.array(field_info_schema),
|
||||
});
|
||||
const resp = await fetch(
|
||||
`get-data?subfilter=${encodeURIComponent(subfilter)}`,
|
||||
);
|
||||
const body = get_data_response_schema.parse(await resp.json());
|
||||
lazy_data = {
|
||||
count: body.count,
|
||||
fields: body.fields,
|
||||
rows: body.rows.map(({ data, pkey }) => ({ data, key: pkey })),
|
||||
};
|
||||
inserter_rows = [
|
||||
{
|
||||
key: 0,
|
||||
data: body.fields.map(({ field: { presentation } }) =>
|
||||
get_empty_datum_for(presentation),
|
||||
),
|
||||
},
|
||||
];
|
||||
if (lazy_data.fields.length > 0) {
|
||||
set_selections([
|
||||
{
|
||||
region: lazy_data.rows.length > 0 ? "main" : "inserter",
|
||||
row_idx: 0,
|
||||
field_idx: 0,
|
||||
},
|
||||
]);
|
||||
}
|
||||
})().catch(console.error);
|
||||
|
||||
setInterval(tick_delta_queue, 500);
|
||||
</script>
|
||||
|
||||
{#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}
|
||||
<div class="table-viewer__row" role="row">
|
||||
{#each lazy_data.fields as field, field_idx}
|
||||
<TableCell
|
||||
coords={{ region, row_idx, field_idx }}
|
||||
cursor={selections.length !== 0 &&
|
||||
coords_eq(selections[0].coords, {
|
||||
region,
|
||||
row_idx,
|
||||
field_idx,
|
||||
})}
|
||||
{field}
|
||||
onbecomecursor={(focus) => {
|
||||
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}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="table-viewer__layout">
|
||||
{#if lazy_data}
|
||||
<div class="table-viewer__table" role="grid">
|
||||
<div class="table-viewer__headers">
|
||||
{#each lazy_data.fields as _, field_index}
|
||||
<FieldHeader
|
||||
bind:field={lazy_data.fields[field_index]}
|
||||
index={field_index}
|
||||
ondragstart={() => {
|
||||
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}
|
||||
<div class="table-viewer__header-actions">
|
||||
<FieldAdder {columns}></FieldAdder>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-viewer__main">
|
||||
{#if subfilter && subfilter !== "null"}
|
||||
<div class="padded padded-sm">
|
||||
<a href="?">…</a>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
{@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);
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
<div class="table-viewer__count padded padded-sm">
|
||||
{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}
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="insert" class="table-viewer__inserter">
|
||||
<h3 class="table-viewer__inserter-help">
|
||||
<b>Insert rows</b> (press "shift + enter" to jump here or add a row)
|
||||
</h3>
|
||||
<div class="table-viewer__inserter-main">
|
||||
<div class="table-viewer__inserter-rows">
|
||||
{@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);
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
aria-label="Insert rows"
|
||||
class="table-viewer__inserter-submit"
|
||||
onkeydown={(ev) => {
|
||||
// Prevent keypress (e.g. pressing Enter on the button to submit
|
||||
// it) from triggering a table interaction.
|
||||
ev.stopPropagation();
|
||||
}}
|
||||
title="Insert rows"
|
||||
type="submit"
|
||||
>
|
||||
<i class="ti ti-upload"></i>
|
||||
</button>
|
||||
</div>
|
||||
{#each inserter_rows as row}
|
||||
{#each lazy_data.fields as field, field_index}
|
||||
<input
|
||||
type="hidden"
|
||||
name={field.field.name}
|
||||
value={JSON.stringify(row.data[field_index])}
|
||||
/>
|
||||
{/each}
|
||||
{/each}
|
||||
</form>
|
||||
</div>
|
||||
<div class="datum-editor">
|
||||
{#if selections.length !== 0 && selections.every(({ coords: { field_idx } }) => field_idx === selections[0]?.coords.field_idx)}
|
||||
<DatumEditor
|
||||
bind:this={datum_editor}
|
||||
bind:value={editor_value}
|
||||
current_presentation={lazy_data.fields[selections[0].coords.field_idx]
|
||||
.field.presentation}
|
||||
on_blur={() => try_queue_delta()}
|
||||
on_cancel_edit={cancel_edit}
|
||||
on_change={() => {
|
||||
try_sync_edit_to_cells();
|
||||
}}
|
||||
on_restore_focus={handle_restore_focus}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -16,11 +16,11 @@ submission.
|
|||
incompatible with the current presentation configuration.-->
|
||||
|
||||
<script lang="ts">
|
||||
import FieldDetails from "./field-details.svelte";
|
||||
import FieldDetails from "../field-details.svelte";
|
||||
import {
|
||||
all_presentation_tags,
|
||||
type Presentation,
|
||||
} from "./presentation.svelte";
|
||||
} from "../presentation.svelte";
|
||||
|
||||
type Assert<_T extends true> = void;
|
||||
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import BasicDropdown from "./basic-dropdown.webc.svelte";
|
||||
import { type FieldInfo } from "./field.svelte";
|
||||
import FieldDetails from "./field-details.svelte";
|
||||
import BasicDropdown from "../basic-dropdown.webc.svelte";
|
||||
import { type FieldInfo } from "../field.svelte";
|
||||
import FieldDetails from "../field-details.svelte";
|
||||
|
||||
const MAX_COL_WIDTH_PX = 800;
|
||||
const MIN_COL_WIDTH_PX = 160;
|
||||
705
svelte/src/table-viewer.webc/hydrated-table-viewer.svelte
Normal file
705
svelte/src/table-viewer.webc/hydrated-table-viewer.svelte
Normal file
|
|
@ -0,0 +1,705 @@
|
|||
<!--
|
||||
@component
|
||||
Hub of the `<TableViewer>` component, which keeps track of the data in each
|
||||
table region as well as selection state, and coordinates data flow between the
|
||||
commit queue, datum editor, and field headers.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
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,
|
||||
get_box,
|
||||
offset_coords,
|
||||
} from "./coords.svelte";
|
||||
import FieldAdder from "./field-adder.svelte";
|
||||
import FieldHeader from "./field-header.svelte";
|
||||
import TableCell from "./table-cell.svelte";
|
||||
import { AsyncTaskQueue, withRetry } from "attq";
|
||||
|
||||
type Props = {
|
||||
columns: {
|
||||
name: string;
|
||||
regtype: string;
|
||||
}[];
|
||||
fields: FieldInfo[];
|
||||
rows_main: Row[];
|
||||
subfilter_active: boolean;
|
||||
total_count: number;
|
||||
};
|
||||
|
||||
let {
|
||||
columns = [],
|
||||
fields = $bindable([]),
|
||||
rows_main = $bindable([]),
|
||||
subfilter_active,
|
||||
total_count,
|
||||
}: Props = $props();
|
||||
|
||||
type Selection = {
|
||||
coords: Coords;
|
||||
original_value: Datum;
|
||||
};
|
||||
|
||||
type Delta = Undoable<Coords, Datum>;
|
||||
|
||||
let selections = $state<Selection[]>([]);
|
||||
let editor_value = $state<Datum | undefined>(undefined);
|
||||
let datum_editor = $state<DatumEditor | undefined>();
|
||||
let focus_cursor = $state<(() => unknown) | undefined>();
|
||||
let rows_inserter = $state<Row[]>([
|
||||
{
|
||||
key: 0,
|
||||
data: fields.map(({ field: { presentation } }) =>
|
||||
get_empty_datum_for(presentation),
|
||||
),
|
||||
},
|
||||
]);
|
||||
let dragged_header = $state<number | undefined>();
|
||||
|
||||
// This is state, but it doesn't need to be reactive (that is, `$state`).
|
||||
const upload_queue = new AsyncTaskQueue<Delta[]>(
|
||||
withRetry(async ([batch]) => {
|
||||
const cells = batch
|
||||
.filter(({ loc: { region } }) => region === "main")
|
||||
.map(({ loc: { field_idx, row_idx }, value_updated }) => ({
|
||||
pkey: JSON.parse(rows_main[row_idx].key as string),
|
||||
column: fields[field_idx].field.name,
|
||||
value: value_updated,
|
||||
}));
|
||||
if (cells.length > 0) {
|
||||
const resp = await fetch("update-values", {
|
||||
method: "post",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ cells }),
|
||||
});
|
||||
if (resp.status > 299) {
|
||||
// Throw so that retry logic is triggered. Even if the error code is a
|
||||
// 4xx, this could be a temporary error, so still retry.
|
||||
try {
|
||||
throw new Error(`API error (${resp.status}): ${await resp.text()}`);
|
||||
} catch {
|
||||
throw new Error(`API error (${resp.status})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
// The undo stack facilitates most changes to the table data as displayed in
|
||||
// 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;
|
||||
}
|
||||
upload_queue.push(diff);
|
||||
},
|
||||
});
|
||||
|
||||
// -------- Helper Functions -------- //
|
||||
|
||||
function range(from: number, until: number): number[] {
|
||||
const arr: number[] = [];
|
||||
for (let n = from; n < until; n += 1) {
|
||||
arr.push(n);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
function arrow_key_direction(
|
||||
key: string,
|
||||
): "Down" | "Left" | "Right" | "Up" | undefined {
|
||||
if (key === "ArrowDown") {
|
||||
return "Down";
|
||||
} else if (key === "ArrowLeft") {
|
||||
return "Left";
|
||||
} else if (key === "ArrowRight") {
|
||||
return "Right";
|
||||
} else if (key === "ArrowUp") {
|
||||
return "Up";
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function update_field_ordinality({
|
||||
field_index,
|
||||
beyond_index,
|
||||
}: {
|
||||
field_index: number;
|
||||
beyond_index: number;
|
||||
}) {
|
||||
let target_ordinality: number | undefined;
|
||||
const ordinality_near = fields[beyond_index].field.ordinality;
|
||||
if (beyond_index > field_index) {
|
||||
// Field is moving towards the end.
|
||||
const ordinality_far = fields[beyond_index + 1]?.field.ordinality;
|
||||
if (ordinality_far) {
|
||||
target_ordinality = (ordinality_near + ordinality_far) / 2;
|
||||
} else {
|
||||
target_ordinality = ordinality_near + 1;
|
||||
}
|
||||
} else if (beyond_index < field_index) {
|
||||
// Field is moving towards the start.
|
||||
const ordinality_far = fields[beyond_index - 1]?.field.ordinality;
|
||||
if (ordinality_far) {
|
||||
target_ordinality = (ordinality_near + ordinality_far) / 2;
|
||||
} else {
|
||||
// Avoid setting ordinality <= 0.
|
||||
target_ordinality = ordinality_near / 2;
|
||||
}
|
||||
} else {
|
||||
// No movement.
|
||||
return;
|
||||
}
|
||||
|
||||
// Imperatively submit HTML form.
|
||||
const form = document.createElement("form");
|
||||
form.setAttribute("action", "update-field-ordinality");
|
||||
form.setAttribute("method", "post");
|
||||
form.style.display = "none";
|
||||
const field_id_input = document.createElement("input");
|
||||
field_id_input.type = "hidden";
|
||||
field_id_input.name = "field_id";
|
||||
field_id_input.value = fields[field_index].field.id;
|
||||
const ordinality_input = document.createElement("input");
|
||||
ordinality_input.name = "ordinality";
|
||||
ordinality_input.type = "hidden";
|
||||
ordinality_input.value = `${target_ordinality}`;
|
||||
form.appendChild(field_id_input);
|
||||
form.appendChild(ordinality_input);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function update_field_table_width_px(
|
||||
field_index: number,
|
||||
new_width_px: number,
|
||||
) {
|
||||
if (fields[field_index]) {
|
||||
fields[field_index].field.table_width_px = new_width_px;
|
||||
fetch("update-field-table-width-px", {
|
||||
method: "post",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: `field_id=${encodeURIComponent(fields[field_index].field.id)}&table_width_px=${new_width_px}`,
|
||||
}).catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
// -------- Updates and Effects -------- //
|
||||
|
||||
function set_selections(arr: Coords[]) {
|
||||
selections = arr.map((coords) => {
|
||||
let cell_data: Datum | undefined;
|
||||
if (coords.region === "main") {
|
||||
cell_data = rows_main[coords.row_idx].data[coords.field_idx];
|
||||
} else if (coords.region === "inserter") {
|
||||
cell_data = rows_inserter[coords.row_idx].data[coords.field_idx];
|
||||
} else {
|
||||
throw new Error(`invalid region: ${coords.region}`);
|
||||
}
|
||||
return { coords, original_value: cell_data! };
|
||||
});
|
||||
if (arr.length === 1) {
|
||||
const [coords] = arr;
|
||||
let cell_data: Datum | undefined;
|
||||
if (coords.region === "main") {
|
||||
cell_data = rows_main[coords.row_idx].data[coords.field_idx];
|
||||
} else if (coords.region === "inserter") {
|
||||
cell_data = rows_inserter[coords.row_idx].data[coords.field_idx];
|
||||
}
|
||||
editor_value = cell_data;
|
||||
} else {
|
||||
editor_value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function move_cursor(
|
||||
new_cursor: Coords,
|
||||
{ additive }: { additive?: boolean } = {},
|
||||
) {
|
||||
if (selections.length === 0) {
|
||||
console.warn("move_cursor() preconditions not met");
|
||||
return;
|
||||
}
|
||||
|
||||
const first_selection = selections[selections.length - 1];
|
||||
if (
|
||||
additive &&
|
||||
first_selection !== undefined &&
|
||||
!coords_eq(new_cursor, first_selection.coords)
|
||||
) {
|
||||
// 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, {
|
||||
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),
|
||||
),
|
||||
first_selection.coords,
|
||||
]);
|
||||
} else {
|
||||
set_selections([new_cursor]);
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function try_queue_delta() {
|
||||
// Copy value locally so that it can be used intuitively in closures.
|
||||
const editor_value_scoped = editor_value;
|
||||
if (editor_value_scoped === undefined) {
|
||||
return;
|
||||
}
|
||||
if (editor_value_scoped === undefined) {
|
||||
console.debug("not a valid cell value");
|
||||
cancel_edit();
|
||||
} else {
|
||||
if (selections.length > 0) {
|
||||
undo_stack.push(
|
||||
selections.map(({ coords, original_value }) => ({
|
||||
loc: coords,
|
||||
value_initial: original_value,
|
||||
value_updated: editor_value_scoped,
|
||||
})),
|
||||
);
|
||||
selections = selections.map((sel) => ({
|
||||
...sel,
|
||||
original_value: editor_value_scoped,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
});
|
||||
// Reset editor input value
|
||||
set_selections(selections.map(({ coords }) => coords));
|
||||
}
|
||||
|
||||
// -------- Event Handlers -------- //
|
||||
|
||||
function handle_cell_keydown(ev: KeyboardEvent) {
|
||||
const arrow_direction = arrow_key_direction(ev.key);
|
||||
if (arrow_direction) {
|
||||
ev.preventDefault();
|
||||
const field_offset =
|
||||
arrow_direction === "Right" ? 1 : arrow_direction === "Left" ? -1 : 0;
|
||||
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,
|
||||
},
|
||||
);
|
||||
if (ev.shiftKey) {
|
||||
move_cursor(new_cursor, { additive: true });
|
||||
} else {
|
||||
move_cursor(new_cursor);
|
||||
}
|
||||
} else if (ev.metaKey && ev.key == "z") {
|
||||
if (ev.shiftKey) {
|
||||
undo_stack.redo();
|
||||
} else {
|
||||
undo_stack.undo();
|
||||
}
|
||||
} else if (
|
||||
!ev.altKey &&
|
||||
!ev.ctrlKey &&
|
||||
!ev.metaKey &&
|
||||
/^([a-zA-Z0-9`~!@#$%^&*()_=+[\]{}\\|;:'",<.>/?-]|Backspace)$/.test(ev.key)
|
||||
) {
|
||||
const sel = selections[0];
|
||||
if (sel) {
|
||||
editor_value = get_empty_datum_for(
|
||||
fields[sel.coords.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") {
|
||||
set_selections([
|
||||
{
|
||||
region: "inserter",
|
||||
row_idx: 0,
|
||||
field_idx: selections[0]?.coords.field_idx ?? 0,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
rows_inserter = [
|
||||
...rows_inserter,
|
||||
{
|
||||
key: rows_inserter.length,
|
||||
data: fields.map(({ field: { presentation } }) =>
|
||||
get_empty_datum_for(presentation),
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
} else {
|
||||
datum_editor?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handle_restore_focus() {
|
||||
focus_cursor?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* When the user pastes a TSV formatted string into the table, it gets
|
||||
* projected onto the selected cells, with TSV values aligned to the top-left
|
||||
* of the current selection (that is, uppermost row of any selected cell, and
|
||||
* leftmost column of any selected cell, even if these cells are distinct), and
|
||||
* repeated down and to the right to fill all selections.
|
||||
*
|
||||
* If only one cell is selected, the one copy of the full TSV is pasted into
|
||||
* the cells in the down-and/or-to-the-right directions. If the TSV overflows
|
||||
* the available columns, extra columns are truncated. If it overflows the
|
||||
* available rows, additional rows are added to the bottom of the inserter
|
||||
* region and filled as needed.
|
||||
*/
|
||||
function handle_cell_paste(ev: ClipboardEvent) {
|
||||
if (selections.length === 0) {
|
||||
console.warn("preconditions for handle_cell_paste() not met");
|
||||
return;
|
||||
}
|
||||
const paste_text = ev.clipboardData?.getData("text");
|
||||
if (paste_text === undefined) {
|
||||
console.warn("paste text is undefined");
|
||||
return;
|
||||
}
|
||||
const top_left: Coords = {
|
||||
region: selections.some(({ coords: { region } }) => region === "main")
|
||||
? "main"
|
||||
: "inserter",
|
||||
field_idx: Math.min(
|
||||
...selections.map(({ coords: { field_idx } }) => field_idx),
|
||||
),
|
||||
row_idx: Math.min(
|
||||
...(selections.some(({ coords: { region } }) => region === "main")
|
||||
? selections.filter(({ coords: { region } }) => region === "main")
|
||||
: selections
|
||||
).map(({ coords: { row_idx } }) => row_idx),
|
||||
),
|
||||
};
|
||||
const fields_to_right = fields.slice(top_left.field_idx);
|
||||
const parsed_tsv: Datum[][] = paste_text.split("\n").map((line) => {
|
||||
const raw_values = line.split("\t");
|
||||
return fields_to_right.map(({ field: { presentation } }, i) =>
|
||||
parse_datum_from_text(presentation, raw_values[i] ?? ""),
|
||||
);
|
||||
});
|
||||
if (selections.length === 1) {
|
||||
const bottom_left = offset_coords(top_left, parsed_tsv.length - 1, 0, {
|
||||
n_fields: fields.length,
|
||||
n_rows_main: rows_main.length,
|
||||
// Ensure that result won't be clamped.
|
||||
n_rows_inserter: rows_inserter.length + parsed_tsv.length,
|
||||
});
|
||||
if (
|
||||
bottom_left.region === "inserter" &&
|
||||
bottom_left.row_idx >= rows_inserter.length
|
||||
) {
|
||||
rows_inserter = [
|
||||
...rows_inserter,
|
||||
// TypeScript lacks a built-in range function or operator.
|
||||
...range(rows_inserter.length, bottom_left.row_idx + 1).map((i) => ({
|
||||
key: i,
|
||||
data: fields.map(({ field: { presentation } }) =>
|
||||
get_empty_datum_for(presentation),
|
||||
),
|
||||
})),
|
||||
];
|
||||
}
|
||||
undo_stack.push(
|
||||
parsed_tsv.flatMap((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,
|
||||
});
|
||||
return {
|
||||
loc: coords,
|
||||
value_initial: (coords.region === "main"
|
||||
? rows_main
|
||||
: rows_inserter)[coords.row_idx].data[coords.field_idx],
|
||||
value_updated: value,
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
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
|
||||
}
|
||||
|
||||
// Set initial selection on component creation.
|
||||
if (fields.length > 0) {
|
||||
set_selections([
|
||||
{
|
||||
region: rows_main.length > 0 ? "main" : "inserter",
|
||||
row_idx: 0,
|
||||
field_idx: 0,
|
||||
},
|
||||
]);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#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}
|
||||
<div class="table-viewer__row" role="row">
|
||||
{#each fields as field, field_idx}
|
||||
<TableCell
|
||||
coords={{ region, row_idx, field_idx }}
|
||||
cursor={selections.length !== 0 &&
|
||||
coords_eq(selections[0].coords, {
|
||||
region,
|
||||
row_idx,
|
||||
field_idx,
|
||||
})}
|
||||
{field}
|
||||
onbecomecursor={(focus) => {
|
||||
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}
|
||||
</div>
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
<div class="table-viewer__table" role="grid">
|
||||
<div class="table-viewer__headers">
|
||||
{#each fields as _, field_index}
|
||||
<FieldHeader
|
||||
bind:field={fields[field_index]}
|
||||
index={field_index}
|
||||
ondragstart={() => {
|
||||
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}
|
||||
<div class="table-viewer__header-actions">
|
||||
<FieldAdder {columns}></FieldAdder>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-viewer__main">
|
||||
{#if subfilter_active}
|
||||
<div class="padded padded-sm">
|
||||
<a href="?">…</a>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
{@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);
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
<div class="table-viewer__count padded padded-sm">
|
||||
{total_count} records total
|
||||
{#if total_count > rows_main.length}
|
||||
({total_count - rows_main.length} hidden; use filters to narrow your search)
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="insert" class="table-viewer__inserter">
|
||||
<h3 class="table-viewer__inserter-help">
|
||||
<b>Insert rows</b> (press "shift + enter" to jump here or add a row)
|
||||
</h3>
|
||||
<div class="table-viewer__inserter-main">
|
||||
<div class="table-viewer__inserter-rows">
|
||||
{@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);
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
aria-label="Insert rows"
|
||||
class="table-viewer__inserter-submit"
|
||||
onkeydown={(ev) => {
|
||||
// Prevent keypress (e.g. pressing Enter on the button to submit
|
||||
// it) from triggering a table interaction.
|
||||
ev.stopPropagation();
|
||||
}}
|
||||
title="Insert rows"
|
||||
type="submit"
|
||||
>
|
||||
<i class="ti ti-upload"></i>
|
||||
</button>
|
||||
</div>
|
||||
{#each rows_inserter as row}
|
||||
{#each fields as field, field_index}
|
||||
<input
|
||||
type="hidden"
|
||||
name={field.field.name}
|
||||
value={JSON.stringify(row.data[field_index])}
|
||||
/>
|
||||
{/each}
|
||||
{/each}
|
||||
</form>
|
||||
</div>
|
||||
<div class="datum-editor">
|
||||
{#if selections.length !== 0 && selections.every(({ coords: { field_idx } }) => field_idx === selections[0]?.coords.field_idx)}
|
||||
<DatumEditor
|
||||
bind:this={datum_editor}
|
||||
bind:value={editor_value}
|
||||
current_presentation={fields[selections[0].coords.field_idx].field
|
||||
.presentation}
|
||||
on_blur={() => try_queue_delta()}
|
||||
on_cancel_edit={cancel_edit}
|
||||
on_change={() => {
|
||||
try_sync_edit_to_cells();
|
||||
}}
|
||||
on_restore_focus={handle_restore_focus}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
78
svelte/src/table-viewer.webc/index.svelte
Normal file
78
svelte/src/table-viewer.webc/index.svelte
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<svelte:options
|
||||
customElement={{
|
||||
props: {
|
||||
columns: { type: "Array" },
|
||||
subfilter: { type: "String" },
|
||||
},
|
||||
shadow: "none",
|
||||
tag: "table-viewer",
|
||||
}}
|
||||
/>
|
||||
|
||||
<!--
|
||||
@component
|
||||
Top-level web component for the tabular viewing and editing interface for
|
||||
Phonograph portals. Parses HTML attributes, fetches data, and renders the
|
||||
interactive interface implemented by the `<HydratedTableViewer>` Svelte
|
||||
component.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { z } from "zod";
|
||||
|
||||
import { datum_schema } from "../datum.svelte";
|
||||
import { type Row, type FieldInfo, field_info_schema } from "../field.svelte";
|
||||
import HydratedTableViewer from "./hydrated-table-viewer.svelte";
|
||||
|
||||
type Props = {
|
||||
columns?: {
|
||||
name: string;
|
||||
regtype: string;
|
||||
}[];
|
||||
subfilter?: string;
|
||||
};
|
||||
|
||||
let { columns = [], subfilter = "null" }: Props = $props();
|
||||
|
||||
type LazyData = {
|
||||
count: number;
|
||||
rows: Row[];
|
||||
fields: FieldInfo[];
|
||||
};
|
||||
|
||||
let lazy_data = $state<LazyData | undefined>();
|
||||
|
||||
(async function () {
|
||||
const get_data_response_schema = z.object({
|
||||
count: z.number().int(),
|
||||
rows: z.array(
|
||||
z.object({
|
||||
pkey: z.string(),
|
||||
data: z.array(datum_schema),
|
||||
}),
|
||||
),
|
||||
fields: z.array(field_info_schema),
|
||||
});
|
||||
const resp = await fetch(
|
||||
`get-data?subfilter=${encodeURIComponent(subfilter)}`,
|
||||
);
|
||||
const body = get_data_response_schema.parse(await resp.json());
|
||||
lazy_data = {
|
||||
count: body.count,
|
||||
fields: body.fields,
|
||||
rows: body.rows.map(({ data, pkey }) => ({ data, key: pkey })),
|
||||
};
|
||||
})().catch(console.error);
|
||||
</script>
|
||||
|
||||
<div class="table-viewer__layout">
|
||||
{#if lazy_data}
|
||||
<HydratedTableViewer
|
||||
{columns}
|
||||
fields={lazy_data.fields}
|
||||
rows_main={lazy_data.rows}
|
||||
subfilter_active={!!subfilter && subfilter !== "null"}
|
||||
total_count={lazy_data.count}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -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;
|
||||
82
svelte/src/table-viewer.webc/undo-stack.svelte.ts
Normal file
82
svelte/src/table-viewer.webc/undo-stack.svelte.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
export type Undoable<L, T> = {
|
||||
loc: L;
|
||||
value_initial: T;
|
||||
value_updated: T;
|
||||
};
|
||||
|
||||
export class UndoStack<T extends Undoable<unknown, unknown>> {
|
||||
/**
|
||||
* 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<T extends Undoable<unknown, unknown>>(undoable: T): T {
|
||||
return {
|
||||
loc: undoable.loc,
|
||||
value_initial: undoable.value_updated,
|
||||
value_updated: undoable.value_initial,
|
||||
} as T;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue