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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user