From d0500fcaf5c07a6a0817a55323ce527c7990d55a Mon Sep 17 00:00:00 2001 From: Wild Karl-Heinz Date: Wed, 9 Sep 2026 23:33:39 +0200 Subject: [PATCH] Snap-to-grid for moving/resizing canvas objects, plus a per-sheet grid size Sheet.gridSize (4-200px, default 24, configurable in Settings -> Blatt) now doubles as both the visible dot-grid's spacing and the snap increment while dragging to move any canvas object, or free/viewport- resizing a chart/text/button/fixed-viewport table. An "Automatisch" table's resize is left unsnapped on top of this -- it already discretizes to row/col boundaries by construction. Also fixes the visible grid not actually lining up with real snap positions once zoomed: the dot pattern's background-size now scales with zoom like the objects themselves already did, instead of staying a fixed screen-pixel size regardless of zoom level. Co-Authored-By: Claude Sonnet 5 --- README.md | 15 +++++++++++++-- src/components/CanvasObject.vue | 27 ++++++++++++++++++++------- src/components/SettingsPanel.vue | 12 ++++++++++++ src/components/SheetCanvas.vue | 16 ++++++++++++++-- src/io/json.ts | 8 +++++++- src/store/__tests__/document.spec.ts | 21 ++++++++++++++++++++- src/store/document.ts | 9 ++++++++- src/store/types.ts | 7 +++++++ 8 files changed, 101 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 21d96c1..42ee4c0 100644 --- a/README.md +++ b/README.md @@ -632,8 +632,8 @@ wichtigen Punkte: - Textfeld → Name, Text, Schriftgröße, Fett/Kursiv, Ausrichtung, **Textfarbe**, **Hintergrund**, Größe, Position. - **Blatt**: Name, Zahlenformat, **Hintergrundfarbe**, **Schriftfarbe**, - **Raster ein-/ausblenden** (`↺` setzt eine Farbe auf den Theme-Wert zurück), - neues Blatt, Blatt löschen. + **Raster ein-/ausblenden**, **Rasterweite** (`↺` setzt eine Farbe auf den + Theme-Wert zurück), neues Blatt, Blatt löschen. - **Ansicht**: Zoom-Regler (**pro Blatt** gespeichert). - **Dokument**: Erscheinungsbild (Auto/Hell/Dunkel), Zahlenformat (Ländereinstellung), CSV-Standard-Trennzeichen, „Auf Beispiel zurücksetzen". @@ -646,6 +646,17 @@ das **Punkt-Raster** ein- oder ausblenden. Die Farben überschreiben die Theme-Variablen `--bg` / `--text` nur für dieses Blatt; ohne Wert gilt das Theme. Der Canvas-**Zoom** wird ebenfalls pro Blatt gemerkt. +Die **Rasterweite** (`Sheet.gridSize`, 4–200 px, Standard 24) ist ebenfalls +pro Blatt einstellbar (Einstellungen → Blatt) und dient **doppelt**: sie +bestimmt sowohl den Abstand des sichtbaren Punkt-Rasters (skaliert mit dem +Zoom, damit die Punkte immer exakt dort liegen, wo ein Objekt tatsächlich +einrastet) als auch die **Schrittweite beim Einrasten** (Snapping), wenn ein +Tabellen-, Diagramm-, Text- oder Button-Objekt per Maus **verschoben** oder +(bei „Feste Größe"/Diagramm/Text/Button) **frei skaliert** wird. Eine +„Automatisch"-Tabelle rastet beim Skalieren stattdessen bereits durch ihre +Zeilen-/Spaltengrenzen selbst ein und wird nicht zusätzlich pixelweise +gerastert. + Alle diese Einstellungen sind Teil des Dokuments (`DocumentState` / `Sheet`) und werden damit in **localStorage gespeichert und in `.json` exportiert** – Round-Trip verlustfrei, ungültige Werte werden beim Import diff --git a/src/components/CanvasObject.vue b/src/components/CanvasObject.vue index e197368..7bb53c4 100644 --- a/src/components/CanvasObject.vue +++ b/src/components/CanvasObject.vue @@ -17,11 +17,14 @@ * - 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. + * 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 } 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'; @@ -47,6 +50,16 @@ function commitRename() { 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) { @@ -61,7 +74,7 @@ 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, drag.ox + dx, drag.oy + dy); + store.moveObject(props.object.id, snap(drag.ox + dx), snap(drag.oy + dy)); } function onMoveUp() { drag = null; @@ -174,14 +187,14 @@ function onResizeMove(e: MouseEvent) { const dy = (e.clientY - rs.py) / props.zoom; const o = props.object; if (resizeKind.value === 'free') { - if (o.kind === 'chart') store.resizeChart(o.id, rs.w + dx, rs.h + dy); - else if (o.kind === 'text') store.resizeText(o.id, rs.w + dx, rs.h + dy); - else if (o.kind === 'button') store.resizeButton(o.id, rs.w + dx, rs.h + dy); + 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: rs.w + dx, height: rs.h + dy }); + 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 diff --git a/src/components/SettingsPanel.vue b/src/components/SettingsPanel.vue index e68889c..9b7b30d 100644 --- a/src/components/SettingsPanel.vue +++ b/src/components/SettingsPanel.vue @@ -10,6 +10,7 @@ import { useDocument } from '../store/document'; import { useTheme } from '../composables/useTheme'; import { NUMBER_LOCALES, NUM_FORMATS } from '../store/format'; import type { CellFormat, HAlign } from '../store/types'; +import { DEFAULT_GRID_SIZE, MIN_GRID_SIZE, MAX_GRID_SIZE } from '../store/types'; const store = useDocument(); const { mode: themeMode } = useTheme(); @@ -461,6 +462,17 @@ function resetDoc() { /> Raster einblenden +
diff --git a/src/components/SheetCanvas.vue b/src/components/SheetCanvas.vue index 7dbddc9..f1ab11a 100644 --- a/src/components/SheetCanvas.vue +++ b/src/components/SheetCanvas.vue @@ -6,6 +6,7 @@ */ import { computed, ref } from 'vue'; import { useDocument } from '../store/document'; +import { DEFAULT_GRID_SIZE } from '../store/types'; import CanvasObject from './CanvasObject.vue'; import TheLogo from './TheLogo.vue'; @@ -30,6 +31,16 @@ const appearance = computed(() => { return s; }); const showGrid = computed(() => sheet.value.showGrid !== false); +/** Also the snap increment CanvasObject.vue moves/resizes against -- see + * its own doc comment. */ +const gridSize = computed(() => sheet.value.gridSize ?? DEFAULT_GRID_SIZE); +/** Scaled by zoom (unlike the rest of `.canvas-scroll`, which isn't) so the + * visible dots always land exactly where an object will actually snap -- + * otherwise a zoomed-in/out grid would visually lie about the snap. */ +const gridStyle = computed(() => { + const px = gridSize.value * zoom.value; + return { backgroundSize: `${px}px ${px}px` }; +}); /** Unscaled plane size: enclose every object with generous margin. * A `fixed`-viewport table only ever occupies its own `view.width`/ @@ -92,6 +103,7 @@ function selectObj(id: string) {
{ void b; }); + it('grid size (snap increment) is per-sheet, clamped and rounded', () => { + const s = fresh(); + const a = s.doc.sheets[0].id; + const b = s.doc.sheets[1].id; + expect(s.doc.sheets[0].gridSize).toBeUndefined(); // unset = DEFAULT_GRID_SIZE at use-sites + s.setSheetGridSize(a, 40.6); + expect(s.doc.sheets[0].gridSize).toBe(41); // rounded + expect(s.doc.sheets[1].gridSize).toBeUndefined(); // untouched + s.setSheetGridSize(a, 1); // below MIN_GRID_SIZE + expect(s.doc.sheets[0].gridSize).toBe(4); + s.setSheetGridSize(a, 9999); // above MAX_GRID_SIZE + expect(s.doc.sheets[0].gridSize).toBe(200); + void b; + }); + it('zoom is stored per sheet; the active-sheet zoom follows the tab', () => { const s = fresh(); expect(s.activeZoom.value).toBe(1); @@ -310,26 +325,30 @@ describe('per-sheet appearance, zoom & CSV default (saved in the file)', () => { expect(s.activeZoom.value).toBe(1.5); // sheet 1 kept its own }); - it('appearance + zoom + csvDelimiter survive a JSON round-trip; bad values dropped', () => { + it('appearance + zoom + gridSize + csvDelimiter survive a JSON round-trip; bad values dropped', () => { const s = fresh(); const a = s.doc.sheets[0].id; s.setSheetBackground(a, '#abcdef'); s.setSheetForeground(a, '#012'); s.setSheetShowGrid(a, false); s.setSheetZoom(a, 1.25); + s.setSheetGridSize(a, 32); s.setCsvDelimiter(';'); const before = s.snapshot(); s.replaceDocument(parseDocument(serializeDocument(before))); expect(s.snapshot()).toEqual(before); expect(s.doc.csvDelimiter).toBe(';'); + expect(s.doc.sheets[0].gridSize).toBe(32); const doc = JSON.parse(serializeDocument(s.snapshot())); doc.sheets[0].background = 'not-a-color'; doc.sheets[0].zoom = 99; + doc.sheets[0].gridSize = 'huge'; doc.csvDelimiter = 'x'; s.replaceDocument(parseDocument(JSON.stringify(doc))); expect(s.doc.sheets[0].background).toBeUndefined(); expect(s.doc.sheets[0].zoom).toBe(2); // clamped, not dropped + expect(s.doc.sheets[0].gridSize).toBeUndefined(); // not a number -> dropped expect(s.doc.csvDelimiter).toBe(','); }); }); diff --git a/src/store/document.ts b/src/store/document.ts index f02bc7a..33f4d0a 100644 --- a/src/store/document.ts +++ b/src/store/document.ts @@ -75,7 +75,7 @@ import type { TableView, TextObject, } from './types'; -import { defaultTableView, DEFAULT_ROW_H } from './types'; +import { defaultTableView, DEFAULT_ROW_H, MIN_GRID_SIZE, MAX_GRID_SIZE } from './types'; import { createSeedDocument } from '../data/seed'; import { validateDocument } from '../io/json'; @@ -855,6 +855,12 @@ function createStore() { s.showGrid = show; markDirty(); } + function setSheetGridSize(sheetId: string, size: number) { + const s = sheetById(sheetId); + if (!s) return; + s.gridSize = Math.round(Math.min(MAX_GRID_SIZE, Math.max(MIN_GRID_SIZE, size))); + markDirty(); + } function setSheetZoom(sheetId: string, z: number) { const s = sheetById(sheetId); if (!s) return; @@ -2874,6 +2880,7 @@ function createStore() { setSheetBackground, setSheetForeground, setSheetShowGrid, + setSheetGridSize, setSheetZoom, activeZoom, setZoom, diff --git a/src/store/types.ts b/src/store/types.ts index 454dfd2..cb2ccc8 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -161,10 +161,17 @@ export interface Sheet { foreground?: string; /** show the canvas dot-grid; unset / true = shown */ showGrid?: boolean; + /** grid spacing in px -- also the snap increment for moving/resizing + * canvas objects on this sheet; unset = DEFAULT_GRID_SIZE */ + gridSize?: number; /** per-sheet canvas zoom (0.4 – 2); unset = 1 */ zoom?: number; } +export const DEFAULT_GRID_SIZE = 24; +export const MIN_GRID_SIZE = 4; +export const MAX_GRID_SIZE = 200; + export interface SelectionRange { anchor: CellCoord; focus: CellCoord;