Erweiterungen

This commit is contained in:
2026-02-13 09:16:32 +01:00
parent 27f98ca7d9
commit 120a8e0571
9 changed files with 2031 additions and 2223 deletions

View File

@@ -0,0 +1,721 @@
// Invoice Generator - Native HTML5 Canvas Implementation
(function() {
'use strict';
class InvoiceGenerator {
constructor() {
this.canvas = null;
this.ctx = null;
this.elements = new Map();
this.selectedElement = null;
this.elementCounter = 0;
this.isDragging = false;
this.isResizing = false;
this.dragStart = { x: 0, y: 0 };
this.elementStart = { x: 0, y: 0, w: 0, h: 0 };
this.activeHandle = null;
this.container = null;
// Page dimensions (A4 at 96 DPI)
this.pageX = 50;
this.pageY = 30;
this.pageWidth = 794;
this.pageHeight = 1123;
// Handle configuration
this.handleSize = 10;
this.handlePadding = 3;
}
init() {
this.container = document.getElementById('invoice-canvas-container');
if (!this.container) {
console.error('Canvas container not found');
return;
}
// Create canvas
this.canvas = document.createElement('canvas');
this.canvas.style.display = 'block';
this.updateCanvasSize();
this.ctx = this.canvas.getContext('2d');
this.container.innerHTML = '';
this.container.appendChild(this.canvas);
// Bind events
this.canvas.addEventListener('mousedown', this.onMouseDown.bind(this));
this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this));
this.canvas.addEventListener('mouseup', this.onMouseUp.bind(this));
this.canvas.addEventListener('mouseleave', this.onMouseUp.bind(this));
window.addEventListener('resize', () => {
this.updateCanvasSize();
this.draw();
});
this.draw();
console.log('Invoice generator initialized');
}
updateCanvasSize() {
const rect = this.container.getBoundingClientRect();
this.canvas.width = rect.width;
this.canvas.height = rect.height;
this.pageX = Math.max(20, (this.canvas.width - this.pageWidth) / 2);
}
getMousePos(e) {
const rect = this.canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
}
// Check if point is inside element
hitTest(x, y, el) {
const w = el.width || 150;
const h = el.height || 30;
return x >= el.x && x <= el.x + w && y >= el.y && y <= el.y + h;
}
// Check if point is on a resize handle
getHandleAt(x, y, el) {
if (!el) return null;
const w = el.width || 150;
const h = el.height || 30;
const hs = this.handleSize + this.handlePadding;
// Define handle positions (center points)
const handles = {
'nw': { x: el.x, y: el.y },
'n': { x: el.x + w / 2, y: el.y },
'ne': { x: el.x + w, y: el.y },
'w': { x: el.x, y: el.y + h / 2 },
'e': { x: el.x + w, y: el.y + h / 2 },
'sw': { x: el.x, y: el.y + h },
's': { x: el.x + w / 2, y: el.y + h },
'se': { x: el.x + w, y: el.y + h }
};
for (const [name, pos] of Object.entries(handles)) {
const dx = x - pos.x;
const dy = y - pos.y;
if (Math.abs(dx) <= hs && Math.abs(dy) <= hs) {
return name;
}
}
return null;
}
onMouseDown(e) {
const pos = this.getMousePos(e);
// First check if clicking on a handle of selected element
if (this.selectedElement) {
const handle = this.getHandleAt(pos.x, pos.y, this.selectedElement);
if (handle) {
console.log('Resize handle clicked:', handle);
this.isResizing = true;
this.activeHandle = handle;
this.dragStart = { x: pos.x, y: pos.y };
this.elementStart = {
x: this.selectedElement.x,
y: this.selectedElement.y,
w: this.selectedElement.width || 100,
h: this.selectedElement.height || 30
};
return;
}
}
// Check if clicking on an element
let clickedElement = null;
for (const el of Array.from(this.elements.values()).reverse()) {
if (this.hitTest(pos.x, pos.y, el)) {
clickedElement = el;
break;
}
}
if (clickedElement) {
this.selectElement(clickedElement.id);
this.isDragging = true;
this.dragStart = { x: pos.x, y: pos.y };
this.elementStart = { x: clickedElement.x, y: clickedElement.y };
this.canvas.style.cursor = 'move';
} else {
this.deselectAll();
}
this.draw();
}
onMouseMove(e) {
const pos = this.getMousePos(e);
if (this.isResizing && this.selectedElement) {
this.doResize(pos.x, pos.y);
return;
}
if (this.isDragging && this.selectedElement) {
const dx = pos.x - this.dragStart.x;
const dy = pos.y - this.dragStart.y;
this.selectedElement.x = this.elementStart.x + dx;
this.selectedElement.y = this.elementStart.y + dy;
// Constrain to page
this.selectedElement.x = Math.max(this.pageX, Math.min(this.selectedElement.x,
this.pageX + this.pageWidth - (this.selectedElement.width || 100)));
this.selectedElement.y = Math.max(this.pageY, Math.min(this.selectedElement.y,
this.pageY + this.pageHeight - (this.selectedElement.height || 30)));
this.draw();
this.notifyChange();
return;
}
// Update cursor
if (this.selectedElement) {
const handle = this.getHandleAt(pos.x, pos.y, this.selectedElement);
if (handle) {
const cursors = {
'nw': 'nw-resize', 'ne': 'ne-resize', 'sw': 'sw-resize', 'se': 'se-resize',
'n': 'ns-resize', 's': 'ns-resize', 'e': 'ew-resize', 'w': 'ew-resize'
};
this.canvas.style.cursor = cursors[handle] || 'default';
return;
}
}
// Check hover over elements
let hovering = false;
for (const el of Array.from(this.elements.values()).reverse()) {
if (this.hitTest(pos.x, pos.y, el)) {
hovering = true;
break;
}
}
this.canvas.style.cursor = hovering ? 'move' : 'default';
}
onMouseUp(e) {
this.isDragging = false;
this.isResizing = false;
this.activeHandle = null;
this.canvas.style.cursor = 'default';
}
doResize(mouseX, mouseY) {
if (!this.selectedElement || !this.activeHandle) return;
const el = this.selectedElement;
el.manuallyResized = true; // Mark as manually resized
const start = this.elementStart;
const minSize = 20;
// Calculate new dimensions based on handle
switch (this.activeHandle) {
case 'se':
el.width = Math.max(minSize, mouseX - start.x);
el.height = Math.max(minSize, mouseY - start.y);
break;
case 'sw':
const newWsw = start.w + (start.x - mouseX);
if (newWsw >= minSize) {
el.x = mouseX;
el.width = newWsw;
}
el.height = Math.max(minSize, mouseY - start.y);
break;
case 'ne':
el.width = Math.max(minSize, mouseX - start.x);
const newHne = start.h + (start.y - mouseY);
if (newHne >= minSize) {
el.y = mouseY;
el.height = newHne;
}
break;
case 'nw':
const newWnw = start.w + (start.x - mouseX);
const newHnw = start.h + (start.y - mouseY);
if (newWnw >= minSize) {
el.x = mouseX;
el.width = newWnw;
}
if (newHnw >= minSize) {
el.y = mouseY;
el.height = newHnw;
}
break;
case 'e':
el.width = Math.max(minSize, mouseX - start.x);
break;
case 'w':
const newWw = start.w + (start.x - mouseX);
if (newWw >= minSize) {
el.x = mouseX;
el.width = newWw;
}
break;
case 's':
el.height = Math.max(minSize, mouseY - start.y);
break;
case 'n':
const newHn = start.h + (start.y - mouseY);
if (newHn >= minSize) {
el.y = mouseY;
el.height = newHn;
}
break;
}
this.draw();
this.notifyChange();
}
notifyChange() {
if (this.selectedElement && window.invoiceGeneratorView?.$server) {
const el = this.selectedElement;
window.invoiceGeneratorView.$server.updatePropertiesPanel(
el.id, el.type, el.text || '', el.x, el.y, el.fontSize || 14, el.color || '#000000'
);
}
}
draw() {
if (!this.ctx) return;
const ctx = this.ctx;
// Clear
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// Background
ctx.fillStyle = '#e8e8e8';
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
// Page shadow
ctx.fillStyle = 'rgba(0,0,0,0.2)';
ctx.fillRect(this.pageX + 4, this.pageY + this.pageHeight, this.pageWidth, 4);
ctx.fillRect(this.pageX + this.pageWidth, this.pageY + 4, 4, this.pageHeight);
// Page
ctx.fillStyle = '#ffffff';
ctx.fillRect(this.pageX, this.pageY, this.pageWidth, this.pageHeight);
ctx.strokeStyle = '#cccccc';
ctx.lineWidth = 1;
ctx.strokeRect(this.pageX, this.pageY, this.pageWidth, this.pageHeight);
// Draw elements
this.elements.forEach(el => this.drawElement(el));
// Draw selection
if (this.selectedElement) {
this.drawSelection(this.selectedElement);
}
}
drawImagePlaceholder(ctx, el) {
ctx.fillStyle = '#f0f0f0';
ctx.fillRect(el.x, el.y, el.width || 100, el.height || 100);
ctx.strokeStyle = '#999999';
ctx.lineWidth = 1;
ctx.strokeRect(el.x, el.y, el.width || 100, el.height || 100);
ctx.fillStyle = '#666666';
ctx.font = '12px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Bild', el.x + (el.width || 100) / 2, el.y + (el.height || 100) / 2);
}
drawElement(el) {
const ctx = this.ctx;
ctx.save();
switch (el.type) {
case 'line':
ctx.strokeStyle = el.color || '#333333';
ctx.lineWidth = el.strokeWidth || 1;
ctx.beginPath();
ctx.moveTo(el.x, el.y);
ctx.lineTo(el.x + (el.width || 200), el.y);
ctx.stroke();
break;
case 'image':
if (el.imageData) {
// Draw actual image
const img = el.imageObj;
if (img && img.complete) {
// Draw image scaled to fit element while maintaining aspect ratio
const imgAspect = img.width / img.height;
const elAspect = (el.width || 100) / (el.height || 100);
let drawWidth, drawHeight, drawX, drawY;
if (imgAspect > elAspect) {
// Image is wider than element
drawWidth = el.width || 100;
drawHeight = drawWidth / imgAspect;
drawX = el.x;
drawY = el.y + ((el.height || 100) - drawHeight) / 2;
} else {
// Image is taller than element
drawHeight = el.height || 100;
drawWidth = drawHeight * imgAspect;
drawX = el.x + ((el.width || 100) - drawWidth) / 2;
drawY = el.y;
}
ctx.drawImage(img, drawX, drawY, drawWidth, drawHeight);
// Draw border
ctx.strokeStyle = '#cccccc';
ctx.lineWidth = 1;
ctx.strokeRect(el.x, el.y, el.width || 100, el.height || 100);
} else {
// Image still loading or failed
this.drawImagePlaceholder(ctx, el);
}
} else {
// No image uploaded yet - draw placeholder
this.drawImagePlaceholder(ctx, el);
}
break;
default: // text elements
this.drawTextElement(el, ctx);
}
ctx.restore();
}
// Wrap text to fit within maxWidth
wrapText(ctx, text, maxWidth) {
const words = text.split(' ');
const lines = [];
let currentLine = words[0];
for (let i = 1; i < words.length; i++) {
const word = words[i];
const width = ctx.measureText(currentLine + ' ' + word).width;
if (width < maxWidth) {
currentLine += ' ' + word;
} else {
lines.push(currentLine);
currentLine = word;
}
}
lines.push(currentLine);
return lines;
}
drawTextElement(el, ctx) {
ctx.font = `${el.fontStyle || ''} ${el.fontSize || 14}px ${el.fontFamily || 'Arial'}`;
ctx.fillStyle = el.color || '#333333';
ctx.textBaseline = 'top';
const maxWidth = el.width || 150;
const lineHeight = (el.fontSize || 14) * 1.2;
const maxHeight = el.height || 1000;
// Calculate max lines that fit
const maxLines = Math.floor(maxHeight / lineHeight);
// Split by explicit newlines first, then wrap each line
const explicitLines = (el.text || '').split('\n');
const allLines = [];
for (const line of explicitLines) {
const metrics = ctx.measureText(line);
if (metrics.width <= maxWidth) {
allLines.push(line);
} else {
const wrapped = this.wrapText(ctx, line, maxWidth);
allLines.push(...wrapped);
}
}
// Determine if we need ellipsis
const needsEllipsis = allLines.length > maxLines;
const linesToDraw = needsEllipsis ? maxLines : allLines.length;
// Draw lines
let y = el.y;
for (let i = 0; i < linesToDraw; i++) {
const line = allLines[i];
// If this is the last line and we need ellipsis
if (needsEllipsis && i === linesToDraw - 1) {
const ellipsis = '...';
// Check if the line with ellipsis would fit
if (ctx.measureText(line + ellipsis).width <= maxWidth) {
ctx.fillText(line + ellipsis, el.x, y);
} else {
// Need to trim the line to fit ellipsis
let trimmed = line;
while (trimmed.length > 0 && ctx.measureText(trimmed + ellipsis).width > maxWidth) {
trimmed = trimmed.slice(0, -1);
}
ctx.fillText(trimmed + ellipsis, el.x, y);
}
} else {
ctx.fillText(line, el.x, y);
}
y += lineHeight;
}
// Update height based on actual content if not manually resized
if (!el.manuallyResized) {
el.height = Math.max(lineHeight, allLines.length * lineHeight);
}
}
drawSelection(el) {
const ctx = this.ctx;
const x = el.x;
const y = el.y;
const w = el.width || 150;
const h = el.height || 30;
const hs = this.handleSize;
// Selection border
ctx.strokeStyle = '#1976d2';
ctx.lineWidth = 2;
ctx.setLineDash([5, 3]);
ctx.strokeRect(x, y, w, h);
ctx.setLineDash([]);
// Handles
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#1976d2';
ctx.lineWidth = 2;
const positions = [
[x - hs/2, y - hs/2], // nw
[x + w/2 - hs/2, y - hs/2], // n
[x + w - hs/2, y - hs/2], // ne
[x - hs/2, y + h/2 - hs/2], // w
[x + w - hs/2, y + h/2 - hs/2], // e
[x - hs/2, y + h - hs/2], // sw
[x + w/2 - hs/2, y + h - hs/2], // s
[x + w - hs/2, y + h - hs/2] // se
];
positions.forEach(([hx, hy]) => {
ctx.fillRect(hx, hy, hs, hs);
ctx.strokeRect(hx, hy, hs, hs);
});
}
addElement(type, label, dropX, dropY) {
this.elementCounter++;
const id = `element-${this.elementCounter}`;
const rect = this.container.getBoundingClientRect();
const x = Math.max(this.pageX + 10, dropX - rect.left - 50);
const y = Math.max(this.pageY + 10, dropY - rect.top - 15);
let el = {
id, type, x, y,
width: 150,
height: 30,
fontSize: 14,
fontFamily: 'Arial',
color: '#333333'
};
switch (type) {
case 'text':
el.text = 'Text eingeben...';
el.height = 20;
break;
case 'header':
el.text = 'Überschrift';
el.fontSize = 24;
el.fontStyle = 'bold';
el.color = '#000000';
el.width = 200;
el.height = 30;
break;
case 'date':
el.text = `Datum: ${new Date().toLocaleDateString('de-DE')}`;
el.fontSize = 12;
el.color = '#666666';
el.height = 16;
break;
case 'customer':
case 'company':
el.text = type === 'customer' ? 'Kundenname\nStraße Nr.\nPLZ Ort' : 'Ihr Unternehmen\nIhre Straße\nIhre PLZ Ort';
el.height = 50;
el.fontSize = 12;
break;
case 'amount':
el.text = 'Gesamtbetrag: 0,00 €';
el.fontStyle = 'bold';
el.width = 180;
el.height = 20;
break;
case 'line':
el.text = '';
el.width = 200;
el.height = 2;
break;
case 'image':
el.text = 'Bild';
el.width = 100;
el.height = 100;
break;
default:
el.text = label || 'Neues Element';
el.height = 20;
}
this.elements.set(id, el);
this.selectElement(id);
this.draw();
}
selectElement(id) {
this.selectedElement = this.elements.get(id) || null;
this.draw();
if (this.selectedElement) {
this.notifyChange();
}
}
deselectAll() {
this.selectedElement = null;
this.draw();
if (window.invoiceGeneratorView?.$server) {
window.invoiceGeneratorView.$server.resetPropertiesPanel();
}
}
updateElementText(id, text) {
const el = this.elements.get(id);
if (el) {
el.text = text;
this.draw();
}
}
updateElementPosition(id, x, y) {
const el = this.elements.get(id);
if (el) {
if (x !== null) el.x = x;
if (y !== null) el.y = y;
this.draw();
}
}
updateElementFontSize(id, size) {
const el = this.elements.get(id);
if (el) {
el.fontSize = size;
this.draw();
}
}
updateElementImage(id, imageDataUrl) {
const el = this.elements.get(id);
if (el && el.type === 'image') {
el.imageData = imageDataUrl;
// Create image object
const img = new Image();
img.onload = () => {
el.imageObj = img;
this.draw();
};
img.onerror = () => {
console.error('Failed to load image');
el.imageData = null;
el.imageObj = null;
this.draw();
};
img.src = imageDataUrl;
}
}
deleteElement(id) {
this.elements.delete(id);
if (this.selectedElement?.id === id) {
this.selectedElement = null;
}
this.draw();
}
clearCanvas() {
this.elements.clear();
this.selectedElement = null;
this.draw();
}
getCanvasData() {
return {
elements: Array.from(this.elements.values())
};
}
exportTemplate() {
const data = JSON.stringify(this.getCanvasData(), null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'template.json';
a.click();
URL.revokeObjectURL(url);
}
generatePreview() {
const temp = document.createElement('canvas');
temp.width = this.pageWidth;
temp.height = this.pageHeight;
const tctx = temp.getContext('2d');
tctx.fillStyle = '#ffffff';
tctx.fillRect(0, 0, temp.width, temp.height);
// Draw elements offset by page position
const originalPageX = this.pageX;
const originalPageY = this.pageY;
this.pageX = 0;
this.pageY = 0;
this.elements.forEach(el => {
const ox = el.x, oy = el.y;
el.x = ox - originalPageX;
el.y = oy - originalPageY;
this.drawElement.call({ ctx: tctx, elements: this.elements }, el);
el.x = ox;
el.y = oy;
});
this.pageX = originalPageX;
this.pageY = originalPageY;
const win = window.open();
if (win) {
win.document.write(`<img src="${temp.toDataURL()}" style="max-width:100%">`);
}
}
}
window.invoiceGenerator = new InvoiceGenerator();
})();