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:
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useDocument, type DocumentStore } from '../document';
|
||||
import { isError, toA1 } from '../../engine';
|
||||
import { serializeDocument, parseDocument } from '../../io/json';
|
||||
@@ -489,6 +490,16 @@ describe('cell formatting, number formats, row height', () => {
|
||||
expect(s.tableById(id)!.rowHeights).toBeUndefined();
|
||||
});
|
||||
|
||||
it('underline is a cell format like bold and survives a JSON round-trip', () => {
|
||||
const s = fresh();
|
||||
const id = tableId(s, 'Umsatz');
|
||||
s.setRangeFormat(id, { col: 0, row: 0 }, { col: 1, row: 1 }, { underline: true, italic: true });
|
||||
s.replaceDocument(parseDocument(serializeDocument(s.snapshot())));
|
||||
expect(s.cellFormat(id, 1, 1)).toEqual({ underline: true, italic: true });
|
||||
s.setRangeFormat(id, { col: 0, row: 0 }, { col: 1, row: 1 }, { underline: false });
|
||||
expect(s.cellFormat(id, 0, 0)).toEqual({ italic: true });
|
||||
});
|
||||
|
||||
it('formatting + row heights survive a JSON round-trip', () => {
|
||||
const s = fresh();
|
||||
const id = tableId(s, 'Umsatz');
|
||||
@@ -2397,6 +2408,27 @@ describe('formula "point mode" (click/drag a cell to insert a reference)', () =>
|
||||
expect(ed.text).toBe('=B2');
|
||||
});
|
||||
|
||||
it('pointRect outlines the picked cell/range until typing or closing the editor', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const ed = mockEditor(id, '=');
|
||||
s.registerFormulaEditor(ed.handle);
|
||||
expect(s.pointRect.value).toBeNull();
|
||||
s.pointClick(id, 1, 2);
|
||||
expect(s.pointRect.value).toEqual({ tableId: id, c0: 1, r0: 2, c1: 1, r1: 2 });
|
||||
s.pointDrag(id, 2, 3, 0, 1); // dragged up-left: normalised
|
||||
expect(s.pointRect.value).toEqual({ tableId: id, c0: 0, r0: 1, c1: 2, r1: 3 });
|
||||
s.noteFormulaTyped();
|
||||
expect(s.pointRect.value).toBeNull();
|
||||
s.pointClick(id, 0, 0);
|
||||
s.registerFormulaEditor(null);
|
||||
expect(s.pointRect.value).toBeNull();
|
||||
// no formula in the buffer -> nothing picked, nothing outlined
|
||||
s.registerFormulaEditor(mockEditor(id, 'text').handle);
|
||||
s.pointClick(id, 0, 0);
|
||||
expect(s.pointRect.value).toBeNull();
|
||||
});
|
||||
|
||||
it('typing between two clicks makes the second an insert, not a replace', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
@@ -3016,3 +3048,69 @@ describe('whole-column / whole-row references', () => {
|
||||
expect(val(s, name, 'A1')).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('references of the formula being edited (coloured, draggable)', () => {
|
||||
afterEach(() => useDocument().registerFormulaEditor(null));
|
||||
|
||||
/** Like mockEditor(), but its text is a ref -- as in the real editors,
|
||||
* which is what keeps store.formulaRefs live. */
|
||||
function reactiveEditor(tableId: string, initialText: string) {
|
||||
const text = ref(initialText);
|
||||
return {
|
||||
handle: {
|
||||
tableId,
|
||||
getState: () => ({ text: text.value, start: text.value.length, end: text.value.length }),
|
||||
replace: (s: number, e: number, value: string) => {
|
||||
text.value = text.value.slice(0, s) + value + text.value.slice(e);
|
||||
},
|
||||
},
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
it('lists each reference with its table, rectangle and colour, following the text', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const other = s.addTable(s.doc.activeSheetId);
|
||||
s.renameObject(other, 'Reisen');
|
||||
const ed = reactiveEditor(id, '=SUMME(B2:C3)+A1+Reisen!A2+B2:C3');
|
||||
s.registerFormulaEditor(ed.handle);
|
||||
const refs = s.formulaRefs.value;
|
||||
expect(refs.map((r) => [r.tableId, r.c0, r.r0, r.c1, r.r1])).toEqual([
|
||||
[id, 1, 1, 2, 2],
|
||||
[id, 0, 0, 0, 0],
|
||||
[other, 0, 1, 0, 1],
|
||||
[id, 1, 1, 2, 2],
|
||||
]);
|
||||
expect(refs[0].color).not.toBe(refs[1].color);
|
||||
expect(refs[3].color).toBe(refs[0].color); // same reference, same colour
|
||||
ed.text.value = '=D4';
|
||||
expect(s.formulaRefs.value.map((r) => [r.c0, r.r0])).toEqual([[3, 3]]);
|
||||
s.registerFormulaEditor(null);
|
||||
expect(s.formulaRefs.value).toEqual([]);
|
||||
});
|
||||
|
||||
it('whole columns are clamped to the table and marked as not draggable', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
s.registerFormulaEditor(reactiveEditor(id, '=SUMME(B:B)').handle);
|
||||
const [r] = s.formulaRefs.value;
|
||||
expect(r).toMatchObject({ c0: 1, r0: 0, c1: 1, r1: s.tableById(id)!.rows - 1, whole: true });
|
||||
expect(s.moveFormulaRef(0, 2, 0, 2, 3)).toBe(false);
|
||||
});
|
||||
|
||||
it('moveFormulaRef rewrites one reference, keeping qualifier and $ locks', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const other = s.addTable(s.doc.activeSheetId);
|
||||
s.renameObject(other, 'Reisen');
|
||||
const ed = reactiveEditor(id, '=SUMME($A$1:B2)*Reisen!C3');
|
||||
s.registerFormulaEditor(ed.handle);
|
||||
s.moveFormulaRef(0, 1, 1, 2, 3); // moved/resized
|
||||
expect(ed.text.value).toBe('=SUMME($B$2:C4)*Reisen!C3');
|
||||
s.moveFormulaRef(1, 0, 0, 1, 1); // a single cell grown into a range
|
||||
expect(ed.text.value).toBe('=SUMME($B$2:C4)*Reisen!A1:B2');
|
||||
s.moveFormulaRef(0, 3, 3, 3, 3); // shrunk to one cell
|
||||
expect(ed.text.value).toBe('=SUMME($D$4)*Reisen!A1:B2');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { dateToSerial, timeSerial } from '../../engine';
|
||||
import { formatValue } from '../format';
|
||||
import { formatValue, readableTextOn } from '../format';
|
||||
|
||||
describe('formatValue: date/time', () => {
|
||||
const day = dateToSerial(2026, 9, 2); // a Wednesday
|
||||
@@ -46,3 +46,23 @@ describe('formatValue: date/time', () => {
|
||||
expect(formatValue(day, 'plain', 'dateiso')).toBe('2026-09-02');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readableTextOn', () => {
|
||||
it('light text on dark fills, dark text on light fills', () => {
|
||||
expect(readableTextOn('#000000')).toBe('#f5f5f7');
|
||||
expect(readableTextOn('#000')).toBe('#f5f5f7');
|
||||
expect(readableTextOn('#1b1b1b')).toBe('#f5f5f7');
|
||||
expect(readableTextOn('#0057ff')).toBe('#f5f5f7');
|
||||
expect(readableTextOn('#ffffff')).toBe('#1d1d1f');
|
||||
expect(readableTextOn('#ffd400')).toBe('#1d1d1f');
|
||||
expect(readableTextOn('#FFF')).toBe('#1d1d1f');
|
||||
});
|
||||
|
||||
it('ignores what it cannot read, and mostly transparent fills', () => {
|
||||
expect(readableTextOn(undefined)).toBeNull();
|
||||
expect(readableTextOn('red')).toBeNull();
|
||||
expect(readableTextOn('#12345')).toBeNull();
|
||||
expect(readableTextOn('#00000020')).toBeNull();
|
||||
expect(readableTextOn('#000000cc')).toBe('#f5f5f7');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
parseA1,
|
||||
wholeLineAt,
|
||||
formatLinePart,
|
||||
formulaRefSpans,
|
||||
parseFormula,
|
||||
parseFunctionBlock,
|
||||
parseMacroBlock,
|
||||
@@ -206,6 +207,25 @@ interface ClipboardData {
|
||||
* the table the edited cell itself lives in (the "home" table for deciding
|
||||
* whether an inserted reference needs a `Name!` qualifier).
|
||||
*/
|
||||
/** Colours for the references of the formula being edited, in text order
|
||||
* (cycling), same idea as Excel: the n-th reference in the text and its
|
||||
* outline in the grid share a colour. */
|
||||
const REF_COLORS = ['#2f6fde', '#d6453d', '#8e44ad', '#1e9e5a', '#c77c02', '#0b9bb5', '#d6338a'];
|
||||
|
||||
/** One reference of the formula being edited, as the grid outlines it. */
|
||||
export interface FormulaRefHighlight {
|
||||
/** position among the formula's references -- what `moveFormulaRef` takes */
|
||||
index: number;
|
||||
tableId: string;
|
||||
c0: number;
|
||||
r0: number;
|
||||
c1: number;
|
||||
r1: number;
|
||||
color: string;
|
||||
/** whole column/row: outlined, but not draggable */
|
||||
whole: boolean;
|
||||
}
|
||||
|
||||
interface FormulaEditorHandle {
|
||||
tableId: string;
|
||||
getState(): { text: string; start: number; end: number };
|
||||
@@ -670,6 +690,12 @@ function createStore() {
|
||||
* until the user types something (a plain assignment, not a ref(), so it
|
||||
* never itself triggers reactivity/undo history). */
|
||||
let pointSpan: { start: number; end: number } | null = null;
|
||||
/** The cell/range the last point-mode click, drag or arrow key picked, so
|
||||
* the grid can outline it while it is still the reference being chosen
|
||||
* (otherwise picking with the keyboard happens completely blind). Lives
|
||||
* exactly as long as `pointSpan`: typing, a caret click or closing the
|
||||
* editor ends it. View state only, never synced or saved. */
|
||||
const pointRect = ref<{ tableId: string; c0: number; r0: number; c1: number; r1: number } | null>(null);
|
||||
const clampZoom = (z: number) => Math.min(2, Math.max(0.4, Math.round(z * 20) / 20));
|
||||
|
||||
function syncWorkbook() {
|
||||
@@ -2335,7 +2361,7 @@ function createStore() {
|
||||
markDirty();
|
||||
}
|
||||
|
||||
// ---- cell formatting (alignment, number format, colours, bold/italic) ---
|
||||
// ---- cell formatting (alignment, number format, colours, bold/italic/underline) ---
|
||||
const fmtKey = (c: number, r: number) => `${c},${r}`;
|
||||
function cellFormat(tableId: string, col: number, row: number): CellFormat {
|
||||
return tableById(tableId)?.formats?.[fmtKey(col, row)] ?? {};
|
||||
@@ -2719,6 +2745,7 @@ function createStore() {
|
||||
function registerFormulaEditor(handle: FormulaEditorHandle | null) {
|
||||
formulaEditor.value = handle;
|
||||
pointSpan = null;
|
||||
pointRect.value = null;
|
||||
}
|
||||
/** The user typed (as opposed to a click inserting a reference for them):
|
||||
* the next click should insert a fresh reference, not replace the last
|
||||
@@ -2728,6 +2755,7 @@ function createStore() {
|
||||
* and before picking the next). */
|
||||
function noteFormulaTyped() {
|
||||
pointSpan = null;
|
||||
pointRect.value = null;
|
||||
}
|
||||
/** True while the registered editor's buffer is a formula -- point mode
|
||||
* (click-to-insert-reference) only applies then. */
|
||||
@@ -2893,21 +2921,97 @@ function createStore() {
|
||||
pointSpan = { start: span.start, end: span.start + text.length };
|
||||
return true;
|
||||
}
|
||||
/** The table a reference in the edited formula points at: its own table
|
||||
* when unqualified, else by name -- preferring the editor's sheet when the
|
||||
* name exists on several, as the engine does (Workbook.resolveTableName). */
|
||||
function refTableId(homeTableId: string, qualifier: string | null, sheetQualifier?: string): string | null {
|
||||
if (qualifier === null) return homeTableId;
|
||||
const needle = qualifier.trim().toLowerCase();
|
||||
const sheetNeedle = sheetQualifier?.trim().toLowerCase();
|
||||
const candidates = allTables(doc).filter((t) => {
|
||||
if (t.name.trim().toLowerCase() !== needle) return false;
|
||||
if (sheetNeedle === undefined) return true;
|
||||
const sid = sheetIdOfObject(t.id);
|
||||
return (sid && sheetById(sid)?.name.trim().toLowerCase()) === sheetNeedle;
|
||||
});
|
||||
if (candidates.length <= 1) return candidates[0]?.id ?? null;
|
||||
const homeSheet = sheetIdOfObject(homeTableId);
|
||||
return (candidates.find((t) => sheetIdOfObject(t.id) === homeSheet) ?? candidates[0]).id;
|
||||
}
|
||||
/** Every cell/range reference of the formula being edited (in-cell editor
|
||||
* or formula bar), resolved to its table and clamped to it, so each grid
|
||||
* can outline the ones that point into it. Reactive on the editor's text:
|
||||
* both editors' `getState()` read their own text ref. */
|
||||
const formulaRefs = computed<FormulaRefHighlight[]>(() => {
|
||||
const ed = formulaEditor.value;
|
||||
if (!ed) return [];
|
||||
const colorOf = new Map<string, string>();
|
||||
const out: FormulaRefHighlight[] = [];
|
||||
formulaRefSpans(ed.getState().text).forEach((span, index) => {
|
||||
const tableId = refTableId(ed.tableId, span.qualifier, span.sheetQualifier);
|
||||
const t = tableId ? tableById(tableId) : undefined;
|
||||
if (!t) return;
|
||||
const c0 = Math.min(span.a.col, span.b.col);
|
||||
const r0 = Math.min(span.a.row, span.b.row);
|
||||
if (c0 >= t.cols || r0 >= t.rows) return; // entirely outside the table
|
||||
const rect = {
|
||||
c0,
|
||||
r0,
|
||||
c1: Math.min(Math.max(span.a.col, span.b.col), t.cols - 1),
|
||||
r1: Math.min(Math.max(span.a.row, span.b.row), t.rows - 1),
|
||||
};
|
||||
// the same reference written twice keeps one colour (as in Excel)
|
||||
const key = `${t.id}|${rect.c0},${rect.r0},${rect.c1},${rect.r1}`;
|
||||
if (!colorOf.has(key)) colorOf.set(key, REF_COLORS[colorOf.size % REF_COLORS.length]);
|
||||
out.push({ index, tableId: t.id, ...rect, color: colorOf.get(key)!, whole: !!span.whole });
|
||||
});
|
||||
return out;
|
||||
});
|
||||
/** Rewrite the `index`-th reference of the formula being edited to cover
|
||||
* (c0,r0)-(c1,r1) instead -- dragging a reference outline's edge (move)
|
||||
* or corner (resize) in the grid, Excel-style. Its qualifier and `$`
|
||||
* locks stay as written; a range shrunk to one cell becomes a plain cell
|
||||
* reference. Whole-column/-row references are left alone. */
|
||||
function moveFormulaRef(index: number, c0: number, r0: number, c1: number, r1: number): boolean {
|
||||
const ed = formulaEditor.value;
|
||||
if (!ed) return false;
|
||||
const text = ed.getState().text;
|
||||
const span = formulaRefSpans(text)[index];
|
||||
if (!span || span.whole) return false;
|
||||
const single = span.a.col === span.b.col && span.a.row === span.b.row;
|
||||
const lockB = single ? span.a : span.b;
|
||||
const lo = toA1(Math.min(c0, c1), Math.min(r0, r1), span.a.colAbs, span.a.rowAbs);
|
||||
const hi = toA1(Math.max(c0, c1), Math.max(r0, r1), lockB.colAbs, lockB.rowAbs);
|
||||
const body = c0 === c1 && r0 === r1 ? lo : `${lo}:${hi}`;
|
||||
const next = text.slice(span.start, span.refStart) + body;
|
||||
if (next === text.slice(span.start, span.end)) return true;
|
||||
ed.replace(span.start, span.end, next);
|
||||
// the text around the caret moved: a following grid click must insert a
|
||||
// fresh reference, not overwrite a span that no longer exists
|
||||
pointSpan = null;
|
||||
pointRect.value = null;
|
||||
return true;
|
||||
}
|
||||
/** A plain click on (tableId, col, row) while editing a formula: insert or
|
||||
* (immediately after another click/drag, before any typing) replace a
|
||||
* reference at the caret. Returns false when there's no formula editor
|
||||
* active, so the caller can fall back to its normal click handling. */
|
||||
function pointClick(tableId: string, col: number, row: number): boolean {
|
||||
return applyPointRef(tableId, toA1(col, row));
|
||||
if (!applyPointRef(tableId, toA1(col, row))) return false;
|
||||
pointRect.value = { tableId, c0: col, r0: row, c1: col, r1: row };
|
||||
return true;
|
||||
}
|
||||
/** A drag from (c0,r0) to (c1,r1) on tableId while editing a formula:
|
||||
* insert/replace a *range* reference, meant to be called live as the drag
|
||||
* updates (each call replaces the same span, growing/shrinking the range
|
||||
* as the user drags), not just once at the end. */
|
||||
function pointDrag(tableId: string, c0: number, r0: number, c1: number, r1: number): boolean {
|
||||
const a = toA1(Math.min(c0, c1), Math.min(r0, r1));
|
||||
const b = toA1(Math.max(c0, c1), Math.max(r0, r1));
|
||||
return applyPointRef(tableId, a === b ? a : `${a}:${b}`);
|
||||
const rect = { tableId, c0: Math.min(c0, c1), r0: Math.min(r0, r1), c1: Math.max(c0, c1), r1: Math.max(r0, r1) };
|
||||
const a = toA1(rect.c0, rect.r0);
|
||||
const b = toA1(rect.c1, rect.r1);
|
||||
if (!applyPointRef(tableId, a === b ? a : `${a}:${b}`)) return false;
|
||||
pointRect.value = rect;
|
||||
return true;
|
||||
}
|
||||
|
||||
// The "active cell" is always the anchor -- the first cell of the current
|
||||
@@ -3224,6 +3328,9 @@ function createStore() {
|
||||
jumpToNameInput,
|
||||
pointClick,
|
||||
pointDrag,
|
||||
pointRect,
|
||||
formulaRefs,
|
||||
moveFormulaRef,
|
||||
// document
|
||||
replaceDocument,
|
||||
newDocument,
|
||||
|
||||
@@ -165,3 +165,35 @@ export function rawString(v: CellValue): string {
|
||||
export function isNumeric(v: CellValue): boolean {
|
||||
return typeof v === 'number' && Number.isFinite(v);
|
||||
}
|
||||
|
||||
// ---- readable text on a fill ---------------------------------------------
|
||||
|
||||
const DARK_TEXT = '#1d1d1f';
|
||||
const LIGHT_TEXT = '#f5f5f7';
|
||||
|
||||
/** sRGB channel (0–255) → linear light, per WCAG 2.x */
|
||||
const linear = (c: number) => {
|
||||
const s = c / 255;
|
||||
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
|
||||
/**
|
||||
* Text colour that stays readable on the fill `bg` (#rgb, #rgba, #rrggbb,
|
||||
* #rrggbbaa): dark or light, whichever contrasts more (WCAG luminance).
|
||||
* Used when a cell or table has a fill but no text colour of its own -- the
|
||||
* theme's text colour would otherwise land on it regardless (black text on
|
||||
* a black fill). `null` when `bg` can't be read or is mostly transparent
|
||||
* (the theme's surface shows through, its text colour already fits).
|
||||
*/
|
||||
export function readableTextOn(bg: string | undefined | null): string | null {
|
||||
const m = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec((bg ?? '').trim());
|
||||
if (!m) return null;
|
||||
let hex = m[1];
|
||||
if (hex.length <= 4) hex = [...hex].map((ch) => ch + ch).join('');
|
||||
const [r, g, b] = [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
||||
const alpha = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
|
||||
if (alpha < 0.5) return null;
|
||||
const lum = 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
|
||||
// contrast with black vs. with white: (L + 0.05) / 0.05 vs 1.05 / (L + 0.05)
|
||||
return (lum + 0.05) / 0.05 >= 1.05 / (lum + 0.05) ? DARK_TEXT : LIGHT_TEXT;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface CellFormat {
|
||||
bg?: string;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
}
|
||||
|
||||
/** How a table object is sized on the canvas. */
|
||||
|
||||
Reference in New Issue
Block a user