1
0
Fork 0
forked from 2sys/phonograph
phonograph/svelte/src/table-viewer.webc.svelte

720 lines
22 KiB
Svelte
Raw Normal View History

2025-09-08 15:56:57 -07:00
<svelte:options
customElement={{
props: {
columns: { type: "Array" },
},
shadow: "none",
tag: "table-viewer",
}}
/>
2025-08-10 14:32:15 -07:00
<script lang="ts">
2025-08-13 18:52:37 -07:00
import { z } from "zod";
2025-08-10 14:32:15 -07:00
import {
type Coords,
coords_eq,
2025-11-10 08:43:33 +00:00
get_box,
offset_coords,
} from "./coords.svelte";
import {
type Datum,
datum_schema,
2025-11-11 01:26:48 +00:00
parse_datum_from_text,
2025-11-10 08:43:33 +00:00
} from "./datum.svelte";
import DatumEditor from "./datum-editor.svelte";
import { type Row, type FieldInfo, field_info_schema } from "./field.svelte";
import FieldAdder from "./field-adder.svelte";
2025-08-13 18:52:37 -07:00
import FieldHeader from "./field-header.svelte";
import { get_empty_datum_for } from "./presentation.svelte";
2025-11-02 20:26:33 +00:00
import TableCell from "./table-cell.svelte";
2025-09-08 15:56:57 -07:00
type Props = {
columns?: {
name: string;
regtype: string;
}[];
2025-09-08 15:56:57 -07:00
};
let { columns = [] }: Props = $props();
2025-08-10 14:32:15 -07:00
2025-10-16 06:40:11 +00:00
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;
2025-08-10 14:32:15 -07:00
};
2025-10-16 06:40:11 +00:00
type Delta = {
cells: CellDelta[];
};
2025-08-10 14:32:15 -07:00
type LazyData = {
rows: Row[];
2025-08-13 18:52:37 -07:00
fields: FieldInfo[];
2025-08-10 14:32:15 -07:00
};
type Selection = {
coords: Coords;
original_value: Datum;
2025-08-10 14:32:15 -07:00
};
let selections = $state<Selection[]>([]);
2025-10-16 06:40:11 +00:00
// 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>();
2025-11-02 20:26:33 +00:00
let focus_cursor = $state<(() => unknown) | undefined>();
2025-08-10 14:32:15 -07:00
let inserter_rows = $state<Row[]>([]);
let lazy_data = $state<LazyData | undefined>();
2025-11-05 22:48:55 +00:00
let dragged_header = $state<number | undefined>();
2025-08-10 14:32:15 -07:00
// -------- Helper Functions -------- //
2025-11-10 08:43:33 +00:00
function range(from: number, until: number): number[] {
const arr: number[] = [];
for (let n = from; n < until; n += 1) {
arr.push(n);
}
return arr;
}
2025-08-10 14:32:15 -07:00
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;
}
}
2025-11-05 22:48:55 +00:00
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();
}
2025-08-10 14:32:15 -07:00
// -------- Updates and Effects -------- //
2025-11-10 08:43:33 +00:00
function set_selections(arr: Coords[]) {
selections = arr.map((coords) => {
let cell_data: Datum | undefined;
2025-11-10 08:43:33 +00:00
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];
2025-08-10 14:32:15 -07:00
} else {
2025-11-10 08:43:33 +00:00
throw new Error(`invalid region: ${coords.region}`);
2025-08-10 14:32:15 -07:00
}
2025-11-10 08:43:33 +00:00
return { coords, original_value: cell_data! };
2025-08-10 14:32:15 -07:00
});
if (arr.length === 1) {
2025-11-10 08:43:33 +00:00
const [coords] = arr;
let cell_data: Datum | undefined;
2025-11-10 08:43:33 +00:00
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];
2025-08-10 14:32:15 -07:00
}
2025-10-16 06:40:11 +00:00
editor_value = cell_data;
2025-08-10 14:32:15 -07:00
} else {
2025-10-16 06:40:11 +00:00
editor_value = undefined;
2025-08-10 14:32:15 -07:00
}
}
function move_cursor(
2025-11-10 08:43:33 +00:00
new_cursor: Coords,
{ additive }: { additive?: boolean } = {},
) {
2025-10-16 06:40:11 +00:00
if (!lazy_data || selections.length === 0) {
console.warn("move_selection() preconditions not met");
return;
}
2025-11-10 08:43:33 +00:00
const first_selection = selections[selections.length - 1];
2025-10-16 06:40:11 +00:00
if (
2025-11-10 08:43:33 +00:00
additive &&
first_selection !== undefined &&
!coords_eq(new_cursor, first_selection.coords)
2025-10-16 06:40:11 +00:00
) {
2025-11-10 08:43:33 +00:00
// 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]);
}
2025-08-10 14:32:15 -07:00
}
function try_sync_edit_to_cells() {
2025-11-10 08:43:33 +00:00
if (!lazy_data || selections.length === 0) {
console.warn("preconditions for try_sync_edit_to_cells() not met");
return;
}
if (editor_value === undefined) {
return;
}
2025-11-10 08:43:33 +00:00
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");
}
2025-08-10 14:32:15 -07:00
}
}
2025-10-16 06:40:11 +00:00
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");
2025-10-16 06:40:11 +00:00
cancel_edit();
} else {
if (selections.length > 0) {
deltas.commit_queued = [
...deltas.commit_queued,
{
2025-11-10 08:43:33 +00:00
cells: selections.map((sel) => ({
coords: sel.coords,
value_initial: sel.original_value,
value_updated: editor_value_scoped,
})),
2025-10-16 06:40:11 +00:00
},
];
console.debug("Commit queue:", deltas.commit_queued);
2025-10-16 06:40:11 +00:00
selections = selections.map((sel) => ({
...sel,
original_value: editor_value_scoped,
}));
}
2025-08-10 14:32:15 -07:00
}
}
2025-10-16 06:40:11 +00:00
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({
2025-11-10 08:43:33 +00:00
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,
})),
2025-10-16 06:40:11 +00:00
}),
});
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);
}
2025-08-10 14:32:15 -07:00
}
function cancel_edit() {
2025-11-10 08:43:33 +00:00
selections.forEach(({ coords, original_value }) => {
if (coords.region === "main") {
2025-08-10 14:32:15 -07:00
if (lazy_data) {
2025-11-10 08:43:33 +00:00
lazy_data.rows[coords.row_idx].data[coords.field_idx] =
original_value;
2025-08-10 14:32:15 -07:00
}
2025-11-10 08:43:33 +00:00
} else if (coords.region === "inserter") {
inserter_rows[coords.row_idx].data[coords.field_idx] = original_value;
2025-08-10 14:32:15 -07:00
} else {
throw new Error("Unknown region");
}
});
// Reset editor input value
2025-11-10 08:43:33 +00:00
set_selections(selections.map(({ coords }) => coords));
2025-08-10 14:32:15 -07:00
}
2025-11-02 20:26:33 +00:00
// -------- Event Handlers -------- //
2025-08-10 14:32:15 -07:00
function handle_cell_keydown(ev: KeyboardEvent) {
2025-11-02 20:26:33 +00:00
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();
2025-11-10 08:43:33 +00:00
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) {
2025-11-10 08:43:33 +00:00
move_cursor(new_cursor, { additive: true });
} else {
2025-11-10 08:43:33 +00:00
move_cursor(new_cursor);
}
} else if (
!ev.altKey &&
!ev.ctrlKey &&
!ev.metaKey &&
/^[a-zA-Z0-9`~!@#$%^&*()_=+[\]{}\\|;:'",<.>/?-]$/.test(ev.key)
) {
2025-11-02 20:26:33 +00:00
const sel = selections[0];
if (sel) {
editor_value = get_empty_datum_for(
2025-11-10 08:43:33 +00:00
lazy_data.fields[sel.coords.field_idx].field.presentation,
2025-11-02 20:26:33 +00:00
);
datum_editor?.focus();
2025-08-13 18:52:37 -07:00
}
2025-11-02 20:26:33 +00:00
} else if (ev.key === "Enter") {
if (ev.shiftKey) {
2025-11-10 08:43:33 +00:00
if (selections[0]?.coords.region === "main") {
2025-11-02 20:26:33 +00:00
set_selections([
{
region: "inserter",
2025-11-10 08:43:33 +00:00
row_idx: 0,
field_idx: selections[0]?.coords.field_idx ?? 0,
2025-11-02 20:26:33 +00:00
},
]);
2025-08-13 18:52:37 -07:00
} else {
2025-11-02 20:26:33 +00:00
inserter_rows = [
...inserter_rows,
{
key: inserter_rows.length,
data: lazy_data.fields.map(({ field: { presentation } }) =>
get_empty_datum_for(presentation),
),
},
];
2025-08-13 18:52:37 -07:00
}
2025-11-02 20:26:33 +00:00
} else {
datum_editor?.focus();
2025-08-13 18:52:37 -07:00
}
2025-08-10 14:32:15 -07:00
}
}
2025-11-02 20:26:33 +00:00
function handle_restore_focus() {
focus_cursor?.();
2025-08-10 14:32:15 -07:00
}
2025-11-10 08:43:33 +00:00
/**
* 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) =>
2025-11-11 01:26:48 +00:00
parse_datum_from_text(presentation, raw_values[i] ?? ""),
2025-11-10 08:43:33 +00:00
);
});
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
}
2025-08-10 14:32:15 -07:00
// -------- Initial API Fetch -------- //
(async function () {
2025-08-13 18:52:37 -07:00
const get_data_response_schema = z.object({
rows: z.array(
z.object({
pkey: z.string(),
data: z.array(datum_schema),
2025-08-13 18:52:37 -07:00
}),
),
fields: z.array(field_info_schema),
});
2025-08-10 14:32:15 -07:00
const resp = await fetch("get-data");
2025-08-13 18:52:37 -07:00
const body = get_data_response_schema.parse(await resp.json());
2025-08-10 14:32:15 -07:00
lazy_data = {
fields: body.fields,
2025-08-13 18:52:37 -07:00
rows: body.rows.map(({ data, pkey }) => ({ data, key: pkey })),
2025-08-10 14:32:15 -07:00
};
inserter_rows = [
{
key: 0,
2025-09-08 15:56:57 -07:00
data: body.fields.map(({ field: { presentation } }) =>
get_empty_datum_for(presentation),
2025-08-13 18:52:37 -07:00
),
2025-08-10 14:32:15 -07:00
},
];
2025-11-02 20:26:33 +00:00
if (lazy_data.rows.length > 0 && lazy_data.fields.length > 0) {
2025-11-10 08:43:33 +00:00
set_selections([{ region: "main", row_idx: 0, field_idx: 0 }]);
2025-11-02 20:26:33 +00:00
}
2025-08-10 14:32:15 -07:00
})().catch(console.error);
2025-10-16 06:40:11 +00:00
setInterval(tick_delta_queue, 500);
2025-08-10 14:32:15 -07:00
</script>
{#snippet table_region({
2025-11-10 08:43:33 +00:00
region,
2025-08-10 14:32:15 -07:00
rows,
on_cell_click,
}: {
2025-11-10 08:43:33 +00:00
region: "main" | "inserter";
2025-08-10 14:32:15 -07:00
rows: Row[];
on_cell_click(ev: MouseEvent, coords: Coords): void;
})}
{#if lazy_data}
2025-11-10 08:43:33 +00:00
{#each rows as row, row_idx}
2025-08-10 14:32:15 -07:00
<div class="lens-table__row" role="row">
2025-11-10 08:43:33 +00:00
{#each lazy_data.fields as field, field_idx}
2025-11-02 20:26:33 +00:00
<TableCell
2025-11-10 08:43:33 +00:00
coords={{ region, row_idx, field_idx }}
2025-11-11 06:42:54 +00:00
cursor={selections.length !== 0 &&
coords_eq(selections[0].coords, {
region,
row_idx,
field_idx,
})}
2025-11-02 20:26:33 +00:00
{field}
onbecomecursor={(focus) => {
focus_cursor = focus;
2025-10-16 06:40:11 +00:00
}}
2025-11-02 20:26:33 +00:00
ondblclick={() => datum_editor?.focus()}
onkeydown={(ev) => handle_cell_keydown(ev)}
2025-11-02 20:26:33 +00:00
onmousedown={on_cell_click}
2025-11-10 08:43:33 +00:00
onpaste={handle_cell_paste}
2025-11-02 20:26:33 +00:00
selected={selections.some(
(sel) =>
2025-11-10 08:43:33 +00:00
sel.coords.region === region &&
coords_eq(sel.coords, { region, row_idx, field_idx }),
2025-11-02 20:26:33 +00:00
)}
2025-11-10 08:43:33 +00:00
table_region={region}
value={row.data[field_idx]}
2025-11-02 20:26:33 +00:00
/>
2025-08-10 14:32:15 -07:00
{/each}
</div>
{/each}
{/if}
{/snippet}
2025-11-10 08:43:33 +00:00
<div class="lens-grid">
2025-08-10 14:32:15 -07:00
{#if lazy_data}
2025-11-02 20:26:33 +00:00
<div class="lens-table" role="grid">
2025-08-10 14:32:15 -07:00
<div class={["lens-table__headers"]}>
2025-09-08 15:56:57 -07:00
{#each lazy_data.fields as _, field_index}
<FieldHeader
bind:field={lazy_data.fields[field_index]}
index={field_index}
2025-11-05 22:48:55 +00:00
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,
});
}
}}
2025-09-08 15:56:57 -07:00
/>
2025-08-10 14:32:15 -07:00
{/each}
2025-09-08 15:56:57 -07:00
<div class="lens-table__header-actions">
<FieldAdder {columns}></FieldAdder>
2025-09-08 15:56:57 -07:00
</div>
2025-08-10 14:32:15 -07:00
</div>
<div class="lens-table__main">
{@render table_region({
2025-11-10 08:43:33 +00:00
region: "main",
2025-08-10 14:32:15 -07:00
rows: lazy_data.rows,
2025-11-02 20:26:33 +00:00
on_cell_click: (ev: MouseEvent, coords: Coords) => {
if (ev.metaKey || ev.ctrlKey) {
2025-11-10 08:43:33 +00:00
set_selections([
coords,
...selections
.filter((sel) => !coords_eq(sel.coords, coords))
.map((sel) => sel.coords),
]);
} else if (ev.shiftKey) {
move_cursor(coords, { additive: true });
2025-11-02 20:26:33 +00:00
} else {
2025-11-10 08:43:33 +00:00
move_cursor(coords);
2025-11-02 20:26:33 +00:00
}
},
2025-08-10 14:32:15 -07:00
})}
</div>
2025-08-13 18:52:37 -07:00
<form method="post" action="insert">
2025-11-02 20:26:33 +00:00
<div class="lens-inserter">
<h3 class="lens-inserter__help">
Insert rows &mdash; press "shift + enter" to jump here or add a row
</h3>
<div class="lens-inserter__main">
<div class="lens-inserter__rows">
{@render table_region({
2025-11-10 08:43:33 +00:00
region: "inserter",
2025-11-02 20:26:33 +00:00
rows: inserter_rows,
on_cell_click: (ev: MouseEvent, coords: Coords) => {
if (ev.metaKey || ev.ctrlKey) {
2025-11-10 08:43:33 +00:00
set_selections([
coords,
...selections
.filter((sel) => !coords_eq(sel.coords, coords))
.map((sel) => sel.coords),
]);
} else if (ev.shiftKey) {
move_cursor(coords, { additive: true });
2025-11-02 20:26:33 +00:00
} else {
2025-11-10 08:43:33 +00:00
move_cursor(coords);
2025-11-02 20:26:33 +00:00
}
},
})}
</div>
<button
aria-label="Insert rows"
class="lens-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>
2025-08-13 18:52:37 -07:00
</div>
</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>
2025-08-10 14:32:15 -07:00
</div>
2025-10-16 06:40:11 +00:00
<div class="table-viewer__datum-editor">
2025-11-10 08:43:33 +00:00
{#if selections.length !== 0 && selections.every(({ coords: { field_idx } }) => field_idx === selections[0]?.coords.field_idx)}
<DatumEditor
2025-10-16 06:40:11 +00:00
bind:this={datum_editor}
bind:value={editor_value}
2025-11-10 08:43:33 +00:00
field_info={lazy_data.fields[selections[0].coords.field_idx]}
2025-11-02 20:26:33 +00:00
on_blur={() => try_queue_delta()}
on_cancel_edit={cancel_edit}
2025-10-16 06:40:11 +00:00
on_change={() => {
try_sync_edit_to_cells();
}}
2025-11-02 20:26:33 +00:00
on_restore_focus={handle_restore_focus}
2025-08-24 23:24:01 -07:00
/>
{/if}
2025-08-10 14:32:15 -07:00
</div>
{/if}
</div>