Karten-Editor: Komfort — Hintergrund-Vorschau, Resize, Undo

- Hintergrund-PDF wird per pdf.js echt im Canvas gerendert (WYSIWYG);
  neuer Endpunkt GET .../card-template/background liefert das PDF
- Resize-Anfasser am ausgewählten Element (Breite/Höhe)
- Undo (↶ / Strg+Z) mit Snapshot-History; Snapshot erst bei echter Änderung

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thomas Peterson 2026-05-31 17:39:50 +02:00
parent f25ccefa48
commit 904a4184fc
4 changed files with 386 additions and 6 deletions

View File

@ -8,9 +8,11 @@ use App\Repository\CardTemplateRepository;
use App\Security\TenantContext;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@ -52,6 +54,19 @@ final class CardAssetUploadController
return new JsonResponse(['backgroundPath' => $path, 'fileName' => $file->getClientOriginalName()], 201);
}
#[Route('/api/companies/{id}/card-template/background', name: 'card_bg_get', methods: ['GET'])]
public function getBackground(string $id): Response
{
$company = $this->company($id);
$template = $this->templates->findCardForCompany($company);
$path = $template?->getBackgroundPath();
if (!$path || !is_file($path)) {
throw new NotFoundHttpException('Kein Hintergrund-PDF.');
}
return new BinaryFileResponse($path, 200, ['Content-Type' => 'application/pdf']);
}
#[Route('/api/companies/{id}/card-template/background', name: 'card_bg_delete', methods: ['DELETE'])]
public function deleteBackground(string $id): JsonResponse
{

View File

@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"axios": "^1.16.1",
"pdfjs-dist": "^6.0.227",
"pinia": "^3.0.4",
"vue": "^3.5.34",
"vue-router": "^4.6.4"
@ -108,6 +109,271 @@
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@napi-rs/canvas": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.0.tgz",
"integrity": "sha512-Jqxcy1XOIqj+lH9sl1GT+il6GR3uQv13vI2mrwubP3uT8Olak2ClDrK2RnxlQKjwv8BRr4b3ug0YR7c6hBX8wg==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "1.0.0",
"@napi-rs/canvas-darwin-arm64": "1.0.0",
"@napi-rs/canvas-darwin-x64": "1.0.0",
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.0",
"@napi-rs/canvas-linux-arm64-gnu": "1.0.0",
"@napi-rs/canvas-linux-arm64-musl": "1.0.0",
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.0",
"@napi-rs/canvas-linux-x64-gnu": "1.0.0",
"@napi-rs/canvas-linux-x64-musl": "1.0.0",
"@napi-rs/canvas-win32-arm64-msvc": "1.0.0",
"@napi-rs/canvas-win32-x64-msvc": "1.0.0"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.0.tgz",
"integrity": "sha512-3hNKJObUK7JsCF9aJlVCs1J0/KE/gGfZNeK8MO1ge6bB3aicr5walGme9t9No1f/oyk9GgvdAT/rjSdsx3gbIw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.0.tgz",
"integrity": "sha512-ZIja19/BiGz2puhki+WUYSRriwFeFJ8Mi9eK3hZdSS85w4Y60cuEAJVhMCfKwswQkKkUtrnzdKMBuO7TupvexA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.0.tgz",
"integrity": "sha512-hImggWc82jqZVpEsFR9S7PE9OQYjq/H/D7vwCGB6X1jRH+UVBP1+1niJTPBOat1B154T6GKK7/kcFtoWgjgFzQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.0.tgz",
"integrity": "sha512-hlJRy6d+kWLKVOG/+1rEvNQVURZ0DxxRPJsLmEWwhwiXZUJc0BF5o9esALHSEP4CoJK4wChRtj3hnyBgVx2oWA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.0.tgz",
"integrity": "sha512-5Hru4T3RXkosRQafcjelv7AUzw9mXqmGYsxnzeDDOWveFCJyEPMSJltvGCM+jfH98seOCbfwm9KyFg6Jm5FhAA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.0.tgz",
"integrity": "sha512-LTUl9jS8WsLSUGaxQZKQkxfluOJRpgvBuxxdM4pYcjib+di8AU4OzQc6+L6SzGMLcKc9H0RAjojRatBhTMqYdg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.0.tgz",
"integrity": "sha512-Iz931SAZf+WVDzpjk52Q3ffW3zw0YflFwEZMgs036Wfu1kX/LrwT9wGjsuSqyduqefUkl91/vTdAjn8hQu5ezA==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.0.tgz",
"integrity": "sha512-pFEQ5eFK4JusgN1K6KkO9DKP/Hi1WMJOkF8Ch03/khTc4bFbCKkCCsJG4YcOMOW9bI4XbT2/eMAWxhO0xaWgPA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.0.tgz",
"integrity": "sha512-jnvr8NrLHiZ3NCiOKWqDbkI4Ah+QDrqtZ+sddPZBltEb1mQ2coSvCSJYfict+oAwcm0c970oTmVySpjKP/lnaA==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.0.tgz",
"integrity": "sha512-y2j9/Gfd5joqiqxdP/L1smqjQ+uAx3C4N0EC7bDHrnZEEH8ToM/OC5p3uHvtj4Lq591aHj+ArL01UDLNwT5HgQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.0.tgz",
"integrity": "sha512-qwdhh9N6Gge/hC4pL9S1tQp0iKwhSl/dYjg7+RGp9k26iRGRi5MqqUyKGOXIWli0zOcuy5Y2wIH/jk2ry6i/jA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
@ -1412,6 +1678,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/pdfjs-dist": {
"version": "6.0.227",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.0.227.tgz",
"integrity": "sha512-/P6M4SXw+70waMVLUM7rdRtvo+dEzqE1t6W/zQNvBETo2MaRa5rrvCcAYdfWGiUzadTgM0lJmRApUrW0d9zgKg==",
"license": "Apache-2.0",
"engines": {
"node": ">=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^1.0.0"
}
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",

View File

@ -10,6 +10,7 @@
},
"dependencies": {
"axios": "^1.16.1",
"pdfjs-dist": "^6.0.227",
"pinia": "^3.0.4",
"vue": "^3.5.34",
"vue-router": "^4.6.4"

View File

@ -1,7 +1,11 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { list } from '@/api/resources'
import client from '@/api/client'
import * as pdfjsLib from 'pdfjs-dist'
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrl
interface El { type: string; [k: string]: any }
interface Template {
@ -34,6 +38,26 @@ const branding = computed(() => {
const els = computed<El[]>(() => (tpl.value ? tpl.value[side.value] : []))
const selected = computed(() => (selectedIndex.value !== null ? els.value[selectedIndex.value] : null))
// Hintergrund-PDF als Bild (pro Seite) für die Canvas-Vorschau
const bgImages = ref<{ front: string | null; back: string | null }>({ front: null, back: null })
const currentBg = computed(() => bgImages.value[side.value])
// Undo-History (Snapshots von front/back)
const history = ref<string[]>([])
function snapshot() {
if (!tpl.value) return
history.value.push(JSON.stringify({ front: tpl.value.front, back: tpl.value.back }))
if (history.value.length > 50) history.value.shift()
}
function undo() {
const snap = history.value.pop()
if (!snap || !tpl.value) return
const { front, back } = JSON.parse(snap)
tpl.value.front = front
tpl.value.back = back
selectedIndex.value = null
}
const BINDINGS = [
['fullName', 'Name'], ['firstName', 'Vorname'], ['lastName', 'Nachname'],
['position', 'Position'], ['department', 'Abteilung'], ['email', 'E-Mail'],
@ -100,7 +124,34 @@ async function loadTemplate() {
const { data } = await client.get<Template>(`/companies/${cid}/card-template`)
tpl.value = data
selectedIndex.value = null
history.value = []
sample.value = employees.value.find((e) => e.company === selectedCompanyId.value) ?? null
await renderBackground()
}
// Rendert das Hintergrund-PDF (Seite 1 = Vorder-, 2 = Rückseite) als Bild für den Canvas
async function renderBackground() {
bgImages.value = { front: null, back: null }
if (!tpl.value?.hasBackground) return
try {
const res = await client.get(`/companies/${selectedCompany.value!.id}/card-template/background`, { responseType: 'arraybuffer' })
const doc = await pdfjsLib.getDocument({ data: new Uint8Array(res.data as ArrayBuffer) }).promise
const pxW = (tpl.value.widthMm + 2 * tpl.value.bleedMm) * SCALE
for (let p = 1; p <= Math.min(2, doc.numPages); p++) {
const page = await doc.getPage(p)
const scale = pxW / page.getViewport({ scale: 1 }).width
const vp = page.getViewport({ scale })
const canvas = document.createElement('canvas')
canvas.width = vp.width
canvas.height = vp.height
await page.render({ canvas, canvasContext: canvas.getContext('2d')!, viewport: vp }).promise
const url = canvas.toDataURL('image/png')
if (p === 1) bgImages.value.front = url
else bgImages.value.back = url
}
} catch {
// Vorschau optional bei Fehler bleibt der Hinweis-Hintergrund
}
}
async function load() {
loading.value = true
@ -119,7 +170,9 @@ function startDrag(e: MouseEvent, idx: number) {
const el = els.value[idx]
const sx = e.clientX, sy = e.clientY, ox = el.x, oy = el.y
const w = tpl.value!.widthMm, h = tpl.value!.heightMm, bl = tpl.value!.bleedMm
let snapped = false
const move = (ev: MouseEvent) => {
if (!snapped) { snapshot(); snapped = true }
el.x = Math.min(w, Math.max(-bl, +(ox + (ev.clientX - sx) / SCALE).toFixed(1)))
el.y = Math.min(h, Math.max(-bl, +(oy + (ev.clientY - sy) / SCALE).toFixed(1)))
}
@ -128,6 +181,24 @@ function startDrag(e: MouseEvent, idx: number) {
e.preventDefault()
}
function startResize(e: MouseEvent, idx: number) {
selectedIndex.value = idx
const el = els.value[idx]
const sx = e.clientX, sy = e.clientY
const ow = el.w ?? 40, oh = el.h ?? 0
const hasH = ['rect', 'qr', 'image'].includes(el.type)
let snapped = false
const move = (ev: MouseEvent) => {
if (!snapped) { snapshot(); snapped = true }
el.w = Math.max(2, +(ow + (ev.clientX - sx) / SCALE).toFixed(1))
if (hasH) el.h = Math.max(2, +(oh + (ev.clientY - sy) / SCALE).toFixed(1))
}
const up = () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up) }
window.addEventListener('mousemove', move); window.addEventListener('mouseup', up)
e.preventDefault()
e.stopPropagation()
}
// --- Elemente hinzufügen / löschen ---
function add(type: string) {
const defs: Record<string, El> = {
@ -138,11 +209,13 @@ function add(type: string) {
rect: { type: 'rect', x: 8, y: 8, w: 30, h: 15, fill: { ref: 'primary' } },
line: { type: 'line', x: 8, y: 8, w: 25, h: 0, lineWidth: 0.5, color: { ref: 'primary' } },
}
snapshot()
els.value.push({ ...defs[type] })
selectedIndex.value = els.value.length - 1
}
function removeSelected() {
if (selectedIndex.value === null) return
snapshot()
els.value.splice(selectedIndex.value, 1)
selectedIndex.value = null
}
@ -181,12 +254,14 @@ async function uploadBackground(e: Event) {
const fd = new FormData(); fd.append('file', file)
await client.post(`/companies/${cidPath()}/card-template/background`, fd)
tpl.value.hasBackground = true
await renderBackground()
} catch { alert('Upload fehlgeschlagen (nur PDF).') } finally { uploading.value = false; (e.target as HTMLInputElement).value = '' }
}
async function removeBackground() {
if (!tpl.value || !confirm('Hintergrund-PDF entfernen?')) return
await client.delete(`/companies/${cidPath()}/card-template/background`)
tpl.value.hasBackground = false
bgImages.value = { front: null, back: null }
}
async function uploadFont(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
@ -207,7 +282,11 @@ async function previewPdf() {
window.open(URL.createObjectURL(res.data as Blob), '_blank')
}
onMounted(load)
function onKey(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') { e.preventDefault(); undo() }
}
onMounted(() => { load(); window.addEventListener('keydown', onKey) })
onUnmounted(() => window.removeEventListener('keydown', onKey))
</script>
<template>
@ -218,6 +297,7 @@ onMounted(load)
<select v-if="companies.length > 1" class="input" v-model="selectedCompanyId" @change="loadTemplate">
<option v-for="c in companies" :key="c['@id']" :value="c['@id']">{{ c.name }}</option>
</select>
<button class="btn btn-ghost" :disabled="!history.length" @click="undo" title="Rückgängig (Strg+Z)"></button>
<button class="btn btn-ghost" @click="previewPdf">PDF-Vorschau</button>
<button class="btn btn-primary" :disabled="saving" @click="save">{{ saving ? 'Speichern' : 'Speichern' }}</button>
</div>
@ -263,10 +343,12 @@ onMounted(load)
<div class="stage">
<!-- Beschnitt-Fläche -->
<div class="bleed" :style="{ width: (tpl.widthMm + 2 * tpl.bleedMm) * SCALE + 'px', height: (tpl.heightMm + 2 * tpl.bleedMm) * SCALE + 'px' }">
<!-- Hintergrund-PDF als Bild (füllt inkl. Beschnitt) -->
<img v-if="currentBg" :src="currentBg" class="bg-img" />
<!-- Endformat (Trim) -->
<div class="trim" :style="{ left: tpl.bleedMm * SCALE + 'px', top: tpl.bleedMm * SCALE + 'px', width: tpl.widthMm * SCALE + 'px', height: tpl.heightMm * SCALE + 'px' }" @mousedown.self="selectedIndex = null">
<!-- Hinweis: Hintergrund-PDF aktiv (echtes Bild in der PDF-Vorschau) -->
<div v-if="tpl.hasBackground" class="bg-note">Hintergrund-PDF aktiv<br /><small>sichtbar in der PDF-Vorschau</small></div>
<div class="trim" :class="{ transparent: !!currentBg }" :style="{ left: tpl.bleedMm * SCALE + 'px', top: tpl.bleedMm * SCALE + 'px', width: tpl.widthMm * SCALE + 'px', height: tpl.heightMm * SCALE + 'px' }" @mousedown.self="selectedIndex = null">
<!-- Hinweis nur, solange Hintergrund-PDF noch nicht gerendert ist -->
<div v-if="tpl.hasBackground && !currentBg" class="bg-note">Hintergrund-PDF wird geladen</div>
<!-- Sicherheitsabstand -->
<div class="safe" :style="{ inset: tpl.safeMm * SCALE + 'px' }"></div>
<!-- Elemente -->
@ -280,6 +362,7 @@ onMounted(load)
<div v-else-if="el.type === 'qr'" class="ph-qr">QR</div>
<img v-else-if="el.type === 'image' && branding.logoUrl" :src="branding.logoUrl" class="logo-img" />
<div v-else-if="el.type === 'image'" class="ph-logo">Logo</div>
<div v-if="i === selectedIndex" class="handle" @mousedown.stop="startResize($event, i)"></div>
</div>
</div>
</div>
@ -377,7 +460,10 @@ onMounted(load)
.bg-note small { font-size: 10px; }
.stage { background: #ececec; border-radius: var(--radius); padding: 1.5rem; display: flex; flex-direction: column; align-items: center; overflow-x: auto; }
.bleed { position: relative; background: #fbe9da; box-shadow: var(--shadow-sm); }
.trim { position: absolute; background: #fff; overflow: hidden; }
.bg-img { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 0; pointer-events: none; }
.trim { position: absolute; background: #fff; overflow: hidden; z-index: 1; }
.trim.transparent { background: transparent; }
.handle { position: absolute; right: -5px; bottom: -5px; width: 10px; height: 10px; background: var(--psc-orange); border: 2px solid #fff; border-radius: 2px; cursor: nwse-resize; z-index: 5; }
.safe { position: absolute; border: 1px dashed #c9c9c9; pointer-events: none; }
.el { position: absolute; cursor: move; user-select: none; }
.el.sel { outline: 2px solid var(--psc-orange); outline-offset: 1px; }