feat: Schrift-Optionen, Zoom, drehbarer Pfeil mit Spitze, ResizeObserver
All checks were successful
Deploy bericht / deploy (push) Successful in 1s

- Toolbar: Schriftart (Helvetica/Arial/Times/Courier/Verdana/Georgia),
  Größe (8-120), Bold/Italic Checkboxen — wirken auf neues Text-Tool
  und auf selektierte Texte (Sync via selection:created/updated)
- Zoom-Buttons (-/+/Reset) mit Anzeige in %, skaliert getTargetCanvasWidth
- Pfeil-Tool: Drag-to-Draw mit echter Pfeilspitze als Group (Linie +
  Triangle), drehbar/skalierbar/verschiebbar wie alle anderen Shapes
- Rect/Ellipse: ebenfalls Drag-to-Draw statt fester Größe
- ResizeObserver auf canvas-wrap re-rendert die Seite wenn sich die
  Container-Breite ändert (z.B. DevTools öffnen)
- Toolbar-Inputs/Selects nutzen Dolibarr CSS-Variablen für Dark-Theme

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
[deploy]
This commit is contained in:
Eduard Wisch 2026-04-08 16:28:06 +02:00
parent b61b3f2b88
commit 1d3315a0b5
3 changed files with 225 additions and 16 deletions

View file

@ -278,6 +278,25 @@ if (!$bericht) {
print '<label>'.$langs->trans("BerichtColor").': <input type="color" id="tool-color" value="#ff0000"></label>';
print '<label>'.$langs->trans("BerichtStrokeWidth").': <input type="range" id="tool-stroke" min="1" max="20" value="3"></label>';
print '<span class="sep"></span>';
// Text-Optionen (nur sichtbar wenn Text-Tool aktiv oder ein Text-Objekt selektiert)
print '<label class="text-tool-option">Schrift: <select id="tool-fontfamily">'
.'<option value="Helvetica">Helvetica</option>'
.'<option value="Arial">Arial</option>'
.'<option value="Times New Roman">Times New Roman</option>'
.'<option value="Courier New">Courier New</option>'
.'<option value="Verdana">Verdana</option>'
.'<option value="Georgia">Georgia</option>'
.'</select></label>';
print '<label class="text-tool-option">Größe: <input type="number" id="tool-fontsize" min="8" max="120" value="24" style="width:60px"></label>';
print '<label class="text-tool-option"><input type="checkbox" id="tool-bold"> <b>B</b></label>';
print '<label class="text-tool-option"><input type="checkbox" id="tool-italic"> <i>I</i></label>';
print '<span class="sep"></span>';
// Zoom
print '<button type="button" id="btn-zoom-out" title="Zoom -">🔍−</button>';
print '<span id="zoom-label" style="min-width:42px;text-align:center;font-size:12px;">100%</span>';
print '<button type="button" id="btn-zoom-in" title="Zoom +">🔍+</button>';
print '<button type="button" id="btn-zoom-reset" title="Zoom 100%">⟳%</button>';
print '<span class="sep"></span>';
print '<button type="button" id="btn-rotate-left" title="'.$langs->trans("BerichtRotateLeft").'">⟲</button>';
print '<button type="button" id="btn-rotate-right" title="'.$langs->trans("BerichtRotateRight").'">⟳</button>';
print '<button type="button" id="btn-delete-selected" title="'.$langs->trans("BerichtToolDelete").'">🗑️</button>';

View file

@ -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;

View file

@ -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;