1
0
Fork 0
forked from 2sys/phonograph

implement front-end undo/redo key bindings

This commit is contained in:
Brent Schroeter 2026-02-09 21:39:13 +00:00
parent 4ce999dc46
commit cd54937573
11 changed files with 904 additions and 804 deletions

35
deno.lock generated
View file

@ -10,16 +10,17 @@
"jsr:@std/uuid@*": "1.0.9", "jsr:@std/uuid@*": "1.0.9",
"jsr:@std/uuid@^1.0.9": "1.0.9", "jsr:@std/uuid@^1.0.9": "1.0.9",
"npm:@date-fns/utc@^2.1.1": "2.1.1", "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:@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_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",
"npm:@tsconfig/svelte@^5.0.4": "5.0.4", "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: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-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-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:svelte@^5.37.3": "5.38.1_acorn@8.15.0",
"npm:typescript@~5.8.3": "5.8.3", "npm:typescript@~5.8.3": "5.8.3",
"npm:uuid@^11.1.0": "11.1.0", "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" "npm:zod@^4.0.17": "4.0.17"
}, },
"jsr": { "jsr": {
@ -69,7 +70,7 @@
"@date-fns/utc@2.1.1": { "@date-fns/utc@2.1.1": {
"integrity": "sha512-SlJDfG6RPeEX8wEVv6ZB3kak4MmbtyiI2qX/5zuKdordbrhB/iaJ58GVMZgJ6P1sJaM1gMgENFYYeg1JWrCFrA==" "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==", "integrity": "sha512-tLja5n4dyMhcze1NzvSs2iiriBymfBlDCZIrjMTxb9O2ru0gvmV6mn5oBD2teNw5Sd92cj3YJzKwsAs8tMJXlg==",
"dependencies": [ "dependencies": [
"vite" "vite"
@ -443,7 +444,7 @@
"acorn" "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==", "integrity": "sha512-iwQ8Z4ET6ZFSt/gC+tVfcsSBHwsqc6RumSaiLUkAurW3BCpJam65cmHw0oOlDMTO0u+PZi9hilBRYN+LZNHTUQ==",
"dependencies": [ "dependencies": [
"@sveltejs/vite-plugin-svelte", "@sveltejs/vite-plugin-svelte",
@ -452,7 +453,7 @@
"vite" "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==", "integrity": "sha512-vB0Vq47Js7C11L2JrwhncIAoDNkdKDPI500SjLSb34X48dDcsSH5JpLl0cHT0sfO997BrzAS6PKjiZEey/S0VQ==",
"dependencies": [ "dependencies": [
"@sveltejs/vite-plugin-svelte-inspector", "@sveltejs/vite-plugin-svelte-inspector",
@ -492,6 +493,9 @@
"aria-query@5.3.2": { "aria-query@5.3.2": {
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==" "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="
}, },
"attq@0.2.0": {
"integrity": "sha512-I6QcR5BrweKK2HuzFovudxHFNRjcLjYM3HyE+9ufdPxBivU6ZL4TUwSO7C59odsz6HRqp1J0VXnta7FjwSwQEw=="
},
"axobject-query@4.1.0": { "axobject-query@4.1.0": {
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==" "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="
}, },
@ -613,10 +617,10 @@
"fdir@6.4.6_picomatch@4.0.3": { "fdir@6.4.6_picomatch@4.0.3": {
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
"dependencies": [ "dependencies": [
"picomatch@4.0.3" "picomatch"
], ],
"optionalPeers": [ "optionalPeers": [
"picomatch@4.0.3" "picomatch"
] ]
}, },
"fill-range@7.1.1": { "fill-range@7.1.1": {
@ -687,8 +691,7 @@
"micromatch@4.0.8": { "micromatch@4.0.8": {
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dependencies": [ "dependencies": [
"braces", "braces"
"picomatch@2.3.1"
] ]
}, },
"mri@1.2.0": { "mri@1.2.0": {
@ -729,9 +732,6 @@
"picocolors@1.1.1": { "picocolors@1.1.1": {
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
}, },
"picomatch@2.3.1": {
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="
},
"picomatch@4.0.3": { "picomatch@4.0.3": {
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==" "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="
}, },
@ -1050,7 +1050,7 @@
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"dependencies": [ "dependencies": [
"fdir", "fdir",
"picomatch@4.0.3" "picomatch"
] ]
}, },
"to-regex-range@5.0.1": { "to-regex-range@5.0.1": {
@ -1083,12 +1083,12 @@
"varint@6.0.0": { "varint@6.0.0": {
"integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==" "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==", "integrity": "sha512-J0SQBPlQiEXAF7tajiH+rUooJPo0l8KQgyg4/aMunNtrOa7bwuZJsJbDWzeljqQpgftxuq5yNJxQ91O9ts29UQ==",
"dependencies": [ "dependencies": [
"esbuild", "esbuild",
"fdir", "fdir",
"picomatch@4.0.3", "picomatch",
"postcss", "postcss",
"rollup", "rollup",
"sass-embedded", "sass-embedded",
@ -1102,7 +1102,7 @@
], ],
"bin": true "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==", "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
"dependencies": [ "dependencies": [
"vite" "vite"
@ -1180,6 +1180,7 @@
"npm:@deno/vite-plugin@^1.0.5", "npm:@deno/vite-plugin@^1.0.5",
"npm:@sveltejs/vite-plugin-svelte@^6.1.1", "npm:@sveltejs/vite-plugin-svelte@^6.1.1",
"npm:@tsconfig/svelte@^5.0.4", "npm:@tsconfig/svelte@^5.0.4",
"npm:attq@0.2",
"npm:date-fns@^4.1.0", "npm:date-fns@^4.1.0",
"npm:svelte-check@^4.3.1", "npm:svelte-check@^4.3.1",
"npm:svelte-language-server@~0.17.19", "npm:svelte-language-server@~0.17.19",

View file

@ -14,6 +14,7 @@
"@date-fns/utc": "^2.1.1", "@date-fns/utc": "^2.1.1",
"@deno/vite-plugin": "^1.0.5", "@deno/vite-plugin": "^1.0.5",
"@sveltejs/vite-plugin-svelte": "^6.1.1", "@sveltejs/vite-plugin-svelte": "^6.1.1",
"attq": "^0.2.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"svelte-language-server": "^0.17.19", "svelte-language-server": "^0.17.19",
"uuid": "^11.1.0", "uuid": "^11.1.0",

View file

@ -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="?">&hellip;</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>

View file

@ -16,11 +16,11 @@ submission.
incompatible with the current presentation configuration.--> incompatible with the current presentation configuration.-->
<script lang="ts"> <script lang="ts">
import FieldDetails from "./field-details.svelte"; import FieldDetails from "../field-details.svelte";
import { import {
all_presentation_tags, all_presentation_tags,
type Presentation, type Presentation,
} from "./presentation.svelte"; } from "../presentation.svelte";
type Assert<_T extends true> = void; type Assert<_T extends true> = void;

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import BasicDropdown from "./basic-dropdown.webc.svelte"; import BasicDropdown from "../basic-dropdown.webc.svelte";
import { type FieldInfo } from "./field.svelte"; import { type FieldInfo } from "../field.svelte";
import FieldDetails from "./field-details.svelte"; import FieldDetails from "../field-details.svelte";
const MAX_COL_WIDTH_PX = 800; const MAX_COL_WIDTH_PX = 800;
const MIN_COL_WIDTH_PX = 160; const MIN_COL_WIDTH_PX = 160;

View 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="?">&hellip;</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>

View 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>

View file

@ -2,9 +2,9 @@
import { utc } from "@date-fns/utc"; import { utc } from "@date-fns/utc";
import { format as format_date } from "date-fns"; 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 Coords } from "./coords.svelte";
import { type Datum } from "./datum.svelte";
import { type FieldInfo } from "./field.svelte";
type Props = { type Props = {
coords: Coords; coords: Coords;

View 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;
}

View file

@ -7,11 +7,20 @@ export default defineConfig({
plugins: [svelte()], plugins: [svelte()],
build: { build: {
rollupOptions: { rollupOptions: {
input: [ input: Object.fromEntries([
...Deno.readDirSync("./src") ...Deno.readDirSync("./src")
.filter(({ name }) => name.endsWith(".webc.svelte")) .filter(({ isFile, name }) => isFile && name.endsWith(".webc.svelte"))
.map(({ name }) => path.join("./src", name)), .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: { output: {
dir: path.fromFileUrl(new URL("../js_dist", import.meta.url)), dir: path.fromFileUrl(new URL("../js_dist", import.meta.url)),
entryFileNames: "[name].mjs", entryFileNames: "[name].mjs",