Files
numolus/app/frontend/components/FormulaBar.vue
T
wkhandClaude Opus 5.5 e14bada114 Add Rails backend: accounts via magic link, server documents, sharing
The app becomes a Rails 8 application; the Vue frontend moves to
app/frontend and is served through vite_rails.

- Without an account documents stay in the browser (IndexedDB). Sign-in
  is by magic link or the 6-digit code from the same mail; afterwards
  local documents can be uploaded into the account.
- Server documents (/d/<id>) with versioned saves and a single-editor
  lock per browser tab; others see them read-only and follow by polling.
- Sharing via link (works without an account) and via e-mail invitation,
  roles viewer/editor; the owner manages links and people.
- Store gains a read-only mode that blocks all content changes.
- Solid Queue, Cache and Cable share the one PostgreSQL database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:09:06 +02:00

379 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)) {
(document.activeElement as HTMLElement)?.blur();
} 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();
}
}
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();
(document.activeElement as HTMLElement)?.blur();
}
function onBlur() {
focused.value = false;
commit();
store.registerFormulaEditor(null);
autocomplete.close();
}
function onEscape() {
draft.value = store.activeCellInput.value;
(document.activeElement as HTMLElement)?.blur();
}
/** 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>