- Enter/Escape in the formula bar (and a jump via the Name Box) hand the focus back to the table of the selection, so the arrow keys move the active cell again instead of doing nothing. - The in-cell editor takes the cell's own text/fill colour and bold/ italic, so a light-on-dark cell no longer turns into dark text on a dark fill while editing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
388 lines
12 KiB
Vue
388 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import { computed, nextTick, ref, watch } from 'vue';
|
|
import { useDocument } from '../store/document';
|
|
import { cycleReferenceLocks } 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;
|
|
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>
|