Add in-app help, formula reference outlines, underline and auto-closing

- Help dialog (?, F1, Datei > Hilfe): user topics, all shortcuts and the
  formula functions with one search over everything. Topics and shortcuts
  are plain data in data/help.ts; the function list comes from
  codeReference.ts so new built-ins appear by themselves.
- While a formula is edited, the grid outlines each referenced cell/range
  in the colour of its position in the text, and the outline can be
  dragged to move or resize the reference, as in Excel. Point-mode picks
  (click, drag, arrow keys) are outlined too, so keyboard picking is no
  longer blind.
- Underline as a cell format, plus Mod+Shift+B/I/U toggles that switch on
  unless every selected cell already has the format.
- A formula committed with open parentheses or an open text literal gets
  the missing closers appended; anything that still doesn't parse is left
  alone so the usual error shows instead of a guess.
- Cells and tables with a fill but no text colour get dark or light text
  by WCAG contrast instead of the theme colour (no black on black).
This commit is contained in:
wkh
2026-10-24 00:38:01 +02:00
parent 0368e96e0c
commit 0fca61eb65
19 changed files with 1420 additions and 16 deletions
+220 -4
View File
@@ -10,8 +10,8 @@
* 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 { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { numberToCol, cycleReferenceLocks, completeFormula } from '../engine';
import { useDocument } from '../store/document';
import { useLibrary } from '../store/library';
import { DEFAULT_ROW_H, type SelectionState } from '../store/types';
@@ -116,6 +116,7 @@ function formatStyle(c: number, r: number): Record<string, string> {
if (f.bg) s.background = f.bg;
if (f.bold) s.fontWeight = '600';
if (f.italic) s.fontStyle = 'italic';
if (f.underline) s.textDecoration = 'underline';
return s;
}
@@ -306,6 +307,21 @@ const clipMarqueeStyle = computed(() => {
height: rowTop(k.r1 + 1) - top + 'px',
};
});
/** The cell/range just picked for the formula being edited (point mode, by
* mouse or arrow keys) -- the grid selection itself stays on the edited
* cell meanwhile, so without this the pick would be invisible. */
const pointRectStyle = computed(() => {
const k = store.pointRect.value;
if (!k || k.tableId !== props.tableId) return null;
const left = colLeft(k.c0);
const top = rowTop(k.r0);
return {
left: left + 'px',
top: top + 'px',
width: colLeft(k.c1 + 1) - left + 'px',
height: rowTop(k.r1 + 1) - top + 'px',
};
});
/** The copy/cut selection's *own* first cell, called out inside the marquee
* even once the live selection has moved on to pick a paste destination
* (which is when `is-active` no longer sits there). */
@@ -643,6 +659,81 @@ function onPointDragUp() {
window.removeEventListener('blur', onPointDragUp);
}
// ---- references of the formula being edited (coloured, draggable) -------
/** The edited formula's references that point into this table, outlined in
* their colour. Dragging an outline's edge moves the reference, dragging a
* corner resizes it -- the formula text follows live (store.moveFormulaRef),
* same as Excel. */
const refHighlights = computed(() =>
store.formulaRefs.value
.filter((h) => h.tableId === props.tableId)
.map((h) => {
const left = colLeft(h.c0);
const top = rowTop(h.r0);
return {
...h,
style: {
left: left + 'px',
top: top + 'px',
width: colLeft(h.c1 + 1) - left + 'px',
height: rowTop(h.r1 + 1) - top + 'px',
'--ref': h.color,
},
};
}),
);
type RefCorner = 'nw' | 'ne' | 'sw' | 'se';
let refDrag: {
index: number;
orig: { c0: number; r0: number; c1: number; r1: number };
/** null = move by the edge, else the corner being dragged */
corner: RefCorner | null;
start: { c: number; r: number };
} | null = null;
function onRefHandleDown(e: MouseEvent, h: { index: number; c0: number; r0: number; c1: number; r1: number }, corner: RefCorner | null) {
if (e.button !== 0) return;
e.preventDefault(); // keep focus (and the caret) in the formula editor
e.stopPropagation(); // no cell/canvas-object mousedown underneath
const start = cellFromPoint(e.clientX, e.clientY);
if (!start) return;
refDrag = { index: h.index, orig: { c0: h.c0, r0: h.r0, c1: h.c1, r1: h.r1 }, corner, start };
document.body.style.userSelect = 'none';
window.addEventListener('mousemove', onRefDragMove, true);
window.addEventListener('mouseup', onRefDragUp, true);
window.addEventListener('blur', onRefDragUp);
}
function onRefDragMove(e: MouseEvent) {
const d = refDrag;
const t = table.value;
if (!d || !t) return;
if (e.buttons === 0) {
onRefDragUp(); // a mouseup we never saw -- self-heal a stuck drag
return;
}
const hit = cellFromPoint(e.clientX, e.clientY);
if (!hit) return;
const o = d.orig;
if (!d.corner) {
// shift the whole rectangle, but never past the table's edges
const dc = Math.max(-o.c0, Math.min(t.cols - 1 - o.c1, hit.c - d.start.c));
const dr = Math.max(-o.r0, Math.min(t.rows - 1 - o.r1, hit.r - d.start.r));
store.moveFormulaRef(d.index, o.c0 + dc, o.r0 + dr, o.c1 + dc, o.r1 + dr);
return;
}
// the opposite corner stays put; the dragged one follows the pointer
const fixedC = d.corner === 'nw' || d.corner === 'sw' ? o.c1 : o.c0;
const fixedR = d.corner === 'nw' || d.corner === 'ne' ? o.r1 : o.r0;
store.moveFormulaRef(d.index, fixedC, fixedR, hit.c, hit.r);
}
function onRefDragUp() {
if (!refDrag) return;
refDrag = null;
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onRefDragMove, true);
window.removeEventListener('mouseup', onRefDragUp, true);
window.removeEventListener('blur', onRefDragUp);
}
// ---- fill handle (drag to copy/fill, Excel/Numbers-style) ---------------
/** Small square at the selection's bottom-right corner; dragging it repeats
* the selection's cells (values, formulas -- reference-adjusted like a
@@ -879,7 +970,7 @@ const editStyle = computed(() => {
const s = editing.value;
if (!s) return {};
// look like the cell being edited: its own text/fill colour (else the
// table's), bold/italic -- a white-on-dark cell must not turn into dark
// table's), bold/italic/underline -- a white-on-dark cell must not turn into dark
// text on a dark fill while editing
const f = store.cellFormat(props.tableId, s.col, s.row);
const style: Record<string, string> = {
@@ -892,6 +983,7 @@ const editStyle = computed(() => {
if (f.bg) style['--surface'] = f.bg;
if (f.bold) style.fontWeight = '600';
if (f.italic) style.fontStyle = 'italic';
if (f.underline) style.textDecoration = 'underline';
return style;
});
const editMirrorStyle = computed(() => {
@@ -965,7 +1057,8 @@ function commitEdit() {
if (!editing.value) return;
committing = true;
const { col, row } = editing.value;
store.setCellInput(props.tableId, col, row, editValue.value);
// a forgotten closing ")" is completed rather than stored as an error
store.setCellInput(props.tableId, col, row, completeFormula(editValue.value));
editing.value = null;
store.registerFormulaEditor(null);
autocomplete.close();
@@ -1126,6 +1219,17 @@ function onKeydown(e: KeyboardEvent) {
if (mod) {
const k = e.key.toLowerCase();
// Ctrl/Cmd+Shift+B (or F, "fett") / I / U: toggle bold / italic /
// underline for the whole selection -- on when not every selected cell
// has it yet, else off, same as the toggle buttons in the side panel.
// With Shift, so plain Cmd+B/I/U stay free for the browser/OS.
const toggles: Record<string, 'bold' | 'italic' | 'underline'> = { b: 'bold', f: 'bold', i: 'italic', u: 'underline' };
const toggle = e.shiftKey ? toggles[k] : undefined;
if (toggle) {
e.preventDefault();
e.stopPropagation();
return store.applySelectionFormat({ [toggle]: !store.selectionFormat.value[toggle] });
}
if (k === 'a') {
e.preventDefault();
e.stopPropagation();
@@ -1347,10 +1451,25 @@ function onFocusGridRequest(e: Event) {
}
window.addEventListener('numolus:focus-grid', onFocusGridRequest);
// After a reload (or opening a document / switching sheets) the restored
// selection is visible but nothing had the keyboard, so arrow keys and typing
// went nowhere until the user clicked. Take it whenever the selection sits in
// this table and the keyboard is otherwise unclaimed -- never away from an
// input, the formula bar or another table someone is working in.
function claimIdleKeyboard() {
if (!mine.value || !root.value) return;
const active = document.activeElement;
if (active && active !== document.body) return;
root.value.focus({ preventScroll: true });
}
onMounted(claimIdleKeyboard);
watch(() => mine.value?.tableId, claimIdleKeyboard, { flush: 'post' });
onBeforeUnmount(() => {
window.removeEventListener('numolus:focus-grid', onFocusGridRequest);
onDragUp();
onPointDragUp();
onRefDragUp();
onFillDragUp();
onResizeUp();
onRowResizeUp();
@@ -1456,6 +1575,25 @@ defineExpose({
</div>
<div v-if="clipMarqueeStyle" class="clip-marquee" :style="clipMarqueeStyle" />
<div v-if="clipAnchorStyle" class="clip-anchor" :style="clipAnchorStyle" />
<div v-for="h in refHighlights" :key="'ref' + h.index" class="ref-hl" :style="h.style">
<template v-if="!h.whole">
<span
v-for="edge in ['top', 'right', 'bottom', 'left']"
:key="edge"
class="ref-edge"
:class="'ref-edge-' + edge"
@mousedown="onRefHandleDown($event, h, null)"
/>
<span
v-for="corner in (['nw', 'ne', 'sw', 'se'] as const)"
:key="corner"
class="ref-corner"
:class="'ref-corner-' + corner"
@mousedown="onRefHandleDown($event, h, corner)"
/>
</template>
</div>
<div v-if="pointRectStyle" class="point-rect" :style="pointRectStyle" />
<div v-if="fillPreviewStyle" class="fill-preview" :style="fillPreviewStyle" />
<div
v-if="fillHandleStyle"
@@ -1666,6 +1804,84 @@ defineExpose({
box-shadow: inset 0 0 0 2px var(--accent);
z-index: 8;
}
/* the reference being picked for a formula (point mode) */
.point-rect {
position: absolute;
pointer-events: none;
border: 2px dashed var(--accent);
background: var(--accent-weak);
box-sizing: border-box;
z-index: 8;
}
/* a reference of the formula being edited, in its colour (Excel-style) */
.ref-hl {
position: absolute;
pointer-events: none; /* the inside stays clickable for picking cells */
border: 2px solid var(--ref);
background: color-mix(in srgb, var(--ref) 10%, transparent);
box-sizing: border-box;
z-index: 9;
}
/* the edges move the reference, the corners resize it */
.ref-edge {
position: absolute;
pointer-events: auto;
cursor: move;
}
.ref-edge-top,
.ref-edge-bottom {
left: 0;
right: 0;
height: 6px;
}
.ref-edge-top {
top: -4px;
}
.ref-edge-bottom {
bottom: -4px;
}
.ref-edge-left,
.ref-edge-right {
top: 0;
bottom: 0;
width: 6px;
}
.ref-edge-left {
left: -4px;
}
.ref-edge-right {
right: -4px;
}
.ref-corner {
position: absolute;
pointer-events: auto;
width: 7px;
height: 7px;
background: var(--ref);
border: 1px solid var(--surface);
border-radius: 1px;
z-index: 1;
}
.ref-corner-nw {
left: -5px;
top: -5px;
cursor: nwse-resize;
}
.ref-corner-ne {
right: -5px;
top: -5px;
cursor: nesw-resize;
}
.ref-corner-sw {
left: -5px;
bottom: -5px;
cursor: nesw-resize;
}
.ref-corner-se {
right: -5px;
bottom: -5px;
cursor: nwse-resize;
}
/* small square at the selection's bottom-right corner -- drag to fill/copy */
.fill-handle {
position: absolute;