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;