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;