feat: Schrift-Optionen, Zoom, drehbarer Pfeil mit Spitze, ResizeObserver
All checks were successful
Deploy bericht / deploy (push) Successful in 1s
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:
parent
b61b3f2b88
commit
1d3315a0b5
3 changed files with 225 additions and 16 deletions
|
|
@ -278,6 +278,25 @@ if (!$bericht) {
|
||||||
print '<label>'.$langs->trans("BerichtColor").': <input type="color" id="tool-color" value="#ff0000"></label>';
|
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 '<label>'.$langs->trans("BerichtStrokeWidth").': <input type="range" id="tool-stroke" min="1" max="20" value="3"></label>';
|
||||||
print '<span class="sep"></span>';
|
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-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-rotate-right" title="'.$langs->trans("BerichtRotateRight").'">⟳</button>';
|
||||||
print '<button type="button" id="btn-delete-selected" title="'.$langs->trans("BerichtToolDelete").'">🗑️</button>';
|
print '<button type="button" id="btn-delete-selected" title="'.$langs->trans("BerichtToolDelete").'">🗑️</button>';
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,19 @@
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--colortext, inherit);
|
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 {
|
.bericht-canvas-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
|
||||||
209
js/editor.js
209
js/editor.js
|
|
@ -36,6 +36,7 @@
|
||||||
let currentPageRotation = 0; // 0 / 90 / 180 / 270
|
let currentPageRotation = 0; // 0 / 90 / 180 / 270
|
||||||
let currentPageBuffer = null; // ArrayBuffer der aktuellen Quelle
|
let currentPageBuffer = null; // ArrayBuffer der aktuellen Quelle
|
||||||
let currentPageMime = '';
|
let currentPageMime = '';
|
||||||
|
let currentZoom = 1.0; // 1.0 = 100% (Container-Fit), 0.5..3.0
|
||||||
let fabricCanvas = null;
|
let fabricCanvas = null;
|
||||||
const pdfCanvas = document.getElementById('pdf-canvas');
|
const pdfCanvas = document.getElementById('pdf-canvas');
|
||||||
let currentTool = 'select';
|
let currentTool = 'select';
|
||||||
|
|
@ -60,6 +61,21 @@
|
||||||
bindExtraUpload();
|
bindExtraUpload();
|
||||||
bindActions();
|
bindActions();
|
||||||
bindSortable();
|
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 ---------- */
|
/* ---------- Seiten laden ---------- */
|
||||||
|
|
@ -110,8 +126,9 @@
|
||||||
if (!wrap) return 800;
|
if (!wrap) return 800;
|
||||||
const cs = getComputedStyle(wrap);
|
const cs = getComputedStyle(wrap);
|
||||||
const padX = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight);
|
const padX = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight);
|
||||||
const avail = wrap.clientWidth - padX - 4; // 4px Sicherheitsabstand
|
const avail = wrap.clientWidth - padX - 4;
|
||||||
return Math.max(300, Math.min(1200, Math.floor(avail)));
|
const base = Math.max(300, Math.min(1200, Math.floor(avail)));
|
||||||
|
return Math.round(base * currentZoom);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderPdf(arrayBuffer) {
|
async function renderPdf(arrayBuffer) {
|
||||||
|
|
@ -242,9 +259,57 @@
|
||||||
fabricCanvas.discardActiveObject();
|
fabricCanvas.discardActiveObject();
|
||||||
fabricCanvas.requestRenderAll();
|
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-left').addEventListener('click', () => rotatePage(-90));
|
||||||
document.getElementById('btn-rotate-right').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) {
|
async function rotatePage(deg) {
|
||||||
|
|
@ -258,41 +323,153 @@
|
||||||
await savePageAnnotations(false);
|
await savePageAnnotations(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Shape-Drawing per Drag ---------- */
|
||||||
|
let drawState = null; // { shape, startX, startY }
|
||||||
|
|
||||||
function applyTool() {
|
function applyTool() {
|
||||||
fabricCanvas.isDrawingMode = (currentTool === 'draw');
|
fabricCanvas.isDrawingMode = (currentTool === 'draw');
|
||||||
fabricCanvas.selection = (currentTool === 'select');
|
fabricCanvas.selection = (currentTool === 'select');
|
||||||
|
// Cursor-Hint
|
||||||
|
fabricCanvas.defaultCursor = (currentTool === 'select') ? 'default' : 'crosshair';
|
||||||
|
|
||||||
// Click-Handler für Shape-Tools
|
|
||||||
fabricCanvas.off('mouse:down', shapeDown);
|
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);
|
fabricCanvas.on('mouse:down', shapeDown);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function shapeDown(opt) {
|
function shapeDown(opt) {
|
||||||
|
// Wenn wir ein vorhandenes Objekt anklicken: nicht zeichnen, selektieren
|
||||||
|
if (opt.target) return;
|
||||||
const p = fabricCanvas.getPointer(opt.e);
|
const p = fabricCanvas.getPointer(opt.e);
|
||||||
const color = document.getElementById('tool-color').value;
|
const color = document.getElementById('tool-color').value;
|
||||||
const sw = parseInt(document.getElementById('tool-stroke').value, 10);
|
const sw = parseInt(document.getElementById('tool-stroke').value, 10);
|
||||||
let shape = null;
|
|
||||||
if (currentTool === 'rect') {
|
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') {
|
} 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') {
|
} else if (currentTool === 'arrow') {
|
||||||
// Pfeil = Linie mit Pfeilspitze (vereinfacht)
|
const a = makeArrow(p.x, p.y, p.x + 1, p.y + 1, color, sw);
|
||||||
shape = new fabric.Line([p.x, p.y, p.x + 100, p.y + 50], { stroke: color, strokeWidth: sw });
|
fabricCanvas.add(a);
|
||||||
|
drawState = { shape: a, startX: p.x, startY: p.y, isArrow: true, color, sw };
|
||||||
} else if (currentTool === 'text') {
|
} else if (currentTool === 'text') {
|
||||||
shape = new fabric.IText('Text…', { left: p.x, top: p.y, fontSize: 24, fill: color });
|
const ff = document.getElementById('tool-fontfamily').value || 'Helvetica';
|
||||||
}
|
const fs = parseInt(document.getElementById('tool-fontsize').value, 10) || 24;
|
||||||
if (shape) {
|
const bold = document.getElementById('tool-bold').checked;
|
||||||
fabricCanvas.add(shape);
|
const ital = document.getElementById('tool-italic').checked;
|
||||||
fabricCanvas.setActiveObject(shape);
|
const t = new fabric.IText('Text…', {
|
||||||
// Nach dem Setzen wieder zurück auf Select, damit User direkt verschieben/skalieren kann
|
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"]');
|
const sb = document.querySelector('.tool-btn[data-tool="select"]');
|
||||||
if (sb) sb.click();
|
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) ---------- */
|
/* ---------- Undo/Redo (einfach) ---------- */
|
||||||
const history = [];
|
const history = [];
|
||||||
let histIdx = -1;
|
let histIdx = -1;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue