This commit is contained in:
wkh
2026-08-29 17:47:12 +02:00
commit c7885067e7
53 changed files with 13458 additions and 0 deletions
+204
View File
@@ -0,0 +1,204 @@
<script setup lang="ts">
/**
* The sheet canvas: a pan/scroll/zoom surface that hosts table and chart
* objects. Empty-space click deselects; objects come to front on select.
* Files dropped anywhere on the canvas are handed to the import pipeline.
*/
import { computed, ref } from 'vue';
import { useDocument } from '../store/document';
import CanvasObject from './CanvasObject.vue';
const emit = defineEmits<{ (e: 'files', files: FileList): void }>();
const store = useDocument();
const sheet = computed(() => store.activeSheet.value);
const zoom = computed(() => store.activeZoom.value);
const setZoom = store.setZoom;
const dropActive = ref(false);
/** Per-sheet appearance -> CSS custom properties scoped to this canvas. */
const appearance = computed(() => {
const s: Record<string, string> = {};
if (sheet.value.background) s['--bg'] = sheet.value.background;
if (sheet.value.foreground) {
s['--text'] = sheet.value.foreground;
s['--text-dim'] = sheet.value.foreground;
}
return s;
});
const showGrid = computed(() => sheet.value.showGrid !== false);
/** Unscaled plane size: enclose every object with generous margin. */
const plane = computed(() => {
let w = 1600;
let h = 1000;
for (const o of sheet.value.objects) {
const ow = o.kind === 'table' ? o.cols * 104 + 60 : o.width;
const oh = o.kind === 'table' ? o.rows * 28 + 60 : o.height;
w = Math.max(w, o.x + ow + 500);
h = Math.max(h, o.y + oh + 400);
}
return { w, h };
});
function onPlaneMouseDown(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains('plane')) store.selectObject(null);
}
function onWheel(e: WheelEvent) {
if (!(e.ctrlKey || e.metaKey)) return;
e.preventDefault();
setZoom(zoom.value - Math.sign(e.deltaY) * 0.1);
}
function onKeydown(e: KeyboardEvent) {
const o = store.activeObject.value;
if (!o) return;
if ((e.key === 'Delete' || e.key === 'Backspace') && o.kind === 'chart') {
e.preventDefault();
store.deleteObject(o.id);
}
}
function onDrop(e: DragEvent) {
dropActive.value = false;
if (e.dataTransfer?.files?.length) emit('files', e.dataTransfer.files);
}
function selectObj(id: string) {
store.selectObject(id);
store.bringToFront(id);
}
</script>
<template>
<div
class="canvas-wrap"
:class="{ 'drawer-open': store.ui.settingsOpen }"
:style="appearance"
>
<div
class="canvas-scroll"
:class="{ 'no-grid': !showGrid }"
tabindex="0"
@wheel="onWheel"
@keydown="onKeydown"
@dragover.prevent="dropActive = true"
@dragleave="dropActive = false"
@drop.prevent="onDrop"
>
<div class="zoom-layer" :style="{ width: plane.w * zoom + 'px', height: plane.h * zoom + 'px' }">
<div
class="plane"
:style="{ width: plane.w + 'px', height: plane.h + 'px', transform: `scale(${zoom})` }"
@mousedown="onPlaneMouseDown"
>
<CanvasObject
v-for="o in sheet.objects"
:key="o.id"
:object="o"
:zoom="zoom"
:selected="store.doc.activeObjectId === o.id"
@select="selectObj(o.id)"
/>
</div>
</div>
<p v-if="sheet.objects.length === 0" class="hint">
Leeres Blatt — über die Toolbar „Tabelle" oder „Diagramm" einfügen.
</p>
</div>
<div class="zoom-ctl">
<button @click="setZoom(zoom - 0.1)">−</button>
<span>{{ Math.round(zoom * 100) }}%</span>
<button @click="setZoom(zoom + 0.1)">+</button>
<button class="fit" @click="setZoom(1)">100%</button>
</div>
<div v-if="dropActive" class="drop">Datei ablegen (.json .csv .xlsx)</div>
</div>
</template>
<style scoped>
.canvas-wrap {
position: relative;
height: 100%;
overflow: hidden;
transition: margin-left 0.18s ease;
}
.canvas-wrap.drawer-open {
margin-left: 320px;
}
@media (max-width: 640px) {
.canvas-wrap.drawer-open {
margin-left: 0; /* drawer overlays on narrow screens */
}
}
.canvas-scroll {
position: absolute;
inset: 0;
overflow: auto;
background-color: var(--bg);
/* grid dots use a fixed neutral so they read on any custom backdrop */
background-image:
linear-gradient(rgba(128, 128, 128, 0.22) 1px, transparent 1px),
linear-gradient(90deg, rgba(128, 128, 128, 0.22) 1px, transparent 1px);
background-size: 24px 24px;
background-position: 0 0;
outline: none;
}
.canvas-scroll.no-grid {
background-image: none;
}
.plane {
position: relative;
transform-origin: top left;
}
.hint {
position: absolute;
top: 40px;
left: 40px;
color: var(--text-dim);
font-size: 13px;
pointer-events: none;
}
.zoom-ctl {
position: absolute;
right: 16px;
bottom: 16px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 999px;
box-shadow: var(--shadow);
font-size: 12px;
font-variant-numeric: tabular-nums;
z-index: 15;
}
.zoom-ctl button {
border: 1px solid var(--border);
background: var(--surface);
border-radius: 6px;
width: 22px;
height: 22px;
cursor: pointer;
}
.zoom-ctl button.fit {
width: auto;
padding: 0 8px;
}
.drop {
position: absolute;
right: 24px;
bottom: 24px;
padding: 14px 20px;
border: 2px dashed var(--accent);
border-radius: var(--radius);
background: var(--accent-weak);
color: var(--accent);
font-weight: 600;
z-index: 20;
}
</style>