Files
numolus/app/frontend/components/TableGrid.vue
T
wkhandClaude Opus 5.5 7fce972fdc Live collaboration with Yjs over ActionCable
All editors now work on a server document at the same time instead of
taking turns behind an edit lock.

- The document's content is a sequence of Yjs updates (document_updates);
  DocumentChannel stores and relays them immediately, checks access on
  subscribe and again whenever links or memberships change.
- app/frontend/collab/: the store stays position-based; a binding maps it
  to a Yjs form with stable row/column ids, so a row inserted by one
  person doesn't misplace what another is typing. Remote changes are
  applied only after pending local ones went into Yjs.
- Per-person undo (Y.UndoManager), presence via awareness: the others'
  selections as coloured boxes with names, avatars in the toolbar.
- Existing JSON documents convert on first open, deterministically, so
  two people opening one at once end up with the same Yjs state.
- Editors compact the stored updates after 200 and keep a JSON snapshot
  current for export and duplicating. Edit lock and polling are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:41:02 +02:00

1715 lines
61 KiB
Vue

<script setup lang="ts">
/**
* One table object's grid. Hand-built, no grid/spreadsheet widget.
*
* `view.mode = 'auto'` -> intrinsic size, all cells visible (Numbers default).
* `view.mode = 'fixed'` -> fixed viewport, scrolls internally; the first
* `frozenRows` / `frozenCols` are pinned with
* `position: sticky` against the scroll container.
*
* Also handles cell selection (click / shift / drag), keyboard navigation,
* in-cell editing and per-column resize.
*/
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
import { numberToCol, cycleReferenceLocks } from '../engine';
import { useDocument } from '../store/document';
import { useLibrary } from '../store/library';
import { DEFAULT_ROW_H, type SelectionState } from '../store/types';
import { useFormulaAutocomplete } from '../composables/useFormulaAutocomplete';
import { useKeyboardPointMode } from '../composables/useKeyboardPointMode';
import ContextMenu from './ContextMenu.vue';
import FormulaSuggest from './FormulaSuggest.vue';
const props = defineProps<{ tableId: string; selected: boolean }>();
const store = useDocument();
const library = useLibrary();
const table = computed(() => store.tableById(props.tableId));
const DEFAULT_W = 104;
/** How far the in-cell editor may grow beyond the cell before it wraps/scrolls
* -- an approximation of "the visible display width/height" rather than
* exact viewport math (the canvas can be panned/zoomed independently). */
const EDIT_MAX_W = 560;
const EDIT_MAX_H = 320;
const colWidth = (c: number) => table.value?.colWidths[c] ?? DEFAULT_W;
const rowH = (r: number) => store.rowHeightOf(props.tableId, r);
/** `hideChromeUnfocused` (Settings panel "Ohne Rahmen/Kopfzeilen, wenn nicht
* ausgewählt") hides the row/column headers -- not just visually, their
* reserved space collapses to 0 too -- whenever this table isn't the
* selected canvas object (CanvasObject.vue hides its title bar the same
* way, via its own `showChrome`). `headW`/`headerH` below are what
* actually implement the collapse: every position calculation in this file
* already goes through them (or through `rowTop`/`colLeft`, which do), so
* making them 0 when hidden is the entire fix -- cells simply start at
* (0,0) instead of past a header that isn't there. */
const showHeaders = computed(() => props.selected || table.value?.hideChromeUnfocused !== true);
const headW = computed(() => (showHeaders.value ? 44 : 0));
const headerH = computed(() => (showHeaders.value ? (table.value?.rowHeight ?? DEFAULT_ROW_H) : 0));
/** Prefix sums of row heights (`rowCumHeights[i]` = total height of rows
* `0..i-1`, excluding the header) -- lets `rowTop()` and the row
* virtualization below answer "how tall is everything above row r" and
* "which row is at pixel y" in O(1)/O(log rows) instead of walking from row
* 0 on every call, which used to dominate render cost on large tables (a
* single cell's position requires summing every row above it; multiplied
* across thousands of rendered cells, that's O(rows^2) per render). Only
* recomputed when the row count or any row height actually changes (a Vue
* `computed`, not on every scroll/render). */
const rowCumHeights = computed(() => {
const t = table.value;
const n = t?.rows ?? 0;
const arr = new Array<number>(n + 1);
arr[0] = 0;
for (let i = 0; i < n; i++) arr[i + 1] = arr[i] + rowH(i);
return arr;
});
/** Same idea for columns -- cheap regardless (column counts are small), kept
* for consistency and because `colLeft()` is also called per rendered cell. */
const colCumWidths = computed(() => {
const t = table.value;
const n = t?.cols ?? 0;
const arr = new Array<number>(n + 1);
arr[0] = 0;
for (let i = 0; i < n; i++) arr[i + 1] = arr[i] + colWidth(i);
return arr;
});
/** Largest `i` (0-based row/col index) whose cumulative offset is `<= y`,
* clamped into `[0, cum.length - 2]` -- binary search over a prefix-sum
* array built by `rowCumHeights`/`colCumWidths`. */
function indexAtOffset(cum: number[], y: number): number {
let lo = 0;
let hi = cum.length - 2;
if (hi < 0) return 0;
if (y <= 0) return 0;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (cum[mid] <= y) lo = mid;
else hi = mid - 1;
}
return lo;
}
/** px from the table's top edge to the start of row `r` (incl. header). */
function rowTop(r: number): number {
return headerH.value + (rowCumHeights.value[r] ?? 0);
}
const rootStyle = computed(() => {
const t = table.value;
const s: Record<string, string> = {};
if (t?.bg) s['--surface'] = t.bg;
if (t?.fg) {
s['--text'] = t.fg;
s['--text-dim'] = t.fg;
}
return s;
});
/** Explicit cell formatting -> inline style. */
function formatStyle(c: number, r: number): Record<string, string> {
const f = store.cellFormat(props.tableId, c, r);
const s: Record<string, string> = {};
if (f.align) s.textAlign = f.align;
if (f.valign) s.verticalAlign = { top: 'top', middle: 'middle', bottom: 'bottom' }[f.valign];
if (f.color) s.color = f.color;
if (f.bg) s.background = f.bg;
if (f.bold) s.fontWeight = '600';
if (f.italic) s.fontStyle = 'italic';
return s;
}
/** A cell holding an explicit line break (Alt+Enter while editing) wraps and
* shows it instead of collapsing to a single ellipsised line. */
const isMultiline = (c: number, r: number) => store.cellInput(props.tableId, c, r).includes('\n');
const root = ref<HTMLElement | null>(null);
// ---- fixed viewport + frozen panes -------------------------------
const view = computed(() => table.value?.view ?? { mode: 'auto' as const, width: 640, height: 320, frozenRows: 0, frozenCols: 0 });
const isFixed = computed(() => view.value.mode === 'fixed');
const frozenRows = computed(() => (isFixed.value ? view.value.frozenRows : 0));
const frozenCols = computed(() => (isFixed.value ? view.value.frozenCols : 0));
const scrollStyle = computed(() =>
isFixed.value
? { width: view.value.width + 'px', height: view.value.height + 'px', overflow: 'auto' }
: {},
);
/** px from the table's left edge to the start of column `c` (incl. row header). */
function colLeft(c: number): number {
return headW.value + (colCumWidths.value[c] ?? 0);
}
/** The internal scroll element of a fixed table (null for auto-size tables). */
const scrollEl = () =>
isFixed.value ? (root.value?.querySelector('.tg-scroll') as HTMLElement | null) : null;
// ---- row virtualization (fixed-viewport tables only) -----------------
// A large `auto`-size table (intrinsic height, no internal scroll -- the
// surrounding pan/zoom canvas scrolls it instead) still renders every row;
// virtualizing that would mean tracking visibility against a transformed
// canvas ancestor, a much bigger change for a case that's already
// impractical regardless (a 50k-row `auto` table would be a
// million-pixel-tall canvas object). `fixed` mode already has exactly the
// bounded, actually-scrollable container this needs (`.tg-scroll`), and is
// the mode the README already describes as the one for a large table -- so
// virtualization is scoped to it. Only rows are virtualized (not columns):
// row counts are what actually get large in practice.
const OVERSCAN_PX = 300;
// rAF-throttled rather than updated straight from the native 'scroll' event,
// which can fire far more often than once per frame during a fling --
// autoScrollTick() (drag-to-scroll near a fixed table's edge) sets this
// directly instead, since it needs the newly-revealed row rendered before
// its own immediately-following hit-test, not after the next 'scroll' event.
const scrollTop = ref(0);
let scrollRaf = 0;
function onScroll() {
if (scrollRaf) return;
scrollRaf = requestAnimationFrame(() => {
scrollRaf = 0;
const box = scrollEl();
if (box) scrollTop.value = box.scrollTop;
});
}
/** [startRow, endRow] (inclusive) of the rows that should actually be
* rendered -- the full range for an `auto` table (unvirtualized, as
* before), or the scrolled-to window (+ overscan) for a `fixed` one. Always
* includes at least the frozen rows, though those are rendered separately
* (see `renderedRows`) regardless of this range. */
const visibleRowRange = computed<[number, number]>(() => {
const t = table.value;
const rows = t?.rows ?? 1;
if (!isFixed.value) return [0, rows - 1];
const cum = rowCumHeights.value;
const top = Math.max(0, scrollTop.value - headerH.value - OVERSCAN_PX);
const bottom = Math.max(0, scrollTop.value - headerH.value + view.value.height + OVERSCAN_PX);
const start = indexAtOffset(cum, top);
const end = Math.min(rows - 1, indexAtOffset(cum, bottom) + 1);
return [start, end];
});
type RowItem =
| { kind: 'row'; r: number; key: string }
| { kind: 'spacer'; height: number; key: string };
/** The actual `<tr>`s to render, in order: the (always-on) frozen rows first,
* then -- for a virtualized table -- a spacer standing in for every hidden
* row above the visible window, the visible rows themselves, and a closing
* spacer for everything hidden below. A plain `<table>` doesn't support
* absolutely-positioned rows, so this (rather than positioning each row by
* pixel offset) is what keeps the scrollbar's size/position correct while
* only a bounded number of rows actually exist in the DOM. */
const renderedRows = computed<RowItem[]>(() => {
const t = table.value;
if (!t) return [];
if (!isFixed.value) {
return Array.from({ length: t.rows }, (_, r) => ({ kind: 'row', r, key: 'r' + r }) as const);
}
const fr = frozenRows.value;
const [start, end] = visibleRowRange.value;
const cum = rowCumHeights.value;
const items: RowItem[] = [];
for (let r = 0; r < fr; r++) items.push({ kind: 'row', r, key: 'r' + r });
const bodyStart = Math.max(start, fr);
const topSpacer = (cum[bodyStart] ?? 0) - (cum[fr] ?? 0);
if (topSpacer > 0) items.push({ kind: 'spacer', height: topSpacer, key: 'top-spacer' });
for (let r = bodyStart; r <= end; r++) items.push({ kind: 'row', r, key: 'r' + r });
const bottomSpacer = (cum[t.rows] ?? 0) - (cum[Math.max(end + 1, fr)] ?? 0);
if (bottomSpacer > 0) items.push({ kind: 'spacer', height: bottomSpacer, key: 'bottom-spacer' });
return items;
});
function cellStyle(c: number, r: number): Record<string, string> {
const s: Record<string, string> = formatStyle(c, r);
if (!isFixed.value) return s;
const fr = r < frozenRows.value;
const fc = c < frozenCols.value;
if (fr) {
s.position = 'sticky';
s.top = rowTop(r) + 'px';
}
if (fc) {
s.position = 'sticky';
s.left = colLeft(c) + 'px';
}
if (fr || fc) s.zIndex = fr && fc ? '5' : '3';
return s;
}
/** Caps a cell's rendered text to the row's own height. A `<tr height>` is
* only a *minimum* -- a `<td>`'s own `max-height` doesn't clip it either
* (browsers don't cap table-row growth that way); a plain block-level child
* with `max-height` + `overflow: hidden` does, so `.cell-content` (not the
* `<td>` itself) carries this. Without it a wrapped multi-line cell (see
* `.cell.multiline`) would grow its row past rowH(r), desyncing every pixel
* position computed from rowH()/rowTop() -- the clip/fill overlays, sticky
* frozen panes, the row-resize handle, ... */
function cellContentStyle(r: number): Record<string, string> {
return { maxHeight: rowH(r) + 'px' };
}
function rowHeadStyle(r: number): Record<string, string> {
if (!isFixed.value || r >= frozenRows.value) return {};
return { top: rowTop(r) + 'px', zIndex: '5' };
}
// ---- selection ------------------------------------------------------
const selection = computed(() => store.doc.selection);
const mine = computed(
() => selection.value?.tableId === props.tableId ? selection.value : null,
);
/** All selected rectangles (primary + any additional) for this table. */
const selRects = computed(() => (mine.value ? store.selectionRects(mine.value) : []));
/** The active cell is the anchor -- the first cell of the current selection --
* so it stays marked while a multi-cell selection is dragged/extended out
* from it (Excel/Numbers behaviour). During Tab/Enter data-entry flow the
* anchor is kept equal to the flowing cell, so this still tracks it there. */
const isActive = (c: number, r: number) =>
!!mine.value && mine.value.anchor.col === c && mine.value.anchor.row === r;
const inSelection = (c: number, r: number) =>
selRects.value.some((k) => c >= k.c0 && c <= k.c1 && r >= k.r0 && r <= k.r1);
const colHot = (c: number) => selRects.value.some((k) => c >= k.c0 && c <= k.c1);
const rowHot = (r: number) => selRects.value.some((k) => r >= k.r0 && r <= k.r1);
// ---- clipboard source marker (one dashed rectangle, Excel-style "marching
// ants" -- overlaid once over the whole range, not per cell). --------------
const clipRect = computed(() => store.clipboardRect.value);
/** Where the others (live collaboration) have their selection in this table. */
const peerRects = computed(() =>
library.peers.value
.filter((p) => p.rect && p.tableId === props.tableId)
.map((p) => {
const k = p.rect!;
const left = colLeft(k.c0);
const top = rowTop(k.r0);
return {
id: p.clientId,
name: p.name,
style: {
left: left + 'px',
top: top + 'px',
width: colLeft(Math.min(k.c1, (table.value?.cols ?? 1) - 1) + 1) - left + 'px',
height: rowTop(Math.min(k.r1, (table.value?.rows ?? 1) - 1) + 1) - top + 'px',
borderColor: p.color,
'--peer': p.color,
},
};
}),
);
const clipMarqueeStyle = computed(() => {
const k = clipRect.value;
if (!k || k.tableId !== props.tableId) return null;
const left = colLeft(k.c0);
const top = rowTop(k.r0);
return {
left: left + 'px',
top: top + 'px',
width: colLeft(k.c1 + 1) - left + 'px',
height: rowTop(k.r1 + 1) - top + 'px',
};
});
/** The copy/cut selection's *own* first cell, called out inside the marquee
* even once the live selection has moved on to pick a paste destination
* (which is when `is-active` no longer sits there). */
const clipAnchorStyle = computed(() => {
const k = clipRect.value;
if (!k || k.tableId !== props.tableId) return null;
const { col, row } = k.anchor;
return {
left: colLeft(col) + 'px',
top: rowTop(row) + 'px',
width: colWidth(col) + 'px',
height: rowH(row) + 'px',
};
});
const clampC = (c: number) => Math.max(0, Math.min((table.value?.cols ?? 1) - 1, c));
const clampR = (r: number) => Math.max(0, Math.min((table.value?.rows ?? 1) - 1, r));
const focusRoot = () => nextTick(() => root.value?.focus());
function select(c: number, r: number, extend = false) {
store.selectCell(props.tableId, clampC(c), clampR(r), extend);
}
// ---- keyboard navigation (Excel-like) --------------------------------
/** Rectangle the active cell "flows" through on Tab/Enter (Excel data-entry). */
let flowRect: { c0: number; c1: number; r0: number; r1: number } | null = null;
/** Column a Tab-run started in, so a following Enter returns there + one row. */
let entryCol: number | null = null;
function clearFlow() {
flowRect = null;
entryCol = null;
}
/** Keep the active cell visible inside a fixed table's scroll viewport. */
function scrollActiveIntoView() {
const box = scrollEl();
const s = mine.value;
if (!box || !s) return;
const { col, row } = s.focus;
const left = colLeft(col);
const right = left + colWidth(col);
const top = rowTop(row);
const bottom = top + rowH(row);
const padL = colLeft(frozenCols.value); // width of the frozen-column band + header
const padT = rowTop(frozenRows.value); // height of the frozen-row band + header
if (left - padL < box.scrollLeft) box.scrollLeft = Math.max(0, left - padL);
else if (right > box.scrollLeft + box.clientWidth) box.scrollLeft = right - box.clientWidth;
if (top - padT < box.scrollTop) box.scrollTop = Math.max(0, top - padT);
else if (bottom > box.scrollTop + box.clientHeight) box.scrollTop = bottom - box.clientHeight;
}
type Rect = { c0: number; c1: number; r0: number; r1: number };
function primaryRect(): Rect | null {
const s = mine.value;
if (!s) return null;
return {
c0: Math.min(s.anchor.col, s.focus.col),
c1: Math.max(s.anchor.col, s.focus.col),
r0: Math.min(s.anchor.row, s.focus.row),
r1: Math.max(s.anchor.row, s.focus.row),
};
}
/** Plain move of the active cell (arrow keys). `ext` = grow the selection.
* A plain move steps from the *active* cell (the anchor) -- collapsing a
* multi-cell selection back to just beside it, not beside whichever corner
* the selection happens to end at. Extending (Shift+Arrow) keeps growing
* from the current extension edge (the focus), same as before. */
function navigate(dc: number, dr: number, ext: boolean) {
clearFlow();
const s = mine.value;
if (!s) return;
const from = ext ? s.focus : s.anchor;
select(from.col + dc, from.row + dr, ext);
scrollActiveIntoView();
}
/** Tab (`axis='h'`) / Enter (`axis='v'`), Excel style. */
function flowMove(axis: 'h' | 'v', back: boolean) {
const s = mine.value;
const t = table.value;
if (!s || !t) return;
const pr = primaryRect()!;
const multi = pr.c0 !== pr.c1 || pr.r0 !== pr.r1;
if (multi || flowRect) {
if (!flowRect) flowRect = pr;
const R = flowRect;
let { col, row } = s.focus;
if (axis === 'h') {
col += back ? -1 : 1;
if (col > R.c1) {
col = R.c0;
row = row >= R.r1 ? R.r0 : row + 1;
} else if (col < R.c0) {
col = R.c1;
row = row <= R.r0 ? R.r1 : row - 1;
}
} else {
row += back ? -1 : 1;
if (row > R.r1) {
row = R.r0;
col = col >= R.c1 ? R.c0 : col + 1;
} else if (row < R.r0) {
row = R.r1;
col = col <= R.c0 ? R.c1 : col - 1;
}
}
// keep the whole range highlighted; move only the cursor cell
store.setSelection({
tableId: props.tableId,
anchor: { col, row },
focus: { col, row },
ranges: [{ anchor: { col: R.c0, row: R.r0 }, focus: { col: R.c1, row: R.r1 } }],
});
scrollActiveIntoView();
return;
}
// single cell: straight move; remember/return the Tab-run column
let col = s.focus.col;
let row = s.focus.row;
if (axis === 'h') {
if (entryCol === null) entryCol = col;
col = clampC(col + (back ? -1 : 1));
} else if (entryCol !== null) {
col = entryCol;
row = clampR(row + (back ? -1 : 1));
entryCol = null;
} else {
row = clampR(row + (back ? -1 : 1));
}
select(col, row, false);
scrollActiveIntoView();
}
/** Ctrl/Cmd+Arrow: jump to the edge of the current data block. Steps from the
* active cell (anchor) when collapsing, or from the extension edge (focus)
* when growing the selection -- see `navigate()`. */
function jumpTo(dc: number, dr: number, ext: boolean) {
clearFlow();
const s = mine.value;
const t = table.value;
if (!s || !t) return;
const filled = (c: number, r: number) =>
c >= 0 && r >= 0 && c < t.cols && r < t.rows && store.cellInput(props.tableId, c, r) !== '';
let { col, row } = ext ? s.focus : s.anchor;
const inB = (c: number, r: number) => c >= 0 && r >= 0 && c < t.cols && r < t.rows;
let nc = col + dc;
let nr = row + dr;
if (!inB(nc, nr)) {
/* already at edge */
} else if (filled(col, row) && filled(nc, nr)) {
while (inB(nc + dc, nr + dr) && filled(nc + dc, nr + dr)) {
nc += dc;
nr += dr;
}
col = nc;
row = nr;
} else {
while (inB(nc, nr) && !filled(nc, nr)) {
nc += dc;
nr += dr;
}
if (inB(nc, nr)) {
col = nc;
row = nr;
} else {
col = clampC(dc ? (dc > 0 ? t.cols - 1 : 0) : col);
row = clampR(dr ? (dr > 0 ? t.rows - 1 : 0) : row);
}
}
select(col, row, ext);
scrollActiveIntoView();
}
function pageRows(): number {
const box = scrollEl();
if (!box) return 12;
return Math.max(1, Math.floor(box.clientHeight / (table.value?.rowHeight ?? 26)) - 1);
}
// ---- mouse selection ---------------------------------------------
const dragging = ref(false);
let dragMode: 'replace' | 'additive' = 'replace';
let lastHit: { c: number; r: number } | null = null;
let lastPointer = { x: 0, y: 0 };
let autoScrollRaf = 0;
/** Hit-test by cursor position -- robust to fast moves, borders and child
* nodes (the event target during a drag is unreliable). Clamps into the
* scroll viewport of a fixed table so dragging past the edge still selects. */
function cellFromPoint(x: number, y: number): { c: number; r: number } | null {
const box = scrollEl();
if (box) {
const r = box.getBoundingClientRect();
x = Math.min(Math.max(x, r.left + 2), r.right - 2);
y = Math.min(Math.max(y, r.top + 2), r.bottom - 2);
}
for (const el of document.elementsFromPoint(x, y)) {
const td = (el as HTMLElement).closest?.('td[data-c]') as HTMLElement | null;
if (td && root.value?.contains(td)) {
const c = Number(td.dataset.c);
const rr = Number(td.dataset.r);
if (!Number.isNaN(c) && !Number.isNaN(rr)) return { c, r: rr };
}
}
return null;
}
function cellFromEvent(e: MouseEvent) {
const el = (e.target as HTMLElement)?.closest?.('td[data-c]') as HTMLElement | null;
return el ? { c: Number(el.dataset.c), r: Number(el.dataset.r) } : null;
}
function applyHit(hit: { c: number; r: number }) {
if (lastHit && lastHit.c === hit.c && lastHit.r === hit.r) return;
lastHit = hit;
if (dragMode === 'additive') store.extendActiveRange(props.tableId, hit.c, hit.r);
else select(hit.c, hit.r, true);
}
/** While dragging near the edge of a fixed table, keep scrolling + selecting. */
function autoScrollTick() {
const box = scrollEl();
if (!dragging.value || !box) {
autoScrollRaf = 0;
return;
}
const r = box.getBoundingClientRect();
const EDGE = 26;
const S = 16;
let dx = 0;
let dy = 0;
if (lastPointer.y < r.top + EDGE) dy = -S;
else if (lastPointer.y > r.bottom - EDGE) dy = S;
if (lastPointer.x < r.left + EDGE) dx = -S;
else if (lastPointer.x > r.right - EDGE) dx = S;
if (dx || dy) {
box.scrollBy(dx, dy);
scrollTop.value = box.scrollTop; // sync eagerly -- see onScroll's doc comment
const hit = cellFromPoint(lastPointer.x, lastPointer.y);
if (hit) applyHit(hit);
}
autoScrollRaf = requestAnimationFrame(autoScrollTick);
}
function startDrag(mode: 'replace' | 'additive') {
dragMode = mode;
dragging.value = true;
lastHit = null;
document.body.style.userSelect = 'none';
window.addEventListener('mousemove', onDragMove, true);
window.addEventListener('mouseup', onDragUp, true);
window.addEventListener('blur', onDragUp);
if (scrollEl() && !autoScrollRaf) autoScrollRaf = requestAnimationFrame(autoScrollTick);
}
function onCellMouseDown(e: MouseEvent, c: number, r: number) {
if (e.button !== 0) return;
if (store.formulaPointModeActive()) {
e.preventDefault(); // keep focus on the formula editor -- must not blur/commit it
// ... and don't let it bubble to the canvas object's own mousedown
// (CanvasObject.vue selects/brings-to-front the clicked table on any
// mousedown; a point-mode click must stay invisible to that, same as
// Excel doesn't change your object selection while you're just picking
// a reference for a formula elsewhere).
e.stopPropagation();
startPointDrag(c, r);
return;
}
if (editing.value) commitEdit();
clearFlow();
lastPointer = { x: e.clientX, y: e.clientY };
if (e.metaKey || e.ctrlKey) {
store.addToSelection(props.tableId, c, r);
startDrag('additive');
} else if (e.shiftKey) {
select(c, r, true);
startDrag('replace');
} else {
select(c, r, false);
startDrag('replace');
}
focusRoot();
}
function onDragMove(e: MouseEvent) {
if (e.buttons === 0) {
onDragUp(); // a mouseup we never saw -- self-heal a stuck drag
return;
}
lastPointer = { x: e.clientX, y: e.clientY };
const hit = cellFromPoint(e.clientX, e.clientY);
if (hit) applyHit(hit);
}
function onDragUp() {
if (!dragging.value) return;
dragging.value = false;
lastHit = null;
if (autoScrollRaf) cancelAnimationFrame(autoScrollRaf);
autoScrollRaf = 0;
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onDragMove, true);
window.removeEventListener('mouseup', onDragUp, true);
window.removeEventListener('blur', onDragUp);
}
// ---- formula "point mode" (click/drag a cell to insert its reference) ---
let pointDragStart: { c: number; r: number } | null = null;
function startPointDrag(c: number, r: number) {
pointDragStart = { c, r };
store.pointClick(props.tableId, c, r);
window.addEventListener('mousemove', onPointDragMove, true);
window.addEventListener('mouseup', onPointDragUp, true);
window.addEventListener('blur', onPointDragUp);
}
function onPointDragMove(e: MouseEvent) {
if (!pointDragStart) return;
if (e.buttons === 0) {
onPointDragUp(); // a mouseup we never saw -- self-heal a stuck drag
return;
}
const hit = cellFromPoint(e.clientX, e.clientY);
if (!hit) return;
if (hit.c === pointDragStart.c && hit.r === pointDragStart.r) {
store.pointClick(props.tableId, hit.c, hit.r);
} else {
store.pointDrag(props.tableId, pointDragStart.c, pointDragStart.r, hit.c, hit.r);
}
}
function onPointDragUp() {
if (!pointDragStart) return;
pointDragStart = null;
window.removeEventListener('mousemove', onPointDragMove, true);
window.removeEventListener('mouseup', onPointDragUp, true);
window.removeEventListener('blur', onPointDragUp);
}
// ---- fill handle (drag to copy/fill, Excel/Numbers-style) ---------------
/** Small square at the selection's bottom-right corner; dragging it repeats
* the selection's cells (values, formulas -- reference-adjusted like a
* copy --, and formatting) into the cells dragged over. Extends in a single
* direction only, whichever axis is dragged further along, same as Excel. */
let fillDrag: { source: Rect; anchor: { col: number; row: number } } | null = null;
const fillPreview = ref<Rect | null>(null);
const fillHandleStyle = computed(() => {
if (editing.value || dragging.value || fillDrag) return null;
const r = primaryRect();
if (!r) return null;
return { left: colLeft(r.c1 + 1) - 3 + 'px', top: rowTop(r.r1 + 1) - 3 + 'px' };
});
const fillPreviewStyle = computed(() => {
const p = fillPreview.value;
if (!p) return null;
const left = colLeft(p.c0);
const top = rowTop(p.r0);
return {
left: left + 'px',
top: top + 'px',
width: colLeft(p.c1 + 1) - left + 'px',
height: rowTop(p.r1 + 1) - top + 'px',
};
});
function onFillHandleDown(e: MouseEvent) {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const s = mine.value;
const r = primaryRect();
if (!s || !r) return;
if (editing.value) commitEdit();
fillDrag = { source: r, anchor: s.anchor };
fillPreview.value = null;
document.body.style.userSelect = 'none';
window.addEventListener('mousemove', onFillDragMove, true);
window.addEventListener('mouseup', onFillDragUp, true);
window.addEventListener('blur', onFillDragUp);
}
function onFillDragMove(e: MouseEvent) {
if (!fillDrag) return;
if (e.buttons === 0) {
onFillDragUp(); // a mouseup we never saw -- self-heal a stuck drag
return;
}
const hit = cellFromPoint(e.clientX, e.clientY);
if (!hit) return;
const src = fillDrag.source;
const c = clampC(hit.c);
const r = clampR(hit.r);
const down = r - src.r1;
const up = src.r0 - r;
const right = c - src.c1;
const left = src.c0 - c;
const vAmount = Math.max(down, up, 0);
const hAmount = Math.max(right, left, 0);
if (vAmount === 0 && hAmount === 0) {
fillPreview.value = null; // back over the source -- nothing to fill
return;
}
fillPreview.value =
vAmount >= hAmount
? down > 0
? { c0: src.c0, c1: src.c1, r0: src.r1 + 1, r1: r }
: { c0: src.c0, c1: src.c1, r0: r, r1: src.r0 - 1 }
: right > 0
? { c0: src.c1 + 1, c1: c, r0: src.r0, r1: src.r1 }
: { c0: c, c1: src.c0 - 1, r0: src.r0, r1: src.r1 };
}
function onFillDragUp() {
if (!fillDrag) return;
const { source, anchor } = fillDrag;
const target = fillPreview.value;
fillDrag = null;
fillPreview.value = null;
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onFillDragMove, true);
window.removeEventListener('mouseup', onFillDragUp, true);
window.removeEventListener('blur', onFillDragUp);
if (!target) return;
store.fillSelection(props.tableId, source, target);
// grow the selection to source + filled area, keeping the original anchor active
const c0 = Math.min(source.c0, target.c0);
const c1 = Math.max(source.c1, target.c1);
const r0 = Math.min(source.r0, target.r0);
const r1 = Math.max(source.r1, target.r1);
store.setSelection({
tableId: props.tableId,
anchor,
focus: { col: anchor.col === c0 ? c1 : c0, row: anchor.row === r0 ? r1 : r0 },
});
focusRoot();
}
function selectColumn(c: number, e?: MouseEvent) {
const t = table.value;
if (!t) return;
clearFlow();
// Shift extends from the current anchor's column to the clicked one
const from = e?.shiftKey && mine.value ? mine.value.anchor.col : c;
const a = { col: from, row: 0 };
const f = { col: c, row: t.rows - 1 };
if (e && (e.metaKey || e.ctrlKey)) store.addRangeToSelection(props.tableId, a, f);
else store.setSelection({ tableId: props.tableId, anchor: a, focus: f });
focusRoot();
}
function selectRow(r: number, e?: MouseEvent) {
const t = table.value;
if (!t) return;
clearFlow();
// Shift extends from the current anchor's row to the clicked one
const from = e?.shiftKey && mine.value ? mine.value.anchor.row : r;
const a = { col: 0, row: from };
const f = { col: t.cols - 1, row: r };
if (e && (e.metaKey || e.ctrlKey)) store.addRangeToSelection(props.tableId, a, f);
else store.setSelection({ tableId: props.tableId, anchor: a, focus: f });
focusRoot();
}
function selectAll() {
const t = table.value;
if (!t) return;
clearFlow();
store.setSelection({ tableId: props.tableId, anchor: { col: 0, row: 0 }, focus: { col: t.cols - 1, row: t.rows - 1 } });
focusRoot();
}
// ---- Ctrl/Cmd+A: two-stage "select current region, then everything" ------
// Same idea as Excel: the first press selects the contiguous block of
// filled cells around the active cell (bounded by a fully-empty row/column
// or the table edge); a *second, immediately-following* press escalates to
// every filled cell in the whole table. Detected by comparing the current
// selection to whatever the previous Ctrl+A press actually set -- if
// anything else changed the selection in between (a click, an arrow key, an
// edit, ...), the next press starts over at stage one, exactly like Excel
// forgets the cycle the moment you do something else.
function isCellEmpty(t: { cells: Record<string, string> }, c: number, r: number): boolean {
return !(`${c},${r}` in t.cells);
}
function isRowEmpty(t: { cells: Record<string, string> }, r: number, c0: number, c1: number): boolean {
for (let c = c0; c <= c1; c++) if (!isCellEmpty(t, c, r)) return false;
return true;
}
function isColEmpty(t: { cells: Record<string, string> }, c: number, r0: number, r1: number): boolean {
for (let r = r0; r <= r1; r++) if (!isCellEmpty(t, c, r)) return false;
return true;
}
/** Excel's "current region": the maximal rectangle around (`ac`,`ar`) such
* that expanding it any further in any direction would immediately cross
* a row/column that is *completely* empty (or the table's own edge). */
function currentRegion(t: { cells: Record<string, string>; rows: number; cols: number }, ac: number, ar: number) {
let c0 = ac, c1 = ac, r0 = ar, r1 = ar;
let grew = true;
while (grew) {
grew = false;
if (r0 > 0 && !isRowEmpty(t, r0 - 1, c0, c1)) { r0--; grew = true; }
if (r1 < t.rows - 1 && !isRowEmpty(t, r1 + 1, c0, c1)) { r1++; grew = true; }
if (c0 > 0 && !isColEmpty(t, c0 - 1, r0, r1)) { c0--; grew = true; }
if (c1 < t.cols - 1 && !isColEmpty(t, c1 + 1, r0, r1)) { c1++; grew = true; }
}
return { c0, r0, c1, r1 };
}
/** Bounding box of every filled cell in the whole table, or `null` if it's
* entirely empty. */
function filledBounds(t: { cells: Record<string, string> }) {
let c0 = Infinity, c1 = -Infinity, r0 = Infinity, r1 = -Infinity;
for (const key of Object.keys(t.cells)) {
const [c, r] = key.split(',').map(Number);
if (c < c0) c0 = c;
if (c > c1) c1 = c;
if (r < r0) r0 = r;
if (r > r1) r1 = r;
}
return r1 === -Infinity ? null : { c0, r0, c1, r1 };
}
type Rect4 = { c0: number; r0: number; c1: number; r1: number };
/** The rect the *previous* Ctrl/Cmd+A press set (so the next press can tell
* whether it's a genuine second press, selection unchanged since) and
* which stage it represents. */
let ctrlARect: Rect4 | null = null;
let ctrlAStage: 1 | 2 = 1;
function rectMatchesSelection(rect: Rect4, sel: SelectionState | null): boolean {
if (!sel || sel.ranges?.length) return false; // a multi-range selection never came from this cycle
const c0 = Math.min(sel.anchor.col, sel.focus.col);
const c1 = Math.max(sel.anchor.col, sel.focus.col);
const r0 = Math.min(sel.anchor.row, sel.focus.row);
const r1 = Math.max(sel.anchor.row, sel.focus.row);
return c0 === rect.c0 && c1 === rect.c1 && r0 === rect.r0 && r1 === rect.r1;
}
function selectAllCycle() {
const t = table.value;
if (!t) return;
clearFlow();
const stillAtLastPress = !!ctrlARect && rectMatchesSelection(ctrlARect, store.doc.selection);
let target: Rect4;
if (stillAtLastPress && ctrlAStage === 1) {
target = filledBounds(t) ?? { c0: 0, r0: 0, c1: t.cols - 1, r1: t.rows - 1 };
ctrlAStage = 2;
} else if (stillAtLastPress && ctrlAStage === 2) {
target = ctrlARect!; // already at "everything" -- stays there, like Excel
} else {
const anchor = mine.value?.focus ?? { col: 0, row: 0 };
target = currentRegion(t, anchor.col, anchor.row);
ctrlAStage = 1;
}
store.setSelection({
tableId: props.tableId,
anchor: { col: target.c0, row: target.r0 },
focus: { col: target.c1, row: target.r1 },
});
ctrlARect = target;
focusRoot();
}
// ---- editing ---------------------------------------------------
// The editor overlays the cell (positioned via colLeft/rowTop, like the
// clip/fill overlays) rather than living inside the <td>, so it's free to
// grow past the cell's own width/height -- clipped only by EDIT_MAX_W/H, not
// by the grid's per-cell `overflow: hidden`.
const editing = ref<{ col: number; row: number } | null>(null);
const editValue = ref('');
const editInput = ref<HTMLTextAreaElement | null>(null);
const editMirrorEl = ref<HTMLDivElement | null>(null);
const setEditRef = (el: unknown) => {
editInput.value = (el as HTMLTextAreaElement) ?? null;
};
const autocomplete = useFormulaAutocomplete();
const keyboardPoint = useKeyboardPointMode();
let committing = false;
const editStyle = computed(() => {
const s = editing.value;
if (!s) return {};
return {
left: colLeft(s.col) + 'px',
top: rowTop(s.row) + 'px',
minWidth: colWidth(s.col) + 'px',
minHeight: rowH(s.row) + 'px',
};
});
const editMirrorStyle = computed(() => {
const s = editing.value;
if (!s) return {};
return {
minWidth: colWidth(s.col) + 'px',
minHeight: rowH(s.row) + 'px',
maxWidth: EDIT_MAX_W + 'px',
};
});
/** Grow the editor to fit its content (width first, then wrap and grow
* height), via a hidden mirror element -- a plain `<textarea>` can't size
* itself to its value with CSS alone. Capped at EDIT_MAX_W/H; beyond that
* it wraps/scrolls internally instead of growing further. */
function resizeEditor() {
const ta = editInput.value;
const mirror = editMirrorEl.value;
if (!ta || !mirror || !editing.value) return;
// a trailing zero-width char keeps a trailing "\n"'s empty last line measurable
mirror.textContent = editValue.value + '\u200b';
ta.style.width = mirror.offsetWidth + 'px';
ta.style.height = Math.min(mirror.offsetHeight, EDIT_MAX_H) + 'px';
}
/** Lets the store insert a reference into this editor when the user clicks
* a grid cell mid-formula ("point mode") -- see store.pointClick/pointDrag. */
function formulaEditorHandle() {
return {
tableId: props.tableId,
getState: () => {
const el = editInput.value;
return {
text: editValue.value,
start: el?.selectionStart ?? editValue.value.length,
end: el?.selectionEnd ?? editValue.value.length,
};
},
replace: (start: number, end: number, value: string) => {
editValue.value = editValue.value.slice(0, start) + value + editValue.value.slice(end);
nextTick(() => {
const el = editInput.value;
el?.focus();
el?.setSelectionRange(start + value.length, start + value.length);
resizeEditor();
});
},
};
}
function beginEdit(c: number, r: number, initial?: string) {
if (store.ui.readOnly) {
store.setCellInput(props.tableId, c, r, ''); // no-op, only tells the user why
return;
}
editing.value = { col: c, row: r };
editValue.value = initial ?? store.cellInput(props.tableId, c, r);
keyboardPoint.reset();
store.registerFormulaEditor(formulaEditorHandle());
nextTick(() => {
const el = editInput.value;
if (!el) return;
el.focus();
if (initial === undefined) el.select();
else el.setSelectionRange(el.value.length, el.value.length);
resizeEditor();
});
}
function commitEdit() {
if (!editing.value) return;
committing = true;
const { col, row } = editing.value;
store.setCellInput(props.tableId, col, row, editValue.value);
editing.value = null;
store.registerFormulaEditor(null);
autocomplete.close();
focusRoot();
setTimeout(() => (committing = false), 0);
}
function cancelEdit() {
editing.value = null;
store.registerFormulaEditor(null);
autocomplete.close();
focusRoot();
}
/** Accept the highlighted autocomplete suggestion (name + "(") via the same
* replace() mechanism point-mode uses. */
function applySuggestion(el: HTMLTextAreaElement) {
const caret = el.selectionStart ?? editValue.value.length;
const rep = autocomplete.accept(editValue.value, caret);
if (!rep) return false;
editValue.value = editValue.value.slice(0, rep.start) + rep.value + editValue.value.slice(rep.end);
autocomplete.close();
nextTick(() => {
el.setSelectionRange(rep.start + rep.value.length, rep.start + rep.value.length);
resizeEditor();
});
return true;
}
function onEditKeydown(e: KeyboardEvent) {
// Function-name autocomplete takes priority over every other key below
// while its dropdown is open (see useFormulaAutocomplete.ts).
if (autocomplete.suggestions.value.length) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
autocomplete.move(e.key === 'ArrowDown' ? 1 : -1);
return;
}
if (e.key === 'Tab' || e.key === 'Enter') {
const el = editInput.value;
if (el && applySuggestion(el)) {
e.preventDefault();
e.stopPropagation();
return;
}
}
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
autocomplete.close();
return;
}
}
// Keyboard "point mode" -- arrow keys pick a cell/range reference right
// where a formula expects one, same as clicking/dragging a grid cell
// already does (see useKeyboardPointMode's own doc comment). Only takes
// over when it actually applies; otherwise falls through to the native
// textarea caret movement below.
if (editing.value) {
const el = editInput.value;
const caret = el?.selectionStart ?? editValue.value.length;
if (keyboardPoint.handleArrowKey(e, props.tableId, editing.value, editValue.value, caret)) {
e.preventDefault();
e.stopPropagation();
return;
}
}
// Alt+Enter (Windows/Excel-365-Mac) or Cmd+Enter (a macOS-safe fallback --
// Option is a compose key there, e.g. for Control+Option+Return, the
// traditional Mac Excel shortcut, or some layouts/browsers, so `altKey`
// alone isn't fully reliable; `e.code` sidesteps `key` being altered by
// that composition, since Enter itself never composes into another char).
const isEnter = e.key === 'Enter' || e.code === 'Enter' || e.code === 'NumpadEnter';
if (isEnter && (e.altKey || e.metaKey)) {
// insert a literal newline at the caret ourselves rather than relying on
// the textarea's native Enter handling, which some browsers/OSes divert
// this combination away from (e.g. as a window/fullscreen shortcut)
e.preventDefault();
e.stopPropagation();
const el = editInput.value;
if (!el) return;
const start = el.selectionStart ?? editValue.value.length;
const end = el.selectionEnd ?? start;
editValue.value = editValue.value.slice(0, start) + '\n' + editValue.value.slice(end);
nextTick(() => {
el.setSelectionRange(start + 1, start + 1);
resizeEditor();
});
return;
}
if (e.key === 'Enter') {
e.preventDefault();
commitEdit();
flowMove('v', e.shiftKey);
} else if (e.key === 'Tab') {
e.preventDefault();
commitEdit();
flowMove('h', e.shiftKey);
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEdit();
} else if (e.key === 'F4') {
e.preventDefault();
toggleReferenceLock();
}
e.stopPropagation();
}
/** F4: cycle relative -> absolute -> row-locked -> col-locked (Excel/Numbers
* "Bezug sperren") -- for the reference at the caret, or every reference a
* real text selection overlaps at once. */
function toggleReferenceLock() {
const el = editInput.value;
if (!el) return;
const start = el.selectionStart ?? editValue.value.length;
const end = el.selectionEnd ?? start;
const res = cycleReferenceLocks(editValue.value, start, end);
if (!res) return;
editValue.value = res.text;
nextTick(() => {
el.setSelectionRange(res.start, res.end);
resizeEditor();
});
}
function onEditBlur() {
if (!committing && editing.value) commitEdit();
autocomplete.close();
}
/** Actual typing (not a point-mode click inserting a reference programmatically)
* ends the "next click replaces the last reference" window. */
function onEditInput() {
resizeEditor();
store.noteFormulaTyped();
keyboardPoint.reset();
const el = editInput.value;
autocomplete.update(editValue.value, el?.selectionStart ?? editValue.value.length);
}
/** A mouse click on a dropdown entry (see FormulaSuggest's `@mousedown.prevent`
* keeping focus in the textarea, so `editInput` is still the live editor). */
function onPickSuggestion(i: number) {
autocomplete.activeIndex.value = i;
const el = editInput.value;
if (el) applySuggestion(el);
}
/** Clicking inside the field to reposition the caret should also end that
* window (mousedown so it fires before any selection change is read). */
function onEditMousedown(e: MouseEvent) {
e.stopPropagation();
store.noteFormulaTyped();
keyboardPoint.reset();
}
// ---- keyboard navigation (Excel-like) ------------------------
function onKeydown(e: KeyboardEvent) {
if (editing.value) return;
const s = mine.value;
const t = table.value;
if (!s || !t) return;
const ext = e.shiftKey;
const mod = e.ctrlKey || e.metaKey;
if (mod) {
const k = e.key.toLowerCase();
if (k === 'a') {
e.preventDefault();
e.stopPropagation();
return selectAllCycle();
}
if (k === 'c') {
e.preventDefault();
e.stopPropagation();
return store.copySelection();
}
if (k === 'x') {
e.preventDefault();
e.stopPropagation();
return store.cutSelection();
}
if (k === 'v') {
e.preventDefault();
e.stopPropagation();
return store.pasteClipboard();
}
if (k === 'z' && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
return store.undo();
}
if ((k === 'z' && e.shiftKey) || k === 'y') {
e.preventDefault();
e.stopPropagation();
return store.redo();
}
}
switch (e.key) {
case 'ArrowUp':
e.preventDefault();
e.stopPropagation();
return mod ? jumpTo(0, -1, ext) : navigate(0, -1, ext);
case 'ArrowDown':
e.preventDefault();
e.stopPropagation();
return mod ? jumpTo(0, 1, ext) : navigate(0, 1, ext);
case 'ArrowLeft':
e.preventDefault();
e.stopPropagation();
return mod ? jumpTo(-1, 0, ext) : navigate(-1, 0, ext);
case 'ArrowRight':
e.preventDefault();
e.stopPropagation();
return mod ? jumpTo(1, 0, ext) : navigate(1, 0, ext);
case 'Tab':
e.preventDefault();
e.stopPropagation();
return flowMove('h', e.shiftKey);
case 'Enter':
e.preventDefault();
e.stopPropagation();
return flowMove('v', e.shiftKey);
case 'F2':
e.preventDefault();
e.stopPropagation();
return beginEdit(s.anchor.col, s.anchor.row);
case 'Home':
e.preventDefault();
e.stopPropagation();
clearFlow();
select(0, mod ? 0 : (ext ? s.focus : s.anchor).row, ext);
return scrollActiveIntoView();
case 'End':
e.preventDefault();
e.stopPropagation();
clearFlow();
select(t.cols - 1, mod ? t.rows - 1 : (ext ? s.focus : s.anchor).row, ext);
return scrollActiveIntoView();
case 'PageUp':
e.preventDefault();
e.stopPropagation();
return navigate(0, -pageRows(), ext);
case 'PageDown':
e.preventDefault();
e.stopPropagation();
return navigate(0, pageRows(), ext);
case 'Backspace':
case 'Delete':
e.preventDefault();
e.stopPropagation();
return store.clearSelectionCells();
case 'Escape':
clearFlow();
store.cancelClipboard();
return;
default:
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
e.stopPropagation();
beginEdit(s.anchor.col, s.anchor.row, e.key);
}
}
}
// ---- context menu -----------------------------------------
const menu = ref({ x: 0, y: 0, open: false });
/** Which row/col header (if any) the menu was opened on -- drives whether
* it shows the row/col insert-delete items (Excel-style, only from a
* header right-click) or the ordinary cell menu. */
const menuCtx = ref<{ row: number | null; col: number | null }>({ row: null, col: null });
function onContextMenu(e: MouseEvent) {
const target = e.target as HTMLElement | null;
const rowHeadEl = target?.closest?.('th[data-rowhead]') as HTMLElement | null;
const colHeadEl = target?.closest?.('th[data-colhead]') as HTMLElement | null;
menuCtx.value = {
row: rowHeadEl ? Number(rowHeadEl.dataset.rowhead) : null,
col: colHeadEl ? Number(colHeadEl.dataset.colhead) : null,
};
const hit = cellFromEvent(e);
// keep a multi-selection if the click landed inside it; otherwise select the cell
if (hit && !inSelection(hit.c, hit.r)) select(hit.c, hit.r);
menu.value = { x: e.clientX, y: e.clientY, open: true };
}
// ---- dependency-graph view ---------------------------------
/** Opens the (single, document-wide) dependency-graph drawer on the cell
* the context menu was invoked on -- `select()` in onContextMenu above
* already made it the active cell by the time this runs, so the active
* selection's focus is exactly the right cell. */
function openDependencyGraph() {
const s = store.doc.selection;
if (!s || s.tableId !== props.tableId) return;
store.openDependencyGraph(props.tableId, s.focus.col, s.focus.row);
}
const menuItems = computed(() => {
const t = table.value;
const ctx = menuCtx.value;
if (t && ctx.row !== null) {
const r = ctx.row;
const items = [
{ label: 'Zeile oberhalb einfügen', action: () => store.insertRow(props.tableId, r) },
{ label: 'Zeile unterhalb einfügen', action: () => store.insertRow(props.tableId, r + 1) },
];
if (t.rows > 1) items.push({ label: 'Zeile löschen', action: () => store.deleteRow(props.tableId, r) });
return items;
}
if (t && ctx.col !== null) {
const c = ctx.col;
const items = [
{ label: 'Spalte links einfügen', action: () => store.insertColumn(props.tableId, c) },
{ label: 'Spalte rechts einfügen', action: () => store.insertColumn(props.tableId, c + 1) },
];
if (t.cols > 1) items.push({ label: 'Spalte löschen', action: () => store.deleteColumn(props.tableId, c) });
return items;
}
const items = [
{ label: 'Kopieren', action: () => store.copySelection() },
{ label: 'Ausschneiden', action: () => store.cutSelection() },
];
if (store.hasClipboard.value) items.push({ label: 'Einfügen', action: () => store.pasteClipboard() });
items.push(
{ label: '🕸 Abhängigkeiten anzeigen', action: openDependencyGraph },
{ label: 'Zeile hinzufügen', action: () => store.addRows(props.tableId) },
{ label: 'Spalte hinzufügen', action: () => store.addColumns(props.tableId) },
{ label: 'Letzte Zeile entfernen', action: () => store.removeLastRow(props.tableId) },
{ label: 'Letzte Spalte entfernen', action: () => store.removeLastColumn(props.tableId) },
{ label: 'Auswahl leeren', action: () => store.clearSelectionCells() },
);
return items;
});
// ---- column resize --------------------------------------
let resize: { col: number; startX: number; startW: number } | null = null;
function onResizeDown(e: MouseEvent, c: number) {
e.preventDefault();
e.stopPropagation();
resize = { col: c, startX: e.clientX, startW: colWidth(c) };
document.body.style.userSelect = 'none';
window.addEventListener('mousemove', onResizeMove);
window.addEventListener('mouseup', onResizeUp);
}
function onResizeMove(e: MouseEvent) {
if (resize) store.setColWidth(props.tableId, resize.col, resize.startW + (e.clientX - resize.startX));
}
function onResizeUp() {
resize = null;
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onResizeMove);
window.removeEventListener('mouseup', onResizeUp);
}
// ---- row resize (drag the bottom edge of a row header) ----
let rrs: { row: number; startY: number; startH: number } | null = null;
function onRowResizeDown(e: MouseEvent, r: number) {
e.preventDefault();
e.stopPropagation();
rrs = { row: r, startY: e.clientY, startH: rowH(r) };
document.body.style.userSelect = 'none';
window.addEventListener('mousemove', onRowResizeMove);
window.addEventListener('mouseup', onRowResizeUp);
}
function onRowResizeMove(e: MouseEvent) {
if (rrs) store.setRowHeight(props.tableId, rrs.row, rrs.startH + (e.clientY - rrs.startY));
}
function onRowResizeUp() {
rrs = null;
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onRowResizeMove);
window.removeEventListener('mouseup', onRowResizeUp);
}
const totalWidth = computed(() => {
const t = table.value;
if (!t) return headW.value;
let w = headW.value;
for (let c = 0; c < t.cols; c++) w += colWidth(c);
return w;
});
onBeforeUnmount(() => {
onDragUp();
onPointDragUp();
onFillDragUp();
onResizeUp();
onRowResizeUp();
if (scrollRaf) cancelAnimationFrame(scrollRaf);
});
defineExpose({
focus: () => root.value?.focus(),
});
</script>
<template>
<div
v-if="table"
ref="root"
class="table-grid"
:class="{ selecting: dragging, 'tg-fixed': isFixed }"
:style="rootStyle"
tabindex="0"
role="grid"
:aria-label="`Tabelle ${table.name}`"
@keydown="onKeydown"
@contextmenu.prevent="onContextMenu"
>
<div class="tg-scroll" :style="scrollStyle" @scroll.passive="onScroll">
<table class="grid" :style="{ width: totalWidth + 'px' }">
<colgroup>
<col v-if="showHeaders" :style="{ width: headW + 'px' }" />
<col v-for="c in table.cols" :key="'c' + (c - 1)" :style="{ width: colWidth(c - 1) + 'px' }" />
</colgroup>
<thead v-if="showHeaders">
<tr :style="{ height: headerH + 'px' }">
<th class="corner" @click="selectAll" />
<th
v-for="c in table.cols"
:key="'h' + (c - 1)"
class="col-head"
:data-colhead="c - 1"
:class="{ hot: colHot(c - 1), 'freeze-edge-right': c - 1 === frozenCols - 1 }"
:style="c - 1 < frozenCols ? { left: colLeft(c - 1) + 'px', zIndex: '7' } : {}"
@click="selectColumn(c - 1, $event)"
>
{{ numberToCol(c - 1) }}
<span class="col-resize" @mousedown="onResizeDown($event, c - 1)" @click.stop />
</th>
</tr>
</thead>
<tbody>
<tr v-for="item in renderedRows" :key="item.key" :style="{ height: (item.kind === 'row' ? rowH(item.r) : item.height) + 'px' }">
<template v-if="item.kind === 'row'">
<th
v-if="showHeaders"
class="row-head"
:data-rowhead="item.r"
:class="{ hot: rowHot(item.r), 'frozen-row': item.r < frozenRows, 'freeze-edge-bottom': item.r === frozenRows - 1 }"
:style="rowHeadStyle(item.r)"
@click="selectRow(item.r, $event)"
>
{{ item.r + 1 }}
<span class="row-resize" @mousedown="onRowResizeDown($event, item.r)" @click.stop />
</th>
<td
v-for="c in table.cols"
:key="(c - 1) + ',' + item.r"
:data-c="c - 1"
:data-r="item.r"
class="cell"
:class="{
'is-active': isActive(c - 1, item.r),
'in-range': inSelection(c - 1, item.r),
frozen: (c - 1 < frozenCols) || (item.r < frozenRows),
'freeze-edge-right': c - 1 === frozenCols - 1,
'freeze-edge-bottom': item.r === frozenRows - 1,
}"
:style="cellStyle(c - 1, item.r)"
@mousedown="onCellMouseDown($event, c - 1, item.r)"
@dblclick="!store.formulaPointModeActive() && beginEdit(c - 1, item.r)"
>
<div
class="cell-content"
:class="{
numeric: store.cellIsNumeric(tableId, c - 1, item.r),
error: store.cellIsError(tableId, c - 1, item.r),
multiline: isMultiline(c - 1, item.r),
}"
:style="cellContentStyle(item.r)"
>
<template v-if="!(editing && editing.col === c - 1 && editing.row === item.r)">
{{ store.cellDisplay(tableId, c - 1, item.r) }}
</template>
</div>
</td>
</template>
<!-- a spacer standing in for the rows currently scrolled out of
view -- keeps the scrollbar's size/position correct without
those rows' cells actually existing in the DOM -->
<td v-else :colspan="showHeaders ? table.cols + 1 : table.cols" class="row-spacer" />
</tr>
</tbody>
</table>
<div v-for="p in peerRects" :key="p.id" class="peer-sel" :style="p.style">
<span class="peer-name">{{ p.name }}</span>
</div>
<div v-if="clipMarqueeStyle" class="clip-marquee" :style="clipMarqueeStyle" />
<div v-if="clipAnchorStyle" class="clip-anchor" :style="clipAnchorStyle" />
<div v-if="fillPreviewStyle" class="fill-preview" :style="fillPreviewStyle" />
<div
v-if="fillHandleStyle"
class="fill-handle"
:style="fillHandleStyle"
title="Ziehen zum Ausfüllen/Kopieren"
@mousedown="onFillHandleDown"
/>
<div v-if="editing" class="cell-input-wrap" :style="editStyle">
<textarea
:ref="setEditRef"
v-model="editValue"
class="cell-input"
spellcheck="false"
rows="1"
@keydown="onEditKeydown"
@input="onEditInput"
@blur="onEditBlur"
@mousedown="onEditMousedown"
/>
<FormulaSuggest
v-if="autocomplete.suggestions.value.length"
:items="autocomplete.suggestions.value"
:active-index="autocomplete.activeIndex.value"
@pick="onPickSuggestion"
/>
</div>
<div v-if="editing" ref="editMirrorEl" class="cell-input-mirror" :style="editMirrorStyle" aria-hidden="true" />
</div>
<ContextMenu v-model:open="menu.open" :x="menu.x" :y="menu.y" :items="menuItems" />
</div>
</template>
<style scoped>
.table-grid {
position: relative;
display: inline-block;
background: var(--surface);
outline: none;
font-variant-numeric: tabular-nums;
/* a spreadsheet grid is never text-selectable -- prevents native selection
from fighting the JS range selection during a drag */
user-select: none;
-webkit-user-select: none;
}
.tg-scroll {
position: relative;
}
.grid {
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
}
/* frozen panes -- active only in fixed-viewport mode */
.tg-fixed thead th {
position: sticky;
top: 0;
z-index: 6;
}
.tg-fixed .corner {
left: 0;
z-index: 7;
}
.tg-fixed .row-head {
position: sticky;
left: 0;
z-index: 4;
}
.cell.frozen {
background: var(--surface-2);
}
.freeze-edge-right {
border-right: 2px solid var(--border) !important;
}
.freeze-edge-bottom {
border-bottom: 2px solid var(--border) !important;
}
.grid th {
background: var(--header-bg);
color: var(--text-dim);
font-weight: 500;
font-size: 11px;
border-right: 1px solid var(--grid-line);
border-bottom: 1px solid var(--border);
user-select: none;
}
.corner {
cursor: pointer;
border-top-left-radius: 6px;
}
.col-head {
position: relative;
text-align: center;
cursor: pointer;
}
.row-head {
position: relative;
text-align: center;
cursor: pointer;
}
.hot {
background: var(--accent-header);
color: var(--text);
}
.col-resize {
position: absolute;
top: 0;
right: -3px;
width: 6px;
height: 100%;
cursor: col-resize;
z-index: 5;
}
.row-resize {
position: absolute;
left: 0;
bottom: -3px;
width: 100%;
height: 6px;
cursor: row-resize;
z-index: 5;
}
.cell {
padding: 0; /* all padding lives on .cell-content -- see cellContentStyle() */
border-right: 1px solid var(--grid-line);
border-bottom: 1px solid var(--grid-line);
background: var(--surface);
vertical-align: middle;
cursor: cell;
position: relative;
}
/* stands in for the rows currently scrolled out of view -- see renderedRows() */
.row-spacer {
padding: 0;
border: none;
}
/* the cell's text, in its own block box: a <td>'s own max-height doesn't cap
table-row growth, but a plain block child's max-height + overflow:hidden
does -- see cellContentStyle(). */
.cell-content {
padding: 0 7px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cell-content.numeric {
text-align: right;
}
.cell-content.error {
color: var(--danger);
text-align: right;
}
.cell-content.multiline {
white-space: pre-wrap;
text-overflow: clip;
word-break: break-word;
}
.cell.in-range {
background: var(--accent-weak);
}
.cell.is-active {
box-shadow: inset 0 0 0 2px var(--accent);
}
/* someone else's selection (live collaboration) */
.peer-sel {
position: absolute;
pointer-events: none;
border: 2px solid;
z-index: 7;
box-sizing: border-box;
}
.peer-name {
position: absolute;
top: -17px;
left: -2px;
padding: 1px 5px;
border-radius: 3px 3px 3px 0;
background: var(--peer);
color: #fff;
font-size: 10.5px;
line-height: 15px;
white-space: nowrap;
}
/* one dashed rectangle around the whole copied range, not per cell */
.clip-marquee {
position: absolute;
pointer-events: none;
border: 1px dashed var(--accent);
z-index: 8;
animation: clip-src-march 1.2s linear infinite;
}
@keyframes clip-src-march {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
/* the copy/cut selection's own first cell -- called out inside the marquee
even once the live selection has moved elsewhere to pick a paste target */
.clip-anchor {
position: absolute;
pointer-events: none;
box-shadow: inset 0 0 0 2px var(--accent);
z-index: 8;
}
/* small square at the selection's bottom-right corner -- drag to fill/copy */
.fill-handle {
position: absolute;
width: 7px;
height: 7px;
background: var(--accent);
border: 1px solid var(--surface);
border-radius: 2px;
z-index: 9;
cursor: crosshair;
}
/* preview of the cells a fill-handle drag is about to fill */
.fill-preview {
position: absolute;
pointer-events: none;
border: 1px dashed var(--accent);
background: var(--accent-weak);
z-index: 8;
}
/* the in-cell editor -- an overlay (like the clip/fill markers above), not a
child of its <td>, so it's free to grow past the cell (request: a longer
formula gets a box sized to its content / the display's max width, not
squeezed into the cell). Sized imperatively by resizeEditor(); left/top and
the starting min-width/min-height come from editStyle. Positioning lives
on this wrapper (not .cell-input itself) so FormulaSuggest's dropdown,
`position: absolute; top: 100%`, lands right under the textarea. */
.cell-input-wrap {
position: absolute;
z-index: 12;
}
.cell-input {
resize: none;
border: 2px solid var(--accent);
padding: 0 5px;
font: inherit;
line-height: 1.35;
color: var(--text);
background: var(--surface);
outline: none;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
box-sizing: border-box;
}
/* hidden clone of .cell-input's text box, measured to size the real one --
same font/line-height/padding/border, capped at EDIT_MAX_W so it wraps at
the same point the real editor will. */
.cell-input-mirror {
position: fixed;
top: -9999px;
left: -9999px;
visibility: hidden;
display: inline-block;
white-space: pre-wrap;
word-break: break-word;
box-sizing: border-box;
padding: 0 5px;
border: 2px solid transparent;
font: inherit;
line-height: 1.35;
}
</style>