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>
402 lines
14 KiB
Vue
402 lines
14 KiB
Vue
<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>
|