From b4c0327f6325b2deb613bc11ad8eb0da996a4180 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Fri, 15 May 2026 16:07:28 -0500 Subject: [PATCH] =?UTF-8?q?feat(tables):=20row=20editor=20=E2=80=94=20inli?= =?UTF-8?q?ne=20Add=20Row,=20Delete,=20multi-row=20paste,=20min=20row=20he?= =?UTF-8?q?ight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cell-editor was already complete (drafts, row-blur saves, etag concurrency, validation). This commit adds the missing row-level ops: - "+ Add row" appends a draft row inline; first cell focused. Row-blur POSTs to /form.html (the existing form-create endpoint); 201 swaps the synthetic id for the server-returned URL/ETag. Empty rows the user walks away from are silently discarded. - Right-click a row → "Delete row" (or "Delete N rows" when a cell range spans multiple rows). DELETE the row YAML with If-Match; 412 surfaces a conflict warning. - Multi-row clipboard paste creates new rows for grid content that extends past the last existing row, instead of dropping cells past the end. Each new row saves via its own row-blur. - Empty rows now have a 2.4em minimum height so a freshly-added row is visible. Without the floor it collapses to cell-padding (~8px) and looks like a divider line. Server-side: no new endpoints. Form-create (POST /form.html → 201 + Location) and file-API DELETE carry the new client capabilities. Co-Authored-By: Claude Opus 4.7 (1M context) --- tables/build.sh | 4 + tables/css/table.css | 8 + tables/js/add-row.js | 109 ++++ tables/js/clipboard.js | 37 +- tables/js/main.js | 41 +- tables/js/row-ops.js | 201 ++++++ tables/js/save.js | 95 ++- zddc/internal/handler/tables.html | 988 +++++++++++++++++++++++++++++- 8 files changed, 1440 insertions(+), 43 deletions(-) create mode 100644 tables/js/add-row.js create mode 100644 tables/js/row-ops.js diff --git a/tables/build.sh b/tables/build.sh index 43c4610..7d6b207 100755 --- a/tables/build.sh +++ b/tables/build.sh @@ -24,6 +24,7 @@ concat_files \ "../shared/elevation.css" \ "../shared/nav.css" \ "../shared/logo.css" \ + "../shared/context-menu.css" \ "css/table.css" \ "../form/css/form.css" \ > "$css_temp" @@ -43,6 +44,7 @@ concat_files \ "../shared/logo.js" \ "../shared/help.js" \ "../shared/elevation.js" \ + "../shared/context-menu.js" \ "js/mode.js" \ "js/app.js" \ "js/context.js" \ @@ -51,7 +53,9 @@ concat_files \ "js/sort.js" \ "js/editor.js" \ "js/undo.js" \ + "js/add-row.js" \ "js/save.js" \ + "js/row-ops.js" \ "js/clipboard.js" \ "js/render.js" \ "js/main.js" \ diff --git a/tables/css/table.css b/tables/css/table.css index 60e414e..2309ca9 100644 --- a/tables/css/table.css +++ b/tables/css/table.css @@ -103,6 +103,14 @@ background: var(--color-bg-zebra, rgba(0, 0, 0, 0.02)); } +/* Minimum row height so a freshly-added row (every cell empty) stays + visible — without this the row collapses to just cell padding and + looks like a thin divider line. Acts as a floor; rows with content + grow naturally to fit the text. */ +.zddc-table__row { + height: 2.4em; +} + .zddc-table__row--readonly { color: var(--color-text-muted); } diff --git a/tables/js/add-row.js b/tables/js/add-row.js new file mode 100644 index 0000000..d8b1f0f --- /dev/null +++ b/tables/js/add-row.js @@ -0,0 +1,109 @@ +// add-row.js — inline new-row creation. +// +// Click "+ Add row" → append a draft row at the end of state.rows, +// focus its first editable cell, accumulate user typing into the +// drafts buffer like any other row. On row-blur, save.js detects the +// row.isNew flag and POSTs to /form.html (the form-create +// endpoint). The 201 response carries the new row's Location; we swap +// the synthetic url/yamlUrl for the real ones and the draft row +// becomes a normal saved row. +// +// Synthetic identity: each new row gets a temporary "__new-" url +// so rowKey() returns something unique for selection + draft tracking. +// The temporary url is replaced after a successful POST. There is no +// "save on click" UX — the existing row-blur trigger is the save path, +// same as for edits. +(function (app) { + 'use strict'; + + let _counter = 0; + + function makeSyntheticKey() { + _counter += 1; + return '__new-' + _counter; + } + + // Compute the form-create URL for the current page. Both + // //table.html and // (default_tool: tables) shape work; + // //form.html is the form handler's "create" endpoint either + // way (the form handler keys off the in-dir convention, not the + // visiting URL shape). + function formCreateUrl() { + let dir = (location.pathname || '/').replace(/\/table\.html$/, '/'); + if (!dir.endsWith('/')) dir += '/'; + return dir + 'form.html'; + } + + // Create-and-paint: the user-facing path. + function invoke() { + const key = createSilent(); + if (typeof app.repaint === 'function') app.repaint(); + focusNewRow(key); + } + + // Push a draft row WITHOUT painting or focusing. Used by multi-row + // paste (clipboard.js) to create N rows in a single batch, with one + // paint at the end. Returns the synthetic url so callers can address + // the new row in their draft writes. + function createSilent() { + const key = makeSyntheticKey(); + const draftRow = { + url: key, + yamlUrl: null, + data: {}, + etag: null, + editable: true, + isNew: true, + }; + if (!Array.isArray(app.state.rows)) { + app.state.rows = []; + } + app.state.rows.push(draftRow); + return key; + } + + function focusNewRow(key) { + // After repaint, find the tr with our synthetic data-row-id and + // tell the editor to select its first cell. Filtering may have + // hidden the new row if a default filter excludes it; we accept + // that — clearing filters surfaces it. + const tbody = document.querySelector('#table-root tbody'); + if (!tbody) return; + const trs = tbody.querySelectorAll('tr'); + for (let i = 0; i < trs.length; i++) { + if (trs[i].getAttribute('data-row-id') === key) { + const editor = app.modules.editor; + if (editor && typeof editor.setSelected === 'function') { + // Scroll into view so the user sees the new row. + trs[i].scrollIntoView({ block: 'nearest', behavior: 'auto' }); + editor.setSelected(i, 0); + } + return; + } + } + } + + // Cancel-new-row helper: drop the synthetic row entirely. Used when + // the user adds a row, makes no edits, and clicks Add again or + // navigates away — there's nothing to save and an empty draft just + // clutters the table. The save module calls this from row-blur when + // it sees a new row with no drafts. + function discardEmpty(rowId) { + const rows = app.state.rows || []; + for (let i = 0; i < rows.length; i++) { + if (rows[i].isNew && rows[i].url === rowId) { + rows.splice(i, 1); + if (typeof app.repaint === 'function') app.repaint(); + return true; + } + } + return false; + } + + app.modules.addRow = { + invoke: invoke, + createSilent: createSilent, + formCreateUrl: formCreateUrl, + discardEmpty: discardEmpty, + }; +})(window.tablesApp); diff --git a/tables/js/clipboard.js b/tables/js/clipboard.js index ae5b528..b1fc91e 100644 --- a/tables/js/clipboard.js +++ b/tables/js/clipboard.js @@ -119,17 +119,32 @@ // --- Apply paste -------------------------------------------------- function applyPaste(anchorRowIdx, anchorColIdx, grid) { - // grid is string[][]. Returns {applied: int, skipped: int}. + // grid is string[][]. Returns {applied: int, skipped: int, created: int}. + // When the paste extends past the last existing row, the + // add-row module creates new draft rows on the fly so an Excel + // copy lands as a complete data set, not a clipped one. Each + // new row will save on its own row-blur (POST to form-create). const ed = editor(); const totalRows = visibleRowCount(); const cols = (app.context && app.context.columns) || []; const totalCols = cols.length; - let applied = 0, skipped = 0; + const addRow = app.modules.addRow; + let applied = 0, skipped = 0, created = 0; for (let r = 0; r < grid.length; r++) { const dstR = anchorRowIdx + r; - if (dstR >= totalRows) { skipped += grid[r].length; continue; } - const row = rowDataAtIndex(dstR); + let row = null; + if (dstR < totalRows) { + row = rowDataAtIndex(dstR); + } else if (addRow && typeof addRow.createSilent === 'function') { + addRow.createSilent(); + created++; + // After createSilent the new row is at the end of + // state.rows but the DOM hasn't repainted yet — pull + // straight from state.rows to address it. + const all = (app.state && app.state.rows) || []; + row = all[all.length - 1]; + } if (!row) { skipped += grid[r].length; continue; } for (let c = 0; c < grid[r].length; c++) { const dstC = anchorColIdx + c; @@ -141,7 +156,7 @@ applied++; } } - return { applied: applied, skipped: skipped }; + return { applied: applied, skipped: skipped, created: created }; } function visibleRowCount() { @@ -208,11 +223,15 @@ const result = applyPaste(r, c, grid); // Trigger a re-paint so draft values display. if (typeof app.repaint === 'function') app.repaint(); + let msg = 'Pasted ' + result.applied + ' cell' + plural(result.applied); + if (result.created > 0) { + msg += ' into ' + result.created + ' new row' + plural(result.created); + } if (result.skipped > 0) { - notifyToast( - 'Pasted ' + result.applied + ' cell' + plural(result.applied) + - '; ' + result.skipped + ' dropped (out of bounds)' - ); + msg += '; ' + result.skipped + ' dropped (out of bounds)'; + } + if (result.created > 0 || result.skipped > 0) { + notifyToast(msg); } } diff --git a/tables/js/main.js b/tables/js/main.js index 21d34d5..3caef80 100644 --- a/tables/js/main.js +++ b/tables/js/main.js @@ -31,18 +31,33 @@ const clearBtn = document.getElementById('table-clear-filters'); const addRowBtn = document.getElementById('table-add-row'); - // Add-row button: link to .form.html, the form-system's - // empty-form URL for this table's row schema. POST creates a - // new submission and the server redirects to the row's edit - // URL. Hidden when we can't derive a table name from the - // pathname (e.g. inline-context test harness opening tables.html - // directly without a *.table.html URL). + // Add-row button: appends a draft row inline. Save fires on + // row-blur, which POSTs to /form.html and swaps the + // synthetic row id for the server's response. The button shows + // whenever the page is a real table view (http(s) + a table + // context loaded with columns) — the test-fixture inline-context + // harness opens tables.html directly with no URL shape, so we + // gate on having a column list AND running over http(s). if (addRowBtn) { - // Page is at /table.html; the row-creation form is at - // /form.html — same directory, just swap the basename. - if (/\/table\.html$/.test(location.pathname || '')) { - addRowBtn.href = 'form.html'; + const onHttp = location.protocol === 'http:' || location.protocol === 'https:'; + const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0; + if (onHttp && hasCols) { addRowBtn.hidden = false; + addRowBtn.removeAttribute('href'); + addRowBtn.setAttribute('role', 'button'); + addRowBtn.setAttribute('tabindex', '0'); + addRowBtn.style.cursor = 'pointer'; + const handleAdd = function (ev) { + ev.preventDefault(); + const addRow = app.modules.addRow; + if (addRow && typeof addRow.invoke === 'function') { + addRow.invoke(); + } + }; + addRowBtn.addEventListener('click', handleAdd); + addRowBtn.addEventListener('keydown', function (ev) { + if (ev.key === 'Enter' || ev.key === ' ') handleAdd(ev); + }); } } @@ -106,6 +121,12 @@ editor.setSelected(state.selected.row, state.selected.col, { noFocus: true }); } } + // Row context menu re-attaches each paint — renderBody wipes + // the tbody, taking listeners with it. + const rowOps = app.modules.rowOps; + if (rowOps && typeof rowOps.attach === 'function') { + rowOps.attach(); + } // Re-apply Phase-3 dirty-row markers — tbody.innerHTML='' in // renderBody wiped them. const save = app.modules.save; diff --git a/tables/js/row-ops.js b/tables/js/row-ops.js new file mode 100644 index 0000000..1e9e3f1 --- /dev/null +++ b/tables/js/row-ops.js @@ -0,0 +1,201 @@ +// row-ops.js — row-level operations (delete, future: duplicate, +// copy-to-table, etc.). Surfaced via a right-click context menu on +// table rows; the editor's selection state determines which row the +// action targets when the menu is invoked from the keyboard or from a +// future toolbar button. +// +// The shared context-menu primitive (window.zddc.menu) drives the +// rendering and keyboard behaviour. This module owns the menu spec +// and the action handlers. +(function (app) { + 'use strict'; + + function findRowById(rowId) { + const all = (app.state && app.state.rows) || []; + for (let i = 0; i < all.length; i++) { + const editor = app.modules.editor; + const key = editor ? editor.rowKey(all[i]) : (all[i].url || ''); + if (key === rowId) return all[i]; + } + return null; + } + + function removeRowFromState(row) { + const all = app.state.rows || []; + const idx = all.indexOf(row); + if (idx >= 0) all.splice(idx, 1); + // Drop any drafts keyed on the row's url. + if (app.state.drafts && row.url) { + delete app.state.drafts[row.url]; + } + } + + function rowDisplayName(row) { + if (!row) return '(unknown)'; + if (row.isNew) return '(unsaved new row)'; + if (row.yamlUrl) { + const m = row.yamlUrl.match(/[^/]+$/); + if (m) return m[0]; + } + return row.url || '(row)'; + } + + async function deleteRow(rowId) { + const row = findRowById(rowId); + if (!row) return { status: 'noop' }; + if (row.editable === false) return { status: 'readonly' }; + + // Unsaved new row: just drop it. Nothing to call. + if (row.isNew) { + removeRowFromState(row); + if (typeof app.repaint === 'function') app.repaint(); + return { status: 'ok-local' }; + } + + if (!row.yamlUrl) { + // file:// or fixture context — nothing to delete server-side. + removeRowFromState(row); + if (typeof app.repaint === 'function') app.repaint(); + return { status: 'ok-local' }; + } + + const ok = window.confirm('Delete row "' + rowDisplayName(row) + '"?\n\nThis cannot be undone.'); + if (!ok) return { status: 'cancelled' }; + + const headers = {}; + if (row.etag) headers['If-Match'] = '"' + row.etag + '"'; + let resp; + try { + resp = await fetch(row.yamlUrl, { + method: 'DELETE', + headers: headers, + credentials: 'same-origin' + }); + } catch (err) { + window.alert('Delete failed: ' + (err && err.message ? err.message : err)); + return { status: 'network-error', error: err }; + } + if (resp.status === 200 || resp.status === 204) { + removeRowFromState(row); + if (typeof app.repaint === 'function') app.repaint(); + return { status: 'ok' }; + } + if (resp.status === 412) { + window.alert('Cannot delete: this row was changed since you loaded it. Reload to see the latest version.'); + return { status: 'conflict' }; + } + let body = ''; + try { body = await resp.text(); } catch (_) { /* ignore */ } + window.alert('Delete failed (' + resp.status + '): ' + body); + return { status: 'http-error', code: resp.status }; + } + + // Returns the list of visible-row indices currently included in + // the editor's range selection. Empty when no range is active. + function rangeRowIndices() { + const range = app.state && app.state.range; + if (!range) return []; + const r0 = Math.min(range.anchor.row, range.focus.row); + const r1 = Math.max(range.anchor.row, range.focus.row); + const out = []; + for (let r = r0; r <= r1; r++) out.push(r); + return out; + } + + // Map a visible-row index to its data-row-id (synthetic or real). + function rowIdAtIndex(idx) { + const trs = document.querySelectorAll('#table-root tbody > tr'); + const tr = trs[idx]; + return tr ? tr.getAttribute('data-row-id') : null; + } + + async function deleteRows(rowIds) { + if (!rowIds || rowIds.length === 0) return { status: 'noop' }; + if (rowIds.length === 1) return deleteRow(rowIds[0]); + const ok = window.confirm('Delete ' + rowIds.length + ' rows?\n\nThis cannot be undone.'); + if (!ok) return { status: 'cancelled' }; + // Walk back-to-front so removing by index from state.rows + // doesn't shift the indices of pending deletes. + let okCount = 0, failCount = 0; + for (let i = rowIds.length - 1; i >= 0; i--) { + const row = findRowById(rowIds[i]); + if (!row) continue; + if (row.isNew || !row.yamlUrl) { + removeRowFromState(row); + okCount++; + continue; + } + const headers = {}; + if (row.etag) headers['If-Match'] = '"' + row.etag + '"'; + try { + const resp = await fetch(row.yamlUrl, { + method: 'DELETE', + headers: headers, + credentials: 'same-origin' + }); + if (resp.status === 200 || resp.status === 204) { + removeRowFromState(row); + okCount++; + } else { + failCount++; + } + } catch (_err) { + failCount++; + } + } + if (typeof app.repaint === 'function') app.repaint(); + if (failCount > 0) { + window.alert('Deleted ' + okCount + ' row(s); ' + failCount + ' failed.'); + } + return { status: 'ok', deleted: okCount, failed: failCount }; + } + + function buildRowMenu(ctx) { + const rangeRows = ctx.rangeRowIds || []; + const inRange = rangeRows.length > 1 && rangeRows.indexOf(ctx.rowId) !== -1; + const targets = inRange ? rangeRows : [ctx.rowId]; + const label = targets.length > 1 ? 'Delete ' + targets.length + ' rows' : 'Delete row'; + return [ + { + label: label, + icon: '🗑', + danger: true, + disabled: !ctx.row || ctx.row.editable === false, + action: function () { + if (targets.length > 1) deleteRows(targets); + else deleteRow(targets[0]); + } + } + ]; + } + + function onRowContext(ev) { + const tr = ev.target.closest('tr[data-row-id]'); + if (!tr) return; + const rowId = tr.getAttribute('data-row-id'); + const row = findRowById(rowId); + if (!row) return; + ev.preventDefault(); + const menu = window.zddc && window.zddc.menu; + if (!menu || typeof menu.open !== 'function') return; + const rangeRowIds = rangeRowIndices().map(rowIdAtIndex).filter(Boolean); + menu.open({ + x: ev.clientX, + y: ev.clientY, + items: buildRowMenu({ row: row, rowId: rowId, rangeRowIds: rangeRowIds }), + context: { row: row, rowId: rowId, rangeRowIds: rangeRowIds } + }); + } + + function attach() { + const tbody = document.querySelector('#table-root tbody'); + if (!tbody) return; + tbody.addEventListener('contextmenu', onRowContext); + } + + app.modules.rowOps = { + attach: attach, + deleteRow: deleteRow, + deleteRows: deleteRows, + }; +})(window.tablesApp); diff --git a/tables/js/save.js b/tables/js/save.js index 1cdba94..5c3f1cb 100644 --- a/tables/js/save.js +++ b/tables/js/save.js @@ -177,8 +177,21 @@ async function saveRow(rowId, opts) { opts = opts || {}; const { row, drafts } = rowFromState(rowId); - if (!row || !drafts || Object.keys(drafts).length === 0) { - return { status: 'noop' }; + if (!row) return { status: 'noop' }; + const hasDrafts = drafts && Object.keys(drafts).length > 0; + // New (unsaved) rows: if the user added a row and then moved on + // without typing anything, drop the empty placeholder rather + // than POST an empty body that fails schema validation. + if (row.isNew && !hasDrafts) { + const addRow = app.modules.addRow; + if (addRow && typeof addRow.discardEmpty === 'function') { + addRow.discardEmpty(rowId); + } + return { status: 'discarded-empty' }; + } + if (!hasDrafts) return { status: 'noop' }; + if (row.isNew) { + return createRow(rowId, row, drafts, opts); } if (!row.yamlUrl) { // file:// mode or rows from inline-context test fixtures @@ -281,6 +294,84 @@ return { status: 'http-error', code: resp.status }; } + // createRow handles the POST path for an isNew row. Body is YAML of + // the row's draft data (no row.data yet — it's a fresh row). Success + // is 201 + Location pointing at the new .yaml; we swap the + // synthetic url/yamlUrl for the real ones and clear isNew so the + // row behaves like any other from this point on. + async function createRow(rowId, row, drafts, opts) { + const addRow = app.modules.addRow; + if (!addRow || typeof addRow.formCreateUrl !== 'function') { + setRowState(rowId, 'errored'); + return { status: 'no-create-url' }; + } + const createUrl = addRow.formCreateUrl(); + const merged = mergeRow(row.data, drafts); + const yamlBody = window.jsyaml.dump(merged); + + const headers = { 'Content-Type': 'application/yaml; charset=utf-8' }; + const fetchOpts = { + method: 'POST', + body: yamlBody, + headers: headers, + credentials: 'same-origin', + }; + if (opts && opts.keepalive) fetchOpts.keepalive = true; + + setRowState(rowId, 'saving'); + let resp; + try { + resp = await fetch(createUrl, fetchOpts); + } catch (err) { + console.error('[tables] createRow network error', err); + setRowState(rowId, 'errored'); + return { status: 'network-error', error: err }; + } + + if (resp.status === 201) { + // Server wrote the row. Body is {location, filename}; we + // also accept the Location header if the body isn't JSON. + let body = {}; + try { body = await resp.json(); } catch (_) { /* ignore */ } + const location = body.location || resp.headers.get('Location') || ''; + const newEtag = (resp.headers.get('ETag') || '').replace(/"/g, ''); + row.yamlUrl = location; + row.url = location ? location + '.html' : row.url; + row.data = merged; + row.etag = newEtag || null; + row.isNew = false; + // Move the drafts entry (was keyed on the synthetic id) to + // the new url, then clear it (data has the merged values). + delete app.state.drafts[rowId]; + clearCellInvalid(rowId); + setRowState(rowId, ''); + const sb = document.getElementById('table-status'); + if (sb && sb.getAttribute('data-row-id') === rowId) clearStatus(); + // Re-paint so the row picks up its new data-row-id and any + // server-supplied default fields surface. + if (typeof app.repaint === 'function') app.repaint(); + return { status: 'ok' }; + } + + if (resp.status === 422) { + let body = {}; + try { body = await resp.json(); } catch (_) { /* ignore */ } + clearCellInvalid(rowId); + const errs = body.errors || []; + for (let i = 0; i < errs.length; i++) { + const e = errs[i]; + const field = String(e.path || '').replace(/^\//, '').split('/')[0]; + if (field) markCellInvalid(rowId, field, e.message || 'invalid'); + } + setRowState(rowId, 'invalid'); + return { status: 'invalid', errors: errs }; + } + + console.warn('[tables] createRow returned', resp.status); + setRowState(rowId, 'errored'); + return { status: 'http-error', code: resp.status }; + } + async function useMine(rowId) { const { row, drafts } = rowFromState(rowId); if (!row || !drafts) return; diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index 7b79155..20acf84 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -963,6 +963,116 @@ body.help-open .app-header { outline-offset: 2px; } +/* shared/context-menu.css — generic styles for window.zddc.menu. + Mirrors the look-and-feel of native context menus: tight rows, + five-column grid (check | icon | label | accel | arrow), subtle + border + shadow, hover background from the shared --bg-hover token, + danger items tinted with --danger. */ + +.zddc-menu { + position: fixed; + z-index: 10000; + min-width: 12rem; + max-width: 22rem; + padding: 0.25rem 0; + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18), + 0 2px 6px rgba(0, 0, 0, 0.10); + font-family: var(--font); + font-size: 0.85rem; + line-height: 1.2; + user-select: none; + /* Allow focus styles inside without leaking to the menu itself. */ + outline: none; +} + +.zddc-menu__sep { + height: 1px; + margin: 0.25rem 0; + background: var(--border); +} + +.zddc-menu__item { + display: grid; + grid-template-columns: 1.1rem 1.25rem 1fr auto 0.9rem; + align-items: center; + gap: 0.35rem; + padding: 0.3rem 0.7rem; + cursor: pointer; + color: var(--text); + /* Suppress the focus ring on the row itself — hover/focus + background handles the cue. */ + outline: none; +} + +.zddc-menu__item:hover, +.zddc-menu__item:focus, +.zddc-menu__item:focus-visible { + background: var(--bg-hover); +} + +.zddc-menu__item.is-disabled { + color: var(--text-muted); + cursor: default; +} + +.zddc-menu__item.is-disabled:hover, +.zddc-menu__item.is-disabled:focus { + background: transparent; +} + +.zddc-menu__item--danger { + color: var(--danger); +} + +.zddc-menu__item--danger:hover, +.zddc-menu__item--danger:focus { + background: var(--danger); + color: var(--text-light); +} + +.zddc-menu__check { + font-size: 0.9rem; + text-align: center; + color: var(--primary); +} + +.zddc-menu__icon { + font-size: 0.95rem; + text-align: center; +} + +.zddc-menu__label { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.zddc-menu__accel { + color: var(--text-muted); + font-size: 0.78rem; + font-variant-numeric: tabular-nums; + padding-left: 0.5rem; +} + +.zddc-menu__item--danger .zddc-menu__accel { + color: inherit; + opacity: 0.85; +} + +.zddc-menu__arrow { + color: var(--text-muted); + font-size: 0.7rem; + text-align: center; +} + +.zddc-menu__item--has-sub .zddc-menu__arrow { + color: var(--text); +} + /* tables/ — directory-of-YAML table view. Reuses tokens from shared/base.css. */ .table-main { @@ -1068,6 +1178,14 @@ body.help-open .app-header { background: var(--color-bg-zebra, rgba(0, 0, 0, 0.02)); } +/* Minimum row height so a freshly-added row (every cell empty) stays + visible — without this the row collapses to just cell padding and + looks like a thin divider line. Acts as a floor; rows with content + grow naturally to fit the text. */ +.zddc-table__row { + height: 2.4em; +} + .zddc-table__row--readonly { color: var(--color-text-muted); } @@ -1375,7 +1493,7 @@ body.help-open .app-header {
ZDDC Table - v0.0.17-alpha · 2026-05-14 17:23:02 · 050902f-dirty + v0.0.17-alpha · 2026-05-15 20:58:00 · 167a56d-dirty
@@ -1583,6 +1701,7 @@ body.help-open .app-header { 'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU', 'REC', 'RSA', 'RSB', 'RSC', 'RSD', 'RSI', + 'TBD', ]; var STATUS_SET = {}; @@ -2969,6 +3088,388 @@ body.help-open .app-header { window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated }; })(); +// shared/context-menu.js — generic context-menu framework exposed on +// window.zddc.menu. Built so every ZDDC tool can drop a right-click +// menu (or any programmatically-opened menu) onto its UI without +// shipping its own implementation. +// +// API: +// window.zddc.menu.open({ x, y, items, context }) +// window.zddc.menu.close() +// +// `items` is an array (or a function returning an array, evaluated +// against `context` at open-time). Each entry is one of: +// { label, action, icon?, accel?, disabled?, visible?, danger? } +// — a normal menu item; `action(ctx)` fires on click/Enter. +// { label, checked, action, ... } +// — toggle item; `checked` may be a bool or a fn(ctx). Renders +// a ✓ in the gutter when truthy. +// { label, items, ... } +// — submenu; `items` may itself be an array or fn(ctx). +// { separator: true } +// — horizontal divider. Leading/trailing/duplicate separators +// are collapsed automatically so callers can build items +// conditionally without managing dividers. +// +// Any of `label`, `checked`, `visible`, `disabled`, and `items` may +// be a function — each is invoked with the context object so callers +// can render fully context-aware menus from a single declarative +// config. +// +// Keyboard: ArrowUp/Down move within a menu, ArrowRight opens a +// submenu, ArrowLeft / Escape backs up one level (or closes if +// already at the root), Enter / Space activates. Click-outside, +// window blur, scroll, and resize all dismiss. +(function () { + 'use strict'; + + if (!window.zddc) window.zddc = {}; + if (window.zddc.menu) return; + + var SUBMENU_HOVER_MS = 180; + + // Open menu stack — index 0 is the root, deeper entries are + // nested submenus. Each frame: { el, depth, parentRow? }. + var stack = []; + var rootContext = null; + var submenuTimer = null; + + function resolve(val, ctx) { + return typeof val === 'function' ? val(ctx) : val; + } + + function close() { + if (submenuTimer) { clearTimeout(submenuTimer); submenuTimer = null; } + for (var i = 0; i < stack.length; i++) { + var fr = stack[i]; + if (fr.el && fr.el.parentNode) fr.el.parentNode.removeChild(fr.el); + } + stack = []; + rootContext = null; + document.removeEventListener('mousedown', onDocMouseDown, true); + document.removeEventListener('keydown', onDocKeyDown, true); + // blur is bound WITHOUT capture so we only react to the window + // itself losing focus — capturing would also fire when any + // inner element blurs (which happens every time the user moves + // the mouse between menu rows, since hover focuses the row). + window.removeEventListener('blur', close); + window.removeEventListener('resize', close, true); + window.removeEventListener('scroll', onDocScroll, true); + } + + function open(opts) { + opts = opts || {}; + close(); + rootContext = opts.context || {}; + var items = resolve(opts.items, rootContext) || []; + var el = buildMenu(items, rootContext, 0); + document.body.appendChild(el); + position(el, opts.x || 0, opts.y || 0, null); + stack.push({ el: el, depth: 0 }); + + document.addEventListener('mousedown', onDocMouseDown, true); + document.addEventListener('keydown', onDocKeyDown, true); + window.addEventListener('blur', close); + window.addEventListener('resize', close, true); + window.addEventListener('scroll', onDocScroll, true); + + focusFirst(el); + } + + // ── Building ───────────────────────────────────────────────────────── + + function collapseSeparators(items) { + var out = []; + for (var i = 0; i < items.length; i++) { + var it = items[i]; + if (it && it.separator) { + if (out.length === 0) continue; + if (out[out.length - 1].separator) continue; + out.push(it); + } else if (it) { + out.push(it); + } + } + while (out.length && out[out.length - 1].separator) out.pop(); + return out; + } + + function buildMenu(items, ctx, depth) { + var menu = document.createElement('div'); + menu.className = 'zddc-menu'; + menu.setAttribute('role', 'menu'); + menu.dataset.depth = String(depth); + // Suppress the native context menu over our own menu. + menu.addEventListener('contextmenu', function (e) { e.preventDefault(); }); + + var filtered = items.filter(function (it) { + if (!it) return false; + if (it.separator) return true; + if ('visible' in it && !resolve(it.visible, ctx)) return false; + return true; + }); + var pruned = collapseSeparators(filtered); + + for (var i = 0; i < pruned.length; i++) { + menu.appendChild(buildRow(pruned[i], ctx, depth)); + } + return menu; + } + + function buildRow(item, ctx, depth) { + if (item.separator) { + var sep = document.createElement('div'); + sep.className = 'zddc-menu__sep'; + sep.setAttribute('role', 'separator'); + return sep; + } + + var hasSub = !!item.items; + var isToggle = ('checked' in item); + var disabled = 'disabled' in item ? !!resolve(item.disabled, ctx) : false; + + var row = document.createElement('div'); + row.className = 'zddc-menu__item'; + if (item.danger) row.classList.add('zddc-menu__item--danger'); + if (hasSub) row.classList.add('zddc-menu__item--has-sub'); + if (disabled) { + row.classList.add('is-disabled'); + row.setAttribute('aria-disabled', 'true'); + } + row.setAttribute('role', + hasSub ? 'menuitem' + : (isToggle ? 'menuitemcheckbox' : 'menuitem')); + row.tabIndex = -1; + + // Check gutter — present on every row so columns align. + var check = document.createElement('span'); + check.className = 'zddc-menu__check'; + if (isToggle) { + var on = !!resolve(item.checked, ctx); + if (on) { + check.textContent = '✓'; + row.classList.add('is-checked'); + row.setAttribute('aria-checked', 'true'); + } else { + row.setAttribute('aria-checked', 'false'); + } + } + row.appendChild(check); + + // Icon column. + var icon = document.createElement('span'); + icon.className = 'zddc-menu__icon'; + if (item.icon) icon.textContent = item.icon; + row.appendChild(icon); + + // Label. + var label = document.createElement('span'); + label.className = 'zddc-menu__label'; + label.textContent = String(resolve(item.label, ctx) || ''); + row.appendChild(label); + + // Accelerator hint (visual only; no binding). + var accel = document.createElement('span'); + accel.className = 'zddc-menu__accel'; + if (item.accel) accel.textContent = item.accel; + row.appendChild(accel); + + // Submenu arrow. + var arrow = document.createElement('span'); + arrow.className = 'zddc-menu__arrow'; + if (hasSub) arrow.textContent = '▸'; + row.appendChild(arrow); + + if (!disabled) { + row.addEventListener('mouseenter', function () { + // Hovering any row in a menu collapses deeper menus + // (so traversing siblings closes a previously-opened + // submenu) and re-focuses this row for keyboard nav. + closeBelow(depth); + if (submenuTimer) { clearTimeout(submenuTimer); submenuTimer = null; } + if (hasSub) { + submenuTimer = setTimeout(function () { + openSubmenu(row, item, ctx, depth + 1, false); + }, SUBMENU_HOVER_MS); + } + try { row.focus({ preventScroll: true }); } catch (_e) { row.focus(); } + }); + row.addEventListener('click', function (e) { + e.preventDefault(); + e.stopPropagation(); + if (submenuTimer) { clearTimeout(submenuTimer); submenuTimer = null; } + if (hasSub) { + openSubmenu(row, item, ctx, depth + 1, true); + return; + } + activate(item, ctx); + }); + } + return row; + } + + function activate(item, ctx) { + try { + if (typeof item.action === 'function') item.action(ctx); + } finally { + close(); + } + } + + function openSubmenu(parentRow, parentItem, ctx, depth, takeFocus) { + closeBelow(depth - 1); + var items = resolve(parentItem.items, ctx) || []; + var el = buildMenu(items, ctx, depth); + document.body.appendChild(el); + var rect = parentRow.getBoundingClientRect(); + // Slight overlap so pointer-cross feels continuous. + position(el, rect.right - 2, rect.top - 4, parentRow); + stack.push({ el: el, depth: depth, parentRow: parentRow }); + if (takeFocus) focusFirst(el); + } + + function closeBelow(depth) { + while (stack.length && stack[stack.length - 1].depth > depth) { + var fr = stack.pop(); + if (fr.el && fr.el.parentNode) fr.el.parentNode.removeChild(fr.el); + } + } + + // ── Positioning ────────────────────────────────────────────────────── + + function position(el, x, y, parentRow) { + // Fixed so we ignore document scroll; measure after layout. + el.style.position = 'fixed'; + el.style.left = '0px'; + el.style.top = '0px'; + el.style.visibility = 'hidden'; + var rect = el.getBoundingClientRect(); + var w = rect.width; + var h = rect.height; + var vw = window.innerWidth; + var vh = window.innerHeight; + + var leftX = x; + if (leftX + w > vw - 4) { + if (parentRow) { + var pr = parentRow.getBoundingClientRect(); + leftX = pr.left - w + 2; // flip submenu to the left + } else { + leftX = Math.max(4, x - w); // flip root menu left of cursor + } + } + if (leftX < 4) leftX = 4; + + var topY = y; + if (topY + h > vh - 4) topY = Math.max(4, vh - h - 4); + if (topY < 4) topY = 4; + + el.style.left = leftX + 'px'; + el.style.top = topY + 'px'; + el.style.visibility = ''; + } + + // ── Focus + keyboard ───────────────────────────────────────────────── + + function focusable(menuEl) { + return Array.prototype.slice.call( + menuEl.querySelectorAll('.zddc-menu__item:not(.is-disabled)')); + } + + function focusFirst(menuEl) { + var items = focusable(menuEl); + if (items.length) { + try { items[0].focus({ preventScroll: true }); } + catch (_e) { items[0].focus(); } + } + } + + function onDocMouseDown(e) { + for (var i = 0; i < stack.length; i++) { + if (stack[i].el.contains(e.target)) return; + } + close(); + } + + // Scroll listener uses capture so scrolls inside any element (the + // tree pane, the document, etc.) dismiss the menu — its position + // is fixed and would otherwise hang over stale content. Scrolls + // that originate inside the menu itself (a future tall submenu) + // are ignored. + function onDocScroll(e) { + var t = e.target; + for (var i = 0; i < stack.length; i++) { + if (stack[i].el === t || (t && t.nodeType === 1 && stack[i].el.contains(t))) { + return; + } + } + close(); + } + + function onDocKeyDown(e) { + if (!stack.length) return; + var top = stack[stack.length - 1]; + var items = focusable(top.el); + var active = document.activeElement; + var idx = items.indexOf(active); + + switch (e.key) { + case 'Escape': + e.preventDefault(); + if (stack.length > 1) { + var fr = stack.pop(); + if (fr.el.parentNode) fr.el.parentNode.removeChild(fr.el); + if (fr.parentRow) fr.parentRow.focus(); + } else { + close(); + } + return; + case 'ArrowDown': + e.preventDefault(); + if (!items.length) return; + items[idx < 0 ? 0 : (idx + 1) % items.length].focus(); + return; + case 'ArrowUp': + e.preventDefault(); + if (!items.length) return; + items[idx < 0 ? items.length - 1 + : (idx - 1 + items.length) % items.length].focus(); + return; + case 'Home': + e.preventDefault(); + if (items.length) items[0].focus(); + return; + case 'End': + e.preventDefault(); + if (items.length) items[items.length - 1].focus(); + return; + case 'ArrowRight': + if (active && active.classList.contains('zddc-menu__item--has-sub')) { + e.preventDefault(); + active.click(); + } + return; + case 'ArrowLeft': + if (stack.length > 1) { + e.preventDefault(); + var fr2 = stack.pop(); + if (fr2.el.parentNode) fr2.el.parentNode.removeChild(fr2.el); + if (fr2.parentRow) fr2.parentRow.focus(); + } + return; + case 'Enter': + case ' ': + if (active) { + e.preventDefault(); + active.click(); + } + return; + } + } + + window.zddc.menu = { open: open, close: close }; +})(); + // mode.js — picks table-mode vs form-mode at boot time and unhides the // matching container. Both apps (tablesApp, formApp) ship in the same // bundle but each only paints when its container is visible. @@ -4619,6 +5120,116 @@ body.help-open .app-header { }; })(window.tablesApp); +// add-row.js — inline new-row creation. +// +// Click "+ Add row" → append a draft row at the end of state.rows, +// focus its first editable cell, accumulate user typing into the +// drafts buffer like any other row. On row-blur, save.js detects the +// row.isNew flag and POSTs to /form.html (the form-create +// endpoint). The 201 response carries the new row's Location; we swap +// the synthetic url/yamlUrl for the real ones and the draft row +// becomes a normal saved row. +// +// Synthetic identity: each new row gets a temporary "__new-" url +// so rowKey() returns something unique for selection + draft tracking. +// The temporary url is replaced after a successful POST. There is no +// "save on click" UX — the existing row-blur trigger is the save path, +// same as for edits. +(function (app) { + 'use strict'; + + let _counter = 0; + + function makeSyntheticKey() { + _counter += 1; + return '__new-' + _counter; + } + + // Compute the form-create URL for the current page. Both + // //table.html and // (default_tool: tables) shape work; + // //form.html is the form handler's "create" endpoint either + // way (the form handler keys off the in-dir convention, not the + // visiting URL shape). + function formCreateUrl() { + let dir = (location.pathname || '/').replace(/\/table\.html$/, '/'); + if (!dir.endsWith('/')) dir += '/'; + return dir + 'form.html'; + } + + // Create-and-paint: the user-facing path. + function invoke() { + const key = createSilent(); + if (typeof app.repaint === 'function') app.repaint(); + focusNewRow(key); + } + + // Push a draft row WITHOUT painting or focusing. Used by multi-row + // paste (clipboard.js) to create N rows in a single batch, with one + // paint at the end. Returns the synthetic url so callers can address + // the new row in their draft writes. + function createSilent() { + const key = makeSyntheticKey(); + const draftRow = { + url: key, + yamlUrl: null, + data: {}, + etag: null, + editable: true, + isNew: true, + }; + if (!Array.isArray(app.state.rows)) { + app.state.rows = []; + } + app.state.rows.push(draftRow); + return key; + } + + function focusNewRow(key) { + // After repaint, find the tr with our synthetic data-row-id and + // tell the editor to select its first cell. Filtering may have + // hidden the new row if a default filter excludes it; we accept + // that — clearing filters surfaces it. + const tbody = document.querySelector('#table-root tbody'); + if (!tbody) return; + const trs = tbody.querySelectorAll('tr'); + for (let i = 0; i < trs.length; i++) { + if (trs[i].getAttribute('data-row-id') === key) { + const editor = app.modules.editor; + if (editor && typeof editor.setSelected === 'function') { + // Scroll into view so the user sees the new row. + trs[i].scrollIntoView({ block: 'nearest', behavior: 'auto' }); + editor.setSelected(i, 0); + } + return; + } + } + } + + // Cancel-new-row helper: drop the synthetic row entirely. Used when + // the user adds a row, makes no edits, and clicks Add again or + // navigates away — there's nothing to save and an empty draft just + // clutters the table. The save module calls this from row-blur when + // it sees a new row with no drafts. + function discardEmpty(rowId) { + const rows = app.state.rows || []; + for (let i = 0; i < rows.length; i++) { + if (rows[i].isNew && rows[i].url === rowId) { + rows.splice(i, 1); + if (typeof app.repaint === 'function') app.repaint(); + return true; + } + } + return false; + } + + app.modules.addRow = { + invoke: invoke, + createSilent: createSilent, + formCreateUrl: formCreateUrl, + discardEmpty: discardEmpty, + }; +})(window.tablesApp); + // save.js — Phase 3 of editable-cell mode. // // Row-level batch save on row-blur. While the user is editing cells @@ -4798,8 +5409,21 @@ body.help-open .app-header { async function saveRow(rowId, opts) { opts = opts || {}; const { row, drafts } = rowFromState(rowId); - if (!row || !drafts || Object.keys(drafts).length === 0) { - return { status: 'noop' }; + if (!row) return { status: 'noop' }; + const hasDrafts = drafts && Object.keys(drafts).length > 0; + // New (unsaved) rows: if the user added a row and then moved on + // without typing anything, drop the empty placeholder rather + // than POST an empty body that fails schema validation. + if (row.isNew && !hasDrafts) { + const addRow = app.modules.addRow; + if (addRow && typeof addRow.discardEmpty === 'function') { + addRow.discardEmpty(rowId); + } + return { status: 'discarded-empty' }; + } + if (!hasDrafts) return { status: 'noop' }; + if (row.isNew) { + return createRow(rowId, row, drafts, opts); } if (!row.yamlUrl) { // file:// mode or rows from inline-context test fixtures @@ -4902,6 +5526,84 @@ body.help-open .app-header { return { status: 'http-error', code: resp.status }; } + // createRow handles the POST path for an isNew row. Body is YAML of + // the row's draft data (no row.data yet — it's a fresh row). Success + // is 201 + Location pointing at the new .yaml; we swap the + // synthetic url/yamlUrl for the real ones and clear isNew so the + // row behaves like any other from this point on. + async function createRow(rowId, row, drafts, opts) { + const addRow = app.modules.addRow; + if (!addRow || typeof addRow.formCreateUrl !== 'function') { + setRowState(rowId, 'errored'); + return { status: 'no-create-url' }; + } + const createUrl = addRow.formCreateUrl(); + const merged = mergeRow(row.data, drafts); + const yamlBody = window.jsyaml.dump(merged); + + const headers = { 'Content-Type': 'application/yaml; charset=utf-8' }; + const fetchOpts = { + method: 'POST', + body: yamlBody, + headers: headers, + credentials: 'same-origin', + }; + if (opts && opts.keepalive) fetchOpts.keepalive = true; + + setRowState(rowId, 'saving'); + let resp; + try { + resp = await fetch(createUrl, fetchOpts); + } catch (err) { + console.error('[tables] createRow network error', err); + setRowState(rowId, 'errored'); + return { status: 'network-error', error: err }; + } + + if (resp.status === 201) { + // Server wrote the row. Body is {location, filename}; we + // also accept the Location header if the body isn't JSON. + let body = {}; + try { body = await resp.json(); } catch (_) { /* ignore */ } + const location = body.location || resp.headers.get('Location') || ''; + const newEtag = (resp.headers.get('ETag') || '').replace(/"/g, ''); + row.yamlUrl = location; + row.url = location ? location + '.html' : row.url; + row.data = merged; + row.etag = newEtag || null; + row.isNew = false; + // Move the drafts entry (was keyed on the synthetic id) to + // the new url, then clear it (data has the merged values). + delete app.state.drafts[rowId]; + clearCellInvalid(rowId); + setRowState(rowId, ''); + const sb = document.getElementById('table-status'); + if (sb && sb.getAttribute('data-row-id') === rowId) clearStatus(); + // Re-paint so the row picks up its new data-row-id and any + // server-supplied default fields surface. + if (typeof app.repaint === 'function') app.repaint(); + return { status: 'ok' }; + } + + if (resp.status === 422) { + let body = {}; + try { body = await resp.json(); } catch (_) { /* ignore */ } + clearCellInvalid(rowId); + const errs = body.errors || []; + for (let i = 0; i < errs.length; i++) { + const e = errs[i]; + const field = String(e.path || '').replace(/^\//, '').split('/')[0]; + if (field) markCellInvalid(rowId, field, e.message || 'invalid'); + } + setRowState(rowId, 'invalid'); + return { status: 'invalid', errors: errs }; + } + + console.warn('[tables] createRow returned', resp.status); + setRowState(rowId, 'errored'); + return { status: 'http-error', code: resp.status }; + } + async function useMine(rowId) { const { row, drafts } = rowFromState(rowId); if (!row || !drafts) return; @@ -5031,6 +5733,208 @@ body.help-open .app-header { }; })(window.tablesApp); +// row-ops.js — row-level operations (delete, future: duplicate, +// copy-to-table, etc.). Surfaced via a right-click context menu on +// table rows; the editor's selection state determines which row the +// action targets when the menu is invoked from the keyboard or from a +// future toolbar button. +// +// The shared context-menu primitive (window.zddc.menu) drives the +// rendering and keyboard behaviour. This module owns the menu spec +// and the action handlers. +(function (app) { + 'use strict'; + + function findRowById(rowId) { + const all = (app.state && app.state.rows) || []; + for (let i = 0; i < all.length; i++) { + const editor = app.modules.editor; + const key = editor ? editor.rowKey(all[i]) : (all[i].url || ''); + if (key === rowId) return all[i]; + } + return null; + } + + function removeRowFromState(row) { + const all = app.state.rows || []; + const idx = all.indexOf(row); + if (idx >= 0) all.splice(idx, 1); + // Drop any drafts keyed on the row's url. + if (app.state.drafts && row.url) { + delete app.state.drafts[row.url]; + } + } + + function rowDisplayName(row) { + if (!row) return '(unknown)'; + if (row.isNew) return '(unsaved new row)'; + if (row.yamlUrl) { + const m = row.yamlUrl.match(/[^/]+$/); + if (m) return m[0]; + } + return row.url || '(row)'; + } + + async function deleteRow(rowId) { + const row = findRowById(rowId); + if (!row) return { status: 'noop' }; + if (row.editable === false) return { status: 'readonly' }; + + // Unsaved new row: just drop it. Nothing to call. + if (row.isNew) { + removeRowFromState(row); + if (typeof app.repaint === 'function') app.repaint(); + return { status: 'ok-local' }; + } + + if (!row.yamlUrl) { + // file:// or fixture context — nothing to delete server-side. + removeRowFromState(row); + if (typeof app.repaint === 'function') app.repaint(); + return { status: 'ok-local' }; + } + + const ok = window.confirm('Delete row "' + rowDisplayName(row) + '"?\n\nThis cannot be undone.'); + if (!ok) return { status: 'cancelled' }; + + const headers = {}; + if (row.etag) headers['If-Match'] = '"' + row.etag + '"'; + let resp; + try { + resp = await fetch(row.yamlUrl, { + method: 'DELETE', + headers: headers, + credentials: 'same-origin' + }); + } catch (err) { + window.alert('Delete failed: ' + (err && err.message ? err.message : err)); + return { status: 'network-error', error: err }; + } + if (resp.status === 200 || resp.status === 204) { + removeRowFromState(row); + if (typeof app.repaint === 'function') app.repaint(); + return { status: 'ok' }; + } + if (resp.status === 412) { + window.alert('Cannot delete: this row was changed since you loaded it. Reload to see the latest version.'); + return { status: 'conflict' }; + } + let body = ''; + try { body = await resp.text(); } catch (_) { /* ignore */ } + window.alert('Delete failed (' + resp.status + '): ' + body); + return { status: 'http-error', code: resp.status }; + } + + // Returns the list of visible-row indices currently included in + // the editor's range selection. Empty when no range is active. + function rangeRowIndices() { + const range = app.state && app.state.range; + if (!range) return []; + const r0 = Math.min(range.anchor.row, range.focus.row); + const r1 = Math.max(range.anchor.row, range.focus.row); + const out = []; + for (let r = r0; r <= r1; r++) out.push(r); + return out; + } + + // Map a visible-row index to its data-row-id (synthetic or real). + function rowIdAtIndex(idx) { + const trs = document.querySelectorAll('#table-root tbody > tr'); + const tr = trs[idx]; + return tr ? tr.getAttribute('data-row-id') : null; + } + + async function deleteRows(rowIds) { + if (!rowIds || rowIds.length === 0) return { status: 'noop' }; + if (rowIds.length === 1) return deleteRow(rowIds[0]); + const ok = window.confirm('Delete ' + rowIds.length + ' rows?\n\nThis cannot be undone.'); + if (!ok) return { status: 'cancelled' }; + // Walk back-to-front so removing by index from state.rows + // doesn't shift the indices of pending deletes. + let okCount = 0, failCount = 0; + for (let i = rowIds.length - 1; i >= 0; i--) { + const row = findRowById(rowIds[i]); + if (!row) continue; + if (row.isNew || !row.yamlUrl) { + removeRowFromState(row); + okCount++; + continue; + } + const headers = {}; + if (row.etag) headers['If-Match'] = '"' + row.etag + '"'; + try { + const resp = await fetch(row.yamlUrl, { + method: 'DELETE', + headers: headers, + credentials: 'same-origin' + }); + if (resp.status === 200 || resp.status === 204) { + removeRowFromState(row); + okCount++; + } else { + failCount++; + } + } catch (_err) { + failCount++; + } + } + if (typeof app.repaint === 'function') app.repaint(); + if (failCount > 0) { + window.alert('Deleted ' + okCount + ' row(s); ' + failCount + ' failed.'); + } + return { status: 'ok', deleted: okCount, failed: failCount }; + } + + function buildRowMenu(ctx) { + const rangeRows = ctx.rangeRowIds || []; + const inRange = rangeRows.length > 1 && rangeRows.indexOf(ctx.rowId) !== -1; + const targets = inRange ? rangeRows : [ctx.rowId]; + const label = targets.length > 1 ? 'Delete ' + targets.length + ' rows' : 'Delete row'; + return [ + { + label: label, + icon: '🗑', + danger: true, + disabled: !ctx.row || ctx.row.editable === false, + action: function () { + if (targets.length > 1) deleteRows(targets); + else deleteRow(targets[0]); + } + } + ]; + } + + function onRowContext(ev) { + const tr = ev.target.closest('tr[data-row-id]'); + if (!tr) return; + const rowId = tr.getAttribute('data-row-id'); + const row = findRowById(rowId); + if (!row) return; + ev.preventDefault(); + const menu = window.zddc && window.zddc.menu; + if (!menu || typeof menu.open !== 'function') return; + const rangeRowIds = rangeRowIndices().map(rowIdAtIndex).filter(Boolean); + menu.open({ + x: ev.clientX, + y: ev.clientY, + items: buildRowMenu({ row: row, rowId: rowId, rangeRowIds: rangeRowIds }), + context: { row: row, rowId: rowId, rangeRowIds: rangeRowIds } + }); + } + + function attach() { + const tbody = document.querySelector('#table-root tbody'); + if (!tbody) return; + tbody.addEventListener('contextmenu', onRowContext); + } + + app.modules.rowOps = { + attach: attach, + deleteRow: deleteRow, + deleteRows: deleteRows, + }; +})(window.tablesApp); + // clipboard.js — Phase 4 of editable-cell mode. // // Bidirectional clipboard interop with Excel / Google Sheets / any @@ -5152,17 +6056,32 @@ body.help-open .app-header { // --- Apply paste -------------------------------------------------- function applyPaste(anchorRowIdx, anchorColIdx, grid) { - // grid is string[][]. Returns {applied: int, skipped: int}. + // grid is string[][]. Returns {applied: int, skipped: int, created: int}. + // When the paste extends past the last existing row, the + // add-row module creates new draft rows on the fly so an Excel + // copy lands as a complete data set, not a clipped one. Each + // new row will save on its own row-blur (POST to form-create). const ed = editor(); const totalRows = visibleRowCount(); const cols = (app.context && app.context.columns) || []; const totalCols = cols.length; - let applied = 0, skipped = 0; + const addRow = app.modules.addRow; + let applied = 0, skipped = 0, created = 0; for (let r = 0; r < grid.length; r++) { const dstR = anchorRowIdx + r; - if (dstR >= totalRows) { skipped += grid[r].length; continue; } - const row = rowDataAtIndex(dstR); + let row = null; + if (dstR < totalRows) { + row = rowDataAtIndex(dstR); + } else if (addRow && typeof addRow.createSilent === 'function') { + addRow.createSilent(); + created++; + // After createSilent the new row is at the end of + // state.rows but the DOM hasn't repainted yet — pull + // straight from state.rows to address it. + const all = (app.state && app.state.rows) || []; + row = all[all.length - 1]; + } if (!row) { skipped += grid[r].length; continue; } for (let c = 0; c < grid[r].length; c++) { const dstC = anchorColIdx + c; @@ -5174,7 +6093,7 @@ body.help-open .app-header { applied++; } } - return { applied: applied, skipped: skipped }; + return { applied: applied, skipped: skipped, created: created }; } function visibleRowCount() { @@ -5241,11 +6160,15 @@ body.help-open .app-header { const result = applyPaste(r, c, grid); // Trigger a re-paint so draft values display. if (typeof app.repaint === 'function') app.repaint(); + let msg = 'Pasted ' + result.applied + ' cell' + plural(result.applied); + if (result.created > 0) { + msg += ' into ' + result.created + ' new row' + plural(result.created); + } if (result.skipped > 0) { - notifyToast( - 'Pasted ' + result.applied + ' cell' + plural(result.applied) + - '; ' + result.skipped + ' dropped (out of bounds)' - ); + msg += '; ' + result.skipped + ' dropped (out of bounds)'; + } + if (result.created > 0 || result.skipped > 0) { + notifyToast(msg); } } @@ -5440,18 +6363,33 @@ body.help-open .app-header { const clearBtn = document.getElementById('table-clear-filters'); const addRowBtn = document.getElementById('table-add-row'); - // Add-row button: link to .form.html, the form-system's - // empty-form URL for this table's row schema. POST creates a - // new submission and the server redirects to the row's edit - // URL. Hidden when we can't derive a table name from the - // pathname (e.g. inline-context test harness opening tables.html - // directly without a *.table.html URL). + // Add-row button: appends a draft row inline. Save fires on + // row-blur, which POSTs to /form.html and swaps the + // synthetic row id for the server's response. The button shows + // whenever the page is a real table view (http(s) + a table + // context loaded with columns) — the test-fixture inline-context + // harness opens tables.html directly with no URL shape, so we + // gate on having a column list AND running over http(s). if (addRowBtn) { - // Page is at /table.html; the row-creation form is at - // /form.html — same directory, just swap the basename. - if (/\/table\.html$/.test(location.pathname || '')) { - addRowBtn.href = 'form.html'; + const onHttp = location.protocol === 'http:' || location.protocol === 'https:'; + const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0; + if (onHttp && hasCols) { addRowBtn.hidden = false; + addRowBtn.removeAttribute('href'); + addRowBtn.setAttribute('role', 'button'); + addRowBtn.setAttribute('tabindex', '0'); + addRowBtn.style.cursor = 'pointer'; + const handleAdd = function (ev) { + ev.preventDefault(); + const addRow = app.modules.addRow; + if (addRow && typeof addRow.invoke === 'function') { + addRow.invoke(); + } + }; + addRowBtn.addEventListener('click', handleAdd); + addRowBtn.addEventListener('keydown', function (ev) { + if (ev.key === 'Enter' || ev.key === ' ') handleAdd(ev); + }); } } @@ -5515,6 +6453,12 @@ body.help-open .app-header { editor.setSelected(state.selected.row, state.selected.col, { noFocus: true }); } } + // Row context menu re-attaches each paint — renderBody wipes + // the tbody, taking listeners with it. + const rowOps = app.modules.rowOps; + if (rowOps && typeof rowOps.attach === 'function') { + rowOps.attach(); + } // Re-apply Phase-3 dirty-row markers — tbody.innerHTML='' in // renderBody wiped them. const save = app.modules.save;