phonograph/svelte/src/table-viewer.webc.svelte
2025-08-13 14:48:32 -07:00

533 lines
15 KiB
Svelte

<svelte:options customElement={{ tag: "table-viewer", shadow: "none" }} />
<script lang="ts">
import * as uuid from "uuid";
import {
type Coords,
type Encodable,
type Field,
type Row,
type FieldType,
coords_eq,
} from "./field.svelte";
type CommittedChange = {
coords_initial: Coords;
// This will be identical to coords_initial, unless the change altered a
// primary key.
coords_updated: Coords;
value_initial: Encodable;
value_updated: Encodable;
};
type LazyData = {
rows: Row[];
fields: Field[];
};
type Selection = {
region: "main" | "inserter";
coords: Coords;
original_value: Encodable;
};
type ParsedPkey = Record<string, Encodable>;
let selections = $state<Selection[]>([]);
let editing = $state(false);
let editor_input_value = $state("");
let committed_changes = $state<CommittedChange[][]>([]);
let reverted_changes = $state<CommittedChange[][]>([]);
let editor_input_element = $state<HTMLInputElement | undefined>();
let table_element = $state<HTMLDivElement | undefined>();
let inserter_rows = $state<Row[]>([]);
let lazy_data = $state<LazyData | undefined>();
// -------- Helper Functions -------- //
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 try_parse_editor_value(
field_type: FieldType,
): Encodable | undefined {
if (field_type.t === "Text") {
return {
t: "Text",
c: editor_input_value,
};
}
if (field_type.t === "Uuid") {
try {
return {
t: "Uuid",
c: uuid.stringify(uuid.parse(editor_input_value)),
};
} catch {
// uuid.parse() throws a TypeError if unsuccessful.
return undefined;
}
}
throw new Error("Unknown field type");
}
// -------- Updates and Effects -------- //
function set_selections(arr: Omit<Selection, "original_value">[]) {
selections = arr.map((sel) => {
let cell_data: Encodable | undefined;
if (sel.region === "main") {
cell_data = lazy_data?.rows[sel.coords[0]].data[sel.coords[1]];
} else if (sel.region === "inserter") {
cell_data = inserter_rows[sel.coords[0]].data[sel.coords[1]];
} else {
throw new Error("invalid region");
}
return {
...sel,
original_value: cell_data!,
};
});
if (arr.length === 1) {
const [sel] = arr;
let cell_data: Encodable | undefined;
if (sel.region === "main") {
cell_data = lazy_data?.rows[sel.coords[0]].data[sel.coords[1]];
} else if (sel.region === "inserter") {
cell_data = inserter_rows[sel.coords[0]].data[sel.coords[1]];
}
if (cell_data?.t === "Text" || cell_data?.t === "Uuid") {
editor_input_value = cell_data.c ?? "";
} else {
editor_input_value = "";
}
} else {
editor_input_value = "";
}
}
function try_move_selection(direction: "Down" | "Left" | "Right" | "Up") {
if (lazy_data && !editing && selections.length > 0) {
const last_selection = selections[selections.length - 1];
if (
direction === "Right" &&
last_selection.coords[1] < lazy_data.fields.length - 1
) {
set_selections([
{
region: last_selection.region,
coords: [last_selection.coords[0], last_selection.coords[1] + 1],
},
]);
} else if (direction === "Left" && last_selection.coords[1] > 0) {
set_selections([
{
region: last_selection.region,
coords: [last_selection.coords[0], last_selection.coords[1] - 1],
},
]);
} else if (direction === "Down") {
if (last_selection.region === "main") {
if (last_selection.coords[0] < lazy_data.rows.length - 1) {
set_selections([
{
region: "main",
coords: [
last_selection.coords[0] + 1,
last_selection.coords[1],
],
},
]);
} else {
// At bottom of main table.
set_selections([
{
region: "inserter",
coords: [0, last_selection.coords[1]],
},
]);
}
} else if (last_selection.region === "inserter") {
if (last_selection.coords[0] < inserter_rows.length - 1) {
set_selections([
{
region: "inserter",
coords: [
last_selection.coords[0] + 1,
last_selection.coords[1],
],
},
]);
}
}
} else if (direction === "Up") {
if (last_selection.region === "main") {
if (last_selection.coords[0] > 0) {
set_selections([
{
region: "main",
coords: [
last_selection.coords[0] - 1,
last_selection.coords[1],
],
},
]);
}
} else if (last_selection.region === "inserter") {
if (last_selection.coords[0] > 0) {
set_selections([
{
region: "inserter",
coords: [
last_selection.coords[0] - 1,
last_selection.coords[1],
],
},
]);
} else {
// At top of inserter table.
set_selections([
{
region: "main",
coords: [lazy_data.rows.length - 1, last_selection.coords[1]],
},
]);
}
}
}
}
}
function try_sync_edit_to_cells() {
if (lazy_data && editing && selections.length === 1) {
const [sel] = selections;
const parsed = try_parse_editor_value(
lazy_data.fields[sel.coords[1]].field_type,
);
if (parsed !== undefined) {
if (sel.region === "main") {
lazy_data.rows[sel.coords[0]].data[sel.coords[1]] = parsed;
} else if (sel.region === "inserter") {
inserter_rows[sel.coords[0]].data[sel.coords[1]] = parsed;
} else {
throw new Error("Unknown region");
}
}
}
}
function try_start_edit() {
if (!editing) {
editing = true;
editor_input_element?.focus();
}
}
function try_commit_edit() {
(async function () {
if (lazy_data && editing && selections.length === 1) {
const [sel] = selections;
const field = lazy_data.fields[sel.coords[1]];
const parsed = try_parse_editor_value(field.field_type);
if (parsed !== undefined) {
if (sel.region === "main") {
const pkey = JSON.parse(
lazy_data.rows[sel.coords[0]].key as string,
) as ParsedPkey;
const resp = await fetch("update-value", {
method: "post",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
column: field.name,
pkeys: pkey,
value: parsed,
}),
});
if (resp.status >= 200 && resp.status < 300) {
committed_changes.push([
{
coords_initial: sel.coords,
coords_updated: sel.coords, // TODO: this assumes no inserted/deleted rows
value_initial: sel.original_value,
value_updated: parsed,
},
]);
editing = false;
selections = [{ ...sel, original_value: parsed }];
table_element?.focus();
} else {
// TODO display feedback to user
console.error(resp);
console.error(await resp.text());
}
} else if (sel.region === "inserter") {
table_element?.focus();
editing = false;
selections = [{ ...sel, original_value: parsed }];
} else {
throw new Error("Unknown region");
}
} else {
// TODO
}
}
})().catch(console.error);
}
function cancel_edit() {
selections.forEach(({ coords, original_value, region }) => {
if (region === "main") {
if (lazy_data) {
lazy_data.rows[coords[0]].data[coords[1]] = original_value;
}
} else if (region === "inserter") {
inserter_rows[coords[0]].data[coords[1]] = original_value;
} else {
throw new Error("Unknown region");
}
});
// Reset editor input value
set_selections(selections);
editing = false;
table_element?.focus();
}
// -------- Event Handlers: Both Tables -------- //
function handle_table_keydown(ev: KeyboardEvent) {
const arrow_direction = arrow_key_direction(ev.key);
if (arrow_direction) {
try_move_selection(arrow_direction);
ev.preventDefault();
}
if (ev.key === "Enter") {
try_start_edit();
}
}
function handle_table_cell_dblclick(_: Coords) {
try_start_edit();
}
function handle_table_focus() {
if (selections.length === 0 && (lazy_data?.rows[0]?.data.length ?? 0) > 0) {
set_selections([{ region: "main", coords: [0, 0] }]);
}
}
// -------- Event Handlers: Main Table -------- //
function handle_main_cell_click(ev: MouseEvent, coords: Coords) {
if (!editing) {
if (ev.metaKey || ev.ctrlKey) {
// TODO
// selections = [...selections.filter((prev) => !coords_eq(prev, coords)), coords];
// editor_input_value = "";
} else {
set_selections([{ region: "main", coords }]);
}
}
}
// -------- Event Handlers: Inserter Table -------- //
function handle_inserter_cell_click(ev: MouseEvent, coords: Coords) {
if (!editing) {
if (ev.metaKey || ev.ctrlKey) {
// TODO
// selections = [...selections.filter((prev) => !coords_eq(prev, coords)), coords];
// editor_input_value = "";
} else {
set_selections([{ region: "inserter", coords }]);
}
}
}
// -------- Event Handlers: Editor -------- //
function handle_editor_blur() {
try_commit_edit();
}
function handle_editor_focus() {
try_start_edit();
}
function handle_editor_input() {
try_sync_edit_to_cells();
}
function handle_editor_keydown(ev: KeyboardEvent) {
if (ev.key === "Enter") {
try_commit_edit();
} else if (ev.key === "Escape") {
cancel_edit();
}
}
// -------- Initial API Fetch -------- //
(async function () {
interface GetDataResponse {
data: {
pkey: string;
data: Encodable[];
}[];
fields: Field[];
}
const resp = await fetch("get-data");
const body: GetDataResponse = await resp.json();
lazy_data = {
fields: body.fields,
rows: body.data.map(({ data, pkey }) => ({ data, key: pkey })),
};
inserter_rows = [
{
key: 0,
data: body.fields.map(() => ({ t: "Text", c: undefined })),
},
];
})().catch(console.error);
</script>
{#snippet table_region({
region_name,
rows,
on_cell_click,
}: {
region_name: string;
rows: Row[];
on_cell_click(ev: MouseEvent, coords: Coords): void;
})}
{#if lazy_data}
{#each rows as row, row_index}
<div class="lens-table__row" role="row">
{#each lazy_data.fields as field, field_index}
{@const cell_data = row.data[field_index]}
{@const cell_coords: Coords = [row_index, field_index]}
{@const cell_selected = selections.some(
(sel) =>
sel.region === region_name && coords_eq(sel.coords, cell_coords),
)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
aria-colindex={field_index}
aria-rowindex={row_index}
aria-selected={cell_selected}
class={[
"lens-table__cell",
cell_selected && "lens-table__cell--selected",
]}
onmousedown={(ev) => on_cell_click(ev, cell_coords)}
ondblclick={() => handle_table_cell_dblclick(cell_coords)}
role="gridcell"
style:width={`${field.width_px}px`}
tabindex="-1"
>
{#if cell_data.t === "Text"}
<div
class={[
"lens-table__cell-content",
"lens-table__cell-content--text",
(cell_data.c ?? undefined) === undefined &&
"lens-table__cell-content--null",
]}
>
{cell_data.c ?? "Null"}
</div>
{:else if cell_data.t === "Uuid"}
<div
class={[
"lens-table__cell-content",
"lens-table__cell-content--uuid",
(cell_data.c ?? undefined) === undefined &&
"lens-table__cell-content--null",
]}
>
{cell_data.c ?? "Null"}
</div>
{:else}
<div
class={[
"lens-table__cell-content",
"lens-table__cell-content--unknown",
]}
>
UNKNOWN
</div>
{/if}
</div>
{/each}
</div>
{/each}
{/if}
{/snippet}
<div class="lens-grid">
{#if lazy_data}
<div
bind:this={table_element}
class="lens-table"
onfocus={handle_table_focus}
onkeydown={handle_table_keydown}
role="grid"
tabindex="0"
>
<div class={["lens-table__headers"]}>
{#each lazy_data.fields as field, field_index}
<div
aria-colindex={field_index}
class="lens-table__header"
role="columnheader"
style:width={`${field.width_px}px`}
>
<div>{field.label ?? field.name}</div>
</div>
{/each}
<div class="lens-table__header-actions">TODO</div>
</div>
<div class="lens-table__main">
{@render table_region({
region_name: "main",
rows: lazy_data.rows,
on_cell_click: handle_main_cell_click,
})}
</div>
<div class="lens-table__inserter">
{@render table_region({
region_name: "inserter",
rows: inserter_rows,
on_cell_click: handle_inserter_cell_click,
})}
</div>
</div>
<div class="lens-editor">
<input
bind:this={editor_input_element}
bind:value={editor_input_value}
class={[
"lens-editor__input",
selections.length !== 1 && "lens-editor__input--hidden",
]}
onblur={handle_editor_blur}
onfocus={handle_editor_focus}
oninput={handle_editor_input}
onkeydown={handle_editor_keydown}
/>
</div>
{/if}
</div>