- 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).
392 lines
12 KiB
Vue
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>
|