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>
1715 lines
61 KiB
Vue
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>
|