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("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>';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
209
js/editor.js
209
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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue