Files
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

402 lines
14 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>