Files
numolus/app/frontend/components/FormulaBar.vue
T
wkh 0fca61eb65 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).
2026-10-24 00:38:01 +02:00

392 lines
12 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useDocument } from '../store/document';
import { cycleReferenceLocks, completeFormula } from '../engine';
import { useFormulaAutocomplete } from '../composables/useFormulaAutocomplete';
import { useKeyboardPointMode } from '../composables/useKeyboardPointMode';
import { useToasts } from '../composables/useToasts';
import FormulaSuggest from './FormulaSuggest.vue';
const store = useDocument();
const toast = useToasts();
const draft = ref('');
const focused = ref(false);
const fieldEl = ref<HTMLTextAreaElement | null>(null);
const autocomplete = useFormulaAutocomplete();
const keyboardPoint = useKeyboardPointMode();
// keep the field in sync with the active cell unless the user is editing it
watch(
() => store.activeCellInput.value,
(v) => {
if (!focused.value) draft.value = v;
},
{ immediate: true },
);
// ---- Name Box (top-left) ---------------------------------------------
// Doubles as (1) a live "where am I" display (e.g. "Umsatz::B2:E6") and (2)
// a jump/select field: type a cell/range/table/sheet/named-range name and
// press Enter -- see document.ts's `jumpToNameInput` for exactly what it
// accepts. Autocompletes against named ranges, table names, and sheet
// names (`nameBoxSuggestions`), same dropdown component as the formula
// function-name autocomplete.
const nameDraft = ref('');
const nameFocused = ref(false);
const nameActiveIndex = ref(0);
const nameEl = ref<HTMLInputElement | null>(null);
const nameSuggestions = computed(() =>
nameFocused.value ? store.nameBoxSuggestions(nameDraft.value) : [],
);
watch(
() => store.selectionRangeName.value,
(v) => {
if (!nameFocused.value) nameDraft.value = v;
},
{ immediate: true },
);
function onNameFocus() {
nameFocused.value = true;
nameActiveIndex.value = 0;
nextTick(() => nameEl.value?.select());
}
function onNameBlur() {
nameFocused.value = false;
nameDraft.value = store.selectionRangeName.value; // discard an unconfirmed edit
}
function onNameInput() {
nameActiveIndex.value = 0;
}
/** Tries to navigate to `text`; on success blurs back to the grid (so the
* Name Box promptly shows the new selection), on failure keeps the field
* focused/selected (so the user can see and immediately correct it) and
* reports the problem via a toast, same as other invalid-input feedback
* in this app. */
function jumpTo(text: string) {
if (store.jumpToNameInput(text)) {
backToGrid();
} else {
toast.error(`„${text}" ist kein gültiger Bezug oder Name.`);
nextTick(() => nameEl.value?.select());
}
}
function onNamePick(i: number) {
const item = nameSuggestions.value[i];
if (item) jumpTo(item.name);
}
function onNameKeydown(e: KeyboardEvent) {
const hasDropdown = nameSuggestions.value.length > 0;
if (hasDropdown && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
e.preventDefault();
const n = nameSuggestions.value.length;
nameActiveIndex.value = (nameActiveIndex.value + (e.key === 'ArrowDown' ? 1 : -1) + n) % n;
return;
}
if (hasDropdown && e.key === 'Tab') {
e.preventDefault();
nameDraft.value = nameSuggestions.value[nameActiveIndex.value].name;
return;
}
if (e.key === 'Enter') {
e.preventDefault();
jumpTo(nameDraft.value);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
nameDraft.value = store.selectionRangeName.value;
(document.activeElement as HTMLElement)?.blur();
}
}
/** Leave the bar and hand the keyboard back to the grid of the selected
* table, so the arrow keys move the active cell again (just blurring
* would leave nothing focused). */
function backToGrid() {
(document.activeElement as HTMLElement)?.blur();
const tableId = store.doc.selection?.tableId;
if (tableId) window.dispatchEvent(new CustomEvent('numolus:focus-grid', { detail: tableId }));
}
function commit() {
const sel = store.doc.selection;
// a forgotten closing ")" is completed rather than stored as an error; in
// the draft too, since the sync watch above skips changes made while the
// field still has focus (Enter commits before it blurs)
draft.value = completeFormula(draft.value);
if (sel) store.setCellInput(sel.tableId, sel.anchor.col, sel.anchor.row, draft.value);
}
/** Lets the store insert a reference into this field when the user clicks a
* grid cell mid-formula ("point mode") -- see store.pointClick/pointDrag. */
function registerAsFormulaEditor() {
const sel = store.doc.selection;
if (!sel) return;
store.registerFormulaEditor({
tableId: sel.tableId,
getState: () => ({
text: draft.value,
start: fieldEl.value?.selectionStart ?? draft.value.length,
end: fieldEl.value?.selectionEnd ?? draft.value.length,
}),
replace: (start, end, value) => {
draft.value = draft.value.slice(0, start) + value + draft.value.slice(end);
nextTick(() => {
fieldEl.value?.focus();
fieldEl.value?.setSelectionRange(start + value.length, start + value.length);
});
},
});
}
function onFocus() {
focused.value = true;
keyboardPoint.reset();
registerAsFormulaEditor();
}
function onEnter() {
commit();
backToGrid();
}
function onBlur() {
focused.value = false;
commit();
store.registerFormulaEditor(null);
autocomplete.close();
}
function onEscape() {
draft.value = store.activeCellInput.value;
backToGrid();
}
/** Actual typing (not a point-mode click inserting a reference programmatically)
* ends the "next click replaces the last reference" window. */
function onInput(e: Event) {
store.noteFormulaTyped();
keyboardPoint.reset();
const el = e.target as HTMLTextAreaElement;
autocomplete.update(draft.value, el.selectionStart ?? draft.value.length);
}
/** Accept the highlighted autocomplete suggestion (name + "(") via the same
* replace() mechanism point-mode uses. */
function applySuggestion(el: HTMLTextAreaElement) {
const caret = el.selectionStart ?? draft.value.length;
const rep = autocomplete.accept(draft.value, caret);
if (!rep) return false;
draft.value = draft.value.slice(0, rep.start) + rep.value + draft.value.slice(rep.end);
autocomplete.close();
nextTick(() => el.setSelectionRange(rep.start + rep.value.length, rep.start + rep.value.length));
return true;
}
/** A mouse click on a dropdown entry (see FormulaSuggest's `@mousedown.prevent`
* keeping focus in the textarea, so `fieldEl` is still the live editor). */
function onPickSuggestion(i: number) {
autocomplete.activeIndex.value = i;
const el = fieldEl.value;
if (el) applySuggestion(el);
}
/** Clicking inside the field to reposition the caret should also end that window. */
function onMousedown() {
store.noteFormulaTyped();
keyboardPoint.reset();
}
/** F4: cycle relative -> absolute -> row-locked -> col-locked (Excel/Numbers
* "Bezug sperren") -- for the reference at the caret, or every reference a
* real text selection overlaps at once. */
function onF4(el: HTMLTextAreaElement) {
const start = el.selectionStart ?? draft.value.length;
const end = el.selectionEnd ?? start;
const res = cycleReferenceLocks(draft.value, start, end);
if (!res) return;
draft.value = res.text;
nextTick(() => el.setSelectionRange(res.start, res.end));
}
function onKeydown(e: KeyboardEvent) {
const el = e.target as HTMLTextAreaElement;
// Function-name autocomplete takes priority over every other key below
// while its dropdown is open (see useFormulaAutocomplete.ts).
if (autocomplete.suggestions.value.length) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
autocomplete.move(e.key === 'ArrowDown' ? 1 : -1);
return;
}
if (e.key === 'Tab' || e.key === 'Enter') {
e.preventDefault();
if (applySuggestion(el)) return;
}
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
autocomplete.close();
return;
}
}
// Keyboard "point mode" -- see useKeyboardPointMode's own doc comment.
const sel = store.doc.selection;
if (sel) {
const caret = el.selectionStart ?? draft.value.length;
if (keyboardPoint.handleArrowKey(e, sel.tableId, sel.anchor, draft.value, caret)) {
e.preventDefault();
return;
}
}
// Alt+Enter (Windows/Excel-365-Mac) or Cmd+Enter (a macOS-safe fallback --
// Option is a compose key there, so `altKey` alone isn't fully reliable;
// `e.code` sidesteps `key` being altered by that composition).
const isEnter = e.key === 'Enter' || e.code === 'Enter' || e.code === 'NumpadEnter';
if (isEnter && (e.altKey || e.metaKey)) {
e.preventDefault();
const start = el.selectionStart ?? draft.value.length;
const end = el.selectionEnd ?? start;
draft.value = draft.value.slice(0, start) + '\n' + draft.value.slice(end);
nextTick(() => el.setSelectionRange(start + 1, start + 1));
return;
}
if (isEnter) {
e.preventDefault();
onEnter();
} else if (e.key === 'Escape') {
e.preventDefault();
onEscape();
} else if (e.key === 'F4') {
e.preventDefault();
onF4(el);
}
}
</script>
<template>
<div class="formula-bar">
<div class="name-box">
<input
ref="nameEl"
v-model="nameDraft"
class="cell-name"
spellcheck="false"
title="Name/Bezug eingeben und Enter drücken, um dorthin zu springen"
@focus="onNameFocus"
@blur="onNameBlur"
@keydown="onNameKeydown"
@input="onNameInput"
/>
<FormulaSuggest
v-if="nameSuggestions.length"
:items="nameSuggestions"
:active-index="nameActiveIndex"
@pick="onNamePick"
/>
</div>
<span class="fx">fx</span>
<div class="input-wrap">
<textarea
ref="fieldEl"
v-model="draft"
class="formula-input"
spellcheck="false"
rows="1"
:placeholder="store.ui.readOnly ? 'Nur Lesen' : 'Wert oder =Formel'"
:readonly="!!store.ui.readOnly"
title="Ziehen an der unteren Kante, um mehr Zeilen zu zeigen"
@focus="onFocus"
@blur="onBlur"
@keydown="onKeydown"
@input="onInput"
@mousedown="onMousedown"
/>
<FormulaSuggest
v-if="autocomplete.suggestions.value.length"
:items="autocomplete.suggestions.value"
:active-index="autocomplete.activeIndex.value"
@pick="onPickSuggestion"
/>
</div>
</div>
</template>
<style scoped>
.formula-bar {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 6px 12px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
/* holds the FormulaSuggest dropdown, positioned relative to this -- same
role as .input-wrap below, for the Name Box instead of the formula field */
.name-box {
position: relative;
min-width: 128px;
max-width: 128px;
}
.cell-name {
width: 100%;
height: 30px;
margin: 0;
border: 1px solid transparent;
border-radius: var(--radius-sm);
padding: 0 6px;
background: transparent;
font: inherit;
font-weight: 600;
font-size: 12px;
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cell-name:focus {
outline: none;
border-color: var(--accent);
background: var(--surface-2);
color: var(--text);
}
@media (max-width: 480px) {
.name-box {
min-width: 64px;
max-width: 64px;
}
.formula-bar {
gap: 6px;
padding: 6px 8px;
}
}
.fx {
margin-top: 6px;
font-style: italic;
color: var(--text-dim);
font-size: 13px;
}
/* holds the FormulaSuggest dropdown, positioned relative to this */
.input-wrap {
position: relative;
flex: 1;
}
/* a plain <textarea> gives a free, native mouse-draggable resize handle
(bottom-right corner) -- exactly the "erweiterbar/erhöhbar mit der Maus"
ask -- and, unlike the old <input>, actually wraps a long formula instead
of just scrolling it sideways, so growing the box has something to show. */
.formula-input {
width: 100%;
min-height: 30px;
max-height: 50vh;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 10px;
font: inherit;
font-variant-numeric: tabular-nums;
color: var(--text);
background: var(--surface-2);
resize: vertical;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
.formula-input:focus {
outline: none;
border-color: var(--accent);
background: var(--surface);
}
</style>