diff --git a/bericht_card.php b/bericht_card.php index b177334..f7be1d0 100644 --- a/bericht_card.php +++ b/bericht_card.php @@ -278,6 +278,25 @@ if (!$bericht) { print ''; print ''; print ''; + // Text-Optionen (nur sichtbar wenn Text-Tool aktiv oder ein Text-Objekt selektiert) + print ''; + print ''; + print ''; + print ''; + print ''; + // Zoom + print ''; + print '100%'; + print ''; + print ''; + print ''; print ''; print ''; print ''; diff --git a/css/bericht.css b/css/bericht.css index 6ceae12..c68eb56 100644 --- a/css/bericht.css +++ b/css/bericht.css @@ -99,6 +99,19 @@ font-size: 12px; color: var(--colortext, inherit); } +.bericht-toolbar select, +.bericht-toolbar input[type="number"], +.bericht-toolbar input[type="text"] { + background: var(--inputbackgroundcolor, #fff); + color: var(--inputtextcolor, #000); + border: 1px solid var(--colorboxbordertitle1, #ccc); + border-radius: 3px; + padding: 2px 4px; + font-size: 12px; +} +.bericht-toolbar #zoom-label { + color: var(--colortext, inherit); +} .bericht-canvas-wrap { position: relative; diff --git a/js/editor.js b/js/editor.js index 7fc29e8..a060172 100644 --- a/js/editor.js +++ b/js/editor.js @@ -36,6 +36,7 @@ let currentPageRotation = 0; // 0 / 90 / 180 / 270 let currentPageBuffer = null; // ArrayBuffer der aktuellen Quelle let currentPageMime = ''; + let currentZoom = 1.0; // 1.0 = 100% (Container-Fit), 0.5..3.0 let fabricCanvas = null; const pdfCanvas = document.getElementById('pdf-canvas'); let currentTool = 'select'; @@ -60,6 +61,21 @@ bindExtraUpload(); bindActions(); bindSortable(); + + // Re-Render bei Größenänderung des Container (Console öffnen, Window-Resize), + // debounced damit es nicht spamt. Nutzt ResizeObserver auf canvas-wrap. + const wrap = document.querySelector('.bericht-canvas-wrap'); + if (wrap && typeof ResizeObserver !== 'undefined') { + let to = null; + let lastW = wrap.clientWidth; + const ro = new ResizeObserver(() => { + if (Math.abs(wrap.clientWidth - lastW) < 20) return; + lastW = wrap.clientWidth; + clearTimeout(to); + to = setTimeout(() => { rerenderCurrent(); }, 250); + }); + ro.observe(wrap); + } } /* ---------- Seiten laden ---------- */ @@ -110,8 +126,9 @@ if (!wrap) return 800; const cs = getComputedStyle(wrap); const padX = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight); - const avail = wrap.clientWidth - padX - 4; // 4px Sicherheitsabstand - return Math.max(300, Math.min(1200, Math.floor(avail))); + const avail = wrap.clientWidth - padX - 4; + const base = Math.max(300, Math.min(1200, Math.floor(avail))); + return Math.round(base * currentZoom); } async function renderPdf(arrayBuffer) { @@ -242,9 +259,57 @@ fabricCanvas.discardActiveObject(); fabricCanvas.requestRenderAll(); }); - // Seitenrotation: rotate-left/right rotieren die SEITE (nicht das Fabric-Objekt) + // Seitenrotation document.getElementById('btn-rotate-left').addEventListener('click', () => rotatePage(-90)); document.getElementById('btn-rotate-right').addEventListener('click', () => rotatePage(90)); + + // Zoom + document.getElementById('btn-zoom-in').addEventListener('click', () => setZoom(currentZoom + 0.25)); + document.getElementById('btn-zoom-out').addEventListener('click', () => setZoom(currentZoom - 0.25)); + document.getElementById('btn-zoom-reset').addEventListener('click', () => setZoom(1.0)); + + // Schrift-Optionen für Text-Tool / selektierte Texte + const fontFamily = document.getElementById('tool-fontfamily'); + const fontSize = document.getElementById('tool-fontsize'); + const boldChk = document.getElementById('tool-bold'); + const italicChk = document.getElementById('tool-italic'); + + function applyTextProps() { + const sel = fabricCanvas.getActiveObject(); + if (sel && (sel.type === 'i-text' || sel.type === 'text' || sel.type === 'textbox')) { + sel.set({ + fontFamily: fontFamily.value, + fontSize: parseInt(fontSize.value, 10), + fontWeight: boldChk.checked ? 'bold' : 'normal', + fontStyle: italicChk.checked ? 'italic' : 'normal', + }); + fabricCanvas.requestRenderAll(); + } + } + fontFamily.addEventListener('change', applyTextProps); + fontSize.addEventListener('input', applyTextProps); + boldChk.addEventListener('change', applyTextProps); + italicChk.addEventListener('change', applyTextProps); + + // Bei Selektion eines Text-Objekts die Toolbar-Werte synchronisieren + fabricCanvas.on('selection:created', syncTextToolbar); + fabricCanvas.on('selection:updated', syncTextToolbar); + function syncTextToolbar() { + const sel = fabricCanvas.getActiveObject(); + if (!sel) return; + if (sel.type === 'i-text' || sel.type === 'text' || sel.type === 'textbox') { + if (sel.fontFamily) fontFamily.value = sel.fontFamily; + if (sel.fontSize) fontSize.value = sel.fontSize; + boldChk.checked = (sel.fontWeight === 'bold'); + italicChk.checked = (sel.fontStyle === 'italic'); + } + } + } + + async function setZoom(z) { + currentZoom = Math.max(0.25, Math.min(3.0, Math.round(z * 100) / 100)); + document.getElementById('zoom-label').textContent = Math.round(currentZoom * 100) + '%'; + await rerenderCurrent(); } async function rotatePage(deg) { @@ -258,41 +323,153 @@ await savePageAnnotations(false); } + /* ---------- Shape-Drawing per Drag ---------- */ + let drawState = null; // { shape, startX, startY } + function applyTool() { fabricCanvas.isDrawingMode = (currentTool === 'draw'); fabricCanvas.selection = (currentTool === 'select'); + // Cursor-Hint + fabricCanvas.defaultCursor = (currentTool === 'select') ? 'default' : 'crosshair'; - // Click-Handler für Shape-Tools fabricCanvas.off('mouse:down', shapeDown); - if (['rect', 'circle', 'arrow', 'text'].includes(currentTool)) { + fabricCanvas.off('mouse:move', shapeMove); + fabricCanvas.off('mouse:up', shapeUp); + + if (['rect', 'circle', 'arrow'].includes(currentTool)) { + fabricCanvas.on('mouse:down', shapeDown); + fabricCanvas.on('mouse:move', shapeMove); + fabricCanvas.on('mouse:up', shapeUp); + } else if (currentTool === 'text') { fabricCanvas.on('mouse:down', shapeDown); } } function shapeDown(opt) { + // Wenn wir ein vorhandenes Objekt anklicken: nicht zeichnen, selektieren + if (opt.target) return; const p = fabricCanvas.getPointer(opt.e); const color = document.getElementById('tool-color').value; const sw = parseInt(document.getElementById('tool-stroke').value, 10); - let shape = null; + if (currentTool === 'rect') { - shape = new fabric.Rect({ left: p.x, top: p.y, width: 100, height: 60, fill: 'transparent', stroke: color, strokeWidth: sw }); + const r = new fabric.Rect({ + left: p.x, top: p.y, width: 1, height: 1, + fill: 'transparent', stroke: color, strokeWidth: sw, + originX: 'left', originY: 'top' + }); + fabricCanvas.add(r); + drawState = { shape: r, startX: p.x, startY: p.y }; } else if (currentTool === 'circle') { - shape = new fabric.Circle({ left: p.x, top: p.y, radius: 40, fill: 'transparent', stroke: color, strokeWidth: sw }); + const e = new fabric.Ellipse({ + left: p.x, top: p.y, rx: 1, ry: 1, + fill: 'transparent', stroke: color, strokeWidth: sw, + originX: 'left', originY: 'top' + }); + fabricCanvas.add(e); + drawState = { shape: e, startX: p.x, startY: p.y }; } else if (currentTool === 'arrow') { - // Pfeil = Linie mit Pfeilspitze (vereinfacht) - shape = new fabric.Line([p.x, p.y, p.x + 100, p.y + 50], { stroke: color, strokeWidth: sw }); + const a = makeArrow(p.x, p.y, p.x + 1, p.y + 1, color, sw); + fabricCanvas.add(a); + drawState = { shape: a, startX: p.x, startY: p.y, isArrow: true, color, sw }; } else if (currentTool === 'text') { - shape = new fabric.IText('Text…', { left: p.x, top: p.y, fontSize: 24, fill: color }); - } - if (shape) { - fabricCanvas.add(shape); - fabricCanvas.setActiveObject(shape); - // Nach dem Setzen wieder zurück auf Select, damit User direkt verschieben/skalieren kann + const ff = document.getElementById('tool-fontfamily').value || 'Helvetica'; + const fs = parseInt(document.getElementById('tool-fontsize').value, 10) || 24; + const bold = document.getElementById('tool-bold').checked; + const ital = document.getElementById('tool-italic').checked; + const t = new fabric.IText('Text…', { + left: p.x, top: p.y, + fontFamily: ff, fontSize: fs, + fontWeight: bold ? 'bold' : 'normal', + fontStyle: ital ? 'italic' : 'normal', + fill: color + }); + fabricCanvas.add(t); + fabricCanvas.setActiveObject(t); const sb = document.querySelector('.tool-btn[data-tool="select"]'); if (sb) sb.click(); } } + function shapeMove(opt) { + if (!drawState) return; + const p = fabricCanvas.getPointer(opt.e); + const s = drawState.shape; + + if (drawState.isArrow) { + // Alten Pfeil entfernen, neuen mit aktualisierten Endpunkten zeichnen + fabricCanvas.remove(s); + const newArrow = makeArrow(drawState.startX, drawState.startY, p.x, p.y, drawState.color, drawState.sw); + fabricCanvas.add(newArrow); + drawState.shape = newArrow; + } else if (s.type === 'rect') { + const w = p.x - drawState.startX; + const h = p.y - drawState.startY; + s.set({ + left: w < 0 ? p.x : drawState.startX, + top: h < 0 ? p.y : drawState.startY, + width: Math.abs(w), + height: Math.abs(h), + }); + } else if (s.type === 'ellipse') { + const w = p.x - drawState.startX; + const h = p.y - drawState.startY; + s.set({ + left: w < 0 ? p.x : drawState.startX, + top: h < 0 ? p.y : drawState.startY, + rx: Math.abs(w) / 2, + ry: Math.abs(h) / 2, + }); + // Ellipse braucht ihre Größe anhand rx/ry + s.set({ width: Math.abs(w), height: Math.abs(h) }); + } + s.setCoords(); + fabricCanvas.requestRenderAll(); + } + + function shapeUp() { + if (drawState && drawState.shape) { + const s = drawState.shape; + // Sicherstellen dass das Objekt selektierbar/drehbar ist + s.set({ hasControls: true, hasBorders: true, lockRotation: false }); + fabricCanvas.setActiveObject(s); + } + drawState = null; + // Nach dem Zeichnen automatisch auf Select wechseln + const sb = document.querySelector('.tool-btn[data-tool="select"]'); + if (sb) sb.click(); + } + + /** + * Baut einen Pfeil als Fabric-Group: Linie + Dreieck als Spitze. + * Gruppe ist drehbar, skalierbar, verschiebbar. + */ + function makeArrow(x1, y1, x2, y2, color, sw) { + const dx = x2 - x1, dy = y2 - y1; + const len = Math.sqrt(dx * dx + dy * dy) || 1; + const angle = Math.atan2(dy, dx); + const headLen = Math.max(12, sw * 4); + + // Linie als Path damit sie in die Group passt — relative Koordinaten + const line = new fabric.Line([0, 0, len, 0], { + stroke: color, strokeWidth: sw, originX: 'left', originY: 'center', + }); + const head = new fabric.Triangle({ + left: len, top: 0, + width: headLen, height: headLen, + fill: color, + originX: 'center', originY: 'center', + angle: 90, + }); + const grp = new fabric.Group([line, head], { + left: x1, top: y1, + originX: 'left', originY: 'center', + angle: angle * 180 / Math.PI, + hasControls: true, hasBorders: true, lockRotation: false, + }); + return grp; + } + /* ---------- Undo/Redo (einfach) ---------- */ const history = []; let histIdx = -1;