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