This commit is contained in:
wkh
2026-08-29 17:47:12 +02:00
commit c7885067e7
53 changed files with 13458 additions and 0 deletions
+649
View File
@@ -0,0 +1,649 @@
<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 } from '../engine';
import { useDocument } from '../store/document';
import { DEFAULT_ROW_H } from '../store/types';
import ContextMenu from './ContextMenu.vue';
const props = defineProps<{ tableId: string }>();
const store = useDocument();
const table = computed(() => store.tableById(props.tableId));
const HEAD_W = 44;
const DEFAULT_W = 104;
const colWidth = (c: number) => table.value?.colWidths[c] ?? DEFAULT_W;
const rowH = (r: number) => store.rowHeightOf(props.tableId, r);
const headerH = computed(() => table.value?.rowHeight ?? DEFAULT_ROW_H);
/** px from the table's top edge to the start of row `r` (incl. header). */
function rowTop(r: number): number {
let y = headerH.value;
for (let i = 0; i < r; i++) y += rowH(i);
return y;
}
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;
}
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 {
let x = HEAD_W;
for (let i = 0; i < c; i++) x += colWidth(i);
return x;
}
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;
}
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) : []));
const isActive = (c: number, r: number) =>
!!mine.value && mine.value.focus.col === c && mine.value.focus.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);
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);
}
// ---- 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;
const scrollEl = () =>
isFixed.value ? (root.value?.querySelector('.tg-scroll') as HTMLElement | null) : null;
/** 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);
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 (editing.value) commitEdit();
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);
}
function selectColumn(c: number, e?: MouseEvent) {
const t = table.value;
if (!t) return;
const a = { col: c, 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;
const a = { col: 0, row: r };
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;
store.setSelection({ tableId: props.tableId, anchor: { col: 0, row: 0 }, focus: { col: t.cols - 1, row: t.rows - 1 } });
focusRoot();
}
// ---- editing ---------------------------------------------------
const editing = ref<{ col: number; row: number } | null>(null);
const editValue = ref('');
const editInput = ref<HTMLInputElement | null>(null);
const setEditRef = (el: unknown) => {
editInput.value = (el as HTMLInputElement) ?? null;
};
let committing = false;
function beginEdit(c: number, r: number, initial?: string) {
editing.value = { col: c, row: r };
editValue.value = initial ?? store.cellInput(props.tableId, c, r);
nextTick(() => {
const el = editInput.value;
if (!el) return;
el.focus();
if (initial === undefined) el.select();
else el.setSelectionRange(el.value.length, el.value.length);
});
}
function commitEdit(advance: 'down' | 'up' | 'left' | 'right' | 'none' = 'none') {
if (!editing.value) return;
committing = true;
const { col, row } = editing.value;
store.setCellInput(props.tableId, col, row, editValue.value);
editing.value = null;
const d: Record<string, [number, number]> = {
down: [0, 1], up: [0, -1], left: [-1, 0], right: [1, 0], none: [0, 0],
};
const [dc, dr] = d[advance];
select(col + dc, row + dr);
focusRoot();
setTimeout(() => (committing = false), 0);
}
function cancelEdit() {
editing.value = null;
focusRoot();
}
function onEditKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
commitEdit(e.shiftKey ? 'up' : 'down');
} else if (e.key === 'Tab') {
e.preventDefault();
commitEdit(e.shiftKey ? 'left' : 'right');
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEdit();
}
e.stopPropagation();
}
function onEditBlur() {
if (!committing && editing.value) commitEdit('none');
}
// ---- keyboard navigation -------------------------------------
function onKeydown(e: KeyboardEvent) {
if (editing.value) return;
const s = mine.value;
if (!s) return;
const { col, row } = s.focus;
const ext = e.shiftKey;
switch (e.key) {
case 'ArrowUp': e.preventDefault(); return select(col, row - 1, ext);
case 'ArrowDown': e.preventDefault(); return select(col, row + 1, ext);
case 'ArrowLeft': e.preventDefault(); return select(col - 1, row, ext);
case 'ArrowRight': e.preventDefault(); return select(col + 1, row, ext);
case 'Tab': e.preventDefault(); return select(col + (e.shiftKey ? -1 : 1), row);
case 'Enter': e.preventDefault(); return e.shiftKey ? select(col, row - 1) : beginEdit(col, row);
case 'F2': e.preventDefault(); return beginEdit(col, row);
case 'Backspace':
case 'Delete':
e.preventDefault();
return store.clearSelectionCells();
default:
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
beginEdit(col, row, e.key);
}
}
}
// ---- context menu -----------------------------------------
const menu = ref({ x: 0, y: 0, open: false });
function onContextMenu(e: MouseEvent) {
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 };
}
const menuItems = computed(() => [
{ 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() },
]);
// ---- 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 HEAD_W;
let w = HEAD_W;
for (let c = 0; c < t.cols; c++) w += colWidth(c);
return w;
});
onBeforeUnmount(() => {
onDragUp();
onResizeUp();
onRowResizeUp();
});
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">
<table class="grid" :style="{ width: totalWidth + 'px' }">
<colgroup>
<col :style="{ width: HEAD_W + 'px' }" />
<col v-for="c in table.cols" :key="'c' + (c - 1)" :style="{ width: colWidth(c - 1) + 'px' }" />
</colgroup>
<thead>
<tr :style="{ height: headerH + 'px' }">
<th class="corner" @click="selectAll" />
<th
v-for="c in table.cols"
:key="'h' + (c - 1)"
class="col-head"
: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="r in table.rows" :key="'r' + (r - 1)" :style="{ height: rowH(r - 1) + 'px' }">
<th
class="row-head"
:class="{ hot: rowHot(r - 1), 'frozen-row': r - 1 < frozenRows, 'freeze-edge-bottom': r - 1 === frozenRows - 1 }"
:style="rowHeadStyle(r - 1)"
@click="selectRow(r - 1, $event)"
>
{{ r }}
<span class="row-resize" @mousedown="onRowResizeDown($event, r - 1)" @click.stop />
</th>
<td
v-for="c in table.cols"
:key="(c - 1) + ',' + (r - 1)"
:data-c="c - 1"
:data-r="r - 1"
class="cell"
:class="{
'is-active': isActive(c - 1, r - 1),
'in-range': inSelection(c - 1, r - 1),
numeric: store.cellIsNumeric(tableId, c - 1, r - 1),
error: store.cellIsError(tableId, c - 1, r - 1),
frozen: (c - 1 < frozenCols) || (r - 1 < frozenRows),
'freeze-edge-right': c - 1 === frozenCols - 1,
'freeze-edge-bottom': r - 1 === frozenRows - 1,
}"
:style="cellStyle(c - 1, r - 1)"
@mousedown="onCellMouseDown($event, c - 1, r - 1)"
@dblclick="beginEdit(c - 1, r - 1)"
>
<input
v-if="editing && editing.col === c - 1 && editing.row === r - 1"
:ref="setEditRef"
v-model="editValue"
class="cell-input"
type="text"
spellcheck="false"
@keydown="onEditKeydown"
@blur="onEditBlur"
@mousedown.stop
/>
<template v-else>
{{ store.cellDisplay(tableId, c - 1, r - 1) }}
<span v-if="isActive(c - 1, r - 1)" class="handle" />
</template>
</td>
</tr>
</tbody>
</table>
</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 7px;
border-right: 1px solid var(--grid-line);
border-bottom: 1px solid var(--grid-line);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background: var(--surface);
vertical-align: middle;
cursor: cell;
position: relative;
}
.cell.numeric {
text-align: right;
}
.cell.error {
color: var(--danger);
text-align: right;
}
.cell.in-range {
background: var(--accent-weak);
}
.cell.is-active {
box-shadow: inset 0 0 0 2px var(--accent);
}
.handle {
position: absolute;
right: -3px;
bottom: -3px;
width: 7px;
height: 7px;
background: var(--accent);
border: 1px solid var(--surface);
border-radius: 2px;
z-index: 6;
}
.cell-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 2px solid var(--accent);
padding: 0 5px;
font: inherit;
color: var(--text);
background: var(--surface);
outline: none;
z-index: 7;
}
</style>