cursor-based clipboard paste support
This commit is contained in:
parent
52c014e53e
commit
95a4165163
5 changed files with 429 additions and 256 deletions
136
svelte/src/coords.svelte.ts
Normal file
136
svelte/src/coords.svelte.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
export type TableViewerShape = {
|
||||
n_fields: number;
|
||||
n_rows_main: number;
|
||||
n_rows_inserter: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the location of a table cell in the table UI.
|
||||
*/
|
||||
export type Coords = {
|
||||
region: "main" | "inserter";
|
||||
row_idx: number;
|
||||
field_idx: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compare two table coordinates for equality.
|
||||
*/
|
||||
export function coords_eq(a: Coords, b: Coords): boolean {
|
||||
return a.region === b.region && a.row_idx === b.row_idx &&
|
||||
a.field_idx === b.field_idx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cell coordinates offset relative to a starting position. Clamps
|
||||
* the result to available positions. Assumes that each table region has at
|
||||
* least 1 row.
|
||||
*/
|
||||
export function offset_coords(
|
||||
start: Coords,
|
||||
row_offset: number,
|
||||
field_offset: number,
|
||||
boundaries: TableViewerShape,
|
||||
): Coords {
|
||||
const naive_field_idx = start.field_idx + field_offset;
|
||||
const bounded_field_idx = Math.min(
|
||||
boundaries.n_fields - 1,
|
||||
Math.max(0, naive_field_idx),
|
||||
);
|
||||
const naive_row_idx = start.row_idx + row_offset;
|
||||
if (start.region === "inserter" && naive_row_idx < 0) {
|
||||
return {
|
||||
region: "main",
|
||||
row_idx: Math.max(0, boundaries.n_rows_main + naive_row_idx),
|
||||
field_idx: bounded_field_idx,
|
||||
};
|
||||
}
|
||||
if (start.region === "main" && naive_row_idx >= boundaries.n_rows_main) {
|
||||
return {
|
||||
region: "inserter",
|
||||
row_idx: Math.min(
|
||||
boundaries.n_rows_inserter - 1,
|
||||
naive_row_idx - boundaries.n_rows_main,
|
||||
),
|
||||
field_idx: bounded_field_idx,
|
||||
};
|
||||
}
|
||||
const n_rows = start.region === "main"
|
||||
? boundaries.n_rows_main
|
||||
: boundaries.n_rows_inserter;
|
||||
return {
|
||||
region: start.region,
|
||||
row_idx: Math.min(n_rows, Math.max(0, naive_row_idx)),
|
||||
field_idx: bounded_field_idx,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of coordinates comprising the rectangular box defined by the
|
||||
* coordinates of any pair of opposite corners. No guarantees are made
|
||||
* concerning the ordering of the returned array.
|
||||
*/
|
||||
export function get_box(
|
||||
corner_a: Coords,
|
||||
corner_b: Coords,
|
||||
boundaries: TableViewerShape,
|
||||
): Coords[] {
|
||||
const box: Coords[] = [];
|
||||
const left_idx = Math.min(corner_a.field_idx, corner_b.field_idx);
|
||||
const right_idx = Math.max(corner_a.field_idx, corner_b.field_idx);
|
||||
if (corner_a.region === corner_b.region) {
|
||||
for (
|
||||
let row_idx = Math.min(corner_a.row_idx, corner_b.row_idx);
|
||||
row_idx <= Math.max(corner_a.row_idx, corner_b.row_idx);
|
||||
row_idx += 1
|
||||
) {
|
||||
for (let field_idx = left_idx; field_idx <= right_idx; field_idx += 1) {
|
||||
box.push({
|
||||
region: corner_b.region,
|
||||
row_idx,
|
||||
field_idx,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const corner_main = corner_b.region === "main" ? corner_b : corner_a;
|
||||
const corner_inserter = corner_b.region === "inserter"
|
||||
? corner_b
|
||||
: corner_a;
|
||||
for (
|
||||
let row_idx = corner_main.row_idx;
|
||||
row_idx < boundaries.n_rows_main;
|
||||
row_idx += 1
|
||||
) {
|
||||
for (
|
||||
let field_idx = left_idx;
|
||||
field_idx <= right_idx;
|
||||
field_idx += 1
|
||||
) {
|
||||
box.push({
|
||||
region: "main",
|
||||
row_idx,
|
||||
field_idx,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (
|
||||
let row_idx = 0;
|
||||
row_idx <= corner_inserter.row_idx;
|
||||
row_idx += 1
|
||||
) {
|
||||
for (
|
||||
let field_idx = left_idx;
|
||||
field_idx <= right_idx;
|
||||
field_idx += 1
|
||||
) {
|
||||
box.push({
|
||||
region: "inserter",
|
||||
row_idx,
|
||||
field_idx,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { z } from "zod";
|
||||
import { get_empty_datum_for, Presentation } from "./presentation.svelte.ts";
|
||||
|
||||
type Assert<_T extends true> = void;
|
||||
|
||||
|
|
@ -35,3 +36,31 @@ export const datum_schema = z.union([
|
|||
]);
|
||||
|
||||
export type Datum = z.infer<typeof datum_schema>;
|
||||
|
||||
export function parse_clipboard_value(
|
||||
presentation: Presentation,
|
||||
input: string,
|
||||
): Datum {
|
||||
if (presentation.t === "Dropdown") {
|
||||
if (
|
||||
presentation.c.allow_custom ||
|
||||
presentation.c.options.some(({ value }) => value === input)
|
||||
) {
|
||||
return { t: "Text", c: input };
|
||||
} else {
|
||||
return get_empty_datum_for(presentation);
|
||||
}
|
||||
} else if (presentation.t === "Text") {
|
||||
return { t: "Text", c: input };
|
||||
} else if (presentation.t === "Timestamp") {
|
||||
// TODO: implement
|
||||
console.warn("parsing timestamps from clipboard is not yet supported");
|
||||
return get_empty_datum_for(presentation);
|
||||
} else if (presentation.t === "Uuid") {
|
||||
// TODO: implement
|
||||
console.warn("parsing uuids from clipboard is not yet supported");
|
||||
return get_empty_datum_for(presentation);
|
||||
}
|
||||
type _ = Assert<typeof presentation["t"] extends never ? true : false>;
|
||||
throw new Error("should be unreachable");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,13 +25,7 @@ export type FieldInfo = z.infer<typeof field_info_schema>;
|
|||
// -------- Table Utils -------- //
|
||||
// TODO move this to its own module
|
||||
|
||||
export type Coords = [number, number];
|
||||
|
||||
export type Row = {
|
||||
key: string | number;
|
||||
data: Datum[];
|
||||
};
|
||||
|
||||
export function coords_eq(a: Coords, b: Coords): boolean {
|
||||
return a[0] === b[0] && a[1] === b[1];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
<script lang="ts">
|
||||
import { type Coords } from "./coords.svelte";
|
||||
import { type Datum } from "./datum.svelte";
|
||||
import { type FieldInfo, type Coords } from "./field.svelte";
|
||||
import { type FieldInfo } from "./field.svelte";
|
||||
|
||||
type Props = {
|
||||
coords: Coords;
|
||||
cursor: boolean;
|
||||
field: FieldInfo;
|
||||
onbecomecursor?(focus: () => unknown): unknown;
|
||||
oncopy?(ev: ClipboardEvent): unknown;
|
||||
ondblclick?(ev: MouseEvent, coords: Coords): unknown;
|
||||
onfocus?(ev: FocusEvent, coords: Coords): unknown;
|
||||
onkeydown?(ev: KeyboardEvent, coords: Coords): unknown;
|
||||
onmousedown?(ev: MouseEvent, coords: Coords): unknown;
|
||||
onpaste?(ev: ClipboardEvent): unknown;
|
||||
selected: boolean;
|
||||
table_region: "main" | "inserter";
|
||||
value: Datum;
|
||||
|
|
@ -20,11 +23,13 @@
|
|||
coords,
|
||||
cursor,
|
||||
field,
|
||||
oncopy,
|
||||
onbecomecursor,
|
||||
ondblclick,
|
||||
onfocus,
|
||||
onkeydown,
|
||||
onmousedown,
|
||||
onpaste,
|
||||
selected,
|
||||
table_region,
|
||||
value,
|
||||
|
|
@ -50,15 +55,17 @@
|
|||
</script>
|
||||
|
||||
<div
|
||||
aria-colindex={coords[0]}
|
||||
aria-rowindex={coords[1]}
|
||||
aria-colindex={coords.field_idx}
|
||||
aria-rowindex={coords.row_idx}
|
||||
aria-selected={selected}
|
||||
bind:this={cell_element}
|
||||
class="lens-cell"
|
||||
onmousedown={(ev) => onmousedown?.(ev, coords)}
|
||||
{oncopy}
|
||||
ondblclick={(ev) => ondblclick?.(ev, coords)}
|
||||
onfocus={(ev) => onfocus?.(ev, coords)}
|
||||
onkeydown={(ev) => onkeydown?.(ev, coords)}
|
||||
onmousedown={(ev) => onmousedown?.(ev, coords)}
|
||||
{onpaste}
|
||||
role="gridcell"
|
||||
style:width={`${field.field.table_width_px}px`}
|
||||
tabindex={selected ? 0 : -1}
|
||||
|
|
|
|||
|
|
@ -11,15 +11,19 @@
|
|||
<script lang="ts">
|
||||
import { z } from "zod";
|
||||
|
||||
import { type Datum, datum_schema } from "./datum.svelte";
|
||||
import DatumEditor from "./datum-editor.svelte";
|
||||
import {
|
||||
type Coords,
|
||||
type Row,
|
||||
type FieldInfo,
|
||||
coords_eq,
|
||||
field_info_schema,
|
||||
} from "./field.svelte";
|
||||
get_box,
|
||||
offset_coords,
|
||||
} from "./coords.svelte";
|
||||
import {
|
||||
type Datum,
|
||||
datum_schema,
|
||||
parse_clipboard_value,
|
||||
} 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";
|
||||
import FieldHeader from "./field-header.svelte";
|
||||
import { get_empty_datum_for } from "./presentation.svelte";
|
||||
|
|
@ -52,7 +56,6 @@
|
|||
};
|
||||
|
||||
type Selection = {
|
||||
region: "main" | "inserter";
|
||||
coords: Coords;
|
||||
original_value: Datum;
|
||||
};
|
||||
|
|
@ -85,6 +88,14 @@
|
|||
|
||||
// -------- 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 {
|
||||
|
|
@ -101,13 +112,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
function selections_eq(
|
||||
a: Omit<Selection, "original_value">,
|
||||
b: Omit<Selection, "original_value">,
|
||||
): boolean {
|
||||
return a.region === b.region && coords_eq(a.coords, b.coords);
|
||||
}
|
||||
|
||||
function update_field_ordinality({
|
||||
field_index,
|
||||
beyond_index,
|
||||
|
|
@ -166,28 +170,25 @@
|
|||
|
||||
// -------- Updates and Effects -------- //
|
||||
|
||||
function set_selections(arr: Omit<Selection, "original_value">[]) {
|
||||
selections = arr.map((sel) => {
|
||||
function set_selections(arr: Coords[]) {
|
||||
selections = arr.map((coords) => {
|
||||
let cell_data: Datum | 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 (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");
|
||||
throw new Error(`invalid region: ${coords.region}`);
|
||||
}
|
||||
return {
|
||||
...sel,
|
||||
original_value: cell_data!,
|
||||
};
|
||||
return { coords, original_value: cell_data! };
|
||||
});
|
||||
if (arr.length === 1) {
|
||||
const [sel] = arr;
|
||||
const [coords] = arr;
|
||||
let cell_data: Datum | 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 (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 {
|
||||
|
|
@ -196,7 +197,7 @@
|
|||
}
|
||||
|
||||
function move_cursor(
|
||||
direction: "Down" | "Left" | "Right" | "Up",
|
||||
new_cursor: Coords,
|
||||
{ additive }: { additive?: boolean } = {},
|
||||
) {
|
||||
if (!lazy_data || selections.length === 0) {
|
||||
|
|
@ -204,174 +205,51 @@
|
|||
return;
|
||||
}
|
||||
|
||||
const cursor = selections[0];
|
||||
let new_cursor: Omit<Selection, "original_value"> | undefined;
|
||||
const first_selection = selections[selections.length - 1];
|
||||
if (
|
||||
direction === "Right" &&
|
||||
cursor.coords[1] < lazy_data.fields.length - 1
|
||||
additive &&
|
||||
first_selection !== undefined &&
|
||||
!coords_eq(new_cursor, first_selection.coords)
|
||||
) {
|
||||
new_cursor = {
|
||||
region: cursor.region,
|
||||
coords: [cursor.coords[0], cursor.coords[1] + 1],
|
||||
};
|
||||
} else if (direction === "Left" && cursor.coords[1] > 0) {
|
||||
new_cursor = {
|
||||
region: cursor.region,
|
||||
coords: [cursor.coords[0], cursor.coords[1] - 1],
|
||||
};
|
||||
} else if (direction === "Down") {
|
||||
if (cursor.region === "main") {
|
||||
if (cursor.coords[0] < lazy_data.rows.length - 1) {
|
||||
new_cursor = {
|
||||
region: "main",
|
||||
coords: [cursor.coords[0] + 1, cursor.coords[1]],
|
||||
};
|
||||
} else {
|
||||
// At bottom of main table.
|
||||
new_cursor = {
|
||||
region: "inserter",
|
||||
coords: [0, cursor.coords[1]],
|
||||
};
|
||||
}
|
||||
} else if (cursor.region === "inserter") {
|
||||
if (cursor.coords[0] < inserter_rows.length - 1) {
|
||||
new_cursor = {
|
||||
region: "inserter",
|
||||
coords: [cursor.coords[0] + 1, cursor.coords[1]],
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if (direction === "Up") {
|
||||
if (cursor.region === "main") {
|
||||
if (cursor.coords[0] > 0) {
|
||||
new_cursor = {
|
||||
region: "main",
|
||||
coords: [cursor.coords[0] - 1, cursor.coords[1]],
|
||||
};
|
||||
}
|
||||
} else if (cursor.region === "inserter") {
|
||||
if (cursor.coords[0] > 0) {
|
||||
new_cursor = {
|
||||
region: "inserter",
|
||||
coords: [cursor.coords[0] - 1, cursor.coords[1]],
|
||||
};
|
||||
} else {
|
||||
// At top of inserter table.
|
||||
new_cursor = {
|
||||
region: "main",
|
||||
coords: [lazy_data.rows.length - 1, cursor.coords[1]],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (new_cursor !== undefined) {
|
||||
const first_selection = selections[selections.length - 1];
|
||||
if (
|
||||
additive &&
|
||||
first_selection !== undefined &&
|
||||
!selections_eq(new_cursor, first_selection)
|
||||
) {
|
||||
// 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.
|
||||
const all_selections: Omit<Selection, "original_value">[] = [];
|
||||
const left_idx = Math.min(
|
||||
new_cursor.coords[1],
|
||||
first_selection.coords[1],
|
||||
);
|
||||
const right_idx = Math.max(
|
||||
new_cursor.coords[1],
|
||||
first_selection.coords[1],
|
||||
);
|
||||
if (new_cursor.region === first_selection.region) {
|
||||
for (
|
||||
let row_idx = Math.min(
|
||||
new_cursor.coords[0],
|
||||
first_selection.coords[0],
|
||||
);
|
||||
row_idx <=
|
||||
Math.max(new_cursor.coords[0], first_selection.coords[0]);
|
||||
row_idx += 1
|
||||
) {
|
||||
for (
|
||||
let field_idx = left_idx;
|
||||
field_idx <= right_idx;
|
||||
field_idx += 1
|
||||
) {
|
||||
all_selections.push({
|
||||
region: new_cursor.region,
|
||||
coords: [row_idx, field_idx],
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const main_sel =
|
||||
new_cursor.region === "main" ? new_cursor : first_selection;
|
||||
const inserter_sel =
|
||||
new_cursor.region === "inserter" ? new_cursor : first_selection;
|
||||
for (
|
||||
let row_idx = main_sel.coords[0];
|
||||
row_idx < lazy_data.rows.length;
|
||||
row_idx += 1
|
||||
) {
|
||||
for (
|
||||
let field_idx = left_idx;
|
||||
field_idx <= right_idx;
|
||||
field_idx += 1
|
||||
) {
|
||||
all_selections.push({
|
||||
region: "main",
|
||||
coords: [row_idx, field_idx],
|
||||
});
|
||||
}
|
||||
}
|
||||
for (
|
||||
let row_idx = 0;
|
||||
row_idx <= inserter_sel.coords[0];
|
||||
row_idx += 1
|
||||
) {
|
||||
for (
|
||||
let field_idx = left_idx;
|
||||
field_idx <= right_idx;
|
||||
field_idx += 1
|
||||
) {
|
||||
all_selections.push({
|
||||
region: "inserter",
|
||||
coords: [row_idx, field_idx],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
set_selections([
|
||||
new_cursor,
|
||||
...all_selections.filter(
|
||||
(sel) =>
|
||||
!selections_eq(sel, new_cursor) &&
|
||||
!selections_eq(sel, first_selection),
|
||||
),
|
||||
first_selection,
|
||||
]);
|
||||
} else {
|
||||
set_selections([new_cursor]);
|
||||
}
|
||||
// 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 !== 1) {
|
||||
if (!lazy_data || selections.length === 0) {
|
||||
console.warn("preconditions for try_sync_edit_to_cells() not met");
|
||||
return;
|
||||
}
|
||||
if (editor_value === undefined) {
|
||||
return;
|
||||
}
|
||||
const [sel] = selections;
|
||||
if (sel.region === "main") {
|
||||
lazy_data.rows[sel.coords[0]].data[sel.coords[1]] = editor_value;
|
||||
} else if (sel.region === "inserter") {
|
||||
inserter_rows[sel.coords[0]].data[sel.coords[1]] = editor_value;
|
||||
} else {
|
||||
throw new Error("Unknown region");
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -386,13 +264,11 @@
|
|||
deltas.commit_queued = [
|
||||
...deltas.commit_queued,
|
||||
{
|
||||
cells: selections
|
||||
.filter(({ region }) => region === "main")
|
||||
.map((sel) => ({
|
||||
coords: sel.coords,
|
||||
value_initial: sel.original_value,
|
||||
value_updated: editor_value_scoped,
|
||||
})),
|
||||
cells: selections.map((sel) => ({
|
||||
coords: sel.coords,
|
||||
value_initial: sel.original_value,
|
||||
value_updated: editor_value_scoped,
|
||||
})),
|
||||
},
|
||||
];
|
||||
console.debug("Commit queue:", deltas.commit_queued);
|
||||
|
|
@ -416,11 +292,15 @@
|
|||
method: "post",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
cells: delta.cells.map((cell) => ({
|
||||
pkey: JSON.parse(lazy_data_scoped.rows[cell.coords[0]].key as string),
|
||||
column: lazy_data_scoped.fields[cell.coords[1]].field.name,
|
||||
value: cell.value_updated,
|
||||
})),
|
||||
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) {
|
||||
|
|
@ -444,19 +324,20 @@
|
|||
}
|
||||
|
||||
function cancel_edit() {
|
||||
selections.forEach(({ coords, original_value, region }) => {
|
||||
if (region === "main") {
|
||||
selections.forEach(({ coords, original_value }) => {
|
||||
if (coords.region === "main") {
|
||||
if (lazy_data) {
|
||||
lazy_data.rows[coords[0]].data[coords[1]] = original_value;
|
||||
lazy_data.rows[coords.row_idx].data[coords.field_idx] =
|
||||
original_value;
|
||||
}
|
||||
} else if (region === "inserter") {
|
||||
inserter_rows[coords[0]].data[coords[1]] = 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);
|
||||
set_selections(selections.map(({ coords }) => coords));
|
||||
}
|
||||
|
||||
// -------- Event Handlers -------- //
|
||||
|
|
@ -469,10 +350,25 @@
|
|||
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(arrow_direction, { additive: true });
|
||||
move_cursor(new_cursor, { additive: true });
|
||||
} else {
|
||||
move_cursor(arrow_direction);
|
||||
move_cursor(new_cursor);
|
||||
}
|
||||
} else if (
|
||||
!ev.altKey &&
|
||||
|
|
@ -483,17 +379,18 @@
|
|||
const sel = selections[0];
|
||||
if (sel) {
|
||||
editor_value = get_empty_datum_for(
|
||||
lazy_data.fields[sel.coords[1]].field.presentation,
|
||||
lazy_data.fields[sel.coords.field_idx].field.presentation,
|
||||
);
|
||||
datum_editor?.focus();
|
||||
}
|
||||
} else if (ev.key === "Enter") {
|
||||
if (ev.shiftKey) {
|
||||
if (selections[0]?.region === "main") {
|
||||
if (selections[0]?.coords.region === "main") {
|
||||
set_selections([
|
||||
{
|
||||
region: "inserter",
|
||||
coords: [0, selections[0]?.coords[1] ?? 0],
|
||||
row_idx: 0,
|
||||
field_idx: selections[0]?.coords.field_idx ?? 0,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
|
|
@ -517,6 +414,113 @@
|
|||
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_clipboard_value(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 () {
|
||||
|
|
@ -544,12 +548,7 @@
|
|||
},
|
||||
];
|
||||
if (lazy_data.rows.length > 0 && lazy_data.fields.length > 0) {
|
||||
set_selections([
|
||||
{
|
||||
region: "main",
|
||||
coords: [0, 0],
|
||||
},
|
||||
]);
|
||||
set_selections([{ region: "main", row_idx: 0, field_idx: 0 }]);
|
||||
}
|
||||
})().catch(console.error);
|
||||
|
||||
|
|
@ -557,22 +556,25 @@
|
|||
</script>
|
||||
|
||||
{#snippet table_region({
|
||||
region_name,
|
||||
region,
|
||||
rows,
|
||||
on_cell_click,
|
||||
}: {
|
||||
region_name: "main" | "inserter";
|
||||
region: "main" | "inserter";
|
||||
rows: Row[];
|
||||
on_cell_click(ev: MouseEvent, coords: Coords): void;
|
||||
})}
|
||||
{#if lazy_data}
|
||||
{#each rows as row, row_index}
|
||||
{#each rows as row, row_idx}
|
||||
<div class="lens-table__row" role="row">
|
||||
{#each lazy_data.fields as field, field_index}
|
||||
{#each lazy_data.fields as field, field_idx}
|
||||
<TableCell
|
||||
coords={[row_index, field_index]}
|
||||
cursor={selections[0]?.region === region_name &&
|
||||
coords_eq(selections[0].coords, [row_index, field_index])}
|
||||
coords={{ region, row_idx, field_idx }}
|
||||
cursor={coords_eq(selections[0].coords, {
|
||||
region,
|
||||
row_idx,
|
||||
field_idx,
|
||||
})}
|
||||
{field}
|
||||
onbecomecursor={(focus) => {
|
||||
focus_cursor = focus;
|
||||
|
|
@ -580,13 +582,14 @@
|
|||
ondblclick={() => datum_editor?.focus()}
|
||||
onkeydown={(ev) => handle_cell_keydown(ev)}
|
||||
onmousedown={on_cell_click}
|
||||
onpaste={handle_cell_paste}
|
||||
selected={selections.some(
|
||||
(sel) =>
|
||||
sel.region === region_name &&
|
||||
coords_eq(sel.coords, [row_index, field_index]),
|
||||
sel.coords.region === region &&
|
||||
coords_eq(sel.coords, { region, row_idx, field_idx }),
|
||||
)}
|
||||
table_region={region_name}
|
||||
value={row.data[field_index]}
|
||||
table_region={region}
|
||||
value={row.data[field_idx]}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -594,13 +597,7 @@
|
|||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div
|
||||
class="lens-grid"
|
||||
onpaste={(ev) => {
|
||||
console.log("paste");
|
||||
console.log(ev.clipboardData?.getData("text"));
|
||||
}}
|
||||
>
|
||||
<div class="lens-grid">
|
||||
{#if lazy_data}
|
||||
<div class="lens-table" role="grid">
|
||||
<div class={["lens-table__headers"]}>
|
||||
|
|
@ -632,15 +629,20 @@
|
|||
</div>
|
||||
<div class="lens-table__main">
|
||||
{@render table_region({
|
||||
region_name: "main",
|
||||
region: "main",
|
||||
rows: lazy_data.rows,
|
||||
on_cell_click: (ev: MouseEvent, coords: Coords) => {
|
||||
if (ev.metaKey || ev.ctrlKey) {
|
||||
// TODO
|
||||
// selections = [...selections.filter((prev) => !coords_eq(prev, coords)), coords];
|
||||
// editor_input_value = "";
|
||||
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 {
|
||||
set_selections([{ region: "main", coords }]);
|
||||
move_cursor(coords);
|
||||
}
|
||||
},
|
||||
})}
|
||||
|
|
@ -653,15 +655,20 @@
|
|||
<div class="lens-inserter__main">
|
||||
<div class="lens-inserter__rows">
|
||||
{@render table_region({
|
||||
region_name: "inserter",
|
||||
region: "inserter",
|
||||
rows: inserter_rows,
|
||||
on_cell_click: (ev: MouseEvent, coords: Coords) => {
|
||||
if (ev.metaKey || ev.ctrlKey) {
|
||||
// TODO
|
||||
// selections = [...selections.filter((prev) => !coords_eq(prev, coords)), coords];
|
||||
// editor_input_value = "";
|
||||
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 {
|
||||
set_selections([{ region: "inserter", coords }]);
|
||||
move_cursor(coords);
|
||||
}
|
||||
},
|
||||
})}
|
||||
|
|
@ -693,11 +700,11 @@
|
|||
</form>
|
||||
</div>
|
||||
<div class="table-viewer__datum-editor">
|
||||
{#if selections.length === 1}
|
||||
{#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}
|
||||
field_info={lazy_data.fields[selections[0].coords[1]]}
|
||||
field_info={lazy_data.fields[selections[0].coords.field_idx]}
|
||||
on_blur={() => try_queue_delta()}
|
||||
on_cancel_edit={cancel_edit}
|
||||
on_change={() => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue