Cell edits now actually persist. Row-level batch save fires on
row-blur (selection moves to a different row); the request is one
PUT with the full merged row (server-side data + client drafts)
and If-Match: <etag> for optimistic concurrency. Conflict and
validation responses are surfaced inline; drafts are NEVER silently
discarded — when the server says no, the user's typing stays put
until they explicitly reload or replay.
Architecture (per the research synthesis from earlier in this
sequence):
- ETag tracking: context.js readRows captures the per-row ETag
from HttpFileHandle's response header on the initial GET.
Stashed at row.etag alongside row.data and row.yamlUrl. Phase 3
reads it; later phases (undo replay) inherit it.
- Row-blur trigger: editor.js setSelected calls a new
notifySelectionChanged() hook after selection lands. save.js's
onSelectionChanged tracks _previousSelectedRowId; when it
changes AND the previous row had drafts, fires saveRow(prevId).
Fire-and-forget — don't block the user's flow on the network.
- save.saveRow flow:
1. mergeRow(row.data, drafts) → full updated row.
2. js-yaml dump → wire body.
3. PUT row.yamlUrl, body, headers={Content-Type, If-Match}.
4. Branch on response status:
- 200/201 → success: clear drafts + invalid marks, capture
new ETag from response, replace row.data with merged.
- 202 → outbox queued (downstream client offline):
clear drafts (the outbox owns them now), mark row queued.
- 412 → stale: drafts STAY; mark row stale; show
status-bar prompt with [Use mine] / [Reload] buttons.
- 422 → server validation failed; body has
{errors: [{path, message}]}; mark each cell invalid via
a red-corner CSS marker + title-attribute tooltip.
- other → mark errored; drafts stay.
- Conflict resolution UX:
- "Use mine" replays the user's drafts onto fresh server
state. Re-GETs the row to learn the new ETag + new server
data, replaces row.data with the fresh server values, then
re-PUTs the merge of fresh + drafts. This is client-side
field-level last-writer-wins: fields the user did NOT
touch get the server's new values automatically; only
fields the user changed override server state. No JSON
Patch endpoint required — pure client logic on top of the
existing whole-row PUT path.
- "Reload" drops drafts entirely, re-GETs the row, repaints.
- Validation error display: per-cell red-corner triangle
(Excel-style) plus title-attribute tooltip on hover. Marker
keyed off data-col-idx + the column's field; survives until
the next edit on that cell or the next paint() cycle.
- beforeunload safety net: any rows with drafts at unload time
get one fire-and-forget save attempt. Modern browsers limit
what beforeunload can do; a follow-up could add fetch's
keepalive flag for a more reliable last-shot.
UI surfaces:
- Per-row state classes drive a left-border swatch in the first
cell:
--dirty subtle blue (uncommitted changes)
--saving muted grey (PUT in flight)
--queued warm yellow (outbox accepted)
--invalid orange (server 422)
--stale warning amber (server 412 — also tints row bg)
--errored red (other failure — also tints row bg)
These re-apply across re-paints via save.markAllDirtyRows()
called from main.js's paint() hook (innerHTML='' wipes them).
- #table-status doubles as the conflict prompt host. When a row
goes stale, the bar shows
"This row was changed by someone else. [Use mine] [Reload] [×]"
and the row-id it's bound to is stored on data-row-id so a
successful reload of that row dismisses the prompt.
Outbox (downstream client) interaction:
The cache layer's PUT-replay queue intercepts saves transparently.
On local network failure the cache returns 202 with
X-ZDDC-Cache: queued; we treat 202 as "succeeded for now" —
drafts clear (the outbox owns them and will replay), but the
row stays marked --queued so the user knows the write hasn't
reached upstream yet. When the cache replays and gets a
real 200/201/412/etc., the row state will reflect that on next
read (next paint cycle / page refresh).
Tests (4 new Phase 3 specs, total 31 in tests/tables.spec.js):
- row-blur fires PUT with merged drafts + If-Match. Edit a
cell in row 0, Enter (commits + moves to row 1). Verifies
PUT went out with the right URL, the merged YAML body
contains the new value AND the unchanged fields, and the
If-Match header carries the original ETag.
- 412 conflict marks row stale + shows status prompt. Verifies
the row gains the stale class, the status bar appears with
both [Use mine] and [Reload] buttons, AND the draft is
preserved (never silently dropped on conflict).
- 422 validation errors mark cells invalid. Verifies multiple
field errors → multiple red-corner cells.
- Reload button drops drafts and refreshes. Verifies the bar
hides and drafts clear after a successful reload GET.
Setup: a small page.route helper intercepts http://test.local/*
PUTs and GETs, lets each test queue the next response via
window.__nextResponse, and captures requests at
window.__capturedRequests for inspection. Test fixtures use
absolute http URLs in row.yamlUrl so the route catches them.
Bundle size: 127 KB → 134 KB.
Files:
- tables/js/save.js (new) — saveRow, useMine, reload, status
prompt, row-state markers, beforeunload flush.
- tables/js/editor.js — notifySelectionChanged hook.
- tables/js/context.js — etag + yamlUrl on each row.
- tables/js/main.js — paint() re-applies dirty markers via
save.markAllDirtyRows; exposes app.repaint for save callbacks.
- tables/build.sh — save.js in concat list.
- tables/css/table.css — row-state classes + invalid-cell corner
+ status-bar prompt styling.
- zddc/internal/handler/tables.html — regenerated bundle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
644 lines
23 KiB
JavaScript
644 lines
23 KiB
JavaScript
// editor.js — Phase 1 of editable-cell mode.
|
|
//
|
|
// Owns the cell-selection + per-cell edit lifecycle. Implements the
|
|
// W3C ARIA grid-pattern keyboard semantics:
|
|
//
|
|
// - Arrow keys move the selected cell.
|
|
// - Tab / Shift-Tab move right / left, wrapping to next / prev row.
|
|
// - Enter, F2, double-click, or any printable character enter edit
|
|
// mode (Enter and F2 keep the existing value; printable chars
|
|
// replace it; double-click opens with the existing value).
|
|
// - In edit mode: Enter commits and moves down, Tab commits and
|
|
// moves right, Escape cancels (restoring the prior value), blur
|
|
// commits.
|
|
//
|
|
// Roving tabindex: only the selected cell carries tabindex=0; all
|
|
// others are tabindex=-1. This makes the grid a single tab-stop in
|
|
// the page's tab order, which is the documented spreadsheet UX.
|
|
//
|
|
// Edits in this phase live in app.state.drafts and never hit the
|
|
// network — Phase 3 wires the row-blur PUT.
|
|
(function (app) {
|
|
'use strict';
|
|
|
|
// --- Helpers ------------------------------------------------------
|
|
|
|
function tableEl() { return document.getElementById('table-root'); }
|
|
function cellAt(r, c) { return cellsByRowCol(r, c); }
|
|
|
|
// The displayed table is filtered+sorted; selection is keyed by
|
|
// VISIBLE row index, not row id, so arrow keys behave intuitively
|
|
// even after sort / filter changes (the cell at row 3 column 2
|
|
// stays at row 3 column 2 even if the underlying row id moved).
|
|
// This is how Excel and Google Sheets behave too.
|
|
function cellsByRowCol(r, c) {
|
|
const t = tableEl();
|
|
if (!t) return null;
|
|
const tbody = t.querySelector('tbody');
|
|
if (!tbody) return null;
|
|
const tr = tbody.children[r];
|
|
if (!tr) return null;
|
|
return tr.querySelector('[role="gridcell"][data-col-idx="' + c + '"]');
|
|
}
|
|
|
|
function isPrintableKey(ev) {
|
|
// A "printable" key produces a single character of text — e.g.
|
|
// 'a', '7', '$'. Function keys, arrows, modifiers etc. either
|
|
// have multi-char `key` values ('ArrowDown') or are non-text.
|
|
// ev.ctrlKey / metaKey suppress so Cmd-A et al. don't trigger
|
|
// edit mode.
|
|
if (ev.key.length !== 1) return false;
|
|
if (ev.ctrlKey || ev.metaKey || ev.altKey) return false;
|
|
return true;
|
|
}
|
|
|
|
function rowCount() {
|
|
const t = tableEl();
|
|
if (!t) return 0;
|
|
return t.querySelectorAll('tbody > tr').length;
|
|
}
|
|
|
|
function colCount() {
|
|
const cols = (app.context && app.context.columns) || [];
|
|
return Array.isArray(cols) ? cols.length : 0;
|
|
}
|
|
|
|
function colAt(c) {
|
|
const cols = (app.context && app.context.columns) || [];
|
|
return cols[c] || null;
|
|
}
|
|
|
|
function rowDataAt(r) {
|
|
// The visible row at index r. Walk the rendered tbody to find
|
|
// its data-row-id, then look up the row in app.state.rows.
|
|
// app.state.rows holds the SORTED+FILTERED current view (kept
|
|
// in sync by main.js paint()).
|
|
const t = tableEl();
|
|
if (!t) return null;
|
|
const tr = t.querySelectorAll('tbody > tr')[r];
|
|
if (!tr) return null;
|
|
const rowId = tr.getAttribute('data-row-id');
|
|
if (rowId == null) return null;
|
|
const all = app.state.rows || [];
|
|
for (let i = 0; i < all.length; i++) {
|
|
if (rowKey(all[i]) === rowId) {
|
|
return all[i];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function rowKey(row) {
|
|
// Stable per-row identity. Each context row has a `url` (the
|
|
// <id>.yaml.html re-edit URL); the file basename inside that
|
|
// URL is unique per directory and survives sort/filter.
|
|
if (!row || !row.url) return '';
|
|
return row.url;
|
|
}
|
|
|
|
// --- Draft buffer -------------------------------------------------
|
|
|
|
function getDraft(rowId, field) {
|
|
const r = app.state.drafts[rowId];
|
|
if (!r) return undefined;
|
|
return r[field];
|
|
}
|
|
|
|
function setDraft(rowId, field, value) {
|
|
if (!app.state.drafts[rowId]) {
|
|
app.state.drafts[rowId] = {};
|
|
}
|
|
app.state.drafts[rowId][field] = value;
|
|
}
|
|
|
|
function clearDraftField(rowId, field) {
|
|
const r = app.state.drafts[rowId];
|
|
if (!r) return;
|
|
delete r[field];
|
|
if (Object.keys(r).length === 0) {
|
|
delete app.state.drafts[rowId];
|
|
}
|
|
}
|
|
|
|
function effectiveCellValue(row, col) {
|
|
// Display draft value if present; otherwise the row's stored
|
|
// value. Used by render to keep the visible cell content in
|
|
// sync with uncommitted edits.
|
|
const drafted = getDraft(rowKey(row), col.field);
|
|
if (drafted !== undefined) {
|
|
return drafted;
|
|
}
|
|
return app.modules.util.resolveField(row.data, col.field);
|
|
}
|
|
|
|
// --- Selection (roving tabindex) ----------------------------------
|
|
|
|
function setSelected(r, c, opts) {
|
|
opts = opts || {};
|
|
const total = rowCount();
|
|
const cols = colCount();
|
|
if (total === 0 || cols === 0) {
|
|
app.state.selected = null;
|
|
notifySelectionChanged();
|
|
return;
|
|
}
|
|
if (r < 0) r = 0;
|
|
if (r > total - 1) r = total - 1;
|
|
if (c < 0) c = 0;
|
|
if (c > cols - 1) c = cols - 1;
|
|
|
|
const t = tableEl();
|
|
if (t) {
|
|
const all = t.querySelectorAll('[role="gridcell"]');
|
|
for (let i = 0; i < all.length; i++) {
|
|
all[i].setAttribute('tabindex', '-1');
|
|
all[i].classList.remove('zddc-table__cell--selected');
|
|
}
|
|
}
|
|
const target = cellAt(r, c);
|
|
if (target) {
|
|
target.setAttribute('tabindex', '0');
|
|
target.classList.add('zddc-table__cell--selected');
|
|
if (!opts.noFocus) {
|
|
target.focus({ preventScroll: false });
|
|
}
|
|
}
|
|
app.state.selected = { row: r, col: c };
|
|
notifySelectionChanged();
|
|
}
|
|
|
|
function notifySelectionChanged() {
|
|
// Phase 3 wires the row-blur save trigger here. save module is
|
|
// optional in test fixtures that don't include it.
|
|
const save = app.modules.save;
|
|
if (save && typeof save.onSelectionChanged === 'function') {
|
|
save.onSelectionChanged(app.state.selected);
|
|
}
|
|
}
|
|
|
|
function clearSelection() {
|
|
const t = tableEl();
|
|
if (t) {
|
|
const all = t.querySelectorAll('[role="gridcell"]');
|
|
for (let i = 0; i < all.length; i++) {
|
|
all[i].setAttribute('tabindex', '-1');
|
|
all[i].classList.remove('zddc-table__cell--selected');
|
|
}
|
|
}
|
|
app.state.selected = null;
|
|
}
|
|
|
|
// --- Edit mode ----------------------------------------------------
|
|
|
|
function enterEdit(initial) {
|
|
if (!app.state.selected) return;
|
|
if (app.state.editing) return;
|
|
const { row: r, col: c } = app.state.selected;
|
|
const cell = cellAt(r, c);
|
|
if (!cell) return;
|
|
const row = rowDataAt(r);
|
|
const col = colAt(c);
|
|
if (!row || !col) return;
|
|
|
|
const propSchema = propertySchemaFor(col);
|
|
|
|
// Complex-type cells (nested object, generic array, oneOf)
|
|
// can't be inline-edited cleanly — punt to the row's form
|
|
// editor in a side panel / new page. Phase 2 ships the
|
|
// navigation; Phase 5 may add a side-panel mount.
|
|
if (isComplexSchema(propSchema)) {
|
|
navigateToRowForm(row);
|
|
return;
|
|
}
|
|
|
|
const currentValue = effectiveCellValue(row, col);
|
|
const widget = makeWidget(propSchema, col, initial != null ? initial : currentValue);
|
|
const inputEl = widget.element;
|
|
inputEl.classList.add('zddc-table__cell-input');
|
|
inputEl.setAttribute('aria-label', 'Edit ' + (col.title || col.field));
|
|
|
|
// Replace the cell's text content with the editor widget.
|
|
// Stash the original text in dataset so cancel can restore it
|
|
// verbatim without re-running the formatCell logic.
|
|
cell.setAttribute('data-display', cell.textContent || '');
|
|
cell.textContent = '';
|
|
cell.appendChild(inputEl);
|
|
widget.focus();
|
|
|
|
app.state.editing = true;
|
|
|
|
function commit() {
|
|
if (!app.state.editing) return;
|
|
const newValue = widget.getValue();
|
|
const oldRaw = app.modules.util.resolveField(row.data, col.field);
|
|
// Compare by JSON-string equality so number 42 == "42"
|
|
// entered into a number input doesn't false-positive as
|
|
// a change. resolveField already returns the raw typed
|
|
// value from row.data.
|
|
if (sameValue(oldRaw, newValue)) {
|
|
clearDraftField(rowKey(row), col.field);
|
|
} else {
|
|
setDraft(rowKey(row), col.field, newValue);
|
|
}
|
|
tearDown(newValue);
|
|
}
|
|
|
|
function cancel() {
|
|
tearDown(null); // null = restore from data-display, no draft change
|
|
}
|
|
|
|
function tearDown(displayValue) {
|
|
inputEl.removeEventListener('keydown', onKey);
|
|
inputEl.removeEventListener('blur', onBlur);
|
|
const display = (displayValue !== undefined && displayValue !== null)
|
|
? renderableText(displayValue, col)
|
|
: (cell.getAttribute('data-display') || '');
|
|
cell.removeAttribute('data-display');
|
|
cell.textContent = display;
|
|
app.state.editing = false;
|
|
cell.focus({ preventScroll: false });
|
|
}
|
|
|
|
function onKey(ev) {
|
|
if (ev.key === 'Enter') {
|
|
ev.preventDefault();
|
|
commit();
|
|
setSelected(r + 1, c);
|
|
} else if (ev.key === 'Escape') {
|
|
ev.preventDefault();
|
|
cancel();
|
|
} else if (ev.key === 'Tab') {
|
|
ev.preventDefault();
|
|
commit();
|
|
if (ev.shiftKey) {
|
|
moveSelection('left-wrap');
|
|
} else {
|
|
moveSelection('right-wrap');
|
|
}
|
|
}
|
|
// Other keys: stay in edit mode, let the input handle them.
|
|
}
|
|
|
|
function onBlur(_ev) {
|
|
// Blur (focus moved elsewhere). Commit any pending value.
|
|
// Schedule via setTimeout(0) so a programmatic refocus by
|
|
// tearDown→cell.focus doesn't re-fire blur during teardown.
|
|
if (app.state.editing) {
|
|
commit();
|
|
}
|
|
}
|
|
|
|
inputEl.addEventListener('keydown', onKey);
|
|
inputEl.addEventListener('blur', onBlur);
|
|
}
|
|
|
|
function renderableText(value, col) {
|
|
return app.modules.util.formatCell(value, col.format);
|
|
}
|
|
|
|
// --- Schema → editor widget factory --------------------------------
|
|
|
|
function propertySchemaFor(col) {
|
|
// Walk the row schema for this column's field. Returns null
|
|
// when no schema is present (best-effort: cells fall back to
|
|
// plain text editors). Supports a single dot-separated path
|
|
// — `properties.a.properties.b` for `field: "a.b"` — to mirror
|
|
// the existing util.resolveField conventions.
|
|
const ctx = app.context || {};
|
|
if (!ctx.rowSchema) return null;
|
|
const parts = String(col.field || '').split('.').filter(Boolean);
|
|
let s = ctx.rowSchema;
|
|
for (let i = 0; i < parts.length; i++) {
|
|
if (!s || !s.properties || !s.properties[parts[i]]) return null;
|
|
s = s.properties[parts[i]];
|
|
}
|
|
return s;
|
|
}
|
|
|
|
function isComplexSchema(s) {
|
|
if (!s) return false;
|
|
if (Array.isArray(s.oneOf) && s.oneOf.length > 0) return true;
|
|
if (Array.isArray(s.anyOf) && s.anyOf.length > 0) return true;
|
|
if (Array.isArray(s.allOf) && s.allOf.length > 0) return true;
|
|
if (s.type === 'object') return true;
|
|
if (s.type === 'array') {
|
|
// Multi-select-friendly arrays (string-enum + uniqueItems)
|
|
// get inline editing; everything else is complex.
|
|
const items = s.items || {};
|
|
const isMultiSelect = items.type === 'string'
|
|
&& Array.isArray(items.enum) && items.enum.length > 0
|
|
&& s.uniqueItems === true;
|
|
return !isMultiSelect;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function makeWidget(propSchema, col, initialValue) {
|
|
// Prefers explicit JSON Schema hints; falls back to column-spec
|
|
// hints (col.format / col.enum) for tables without a form.yaml;
|
|
// defaults to a plain text input.
|
|
const s = propSchema || {};
|
|
const colHint = col || {};
|
|
|
|
// Boolean → checkbox.
|
|
if (s.type === 'boolean') {
|
|
return widgetCheckbox(initialValue);
|
|
}
|
|
|
|
// Enum (string with explicit choices) → select dropdown.
|
|
const enumChoices = (Array.isArray(s.enum) && s.enum)
|
|
|| (Array.isArray(colHint.enum) && colHint.enum)
|
|
|| null;
|
|
if (enumChoices) {
|
|
return widgetSelect(enumChoices, initialValue);
|
|
}
|
|
|
|
// Multi-select (array of string-enum with uniqueItems).
|
|
if (s.type === 'array'
|
|
&& s.items && s.items.type === 'string'
|
|
&& Array.isArray(s.items.enum) && s.uniqueItems === true) {
|
|
return widgetMultiSelect(s.items.enum, initialValue);
|
|
}
|
|
|
|
// Number / integer → number input with min/max/step.
|
|
if (s.type === 'number' || s.type === 'integer'
|
|
|| colHint.format === 'number' || colHint.format === 'integer') {
|
|
return widgetNumber(s, initialValue);
|
|
}
|
|
|
|
// Date / date-time / email — typed inputs the browser can
|
|
// help validate.
|
|
const fmt = s.format || colHint.format;
|
|
if (fmt === 'date') return widgetTyped('date', initialValue);
|
|
if (fmt === 'date-time') return widgetTyped('datetime-local', initialValue);
|
|
if (fmt === 'email') return widgetTyped('email', initialValue);
|
|
|
|
// Long text → textarea (still inline; Phase 5 may add expand).
|
|
if (s.type === 'string' && Number(s.maxLength) > 200) {
|
|
return widgetTextarea(initialValue);
|
|
}
|
|
|
|
// Default: plain text input.
|
|
return widgetText(initialValue);
|
|
}
|
|
|
|
function widgetText(initial) {
|
|
const el = document.createElement('input');
|
|
el.type = 'text';
|
|
el.value = stringify(initial);
|
|
return {
|
|
element: el,
|
|
getValue: () => el.value,
|
|
focus: () => { el.focus(); try { el.setSelectionRange(el.value.length, el.value.length); } catch (_) {} }
|
|
};
|
|
}
|
|
|
|
function widgetTextarea(initial) {
|
|
const el = document.createElement('textarea');
|
|
el.rows = 1;
|
|
el.value = stringify(initial);
|
|
return {
|
|
element: el,
|
|
getValue: () => el.value,
|
|
focus: () => { el.focus(); try { el.setSelectionRange(el.value.length, el.value.length); } catch (_) {} }
|
|
};
|
|
}
|
|
|
|
function widgetTyped(htmlType, initial) {
|
|
const el = document.createElement('input');
|
|
el.type = htmlType;
|
|
el.value = stringify(initial);
|
|
return {
|
|
element: el,
|
|
getValue: () => el.value,
|
|
focus: () => el.focus()
|
|
};
|
|
}
|
|
|
|
function widgetNumber(s, initial) {
|
|
const el = document.createElement('input');
|
|
el.type = 'number';
|
|
if (s.minimum != null) el.min = String(s.minimum);
|
|
if (s.maximum != null) el.max = String(s.maximum);
|
|
if (s.type === 'integer') el.step = '1';
|
|
else if (s.multipleOf != null) el.step = String(s.multipleOf);
|
|
el.value = (initial == null || initial === '') ? '' : String(initial);
|
|
return {
|
|
element: el,
|
|
getValue: () => {
|
|
const v = el.value;
|
|
if (v === '') return null;
|
|
const n = Number(v);
|
|
return Number.isNaN(n) ? v : n;
|
|
},
|
|
focus: () => el.focus()
|
|
};
|
|
}
|
|
|
|
function widgetCheckbox(initial) {
|
|
const el = document.createElement('input');
|
|
el.type = 'checkbox';
|
|
el.checked = initial === true || initial === 'true';
|
|
return {
|
|
element: el,
|
|
getValue: () => el.checked,
|
|
focus: () => el.focus()
|
|
};
|
|
}
|
|
|
|
function widgetSelect(choices, initial) {
|
|
const el = document.createElement('select');
|
|
// Empty option lets the cell go back to "unset" without typing.
|
|
const empty = document.createElement('option');
|
|
empty.value = '';
|
|
empty.textContent = '—';
|
|
el.appendChild(empty);
|
|
for (let i = 0; i < choices.length; i++) {
|
|
const opt = document.createElement('option');
|
|
opt.value = String(choices[i]);
|
|
opt.textContent = String(choices[i]);
|
|
el.appendChild(opt);
|
|
}
|
|
el.value = initial == null ? '' : String(initial);
|
|
return {
|
|
element: el,
|
|
getValue: () => (el.value === '' ? null : el.value),
|
|
focus: () => el.focus()
|
|
};
|
|
}
|
|
|
|
function widgetMultiSelect(choices, initial) {
|
|
const el = document.createElement('select');
|
|
el.multiple = true;
|
|
el.size = Math.min(6, choices.length);
|
|
const initialSet = {};
|
|
const initArr = Array.isArray(initial) ? initial : [];
|
|
for (let i = 0; i < initArr.length; i++) initialSet[String(initArr[i])] = true;
|
|
for (let i = 0; i < choices.length; i++) {
|
|
const opt = document.createElement('option');
|
|
opt.value = String(choices[i]);
|
|
opt.textContent = String(choices[i]);
|
|
if (initialSet[opt.value]) opt.selected = true;
|
|
el.appendChild(opt);
|
|
}
|
|
return {
|
|
element: el,
|
|
getValue: () => {
|
|
const out = [];
|
|
for (let i = 0; i < el.options.length; i++) {
|
|
if (el.options[i].selected) out.push(el.options[i].value);
|
|
}
|
|
return out;
|
|
},
|
|
focus: () => el.focus()
|
|
};
|
|
}
|
|
|
|
function stringify(v) {
|
|
if (v == null) return '';
|
|
if (typeof v === 'object') {
|
|
try { return JSON.stringify(v); } catch (_) { return String(v); }
|
|
}
|
|
return String(v);
|
|
}
|
|
|
|
function sameValue(a, b) {
|
|
if (a === b) return true;
|
|
if (a == null && b == null) return true;
|
|
if (a == null || b == null) return false;
|
|
if (typeof a === 'object' || typeof b === 'object') {
|
|
try { return JSON.stringify(a) === JSON.stringify(b); }
|
|
catch (_) { return false; }
|
|
}
|
|
// Loose-string compare so number 42 == "42" from a text input.
|
|
return String(a) === String(b);
|
|
}
|
|
|
|
function navigateToRowForm(row) {
|
|
// Complex-type cells punt to the row's full form editor.
|
|
// The url field on each context row already points at
|
|
// <dir>/<id>.yaml.html — the form-mode re-edit URL.
|
|
if (!row || !row.url) return;
|
|
const nav = (window.tablesApp && window.tablesApp.navigateTo)
|
|
|| function (u) { window.location.assign(u); };
|
|
nav(row.url);
|
|
}
|
|
|
|
// --- Keyboard nav -------------------------------------------------
|
|
|
|
function moveSelection(dir) {
|
|
if (!app.state.selected) return;
|
|
let { row: r, col: c } = app.state.selected;
|
|
const total = rowCount();
|
|
const cols = colCount();
|
|
if (total === 0 || cols === 0) return;
|
|
|
|
switch (dir) {
|
|
case 'up': r = Math.max(0, r - 1); break;
|
|
case 'down': r = Math.min(total - 1, r + 1); break;
|
|
case 'left': c = Math.max(0, c - 1); break;
|
|
case 'right': c = Math.min(cols - 1, c + 1); break;
|
|
case 'home': c = 0; break;
|
|
case 'end': c = cols - 1; break;
|
|
case 'home-row': r = 0; c = 0; break;
|
|
case 'end-row': r = total - 1; c = cols - 1; break;
|
|
case 'left-wrap':
|
|
if (c > 0) { c--; }
|
|
else if (r > 0) { r--; c = cols - 1; }
|
|
break;
|
|
case 'right-wrap':
|
|
if (c < cols - 1) { c++; }
|
|
else if (r < total - 1) { r++; c = 0; }
|
|
break;
|
|
}
|
|
setSelected(r, c);
|
|
}
|
|
|
|
function onCellKey(ev) {
|
|
if (app.state.editing) return; // input owns its own keys
|
|
if (!app.state.selected) return;
|
|
const { row: r, col: c } = app.state.selected;
|
|
|
|
switch (ev.key) {
|
|
case 'ArrowUp': ev.preventDefault(); moveSelection('up'); return;
|
|
case 'ArrowDown': ev.preventDefault(); moveSelection('down'); return;
|
|
case 'ArrowLeft': ev.preventDefault(); moveSelection('left'); return;
|
|
case 'ArrowRight': ev.preventDefault(); moveSelection('right'); return;
|
|
case 'Home':
|
|
ev.preventDefault();
|
|
if (ev.ctrlKey || ev.metaKey) moveSelection('home-row');
|
|
else moveSelection('home');
|
|
return;
|
|
case 'End':
|
|
ev.preventDefault();
|
|
if (ev.ctrlKey || ev.metaKey) moveSelection('end-row');
|
|
else moveSelection('end');
|
|
return;
|
|
case 'Tab':
|
|
ev.preventDefault();
|
|
moveSelection(ev.shiftKey ? 'left-wrap' : 'right-wrap');
|
|
return;
|
|
case 'Enter':
|
|
case 'F2':
|
|
ev.preventDefault();
|
|
enterEdit();
|
|
return;
|
|
case 'Escape':
|
|
ev.preventDefault();
|
|
clearSelection();
|
|
return;
|
|
}
|
|
|
|
if (isPrintableKey(ev)) {
|
|
// Replace value with the typed character (Excel convention).
|
|
ev.preventDefault();
|
|
enterEdit(ev.key);
|
|
}
|
|
}
|
|
|
|
// --- Wiring -------------------------------------------------------
|
|
|
|
function attachToTable() {
|
|
const t = tableEl();
|
|
if (!t) return;
|
|
t.setAttribute('role', 'grid');
|
|
t.addEventListener('keydown', onCellKey);
|
|
}
|
|
|
|
function attachToRow(tr, rowId) {
|
|
tr.setAttribute('role', 'row');
|
|
tr.setAttribute('data-row-id', rowId);
|
|
}
|
|
|
|
function attachToCell(td, rowIdx, colIdx) {
|
|
td.setAttribute('role', 'gridcell');
|
|
td.setAttribute('data-col-idx', String(colIdx));
|
|
td.setAttribute('data-row-idx', String(rowIdx));
|
|
td.setAttribute('tabindex', '-1');
|
|
|
|
td.addEventListener('click', function (ev) {
|
|
ev.stopPropagation();
|
|
setSelected(rowIdx, colIdx);
|
|
});
|
|
td.addEventListener('dblclick', function (ev) {
|
|
ev.stopPropagation();
|
|
setSelected(rowIdx, colIdx, { noFocus: true });
|
|
enterEdit();
|
|
});
|
|
}
|
|
|
|
app.modules.editor = {
|
|
attachToTable: attachToTable,
|
|
attachToRow: attachToRow,
|
|
attachToCell: attachToCell,
|
|
setSelected: setSelected,
|
|
clearSelection: clearSelection,
|
|
moveSelection: moveSelection,
|
|
enterEdit: enterEdit,
|
|
rowKey: rowKey,
|
|
getDraft: getDraft,
|
|
setDraft: setDraft,
|
|
clearDraftField: clearDraftField,
|
|
effectiveCellValue: effectiveCellValue
|
|
};
|
|
})(window.tablesApp);
|