// clipboard handlers extracted from the controller. Imperative event handlers // that read/write controller state via the `ctx` handle; the reactive core // ($state/$derived/$effect) stays in the controller. import { parseEditorValue, type CellEditorType, type RowData, type TableFeatures, } from "./index"; import "./sv-grid-scrollbar"; import { buildFillPattern } from "./fill-patterns"; import { getCellKey, } from "./SvGrid.helpers"; import { getColumnBaseValue, isGroupRow, toolPanelHeaderLabel, } from "./cell-values"; export function createClipboard< TFeatures extends TableFeatures = TableFeatures, TData extends RowData = RowData, >(ctx: any) { /** Read the raw underlying value for the cell at (rowIndex, columnId) * for pattern extraction. */ function readCellRaw(rowIndex: number, columnId: string): unknown { const row = ctx.internalData[rowIndex]; const column = ctx.findColumnById(columnId); if (!row || !column?.columnDef.field) return undefined; return (row as Record)[column.columnDef.field]; } /** Write a value into (rowIndex, columnId) without going through the * edit lifecycle. Fires `onCellValueChange` per write so consumers * can react (formula recompute, autosave, etc.). */ function writeCellRaw(rowIndex: number, columnId: string, value: unknown) { const row = ctx.internalData[rowIndex]; const column = ctx.findColumnById(columnId); if (!row || !column?.columnDef.field) return; const field = column.columnDef.field; const oldValue = (row as Record)[field]; if (oldValue === value) return; // Resolve the row's id BEFORE swapping internalData - otherwise the // recomputed `allRows` references the new row object and the // `r.original === row` lookup fails, dropping our edit out of the // `editedCellValues` map (which getCellDisplayValue consults first). const rowId = ctx.allRows.find((r: any) => r.original === row)?.id; const next = ctx.internalData.slice() as Array; next[rowIndex] = { ...row, [field]: value } as TData; ctx.internalData = next; if (rowId) { const key = getCellKey(rowId, columnId); ctx.editedCellValues = { ...ctx.editedCellValues, [key]: value }; } ctx.props.onCellValueChange?.({ rowIndex, columnId, oldValue, newValue: value, row: next[rowIndex] as TData, }); } /** Apply the pattern fill on pointerup. Each NEW row (or column) is * filled from a pattern derived from the matching column (or row) of * the source. Handles all four drag directions. */ function applyFillPattern() { const d = ctx.fillDrag; if (!d) return; // Clear fillDrag FIRST so a thrown error doesn't leave the grid // stuck tracking the pointer. ctx.fillDrag = null; const newMinRow = Math.min(d.sourceMinRow, d.targetRow); const newMaxRow = Math.max(d.sourceMaxRow, d.targetRow); const newMinCol = Math.min(d.sourceMinCol, d.targetCol); const newMaxCol = Math.max(d.sourceMaxCol, d.targetCol); const verticalExtension = newMaxRow > d.sourceMaxRow || newMinRow < d.sourceMinRow; const horizontalExtension = newMaxCol > d.sourceMaxCol || newMinCol < d.sourceMinCol; if (verticalExtension) { // For each column in the source range, build a pattern from the // column's source values and apply to the new rows (above or below). for (let c = d.sourceMinCol; c <= d.sourceMaxCol; c += 1) { const column = ctx.allColumns[c]; if (!column?.columnDef.field) continue; if (column.columnDef.editable === false) continue; const sourceColValues: unknown[] = []; for (let r = d.sourceMinRow; r <= d.sourceMaxRow; r += 1) { sourceColValues.push(readCellRaw(r, column.id)); } if (newMaxRow > d.sourceMaxRow) { const targetRows = newMaxRow - d.sourceMaxRow; const fills = buildFillPattern(sourceColValues, targetRows); for (let i = 0; i < targetRows; i += 1) { const targetRow = d.sourceMaxRow + 1 + i; if (ctx.isCellEditableAt(targetRow, c)) writeCellRaw(targetRow, column.id, fills[i]); } } if (newMinRow < d.sourceMinRow) { // Filling upward - reverse-extrapolate. const reversed = sourceColValues.slice().reverse(); const targetRows = d.sourceMinRow - newMinRow; const fills = buildFillPattern(reversed, targetRows); for (let i = 0; i < targetRows; i += 1) { const targetRow = d.sourceMinRow - 1 - i; if (ctx.isCellEditableAt(targetRow, c)) writeCellRaw(targetRow, column.id, fills[i]); } } } } else if (horizontalExtension) { // For each row in source range, build pattern from the row's source // values across columns and apply to new columns. for (let r = d.sourceMinRow; r <= d.sourceMaxRow; r += 1) { const sourceRowValues: unknown[] = []; for (let c = d.sourceMinCol; c <= d.sourceMaxCol; c += 1) { const col = ctx.allColumns[c]; if (!col) continue; sourceRowValues.push(readCellRaw(r, col.id)); } if (newMaxCol > d.sourceMaxCol) { const targetCols = newMaxCol - d.sourceMaxCol; const fills = buildFillPattern(sourceRowValues, targetCols); for (let i = 0; i < targetCols; i += 1) { const targetCol = d.sourceMaxCol + 1 + i; const col = ctx.allColumns[targetCol]; if (col && ctx.isCellEditableAt(r, targetCol)) writeCellRaw(r, col.id, fills[i]); } } if (newMinCol < d.sourceMinCol) { const reversed = sourceRowValues.slice().reverse(); const targetCols = d.sourceMinCol - newMinCol; const fills = buildFillPattern(reversed, targetCols); for (let i = 0; i < targetCols; i += 1) { const targetCol = d.sourceMinCol - 1 - i; const col = ctx.allColumns[targetCol]; if (col && ctx.isCellEditableAt(r, targetCol)) writeCellRaw(r, col.id, fills[i]); } } } } // Extend the selection to the new range so the user can immediately // see what got filled. ctx.selectionRange = { anchor: { rowIndex: newMinRow, colIndex: newMinCol }, focus: { rowIndex: newMaxRow, colIndex: newMaxCol }, }; } /** Clear the underlying value of every cell in the current selection * range (or just the active cell when nothing is range-selected). * Mirrors Excel's `Delete` key - values go to `null`, formatting and * the row identity stay intact. */ function clearSelectedCellValues() { const anchor = ctx.selectionRange.anchor; const focus = ctx.selectionRange.focus; if (anchor && focus) { const minRow = Math.min(anchor.rowIndex, focus.rowIndex); const maxRow = Math.max(anchor.rowIndex, focus.rowIndex); const minCol = Math.min(anchor.colIndex, focus.colIndex); const maxCol = Math.max(anchor.colIndex, focus.colIndex); for (let r = minRow; r <= maxRow; r += 1) { for (let c = minCol; c <= maxCol; c += 1) { const col = ctx.allColumns[c]; if (col?.columnDef.field && ctx.isCellEditableAt(r, c)) { writeCellRaw(r, col.id, null); } } } return; } const a = ctx.grid.getState().activeCell; if (a && ctx.userHasActivatedCell) { const col = ctx.allColumns[a.colIndex]; if (col?.columnDef.field && ctx.isCellEditableAt(a.rowIndex, a.colIndex)) { writeCellRaw(a.rowIndex, col.id, null); } } } /** Fill-handle pointerdown - seed the drag with the current selection * range (or active cell as a 1x1) and start tracking the pointer. */ function startFillDrag( event: PointerEvent, rowIndex: number, colIndex: number, ) { event.stopPropagation(); event.preventDefault(); const anchor = ctx.selectionRange.anchor; const focus = ctx.selectionRange.focus; if (anchor && focus) { ctx.fillDrag = { sourceMinRow: Math.min(anchor.rowIndex, focus.rowIndex), sourceMaxRow: Math.max(anchor.rowIndex, focus.rowIndex), sourceMinCol: Math.min(anchor.colIndex, focus.colIndex), sourceMaxCol: Math.max(anchor.colIndex, focus.colIndex), targetRow: rowIndex, targetCol: colIndex, }; } else { ctx.fillDrag = { sourceMinRow: rowIndex, sourceMaxRow: rowIndex, sourceMinCol: colIndex, sourceMaxCol: colIndex, targetRow: rowIndex, targetCol: colIndex, }; } // Don't `setPointerCapture` - we use `elementFromPoint` during the // drag to find the hovered cell, and capture would route the events // back to the handle, breaking the lookup. Window-level handlers // (`onWindowPointerMove` / `endDragSelection`) keep tracking. } /** Pointermove during fill-drag. We don't get cell coords from the * event directly - find the td under the pointer via elementFromPoint * and read its data-svgrid-row / data-svgrid-col attributes. */ function onFillPointerMove(event: PointerEvent) { if (!ctx.fillDrag) return; const el = document.elementFromPoint( event.clientX, event.clientY, ) as HTMLElement | null; const cell = el?.closest( "td[data-svgrid-row][data-svgrid-col]", ) as HTMLElement | null; if (!cell) return; const r = Number(cell.dataset.svgridRow); const c = Number(cell.dataset.svgridCol); if (!Number.isFinite(r) || !Number.isFinite(c)) return; if (r === ctx.fillDrag.targetRow && c === ctx.fillDrag.targetCol) return; ctx.fillDrag = { ...ctx.fillDrag, targetRow: r, targetCol: c }; } function onFillPointerUp() { if (!ctx.fillDrag) return; applyFillPattern(); } function toggleBooleanCell(rowIndex: number, colIndex: number) { const row = ctx.allRows[rowIndex]; const column = ctx.allColumns[colIndex]; if (!row || !column) return; if (!column.columnDef.field) return; const baseValue = getColumnBaseValue(row, column); const currentValue = Boolean( ctx.getCellDisplayValue(row.id, column.id, baseValue), ); const nextValue = !currentValue; (row.original as Record)[column.columnDef.field] = nextValue; const key = getCellKey(row.id, column.id); ctx.editedCellValues = { ...ctx.editedCellValues, [key]: nextValue, }; ctx.grid.store.setState((prev: any) => ({ ...prev })); } /** * Write text to the OS clipboard, with a legacy fallback for insecure * contexts. The async Clipboard API requires a secure context (HTTPS or * localhost); on plain HTTP - e.g. a grid served by XAMPP/Apache over a LAN * host - `navigator.clipboard` is undefined and copy/cut would silently do * nothing. There we fall back to a temporary