// editor.js — Phase 1 of editable-cell mode. // // Owns the cell-selection + per-cell edit lifecycle. Implements the // W3C ARIA grid-pattern keyboard semantics: // // - Arrow keys move the selected cell. // - Tab / Shift-Tab move right / left, wrapping to next / prev row. // - Enter, F2, double-click, or any printable character enter edit // mode (Enter and F2 keep the existing value; printable chars // replace it; double-click opens with the existing value). // - In edit mode: Enter commits and moves down, Tab commits and // moves right, Escape cancels (restoring the prior value), blur // commits. // // Roving tabindex: only the selected cell carries tabindex=0; all // others are tabindex=-1. This makes the grid a single tab-stop in // the page's tab order, which is the documented spreadsheet UX. // // Edits in this phase live in app.state.drafts and never hit the // network — Phase 3 wires the row-blur PUT. (function (app) { 'use strict'; // --- Helpers ------------------------------------------------------ function tableEl() { return document.getElementById('table-root'); } function cellAt(r, c) { return cellsByRowCol(r, c); } // The displayed table is filtered+sorted; selection is keyed by // VISIBLE row index, not row id, so arrow keys behave intuitively // even after sort / filter changes (the cell at row 3 column 2 // stays at row 3 column 2 even if the underlying row id moved). // This is how Excel and Google Sheets behave too. function cellsByRowCol(r, c) { const t = tableEl(); if (!t) return null; const tbody = t.querySelector('tbody'); if (!tbody) return null; const tr = tbody.children[r]; if (!tr) return null; return tr.querySelector('[role="gridcell"][data-col-idx="' + c + '"]'); } function isPrintableKey(ev) { // A "printable" key produces a single character of text — e.g. // 'a', '7', '$'. Function keys, arrows, modifiers etc. either // have multi-char `key` values ('ArrowDown') or are non-text. // ev.ctrlKey / metaKey suppress so Cmd-A et al. don't trigger // edit mode. if (ev.key.length !== 1) return false; if (ev.ctrlKey || ev.metaKey || ev.altKey) return false; return true; } function rowCount() { const t = tableEl(); if (!t) return 0; return t.querySelectorAll('tbody > tr').length; } function colCount() { const cols = (app.context && app.context.columns) || []; return Array.isArray(cols) ? cols.length : 0; } function colAt(c) { const cols = (app.context && app.context.columns) || []; return cols[c] || null; } function rowDataAt(r) { // The visible row at index r. Walk the rendered tbody to find // its data-row-id, then look up the row in app.state.rows. // app.state.rows holds the SORTED+FILTERED current view (kept // in sync by main.js paint()). const t = tableEl(); if (!t) return null; const tr = t.querySelectorAll('tbody > tr')[r]; if (!tr) return null; const rowId = tr.getAttribute('data-row-id'); if (rowId == null) return null; const all = app.state.rows || []; for (let i = 0; i < all.length; i++) { if (rowKey(all[i]) === rowId) { return all[i]; } } return null; } function rowKey(row) { // Stable per-row identity. Each context row has a `url` (the // .yaml.html re-edit URL); the file basename inside that // URL is unique per directory and survives sort/filter. if (!row || !row.url) return ''; return row.url; } // --- Draft buffer ------------------------------------------------- function getDraft(rowId, field) { const r = app.state.drafts[rowId]; if (!r) return undefined; return r[field]; } function setDraft(rowId, field, value) { if (!app.state.drafts[rowId]) { app.state.drafts[rowId] = {}; } app.state.drafts[rowId][field] = value; notifyDraftsChanged(); } function clearDraftField(rowId, field) { const r = app.state.drafts[rowId]; if (!r) return; delete r[field]; if (Object.keys(r).length === 0) { delete app.state.drafts[rowId]; } notifyDraftsChanged(); } // Notify the save module that drafts changed so it can update the // toolbar Save button + count. Save module is optional in test // fixtures, so the call is guarded. function notifyDraftsChanged() { const save = app.modules.save; if (save && typeof save.onDraftsChanged === 'function') { save.onDraftsChanged(); } } function effectiveCellValue(row, col) { // Display draft value if present; otherwise the row's stored // value. Used by render to keep the visible cell content in // sync with uncommitted edits. const drafted = getDraft(rowKey(row), col.field); if (drafted !== undefined) { return drafted; } return app.modules.util.resolveField(row.data, col.field); } // --- Selection (roving tabindex) ---------------------------------- function setSelected(r, c, opts) { opts = opts || {}; const total = rowCount(); const cols = colCount(); if (total === 0 || cols === 0) { app.state.selected = null; notifySelectionChanged(); return; } if (r < 0) r = 0; if (r > total - 1) r = total - 1; if (c < 0) c = 0; if (c > cols - 1) c = cols - 1; const t = tableEl(); if (t) { const all = t.querySelectorAll('[role="gridcell"]'); for (let i = 0; i < all.length; i++) { all[i].setAttribute('tabindex', '-1'); all[i].classList.remove('zddc-table__cell--selected'); } } const target = cellAt(r, c); if (target) { target.setAttribute('tabindex', '0'); target.classList.add('zddc-table__cell--selected'); if (!opts.noFocus) { target.focus({ preventScroll: false }); } } app.state.selected = { row: r, col: c }; // Plain selection moves clear the multi-cell range. Range // operations (Shift+click, Shift+arrow) pass keepRange so the // anchor stays put while the focus cell moves. if (!opts.keepRange) { clearRange(); } notifySelectionChanged(); } function notifySelectionChanged() { // Phase 3 wires the row-blur save trigger here. save module is // optional in test fixtures that don't include it. const save = app.modules.save; if (save && typeof save.onSelectionChanged === 'function') { save.onSelectionChanged(app.state.selected); } } function clearSelection() { const t = tableEl(); if (t) { const all = t.querySelectorAll('[role="gridcell"]'); for (let i = 0; i < all.length; i++) { all[i].setAttribute('tabindex', '-1'); all[i].classList.remove('zddc-table__cell--selected'); } } app.state.selected = null; } // --- Edit mode ---------------------------------------------------- function enterEdit(initial) { if (!app.state.selected) return; if (app.state.editing) return; const { row: r, col: c } = app.state.selected; const cell = cellAt(r, c); if (!cell) return; const row = rowDataAt(r); const col = colAt(c); if (!row || !col) return; // $-prefixed columns are system-synthesized fields (e.g. the // `$party` source-party qualifier on project-rollup MDL/RSK // views). Their value is derived from the row's canonical // path on read and stripped before any write — editing them // would have no effect on disk, so suppress entry to edit // mode entirely. Selection still works for keyboard // navigation across the cell. if (typeof col.field === 'string' && col.field.charAt(0) === '$') { return; } const propSchema = propertySchemaFor(col); // Complex-type cells (nested object, generic array, oneOf) // can't be inline-edited cleanly — punt to the row's form // editor in a side panel / new page. Phase 2 ships the // navigation; Phase 5 may add a side-panel mount. if (isComplexSchema(propSchema)) { navigateToRowForm(row); return; } const currentValue = effectiveCellValue(row, col); const widget = makeWidget(propSchema, col, initial != null ? initial : currentValue); const inputEl = widget.element; inputEl.classList.add('zddc-table__cell-input'); inputEl.setAttribute('aria-label', 'Edit ' + (col.title || col.field)); // Replace the cell's text content with the editor widget. // Stash the original text in dataset so cancel can restore it // verbatim without re-running the formatCell logic. cell.setAttribute('data-display', cell.textContent || ''); cell.textContent = ''; cell.appendChild(inputEl); widget.focus(); app.state.editing = true; function commit() { if (!app.state.editing) return; const newValue = widget.getValue(); const oldRaw = app.modules.util.resolveField(row.data, col.field); // Compare by JSON-string equality so number 42 == "42" // entered into a number input doesn't false-positive as // a change. resolveField already returns the raw typed // value from row.data. if (sameValue(oldRaw, newValue)) { clearDraftField(rowKey(row), col.field); } else { // Capture the prior draft value (or stored value if // no draft) for undo. Lets Ctrl+Z restore intermediate // state: e.g. typing A → B → C and undoing returns to // B, not all the way back to the row's stored value. const priorDraft = getDraft(rowKey(row), col.field); const undoOld = (priorDraft !== undefined) ? priorDraft : oldRaw; setDraft(rowKey(row), col.field, newValue); const undoMod = app.modules.undo; if (undoMod) { undoMod.push({ cells: [{ rowId: rowKey(row), field: col.field, oldValue: undoOld, newValue: newValue, }], }); } } tearDown(newValue); } function cancel() { tearDown(null); // null = restore from data-display, no draft change } function tearDown(displayValue) { inputEl.removeEventListener('keydown', onKey); inputEl.removeEventListener('blur', onBlur); const display = (displayValue !== undefined && displayValue !== null) ? renderableText(displayValue, col) : (cell.getAttribute('data-display') || ''); cell.removeAttribute('data-display'); cell.textContent = display; app.state.editing = false; cell.focus({ preventScroll: false }); } function onKey(ev) { if (ev.key === 'Enter') { ev.preventDefault(); ev.stopPropagation(); // don't let the table's onCellKey re-handle it commit(); setSelected(r + 1, c); } else if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); cancel(); } else if (ev.key === 'Tab') { ev.preventDefault(); ev.stopPropagation(); commit(); if (ev.shiftKey) { moveSelection('left-wrap'); } else { moveSelection('right-wrap'); } } // Other keys: stay in edit mode, let the input handle them. } function onBlur(_ev) { // Blur (focus moved elsewhere). Commit any pending value. // Schedule via setTimeout(0) so a programmatic refocus by // tearDown→cell.focus doesn't re-fire blur during teardown. if (app.state.editing) { commit(); } } inputEl.addEventListener('keydown', onKey); inputEl.addEventListener('blur', onBlur); } function renderableText(value, col) { return app.modules.util.formatCell(value, col.format); } // --- Schema → editor widget factory -------------------------------- function propertySchemaFor(col) { // Walk the row schema for this column's field. Returns null // when no schema is present (best-effort: cells fall back to // plain text editors). Supports a single dot-separated path // — `properties.a.properties.b` for `field: "a.b"` — to mirror // the existing util.resolveField conventions. const ctx = app.context || {}; if (!ctx.rowSchema) return null; const parts = String(col.field || '').split('.').filter(Boolean); let s = ctx.rowSchema; for (let i = 0; i < parts.length; i++) { if (!s || !s.properties || !s.properties[parts[i]]) return null; s = s.properties[parts[i]]; } return s; } function isComplexSchema(s) { if (!s) return false; if (Array.isArray(s.oneOf) && s.oneOf.length > 0) return true; if (Array.isArray(s.anyOf) && s.anyOf.length > 0) return true; if (Array.isArray(s.allOf) && s.allOf.length > 0) return true; if (s.type === 'object') return true; if (s.type === 'array') { // Multi-select-friendly arrays (string-enum + uniqueItems) // get inline editing; everything else is complex. const items = s.items || {}; const isMultiSelect = items.type === 'string' && Array.isArray(items.enum) && items.enum.length > 0 && s.uniqueItems === true; return !isMultiSelect; } return false; } function makeWidget(propSchema, col, initialValue) { // Prefers explicit JSON Schema hints; falls back to column-spec // hints (col.format / col.enum) for tables without a form.yaml; // defaults to a plain text input. const s = propSchema || {}; const colHint = col || {}; // Boolean → checkbox. if (s.type === 'boolean') { return widgetCheckbox(initialValue); } // Enum (string with explicit choices) → select dropdown. const enumChoices = (Array.isArray(s.enum) && s.enum) || (Array.isArray(colHint.enum) && colHint.enum) || null; if (enumChoices) { return widgetSelect(enumChoices, initialValue); } // Multi-select (array of string-enum with uniqueItems). if (s.type === 'array' && s.items && s.items.type === 'string' && Array.isArray(s.items.enum) && s.uniqueItems === true) { return widgetMultiSelect(s.items.enum, initialValue); } // Number / integer → number input with min/max/step. if (s.type === 'number' || s.type === 'integer' || colHint.format === 'number' || colHint.format === 'integer') { return widgetNumber(s, initialValue); } // Date / date-time / email — typed inputs the browser can // help validate. const fmt = s.format || colHint.format; if (fmt === 'date') return widgetTyped('date', initialValue); if (fmt === 'date-time') return widgetTyped('datetime-local', initialValue); if (fmt === 'email') return widgetTyped('email', initialValue); // Long text → textarea (still inline; Phase 5 may add expand). if (s.type === 'string' && Number(s.maxLength) > 200) { return widgetTextarea(initialValue); } // Default: plain text input. return widgetText(initialValue); } function widgetText(initial) { const el = document.createElement('input'); el.type = 'text'; el.value = stringify(initial); return { element: el, getValue: () => el.value, focus: () => { el.focus(); try { el.setSelectionRange(el.value.length, el.value.length); } catch (_) {} } }; } function widgetTextarea(initial) { const el = document.createElement('textarea'); el.rows = 1; el.value = stringify(initial); return { element: el, getValue: () => el.value, focus: () => { el.focus(); try { el.setSelectionRange(el.value.length, el.value.length); } catch (_) {} } }; } function widgetTyped(htmlType, initial) { const el = document.createElement('input'); el.type = htmlType; el.value = stringify(initial); return { element: el, getValue: () => el.value, focus: () => el.focus() }; } function widgetNumber(s, initial) { const el = document.createElement('input'); el.type = 'number'; if (s.minimum != null) el.min = String(s.minimum); if (s.maximum != null) el.max = String(s.maximum); if (s.type === 'integer') el.step = '1'; else if (s.multipleOf != null) el.step = String(s.multipleOf); el.value = (initial == null || initial === '') ? '' : String(initial); return { element: el, getValue: () => { const v = el.value; if (v === '') return null; const n = Number(v); return Number.isNaN(n) ? v : n; }, focus: () => el.focus() }; } function widgetCheckbox(initial) { const el = document.createElement('input'); el.type = 'checkbox'; el.checked = initial === true || initial === 'true'; return { element: el, getValue: () => el.checked, focus: () => el.focus() }; } function widgetSelect(choices, initial) { const el = document.createElement('select'); // Empty option lets the cell go back to "unset" without typing. const empty = document.createElement('option'); empty.value = ''; empty.textContent = '—'; el.appendChild(empty); for (let i = 0; i < choices.length; i++) { const opt = document.createElement('option'); opt.value = String(choices[i]); opt.textContent = String(choices[i]); el.appendChild(opt); } el.value = initial == null ? '' : String(initial); return { element: el, getValue: () => (el.value === '' ? null : el.value), focus: () => el.focus() }; } function widgetMultiSelect(choices, initial) { const el = document.createElement('select'); el.multiple = true; el.size = Math.min(6, choices.length); const initialSet = {}; const initArr = Array.isArray(initial) ? initial : []; for (let i = 0; i < initArr.length; i++) initialSet[String(initArr[i])] = true; for (let i = 0; i < choices.length; i++) { const opt = document.createElement('option'); opt.value = String(choices[i]); opt.textContent = String(choices[i]); if (initialSet[opt.value]) opt.selected = true; el.appendChild(opt); } return { element: el, getValue: () => { const out = []; for (let i = 0; i < el.options.length; i++) { if (el.options[i].selected) out.push(el.options[i].value); } return out; }, focus: () => el.focus() }; } function stringify(v) { if (v == null) return ''; if (typeof v === 'object') { try { return JSON.stringify(v); } catch (_) { return String(v); } } return String(v); } function sameValue(a, b) { if (a === b) return true; if (a == null && b == null) return true; if (a == null || b == null) return false; if (typeof a === 'object' || typeof b === 'object') { try { return JSON.stringify(a) === JSON.stringify(b); } catch (_) { return false; } } // Loose-string compare so number 42 == "42" from a text input. return String(a) === String(b); } function navigateToRowForm(row) { // Complex-type cells punt to the row's full form editor. // The url field on each context row already points at // /.yaml.html — the form-mode re-edit URL. if (!row || !row.url) return; const nav = (window.tablesApp && window.tablesApp.navigateTo) || function (u) { window.location.assign(u); }; nav(row.url); } // --- Keyboard nav ------------------------------------------------- function moveSelection(dir) { if (!app.state.selected) return; let { row: r, col: c } = app.state.selected; const total = rowCount(); const cols = colCount(); if (total === 0 || cols === 0) return; switch (dir) { case 'up': r = Math.max(0, r - 1); break; case 'down': r = Math.min(total - 1, r + 1); break; case 'left': c = Math.max(0, c - 1); break; case 'right': c = Math.min(cols - 1, c + 1); break; case 'home': c = 0; break; case 'end': c = cols - 1; break; case 'home-row': r = 0; c = 0; break; case 'end-row': r = total - 1; c = cols - 1; break; case 'left-wrap': if (c > 0) { c--; } else if (r > 0) { r--; c = cols - 1; } break; case 'right-wrap': if (c < cols - 1) { c++; } else if (r < total - 1) { r++; c = 0; } break; } setSelected(r, c); } function onCellKey(ev) { if (app.state.editing) return; // input owns its own keys if (!app.state.selected) return; const isRangeKey = ev.shiftKey; switch (ev.key) { case 'ArrowUp': ev.preventDefault(); isRangeKey ? extendRange('up') : moveSelection('up'); return; case 'ArrowDown': ev.preventDefault(); isRangeKey ? extendRange('down') : moveSelection('down'); return; case 'ArrowLeft': ev.preventDefault(); isRangeKey ? extendRange('left') : moveSelection('left'); return; case 'ArrowRight': ev.preventDefault(); isRangeKey ? extendRange('right') : moveSelection('right'); return; case 'Home': ev.preventDefault(); if (ev.ctrlKey || ev.metaKey) moveSelection('home-row'); else moveSelection('home'); return; case 'End': ev.preventDefault(); if (ev.ctrlKey || ev.metaKey) moveSelection('end-row'); else moveSelection('end'); return; case 'Tab': ev.preventDefault(); moveSelection(ev.shiftKey ? 'left-wrap' : 'right-wrap'); return; case 'Enter': case 'F2': ev.preventDefault(); enterEdit(); return; case 'Escape': ev.preventDefault(); clearSelection(); clearRange(); return; case 'Delete': case 'Backspace': ev.preventDefault(); bulkClearSelection(); return; case 'd': case 'D': if (ev.ctrlKey || ev.metaKey) { ev.preventDefault(); bulkFill('down'); return; } break; case 'r': case 'R': if (ev.ctrlKey || ev.metaKey) { ev.preventDefault(); bulkFill('right'); return; } break; } if (isPrintableKey(ev)) { // Replace value with the typed character (Excel convention). ev.preventDefault(); enterEdit(ev.key); } } // --- Range selection (multi-cell ops) ----------------------------- function extendRange(dir) { if (!app.state.selected) return; const range = ensureRange(); let { row: r, col: c } = range.focus; const total = rowCount(); const cols = colCount(); switch (dir) { case 'up': r = Math.max(0, r - 1); break; case 'down': r = Math.min(total - 1, r + 1); break; case 'left': c = Math.max(0, c - 1); break; case 'right': c = Math.min(cols - 1, c + 1); break; } range.focus = { row: r, col: c }; applyRangeSelectionStyles(range); } function ensureRange() { if (!app.state.range) { const sel = app.state.selected; app.state.range = { anchor: { row: sel.row, col: sel.col }, focus: { row: sel.row, col: sel.col }, }; } return app.state.range; } function clearRange() { app.state.range = null; const t = tableEl(); if (!t) return; const all = t.querySelectorAll('[role="gridcell"]'); for (let i = 0; i < all.length; i++) { all[i].classList.remove('zddc-table__cell--in-range'); } } function applyRangeSelectionStyles(range) { const t = tableEl(); if (!t) return; const all = t.querySelectorAll('[role="gridcell"]'); for (let i = 0; i < all.length; i++) { all[i].classList.remove('zddc-table__cell--in-range'); } const r0 = Math.min(range.anchor.row, range.focus.row); const r1 = Math.max(range.anchor.row, range.focus.row); const c0 = Math.min(range.anchor.col, range.focus.col); const c1 = Math.max(range.anchor.col, range.focus.col); for (let r = r0; r <= r1; r++) { for (let c = c0; c <= c1; c++) { const cell = cellAt(r, c); if (cell) cell.classList.add('zddc-table__cell--in-range'); } } } function rangeCells() { // Returns an array of {rowIdx, colIdx, row, col} for every // cell in the current range — or just the selected cell if // no range is active. Skips cells whose row data can't be // resolved (defensive). const out = []; const range = app.state.range; if (range) { const r0 = Math.min(range.anchor.row, range.focus.row); const r1 = Math.max(range.anchor.row, range.focus.row); const c0 = Math.min(range.anchor.col, range.focus.col); const c1 = Math.max(range.anchor.col, range.focus.col); for (let r = r0; r <= r1; r++) { const row = rowDataAt(r); if (!row) continue; for (let c = c0; c <= c1; c++) { const col = colAt(c); if (col) out.push({ rowIdx: r, colIdx: c, row: row, col: col }); } } return out; } if (!app.state.selected) return out; const { row: r, col: c } = app.state.selected; const row = rowDataAt(r); const col = colAt(c); if (row && col) out.push({ rowIdx: r, colIdx: c, row: row, col: col }); return out; } function bulkClearSelection() { // Delete / Backspace in nav mode: clear every selected cell. // Pushes one undo Command spanning all affected cells. const cells = rangeCells(); if (cells.length === 0) return; const undoCells = []; for (let i = 0; i < cells.length; i++) { const c = cells[i]; const oldRaw = app.modules.util.resolveField(c.row.data, c.col.field); const priorDraft = getDraft(rowKey(c.row), c.col.field); const undoOld = (priorDraft !== undefined) ? priorDraft : oldRaw; setDraft(rowKey(c.row), c.col.field, null); undoCells.push({ rowId: rowKey(c.row), field: c.col.field, oldValue: undoOld, newValue: null, }); } const undoMod = app.modules.undo; if (undoMod) undoMod.push({ cells: undoCells }); if (typeof app.repaint === 'function') app.repaint(); } function bulkFill(dir) { // Ctrl+D fills the top row's values down through the range. // Ctrl+R fills the left column's values right through the range. // No-op when no range is active (Excel does the same). const range = 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 c0 = Math.min(range.anchor.col, range.focus.col); const c1 = Math.max(range.anchor.col, range.focus.col); const undoCells = []; for (let r = r0; r <= r1; r++) { const row = rowDataAt(r); if (!row) continue; for (let c = c0; c <= c1; c++) { const col = colAt(c); if (!col) continue; const srcR = (dir === 'down') ? r0 : r; const srcC = (dir === 'right') ? c0 : c; if (r === srcR && c === srcC) continue; const srcRow = rowDataAt(srcR); const srcCol = colAt(srcC); if (!srcRow || !srcCol) continue; const value = effectiveCellValue(srcRow, srcCol); const oldRaw = app.modules.util.resolveField(row.data, col.field); const priorDraft = getDraft(rowKey(row), col.field); const undoOld = (priorDraft !== undefined) ? priorDraft : oldRaw; setDraft(rowKey(row), col.field, value); undoCells.push({ rowId: rowKey(row), field: col.field, oldValue: undoOld, newValue: value, }); } } if (undoCells.length > 0) { const undoMod = app.modules.undo; if (undoMod) undoMod.push({ cells: undoCells }); if (typeof app.repaint === 'function') app.repaint(); } } // --- Wiring ------------------------------------------------------- function attachToTable() { const t = tableEl(); if (!t) return; t.setAttribute('role', 'grid'); t.addEventListener('keydown', onCellKey); } function attachToRow(tr, rowId) { tr.setAttribute('role', 'row'); tr.setAttribute('data-row-id', rowId); } function attachToCell(td, rowIdx, colIdx) { td.setAttribute('role', 'gridcell'); td.setAttribute('data-col-idx', String(colIdx)); td.setAttribute('data-row-idx', String(rowIdx)); td.setAttribute('tabindex', '-1'); td.addEventListener('click', function (ev) { ev.stopPropagation(); if (ev.shiftKey && app.state.selected) { // Shift+click extends the range from the existing // anchor to the clicked cell. const range = ensureRange(); range.focus = { row: rowIdx, col: colIdx }; applyRangeSelectionStyles(range); // Move tabindex/focus marker to the clicked cell but // keep the anchor in place. setSelected(rowIdx, colIdx, { keepRange: true }); } else { clearRange(); setSelected(rowIdx, colIdx); } }); td.addEventListener('dblclick', function (ev) { ev.stopPropagation(); clearRange(); setSelected(rowIdx, colIdx, { noFocus: true }); enterEdit(); }); } app.modules.editor = { attachToTable: attachToTable, attachToRow: attachToRow, attachToCell: attachToCell, setSelected: setSelected, clearSelection: clearSelection, moveSelection: moveSelection, enterEdit: enterEdit, rowKey: rowKey, getDraft: getDraft, setDraft: setDraft, clearDraftField: clearDraftField, effectiveCellValue: effectiveCellValue }; })(window.tablesApp);