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,401 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Draggable / resizable wrapper for a table, chart, text or button object on
|
||||
* the sheet canvas.
|
||||
*
|
||||
* - the header strip and the frame border move the object;
|
||||
* - the SE handle resizes a chart/text freely; for a table, what it does
|
||||
* depends on the view mode (see `resizeKind` below):
|
||||
* - "Automatisch" (`mode: 'auto'`, size always == content): dragging
|
||||
* drives the row/col *count* directly -- growing adds empty rows/
|
||||
* cols, shrinking removes them, never past real content;
|
||||
* - "Feste Größe" (`mode: 'fixed'`, an explicit scrollable viewport):
|
||||
* dragging only resizes that pixel viewport, independent of row/col
|
||||
* count -- the original behaviour, since a fixed-size table's whole
|
||||
* point is a box smaller than its (possibly much larger) content,
|
||||
* scrolling internally.
|
||||
* - name is editable via double-click, delete via the × button.
|
||||
*
|
||||
* All pointer deltas are divided by `zoom` so dragging tracks the cursor at any
|
||||
* zoom level. Moving and free/viewport resizing both snap to the object's own
|
||||
* sheet's grid spacing (`Sheet.gridSize`, "Raster" in the Settings panel) --
|
||||
* an "Automatisch" table's resize instead already snaps to row/col
|
||||
* boundaries by construction, so it isn't additionally pixel-snapped.
|
||||
*/
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import type { SheetObject, TableObject } from '../store/types';
|
||||
import { DEFAULT_ROW_H, DEFAULT_GRID_SIZE } from '../store/types';
|
||||
import { useDocument } from '../store/document';
|
||||
import TableGrid from './TableGrid.vue';
|
||||
import ChartView from './ChartView.vue';
|
||||
import TextBox from './TextBox.vue';
|
||||
import ButtonView from './ButtonView.vue';
|
||||
|
||||
const props = defineProps<{ object: SheetObject; selected: boolean; zoom: number }>();
|
||||
const emit = defineEmits<{ (e: 'select'): void }>();
|
||||
|
||||
const store = useDocument();
|
||||
|
||||
const renaming = ref(false);
|
||||
const nameDraft = ref('');
|
||||
const nameInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
function startRename() {
|
||||
renaming.value = true;
|
||||
nameDraft.value = props.object.name;
|
||||
nextTick(() => nameInput.value?.select());
|
||||
}
|
||||
function commitRename() {
|
||||
store.renameObject(props.object.id, nameDraft.value);
|
||||
renaming.value = false;
|
||||
}
|
||||
|
||||
// ---- move/resize snap-to-grid --------------------------------------
|
||||
/** This object's own sheet's grid spacing -- also the snap increment while
|
||||
* dragging to move or resize it (SettingsPanel's "Raster" field). */
|
||||
const gridSize = computed(() => {
|
||||
const sheetId = store.sheetIdOfObject(props.object.id);
|
||||
const sheet = sheetId ? store.sheetById(sheetId) : undefined;
|
||||
return sheet?.gridSize ?? DEFAULT_GRID_SIZE;
|
||||
});
|
||||
const snap = (v: number) => Math.round(v / gridSize.value) * gridSize.value;
|
||||
|
||||
// ---- drag to move -------------------------------------------------
|
||||
let drag: { px: number; py: number; ox: number; oy: number } | null = null;
|
||||
function onMoveDown(e: MouseEvent) {
|
||||
if (e.button !== 0) return;
|
||||
emit('select');
|
||||
drag = { px: e.clientX, py: e.clientY, ox: props.object.x, oy: props.object.y };
|
||||
document.body.style.userSelect = 'none';
|
||||
window.addEventListener('mousemove', onMoveMove);
|
||||
window.addEventListener('mouseup', onMoveUp);
|
||||
}
|
||||
function onMoveMove(e: MouseEvent) {
|
||||
if (!drag) return;
|
||||
const dx = (e.clientX - drag.px) / props.zoom;
|
||||
const dy = (e.clientY - drag.py) / props.zoom;
|
||||
store.moveObject(props.object.id, snap(drag.ox + dx), snap(drag.oy + dy));
|
||||
}
|
||||
function onMoveUp() {
|
||||
drag = null;
|
||||
document.body.style.userSelect = '';
|
||||
window.removeEventListener('mousemove', onMoveMove);
|
||||
window.removeEventListener('mouseup', onMoveUp);
|
||||
}
|
||||
|
||||
// ---- SE resize --------------------------------------------------
|
||||
let rs: { px: number; py: number; w: number; h: number } | null = null;
|
||||
/** Ghost outline shown *only* while dragging an "Automatisch" table's
|
||||
* handle (see onResizeMove) -- the real row/col-count change is expensive
|
||||
* (a full syncWorkbook() per call) so it commits once, on release; this is
|
||||
* purely a lightweight visual preview in the meantime, touching neither
|
||||
* the store nor the table's actual DOM. */
|
||||
const previewSize = ref<{ w: number; h: number } | null>(null);
|
||||
|
||||
// An "Automatisch" table's resize handle drags the row/col *count* directly
|
||||
// -- these mirror TableGrid.vue's own pixel model (HEAD_W/DEFAULT_W are
|
||||
// duplicated there too).
|
||||
const HEAD_W = 44;
|
||||
const DEFAULT_W = 104;
|
||||
|
||||
/** How many columns fit in `targetPx`, at least 1. */
|
||||
function colsForWidth(t: TableObject, targetPx: number): number {
|
||||
let w = HEAD_W;
|
||||
let c = 0;
|
||||
while (true) {
|
||||
const next = w + (t.colWidths[c] ?? DEFAULT_W);
|
||||
if (next > targetPx && c > 0) break;
|
||||
w = next;
|
||||
c++;
|
||||
}
|
||||
return Math.max(1, c);
|
||||
}
|
||||
/** How many rows fit in `targetPx`, at least 1. */
|
||||
function rowsForHeight(t: TableObject, targetPx: number): number {
|
||||
let h = t.rowHeight ?? DEFAULT_ROW_H;
|
||||
let r = 0;
|
||||
while (true) {
|
||||
const next = h + store.rowHeightOf(t.id, r);
|
||||
if (next > targetPx && r > 0) break;
|
||||
h = next;
|
||||
r++;
|
||||
}
|
||||
return Math.max(1, r);
|
||||
}
|
||||
/** Pixel width/height that exactly fits `t`'s *current* rows/cols -- an
|
||||
* "Automatisch" table has no stored viewport size of its own (it always
|
||||
* renders at its natural content size), so this is the reference point a
|
||||
* resize drag starts from; no explicit "resync" is needed afterwards
|
||||
* either, since the DOM already re-renders at the new natural size the
|
||||
* moment `setTableRows`/`setTableCols` changes `rows`/`cols`. */
|
||||
function fittingSize(t: TableObject): { width: number; height: number } {
|
||||
let width = HEAD_W;
|
||||
for (let c = 0; c < t.cols; c++) width += t.colWidths[c] ?? DEFAULT_W;
|
||||
let height = t.rowHeight ?? DEFAULT_ROW_H;
|
||||
for (let r = 0; r < t.rows; r++) height += store.rowHeightOf(t.id, r);
|
||||
return { width, height };
|
||||
}
|
||||
/** The last row/col index holding a real (non-empty) cell, or -1 -- a
|
||||
* row/col-count shrink must never go below this, or it would silently
|
||||
* delete data. `t.cells` never stores an empty-string entry (an emptied
|
||||
* cell removes its key, see setCellInput), so every present key is real. */
|
||||
function lastNonEmptyRow(t: TableObject): number {
|
||||
let max = -1;
|
||||
for (const k of Object.keys(t.cells)) max = Math.max(max, Number(k.split(',')[1]));
|
||||
return max;
|
||||
}
|
||||
function lastNonEmptyCol(t: TableObject): number {
|
||||
let max = -1;
|
||||
for (const k of Object.keys(t.cells)) max = Math.max(max, Number(k.split(',')[0]));
|
||||
return max;
|
||||
}
|
||||
|
||||
/** How the SE handle behaves for this object -- see the module doc for the
|
||||
* "Automatisch" vs. "Feste Größe" split. */
|
||||
const resizeKind = computed<'free' | 'viewport' | 'grid-size'>(() => {
|
||||
const o = props.object;
|
||||
if (o.kind === 'chart' || o.kind === 'text' || o.kind === 'button') return 'free';
|
||||
return o.view.mode === 'fixed' ? 'viewport' : 'grid-size';
|
||||
});
|
||||
|
||||
function onResizeDown(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
emit('select');
|
||||
const o = props.object;
|
||||
let w: number;
|
||||
let h: number;
|
||||
if (o.kind === 'chart' || o.kind === 'text' || o.kind === 'button') {
|
||||
w = o.width;
|
||||
h = o.height;
|
||||
} else if (o.view.mode === 'fixed') {
|
||||
w = o.view.width;
|
||||
h = o.view.height;
|
||||
} else {
|
||||
const size = fittingSize(o);
|
||||
w = size.width;
|
||||
h = size.height;
|
||||
}
|
||||
rs = { px: e.clientX, py: e.clientY, w, h };
|
||||
document.body.style.userSelect = 'none';
|
||||
window.addEventListener('mousemove', onResizeMove);
|
||||
window.addEventListener('mouseup', onResizeUp);
|
||||
}
|
||||
function onResizeMove(e: MouseEvent) {
|
||||
if (!rs) return;
|
||||
const dx = (e.clientX - rs.px) / props.zoom;
|
||||
const dy = (e.clientY - rs.py) / props.zoom;
|
||||
const o = props.object;
|
||||
if (resizeKind.value === 'free') {
|
||||
if (o.kind === 'chart') store.resizeChart(o.id, snap(rs.w + dx), snap(rs.h + dy));
|
||||
else if (o.kind === 'text') store.resizeText(o.id, snap(rs.w + dx), snap(rs.h + dy));
|
||||
else if (o.kind === 'button') store.resizeButton(o.id, snap(rs.w + dx), snap(rs.h + dy));
|
||||
} else if (resizeKind.value === 'viewport') {
|
||||
// "Feste Größe": pure pixel-viewport resize, independent of row/col
|
||||
// count -- cheap (setTableView never rebuilds the workbook), so a live
|
||||
// update on every mousemove is fine, same as it always was.
|
||||
store.setTableView(o.id, { width: snap(rs.w + dx), height: snap(rs.h + dy) });
|
||||
} else {
|
||||
// "Automatisch": dragging drives row/col *count* -- but that's a full
|
||||
// syncWorkbook() per call, too expensive for every mousemove, so only a
|
||||
// lightweight ghost-box preview updates live here (see the template);
|
||||
// the real change commits once, on release, in onResizeUp.
|
||||
previewSize.value = { w: Math.max(80, rs.w + dx), h: Math.max(52, rs.h + dy) };
|
||||
}
|
||||
}
|
||||
function onResizeUp() {
|
||||
const o = props.object;
|
||||
if (rs && resizeKind.value === 'grid-size' && o.kind === 'table' && previewSize.value) {
|
||||
// Never past non-empty content, so a hard shrink can't silently delete
|
||||
// data -- it just settles at the smallest size that still shows
|
||||
// everything, however far past that the drag actually went.
|
||||
const targetCols = colsForWidth(o, previewSize.value.w);
|
||||
const targetRows = rowsForHeight(o, previewSize.value.h);
|
||||
store.setTableCols(o.id, Math.max(targetCols, lastNonEmptyCol(o) + 1));
|
||||
store.setTableRows(o.id, Math.max(targetRows, lastNonEmptyRow(o) + 1));
|
||||
}
|
||||
previewSize.value = null;
|
||||
rs = null;
|
||||
document.body.style.userSelect = '';
|
||||
window.removeEventListener('mousemove', onResizeMove);
|
||||
window.removeEventListener('mouseup', onResizeUp);
|
||||
}
|
||||
|
||||
/** A table with `hideChromeUnfocused` set shows its title bar (and, via the
|
||||
* `selected` prop passed to TableGrid below, its row/column headers) only
|
||||
* while it's the selected object -- a "clean look" presentation option
|
||||
* ("Ohne Rahmen/Kopfzeilen, wenn nicht ausgewählt" in Settings). Every
|
||||
* other object kind is unaffected -- always shown, today's behaviour. */
|
||||
const showChrome = computed(() => {
|
||||
const o = props.object;
|
||||
return props.selected || !(o.kind === 'table' && o.hideChromeUnfocused);
|
||||
});
|
||||
|
||||
const icon = computed(() => {
|
||||
switch (props.object.kind) {
|
||||
case 'table':
|
||||
return '▦';
|
||||
case 'chart':
|
||||
return '▤';
|
||||
case 'button':
|
||||
return '▶';
|
||||
case 'text':
|
||||
default:
|
||||
return '¶';
|
||||
}
|
||||
});
|
||||
|
||||
const style = computed(() => {
|
||||
const o = props.object;
|
||||
const base: Record<string, string> = { left: o.x + 'px', top: o.y + 'px' };
|
||||
if (o.kind === 'chart' || o.kind === 'text' || o.kind === 'button') {
|
||||
base.width = o.width + 'px';
|
||||
base.height = o.height + 'px';
|
||||
}
|
||||
return base;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="canvas-object"
|
||||
:class="{ selected, flex: object.kind === 'chart' || object.kind === 'text' || object.kind === 'button' }"
|
||||
:style="style"
|
||||
@mousedown="emit('select')"
|
||||
>
|
||||
<div v-if="showChrome" class="chrome" @mousedown.stop="onMoveDown">
|
||||
<input
|
||||
v-if="renaming"
|
||||
ref="nameInput"
|
||||
v-model="nameDraft"
|
||||
class="name-edit"
|
||||
@mousedown.stop
|
||||
@blur="commitRename"
|
||||
@keydown.enter.prevent="commitRename"
|
||||
@keydown.esc.prevent="renaming = false"
|
||||
/>
|
||||
<span v-else class="name" @dblclick.stop="startRename">
|
||||
{{ icon }} {{ object.name }}
|
||||
</span>
|
||||
<button
|
||||
class="del"
|
||||
title="Objekt löschen"
|
||||
@mousedown.stop
|
||||
@click.stop="store.deleteObject(object.id)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="content" :class="{ 'full-radius': !showChrome }">
|
||||
<TableGrid v-if="object.kind === 'table'" :table-id="object.id" :selected="selected" />
|
||||
<ChartView v-else-if="object.kind === 'chart'" :obj-id="object.id" />
|
||||
<TextBox v-else-if="object.kind === 'text'" :obj-id="object.id" />
|
||||
<ButtonView v-else-if="object.kind === 'button'" :obj-id="object.id" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="previewSize"
|
||||
class="resize-ghost"
|
||||
:style="{ width: previewSize.w + 'px', height: previewSize.h + 'px' }"
|
||||
/>
|
||||
<div v-if="selected" class="rs-handle" @mousedown="onResizeDown" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.canvas-object {
|
||||
position: absolute;
|
||||
background: var(--surface);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.canvas-object.selected {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-weak), var(--shadow);
|
||||
}
|
||||
.canvas-object.flex {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.chrome {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 26px;
|
||||
padding: 0 4px 0 10px;
|
||||
background: var(--surface-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-radius: 10px 10px 0 0;
|
||||
cursor: grab;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-dim);
|
||||
user-select: none;
|
||||
}
|
||||
.chrome:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.name-edit {
|
||||
flex: 1;
|
||||
font: inherit;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 4px;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
.del {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.del:hover {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
.content {
|
||||
overflow: hidden;
|
||||
border-radius: 0 0 10px 10px;
|
||||
}
|
||||
/* the chrome bar is what normally rounds the top corners -- without it
|
||||
(hideChromeUnfocused, currently unfocused), .content fills the whole box */
|
||||
.content.full-radius {
|
||||
border-radius: 10px;
|
||||
}
|
||||
.canvas-object.flex .content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.resize-ghost {
|
||||
position: absolute;
|
||||
top: 27px;
|
||||
left: 0;
|
||||
border: 2px dashed var(--accent);
|
||||
border-radius: 0 0 8px 8px;
|
||||
background: var(--accent-weak);
|
||||
pointer-events: none;
|
||||
z-index: 7;
|
||||
}
|
||||
.rs-handle {
|
||||
position: absolute;
|
||||
right: -5px;
|
||||
bottom: -5px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
background: var(--accent);
|
||||
border: 2px solid var(--surface);
|
||||
cursor: nwse-resize;
|
||||
z-index: 8;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user