- Help dialog (?, F1, Datei > Hilfe): user topics, all shortcuts and the formula functions with one search over everything. Topics and shortcuts are plain data in data/help.ts; the function list comes from codeReference.ts so new built-ins appear by themselves. - While a formula is edited, the grid outlines each referenced cell/range in the colour of its position in the text, and the outline can be dragged to move or resize the reference, as in Excel. Point-mode picks (click, drag, arrow keys) are outlined too, so keyboard picking is no longer blind. - Underline as a cell format, plus Mod+Shift+B/I/U toggles that switch on unless every selected cell already has the format. - A formula committed with open parentheses or an open text literal gets the missing closers appended; anything that still doesn't parse is left alone so the usual error shows instead of a guess. - Cells and tables with a fill but no text colour get dark or light text by WCAG contrast instead of the theme colour (no black on black).
1947 lines
69 KiB
Vue
1947 lines
69 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, onMounted, ref, watch } from 'vue';
|
|
import { numberToCol, cycleReferenceLocks, completeFormula } 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';
|
|
if (f.underline) s.textDecoration = 'underline';
|
|
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 cell/range just picked for the formula being edited (point mode, by
|
|
* mouse or arrow keys) -- the grid selection itself stays on the edited
|
|
* cell meanwhile, so without this the pick would be invisible. */
|
|
const pointRectStyle = computed(() => {
|
|
const k = store.pointRect.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);
|
|
}
|
|
|
|
// ---- references of the formula being edited (coloured, draggable) -------
|
|
/** The edited formula's references that point into this table, outlined in
|
|
* their colour. Dragging an outline's edge moves the reference, dragging a
|
|
* corner resizes it -- the formula text follows live (store.moveFormulaRef),
|
|
* same as Excel. */
|
|
const refHighlights = computed(() =>
|
|
store.formulaRefs.value
|
|
.filter((h) => h.tableId === props.tableId)
|
|
.map((h) => {
|
|
const left = colLeft(h.c0);
|
|
const top = rowTop(h.r0);
|
|
return {
|
|
...h,
|
|
style: {
|
|
left: left + 'px',
|
|
top: top + 'px',
|
|
width: colLeft(h.c1 + 1) - left + 'px',
|
|
height: rowTop(h.r1 + 1) - top + 'px',
|
|
'--ref': h.color,
|
|
},
|
|
};
|
|
}),
|
|
);
|
|
type RefCorner = 'nw' | 'ne' | 'sw' | 'se';
|
|
let refDrag: {
|
|
index: number;
|
|
orig: { c0: number; r0: number; c1: number; r1: number };
|
|
/** null = move by the edge, else the corner being dragged */
|
|
corner: RefCorner | null;
|
|
start: { c: number; r: number };
|
|
} | null = null;
|
|
function onRefHandleDown(e: MouseEvent, h: { index: number; c0: number; r0: number; c1: number; r1: number }, corner: RefCorner | null) {
|
|
if (e.button !== 0) return;
|
|
e.preventDefault(); // keep focus (and the caret) in the formula editor
|
|
e.stopPropagation(); // no cell/canvas-object mousedown underneath
|
|
const start = cellFromPoint(e.clientX, e.clientY);
|
|
if (!start) return;
|
|
refDrag = { index: h.index, orig: { c0: h.c0, r0: h.r0, c1: h.c1, r1: h.r1 }, corner, start };
|
|
document.body.style.userSelect = 'none';
|
|
window.addEventListener('mousemove', onRefDragMove, true);
|
|
window.addEventListener('mouseup', onRefDragUp, true);
|
|
window.addEventListener('blur', onRefDragUp);
|
|
}
|
|
function onRefDragMove(e: MouseEvent) {
|
|
const d = refDrag;
|
|
const t = table.value;
|
|
if (!d || !t) return;
|
|
if (e.buttons === 0) {
|
|
onRefDragUp(); // a mouseup we never saw -- self-heal a stuck drag
|
|
return;
|
|
}
|
|
const hit = cellFromPoint(e.clientX, e.clientY);
|
|
if (!hit) return;
|
|
const o = d.orig;
|
|
if (!d.corner) {
|
|
// shift the whole rectangle, but never past the table's edges
|
|
const dc = Math.max(-o.c0, Math.min(t.cols - 1 - o.c1, hit.c - d.start.c));
|
|
const dr = Math.max(-o.r0, Math.min(t.rows - 1 - o.r1, hit.r - d.start.r));
|
|
store.moveFormulaRef(d.index, o.c0 + dc, o.r0 + dr, o.c1 + dc, o.r1 + dr);
|
|
return;
|
|
}
|
|
// the opposite corner stays put; the dragged one follows the pointer
|
|
const fixedC = d.corner === 'nw' || d.corner === 'sw' ? o.c1 : o.c0;
|
|
const fixedR = d.corner === 'nw' || d.corner === 'ne' ? o.r1 : o.r0;
|
|
store.moveFormulaRef(d.index, fixedC, fixedR, hit.c, hit.r);
|
|
}
|
|
function onRefDragUp() {
|
|
if (!refDrag) return;
|
|
refDrag = null;
|
|
document.body.style.userSelect = '';
|
|
window.removeEventListener('mousemove', onRefDragMove, true);
|
|
window.removeEventListener('mouseup', onRefDragUp, true);
|
|
window.removeEventListener('blur', onRefDragUp);
|
|
}
|
|
|
|
// ---- 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 {};
|
|
// look like the cell being edited: its own text/fill colour (else the
|
|
// table's), bold/italic/underline -- a white-on-dark cell must not turn into dark
|
|
// text on a dark fill while editing
|
|
const f = store.cellFormat(props.tableId, s.col, s.row);
|
|
const style: Record<string, string> = {
|
|
left: colLeft(s.col) + 'px',
|
|
top: rowTop(s.row) + 'px',
|
|
minWidth: colWidth(s.col) + 'px',
|
|
minHeight: rowH(s.row) + 'px',
|
|
};
|
|
if (f.color) style['--text'] = f.color;
|
|
if (f.bg) style['--surface'] = f.bg;
|
|
if (f.bold) style.fontWeight = '600';
|
|
if (f.italic) style.fontStyle = 'italic';
|
|
if (f.underline) style.textDecoration = 'underline';
|
|
return style;
|
|
});
|
|
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;
|
|
// a forgotten closing ")" is completed rather than stored as an error
|
|
store.setCellInput(props.tableId, col, row, completeFormula(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();
|
|
// Ctrl/Cmd+Shift+B (or F, "fett") / I / U: toggle bold / italic /
|
|
// underline for the whole selection -- on when not every selected cell
|
|
// has it yet, else off, same as the toggle buttons in the side panel.
|
|
// With Shift, so plain Cmd+B/I/U stay free for the browser/OS.
|
|
const toggles: Record<string, 'bold' | 'italic' | 'underline'> = { b: 'bold', f: 'bold', i: 'italic', u: 'underline' };
|
|
const toggle = e.shiftKey ? toggles[k] : undefined;
|
|
if (toggle) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
return store.applySelectionFormat({ [toggle]: !store.selectionFormat.value[toggle] });
|
|
}
|
|
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;
|
|
});
|
|
|
|
// the formula bar / Name Box hand the keyboard back after Enter/Escape
|
|
function onFocusGridRequest(e: Event) {
|
|
if ((e as CustomEvent<string>).detail === props.tableId) root.value?.focus();
|
|
}
|
|
window.addEventListener('numolus:focus-grid', onFocusGridRequest);
|
|
|
|
// After a reload (or opening a document / switching sheets) the restored
|
|
// selection is visible but nothing had the keyboard, so arrow keys and typing
|
|
// went nowhere until the user clicked. Take it whenever the selection sits in
|
|
// this table and the keyboard is otherwise unclaimed -- never away from an
|
|
// input, the formula bar or another table someone is working in.
|
|
function claimIdleKeyboard() {
|
|
if (!mine.value || !root.value) return;
|
|
const active = document.activeElement;
|
|
if (active && active !== document.body) return;
|
|
root.value.focus({ preventScroll: true });
|
|
}
|
|
onMounted(claimIdleKeyboard);
|
|
watch(() => mine.value?.tableId, claimIdleKeyboard, { flush: 'post' });
|
|
|
|
onBeforeUnmount(() => {
|
|
window.removeEventListener('numolus:focus-grid', onFocusGridRequest);
|
|
onDragUp();
|
|
onPointDragUp();
|
|
onRefDragUp();
|
|
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-for="h in refHighlights" :key="'ref' + h.index" class="ref-hl" :style="h.style">
|
|
<template v-if="!h.whole">
|
|
<span
|
|
v-for="edge in ['top', 'right', 'bottom', 'left']"
|
|
:key="edge"
|
|
class="ref-edge"
|
|
:class="'ref-edge-' + edge"
|
|
@mousedown="onRefHandleDown($event, h, null)"
|
|
/>
|
|
<span
|
|
v-for="corner in (['nw', 'ne', 'sw', 'se'] as const)"
|
|
:key="corner"
|
|
class="ref-corner"
|
|
:class="'ref-corner-' + corner"
|
|
@mousedown="onRefHandleDown($event, h, corner)"
|
|
/>
|
|
</template>
|
|
</div>
|
|
<div v-if="pointRectStyle" class="point-rect" :style="pointRectStyle" />
|
|
<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;
|
|
}
|
|
/* the reference being picked for a formula (point mode) */
|
|
.point-rect {
|
|
position: absolute;
|
|
pointer-events: none;
|
|
border: 2px dashed var(--accent);
|
|
background: var(--accent-weak);
|
|
box-sizing: border-box;
|
|
z-index: 8;
|
|
}
|
|
/* a reference of the formula being edited, in its colour (Excel-style) */
|
|
.ref-hl {
|
|
position: absolute;
|
|
pointer-events: none; /* the inside stays clickable for picking cells */
|
|
border: 2px solid var(--ref);
|
|
background: color-mix(in srgb, var(--ref) 10%, transparent);
|
|
box-sizing: border-box;
|
|
z-index: 9;
|
|
}
|
|
/* the edges move the reference, the corners resize it */
|
|
.ref-edge {
|
|
position: absolute;
|
|
pointer-events: auto;
|
|
cursor: move;
|
|
}
|
|
.ref-edge-top,
|
|
.ref-edge-bottom {
|
|
left: 0;
|
|
right: 0;
|
|
height: 6px;
|
|
}
|
|
.ref-edge-top {
|
|
top: -4px;
|
|
}
|
|
.ref-edge-bottom {
|
|
bottom: -4px;
|
|
}
|
|
.ref-edge-left,
|
|
.ref-edge-right {
|
|
top: 0;
|
|
bottom: 0;
|
|
width: 6px;
|
|
}
|
|
.ref-edge-left {
|
|
left: -4px;
|
|
}
|
|
.ref-edge-right {
|
|
right: -4px;
|
|
}
|
|
.ref-corner {
|
|
position: absolute;
|
|
pointer-events: auto;
|
|
width: 7px;
|
|
height: 7px;
|
|
background: var(--ref);
|
|
border: 1px solid var(--surface);
|
|
border-radius: 1px;
|
|
z-index: 1;
|
|
}
|
|
.ref-corner-nw {
|
|
left: -5px;
|
|
top: -5px;
|
|
cursor: nwse-resize;
|
|
}
|
|
.ref-corner-ne {
|
|
right: -5px;
|
|
top: -5px;
|
|
cursor: nesw-resize;
|
|
}
|
|
.ref-corner-sw {
|
|
left: -5px;
|
|
bottom: -5px;
|
|
cursor: nesw-resize;
|
|
}
|
|
.ref-corner-se {
|
|
right: -5px;
|
|
bottom: -5px;
|
|
cursor: nwse-resize;
|
|
}
|
|
/* 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>
|