- init
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user