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>
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user