533 lines
15 KiB
Svelte
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>
|