botui/ui/suite/canvas/canvas.js

1391 lines
37 KiB
JavaScript

/* =============================================================================
CANVAS MODULE - Whiteboard/Drawing Application
============================================================================= */
(function () {
"use strict";
// =============================================================================
// STATE
// =============================================================================
const state = {
canvasId: null,
canvasName: "Untitled Canvas",
tool: "select",
color: "#000000",
strokeWidth: 2,
fillColor: "transparent",
fontSize: 16,
fontFamily: "Inter",
zoom: 1,
panX: 0,
panY: 0,
isDrawing: false,
isPanning: false,
startX: 0,
startY: 0,
elements: [],
selectedElement: null,
clipboard: null,
history: [],
historyIndex: -1,
gridEnabled: true,
snapToGrid: true,
gridSize: 20,
};
let canvas = null;
let ctx = null;
// =============================================================================
// INITIALIZATION
// =============================================================================
function init() {
canvas = document.getElementById("canvas");
if (!canvas) {
console.warn("Canvas element not found");
return;
}
ctx = canvas.getContext("2d");
resizeCanvas();
bindEvents();
loadFromUrl();
render();
console.log("Canvas module initialized");
}
function resizeCanvas() {
if (!canvas) return;
const container = canvas.parentElement;
if (container) {
canvas.width = container.clientWidth || 1200;
canvas.height = container.clientHeight || 800;
}
}
function bindEvents() {
if (!canvas) return;
canvas.addEventListener("mousedown", handleMouseDown);
canvas.addEventListener("mousemove", handleMouseMove);
canvas.addEventListener("mouseup", handleMouseUp);
canvas.addEventListener("mouseleave", handleMouseUp);
canvas.addEventListener("wheel", handleWheel);
canvas.addEventListener("dblclick", handleDoubleClick);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("resize", () => {
resizeCanvas();
render();
});
// Touch support
canvas.addEventListener("touchstart", handleTouchStart);
canvas.addEventListener("touchmove", handleTouchMove);
canvas.addEventListener("touchend", handleTouchEnd);
}
// =============================================================================
// TOOL SELECTION
// =============================================================================
function selectTool(tool) {
state.tool = tool;
// Update UI
document.querySelectorAll(".tool-btn").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.tool === tool);
});
// Update cursor
const cursors = {
select: "default",
pan: "grab",
pencil: "crosshair",
brush: "crosshair",
eraser: "crosshair",
rectangle: "crosshair",
ellipse: "crosshair",
line: "crosshair",
arrow: "crosshair",
text: "text",
sticky: "crosshair",
image: "crosshair",
connector: "crosshair",
frame: "crosshair",
};
canvas.style.cursor = cursors[tool] || "default";
}
// =============================================================================
// MOUSE HANDLERS
// =============================================================================
function handleMouseDown(e) {
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left - state.panX) / state.zoom;
const y = (e.clientY - rect.top - state.panY) / state.zoom;
state.startX = x;
state.startY = y;
if (state.tool === "pan") {
state.isPanning = true;
canvas.style.cursor = "grabbing";
return;
}
if (state.tool === "select") {
const element = findElementAt(x, y);
selectElement(element);
if (element) {
state.isDrawing = true; // For dragging
}
return;
}
state.isDrawing = true;
if (state.tool === "text") {
createTextElement(x, y);
state.isDrawing = false;
return;
}
if (state.tool === "sticky") {
createStickyNote(x, y);
state.isDrawing = false;
return;
}
if (state.tool === "pencil" || state.tool === "brush") {
const element = {
id: generateId(),
type: "path",
points: [{ x, y }],
color: state.color,
strokeWidth:
state.tool === "brush" ? state.strokeWidth * 3 : state.strokeWidth,
};
state.elements.push(element);
state.selectedElement = element;
}
}
function handleMouseMove(e) {
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left - state.panX) / state.zoom;
const y = (e.clientY - rect.top - state.panY) / state.zoom;
if (state.isPanning) {
state.panX += e.movementX;
state.panY += e.movementY;
render();
return;
}
if (!state.isDrawing) return;
if (state.tool === "pencil" || state.tool === "brush") {
if (state.selectedElement && state.selectedElement.points) {
state.selectedElement.points.push({ x, y });
render();
}
return;
}
if (state.tool === "eraser") {
const element = findElementAt(x, y);
if (element) {
state.elements = state.elements.filter((el) => el.id !== element.id);
render();
}
return;
}
if (state.tool === "select" && state.selectedElement) {
const dx = x - state.startX;
const dy = y - state.startY;
state.selectedElement.x += dx;
state.selectedElement.y += dy;
state.startX = x;
state.startY = y;
render();
return;
}
// Preview shape while drawing
render();
drawPreviewShape(state.startX, state.startY, x, y);
}
function handleMouseUp(e) {
if (state.isPanning) {
state.isPanning = false;
canvas.style.cursor = state.tool === "pan" ? "grab" : "default";
return;
}
if (!state.isDrawing) return;
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left - state.panX) / state.zoom;
const y = (e.clientY - rect.top - state.panY) / state.zoom;
if (
["rectangle", "ellipse", "line", "arrow", "frame"].includes(state.tool)
) {
const element = createShapeElement(
state.tool,
state.startX,
state.startY,
x,
y,
);
state.elements.push(element);
saveToHistory();
}
if (state.tool === "pencil" || state.tool === "brush") {
saveToHistory();
}
state.isDrawing = false;
render();
}
function handleWheel(e) {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
const newZoom = Math.max(0.1, Math.min(5, state.zoom + delta));
state.zoom = newZoom;
updateZoomDisplay();
render();
}
function handleDoubleClick(e) {
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left - state.panX) / state.zoom;
const y = (e.clientY - rect.top - state.panY) / state.zoom;
const element = findElementAt(x, y);
if (element && element.type === "text") {
editTextElement(element);
}
}
// =============================================================================
// TOUCH HANDLERS
// =============================================================================
function handleTouchStart(e) {
e.preventDefault();
const touch = e.touches[0];
handleMouseDown({ clientX: touch.clientX, clientY: touch.clientY });
}
function handleTouchMove(e) {
e.preventDefault();
const touch = e.touches[0];
handleMouseMove({
clientX: touch.clientX,
clientY: touch.clientY,
movementX: 0,
movementY: 0,
});
}
function handleTouchEnd(e) {
e.preventDefault();
const touch = e.changedTouches[0];
handleMouseUp({ clientX: touch.clientX, clientY: touch.clientY });
}
// =============================================================================
// KEYBOARD HANDLERS
// =============================================================================
function handleKeyDown(e) {
const isMod = e.ctrlKey || e.metaKey;
// Tool shortcuts
if (!isMod && !e.target.matches("input, textarea")) {
const toolKeys = {
v: "select",
h: "pan",
p: "pencil",
b: "brush",
e: "eraser",
r: "rectangle",
o: "ellipse",
l: "line",
a: "arrow",
t: "text",
s: "sticky",
i: "image",
c: "connector",
f: "frame",
};
if (toolKeys[e.key.toLowerCase()]) {
selectTool(toolKeys[e.key.toLowerCase()]);
return;
}
}
if (isMod && e.key === "z") {
e.preventDefault();
if (e.shiftKey) {
redo();
} else {
undo();
}
} else if (isMod && e.key === "y") {
e.preventDefault();
redo();
} else if (isMod && e.key === "c") {
e.preventDefault();
copyElement();
} else if (isMod && e.key === "v") {
e.preventDefault();
pasteElement();
} else if (isMod && e.key === "x") {
e.preventDefault();
cutElement();
} else if (isMod && e.key === "a") {
e.preventDefault();
selectAll();
} else if (e.key === "Delete" || e.key === "Backspace") {
if (state.selectedElement && !e.target.matches("input, textarea")) {
e.preventDefault();
deleteSelected();
}
} else if (e.key === "Escape") {
selectElement(null);
} else if (e.key === "+" || e.key === "=") {
if (isMod) {
e.preventDefault();
zoomIn();
}
} else if (e.key === "-") {
if (isMod) {
e.preventDefault();
zoomOut();
}
} else if (e.key === "0" && isMod) {
e.preventDefault();
resetZoom();
}
}
// =============================================================================
// ELEMENT CREATION
// =============================================================================
function createShapeElement(type, x1, y1, x2, y2) {
const minX = Math.min(x1, x2);
const minY = Math.min(y1, y2);
const width = Math.abs(x2 - x1);
const height = Math.abs(y2 - y1);
return {
id: generateId(),
type: type,
x: minX,
y: minY,
width: width,
height: height,
x1: x1,
y1: y1,
x2: x2,
y2: y2,
color: state.color,
fillColor: state.fillColor,
strokeWidth: state.strokeWidth,
};
}
function createTextElement(x, y) {
const text = prompt("Enter text:");
if (!text) return;
const element = {
id: generateId(),
type: "text",
x: x,
y: y,
text: text,
color: state.color,
fontSize: state.fontSize,
fontFamily: state.fontFamily,
};
state.elements.push(element);
saveToHistory();
render();
}
function createStickyNote(x, y) {
const element = {
id: generateId(),
type: "sticky",
x: x,
y: y,
width: 200,
height: 200,
text: "Double-click to edit",
color: "#ffeb3b",
};
state.elements.push(element);
saveToHistory();
render();
}
function editTextElement(element) {
const newText = prompt("Edit text:", element.text);
if (newText !== null) {
element.text = newText;
saveToHistory();
render();
}
}
// =============================================================================
// ELEMENT SELECTION & MANIPULATION
// =============================================================================
function findElementAt(x, y) {
for (let i = state.elements.length - 1; i >= 0; i--) {
const el = state.elements[i];
if (isPointInElement(x, y, el)) {
return el;
}
}
return null;
}
function isPointInElement(x, y, el) {
const margin = 5;
switch (el.type) {
case "rectangle":
case "frame":
case "sticky":
return (
x >= el.x - margin &&
x <= el.x + el.width + margin &&
y >= el.y - margin &&
y <= el.y + el.height + margin
);
case "ellipse":
const cx = el.x + el.width / 2;
const cy = el.y + el.height / 2;
const rx = el.width / 2 + margin;
const ry = el.height / 2 + margin;
return (x - cx) ** 2 / rx ** 2 + (y - cy) ** 2 / ry ** 2 <= 1;
case "text":
return (
x >= el.x - margin &&
x <= el.x + 200 &&
y >= el.y - el.fontSize &&
y <= el.y + margin
);
case "line":
case "arrow":
return (
distanceToLine(x, y, el.x1, el.y1, el.x2, el.y2) <=
margin + el.strokeWidth
);
case "path":
if (!el.points) return false;
for (const pt of el.points) {
if (Math.abs(pt.x - x) < margin && Math.abs(pt.y - y) < margin) {
return true;
}
}
return false;
default:
return false;
}
}
function distanceToLine(x, y, x1, y1, x2, y2) {
const A = x - x1;
const B = y - y1;
const C = x2 - x1;
const D = y2 - y1;
const dot = A * C + B * D;
const lenSq = C * C + D * D;
let param = lenSq !== 0 ? dot / lenSq : -1;
let xx, yy;
if (param < 0) {
xx = x1;
yy = y1;
} else if (param > 1) {
xx = x2;
yy = y2;
} else {
xx = x1 + param * C;
yy = y1 + param * D;
}
const dx = x - xx;
const dy = y - yy;
return Math.sqrt(dx * dx + dy * dy);
}
function selectElement(element) {
state.selectedElement = element;
render();
}
function selectAll() {
// Select all - for now just render all as selected
render();
}
function deleteSelected() {
if (!state.selectedElement) return;
state.elements = state.elements.filter(
(el) => el.id !== state.selectedElement.id,
);
state.selectedElement = null;
saveToHistory();
render();
}
function copyElement() {
if (state.selectedElement) {
state.clipboard = JSON.parse(JSON.stringify(state.selectedElement));
}
}
function cutElement() {
copyElement();
deleteSelected();
}
function pasteElement() {
if (!state.clipboard) return;
const newElement = JSON.parse(JSON.stringify(state.clipboard));
newElement.id = generateId();
newElement.x = (newElement.x || 0) + 20;
newElement.y = (newElement.y || 0) + 20;
if (newElement.x1 !== undefined) {
newElement.x1 += 20;
newElement.y1 += 20;
newElement.x2 += 20;
newElement.y2 += 20;
}
state.elements.push(newElement);
state.selectedElement = newElement;
saveToHistory();
render();
}
// =============================================================================
// RENDERING
// =============================================================================
function render() {
if (!ctx || !canvas) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.translate(state.panX, state.panY);
ctx.scale(state.zoom, state.zoom);
// Draw grid
if (state.gridEnabled) {
drawGrid();
}
// Draw elements
for (const element of state.elements) {
drawElement(element);
}
// Draw selection
if (state.selectedElement) {
drawSelection(state.selectedElement);
}
ctx.restore();
}
function drawGrid() {
const gridSize = state.gridSize;
const width = canvas.width / state.zoom;
const height = canvas.height / state.zoom;
ctx.strokeStyle = "#e0e0e0";
ctx.lineWidth = 0.5;
for (let x = 0; x < width; x += gridSize) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 0; y < height; y += gridSize) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
}
function drawElement(el) {
ctx.strokeStyle = el.color || state.color;
ctx.fillStyle = el.fillColor || "transparent";
ctx.lineWidth = el.strokeWidth || state.strokeWidth;
ctx.lineCap = "round";
ctx.lineJoin = "round";
switch (el.type) {
case "rectangle":
case "frame":
ctx.beginPath();
ctx.rect(el.x, el.y, el.width, el.height);
if (el.fillColor && el.fillColor !== "transparent") {
ctx.fill();
}
ctx.stroke();
break;
case "ellipse":
ctx.beginPath();
ctx.ellipse(
el.x + el.width / 2,
el.y + el.height / 2,
el.width / 2,
el.height / 2,
0,
0,
Math.PI * 2,
);
if (el.fillColor && el.fillColor !== "transparent") {
ctx.fill();
}
ctx.stroke();
break;
case "line":
ctx.beginPath();
ctx.moveTo(el.x1, el.y1);
ctx.lineTo(el.x2, el.y2);
ctx.stroke();
break;
case "arrow":
drawArrow(el.x1, el.y1, el.x2, el.y2);
break;
case "path":
if (el.points && el.points.length > 0) {
ctx.beginPath();
ctx.moveTo(el.points[0].x, el.points[0].y);
for (let i = 1; i < el.points.length; i++) {
ctx.lineTo(el.points[i].x, el.points[i].y);
}
ctx.stroke();
}
break;
case "text":
ctx.font = `${el.fontSize || 16}px ${el.fontFamily || "Inter"}`;
ctx.fillStyle = el.color || "#000000";
ctx.fillText(el.text, el.x, el.y);
break;
case "sticky":
ctx.fillStyle = el.color || "#ffeb3b";
ctx.fillRect(el.x, el.y, el.width, el.height);
ctx.strokeStyle = "#c0a000";
ctx.strokeRect(el.x, el.y, el.width, el.height);
ctx.fillStyle = "#000000";
ctx.font = "14px Inter";
wrapText(el.text, el.x + 10, el.y + 25, el.width - 20, 18);
break;
}
}
function drawArrow(x1, y1, x2, y2) {
const headLength = 15;
const angle = Math.atan2(y2 - y1, x2 - x1);
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(
x2 - headLength * Math.cos(angle - Math.PI / 6),
y2 - headLength * Math.sin(angle - Math.PI / 6),
);
ctx.moveTo(x2, y2);
ctx.lineTo(
x2 - headLength * Math.cos(angle + Math.PI / 6),
y2 - headLength * Math.sin(angle + Math.PI / 6),
);
ctx.stroke();
}
function drawPreviewShape(x1, y1, x2, y2) {
ctx.save();
ctx.translate(state.panX, state.panY);
ctx.scale(state.zoom, state.zoom);
ctx.strokeStyle = state.color;
ctx.lineWidth = state.strokeWidth;
ctx.setLineDash([5, 5]);
switch (state.tool) {
case "rectangle":
case "frame":
ctx.strokeRect(
Math.min(x1, x2),
Math.min(y1, y2),
Math.abs(x2 - x1),
Math.abs(y2 - y1),
);
break;
case "ellipse":
ctx.beginPath();
ctx.ellipse(
(x1 + x2) / 2,
(y1 + y2) / 2,
Math.abs(x2 - x1) / 2,
Math.abs(y2 - y1) / 2,
0,
0,
Math.PI * 2,
);
ctx.stroke();
break;
case "line":
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
break;
case "arrow":
drawArrow(x1, y1, x2, y2);
break;
}
ctx.restore();
}
function drawSelection(el) {
ctx.strokeStyle = "#2196f3";
ctx.lineWidth = 2 / state.zoom;
ctx.setLineDash([5 / state.zoom, 5 / state.zoom]);
let x, y, w, h;
if (el.type === "line" || el.type === "arrow") {
x = Math.min(el.x1, el.x2) - 5;
y = Math.min(el.y1, el.y2) - 5;
w = Math.abs(el.x2 - el.x1) + 10;
h = Math.abs(el.y2 - el.y1) + 10;
} else if (el.type === "path") {
const bounds = getPathBounds(el.points);
x = bounds.minX - 5;
y = bounds.minY - 5;
w = bounds.maxX - bounds.minX + 10;
h = bounds.maxY - bounds.minY + 10;
} else {
x = el.x - 5;
y = el.y - 5;
w = (el.width || 100) + 10;
h = (el.height || 20) + 10;
}
ctx.strokeRect(x, y, w, h);
ctx.setLineDash([]);
}
function getPathBounds(points) {
if (!points || points.length === 0) {
return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
}
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (const pt of points) {
minX = Math.min(minX, pt.x);
minY = Math.min(minY, pt.y);
maxX = Math.max(maxX, pt.x);
maxY = Math.max(maxY, pt.y);
}
return { minX, minY, maxX, maxY };
}
function wrapText(text, x, y, maxWidth, lineHeight) {
const words = text.split(" ");
let line = "";
for (const word of words) {
const testLine = line + word + " ";
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && line !== "") {
ctx.fillText(line, x, y);
line = word + " ";
y += lineHeight;
} else {
line = testLine;
}
}
ctx.fillText(line, x, y);
}
// =============================================================================
// ZOOM CONTROLS
// =============================================================================
function zoomIn() {
state.zoom = Math.min(5, state.zoom + 0.1);
updateZoomDisplay();
render();
}
function zoomOut() {
state.zoom = Math.max(0.1, state.zoom - 0.1);
updateZoomDisplay();
render();
}
function resetZoom() {
state.zoom = 1;
state.panX = 0;
state.panY = 0;
updateZoomDisplay();
render();
}
function fitToScreen() {
// Calculate bounds of all elements
if (state.elements.length === 0) {
resetZoom();
return;
}
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (const el of state.elements) {
if (el.x !== undefined) {
minX = Math.min(minX, el.x);
minY = Math.min(minY, el.y);
maxX = Math.max(maxX, el.x + (el.width || 100));
maxY = Math.max(maxY, el.y + (el.height || 50));
}
}
const contentWidth = maxX - minX + 100;
const contentHeight = maxY - minY + 100;
const scaleX = canvas.width / contentWidth;
const scaleY = canvas.height / contentHeight;
state.zoom = Math.min(scaleX, scaleY, 1);
state.panX = -minX * state.zoom + 50;
state.panY = -minY * state.zoom + 50;
updateZoomDisplay();
render();
}
function updateZoomDisplay() {
const el = document.getElementById("zoom-level");
if (el) {
el.textContent = Math.round(state.zoom * 100) + "%";
}
}
// =============================================================================
// HISTORY (UNDO/REDO)
// =============================================================================
function saveToHistory() {
// Remove any redo states
state.history = state.history.slice(0, state.historyIndex + 1);
// Save current state
state.history.push(JSON.stringify(state.elements));
state.historyIndex = state.history.length - 1;
// Limit history size
if (state.history.length > 50) {
state.history.shift();
state.historyIndex--;
}
}
function undo() {
if (state.historyIndex > 0) {
state.historyIndex--;
state.elements = JSON.parse(state.history[state.historyIndex]);
state.selectedElement = null;
render();
}
}
function redo() {
if (state.historyIndex < state.history.length - 1) {
state.historyIndex++;
state.elements = JSON.parse(state.history[state.historyIndex]);
state.selectedElement = null;
render();
}
}
// =============================================================================
// CLEAR CANVAS
// =============================================================================
function clearCanvas() {
if (!confirm("Clear the entire canvas? This cannot be undone.")) return;
state.elements = [];
state.selectedElement = null;
saveToHistory();
render();
}
// =============================================================================
// COLOR & STYLE
// =============================================================================
function setColor(color) {
state.color = color;
if (state.selectedElement) {
state.selectedElement.color = color;
saveToHistory();
render();
}
}
function setFillColor(color) {
state.fillColor = color;
if (state.selectedElement) {
state.selectedElement.fillColor = color;
saveToHistory();
render();
}
}
function setStrokeWidth(width) {
state.strokeWidth = parseInt(width);
if (state.selectedElement) {
state.selectedElement.strokeWidth = state.strokeWidth;
saveToHistory();
render();
}
}
function toggleGrid() {
state.gridEnabled = !state.gridEnabled;
render();
}
// =============================================================================
// SAVE/LOAD
// =============================================================================
function loadFromUrl() {
const urlParams = new URLSearchParams(window.location.search);
const canvasId = urlParams.get("id");
if (canvasId) {
loadCanvas(canvasId);
}
}
async function loadCanvas(canvasId) {
try {
const response = await fetch(`/api/canvas/${canvasId}`);
if (response.ok) {
const data = await response.json();
state.canvasId = canvasId;
state.canvasName = data.name || "Untitled Canvas";
state.elements = data.elements || [];
saveToHistory();
render();
}
} catch (e) {
console.error("Failed to load canvas:", e);
}
}
async function saveCanvas() {
try {
const response = await fetch(
"/api/canvas" + (state.canvasId ? `/${state.canvasId}` : ""),
{
method: state.canvasId ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: state.canvasName,
elements: state.elements,
}),
},
);
if (response.ok) {
const data = await response.json();
if (data.id) {
state.canvasId = data.id;
window.history.replaceState({}, "", `?id=${state.canvasId}`);
}
showNotification("Canvas saved", "success");
}
} catch (e) {
console.error("Failed to save canvas:", e);
showNotification("Failed to save canvas", "error");
}
}
function exportCanvas(format) {
if (format === "png" || format === "jpg") {
const dataUrl = canvas.toDataURL(`image/${format}`);
const link = document.createElement("a");
link.download = `${state.canvasName}.${format}`;
link.href = dataUrl;
link.click();
} else if (format === "json") {
const data = JSON.stringify(
{ name: state.canvasName, elements: state.elements },
null,
2,
);
const blob = new Blob([data], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.download = `${state.canvasName}.json`;
link.href = url;
link.click();
URL.revokeObjectURL(url);
}
}
// =============================================================================
// SHARING & COLLABORATION
// =============================================================================
function shareCanvas() {
if (!state.canvasId) {
// Save canvas first if not saved
saveCanvas().then(() => {
showShareDialog();
});
} else {
showShareDialog();
}
}
function showShareDialog() {
const modal = document.getElementById("share-modal");
if (modal) {
if (modal.showModal) {
modal.showModal();
} else {
modal.classList.add("open");
modal.style.display = "flex";
}
// Generate share link
const shareUrl = `${window.location.origin}/canvas?id=${state.canvasId}`;
const shareLinkInput = document.getElementById("share-link");
if (shareLinkInput) {
shareLinkInput.value = shareUrl;
}
} else {
// Fallback: copy link to clipboard
const shareUrl = `${window.location.origin}/canvas?id=${state.canvasId || "new"}`;
navigator.clipboard
.writeText(shareUrl)
.then(() => {
showNotification("Share link copied to clipboard", "success");
})
.catch(() => {
showNotification(
"Canvas ID: " + (state.canvasId || "unsaved"),
"info",
);
});
}
}
// =============================================================================
// PROPERTIES PANEL
// =============================================================================
function togglePropertiesPanel() {
const panel = document.getElementById("properties-panel");
if (panel) {
panel.classList.toggle("collapsed");
const isCollapsed = panel.classList.contains("collapsed");
// Update toggle button icon if needed
const toggleBtn = panel.querySelector(".panel-toggle span");
if (toggleBtn) {
toggleBtn.textContent = isCollapsed ? "⚙️" : "✕";
}
}
}
// =============================================================================
// LAYERS MANAGEMENT
// =============================================================================
let layers = [
{ id: "layer_1", name: "Layer 1", visible: true, locked: false },
];
let activeLayerId = "layer_1";
function addLayer() {
const newId = "layer_" + (layers.length + 1);
const newLayer = {
id: newId,
name: "Layer " + (layers.length + 1),
visible: true,
locked: false,
};
layers.push(newLayer);
activeLayerId = newId;
renderLayers();
showNotification("Layer added", "success");
}
function renderLayers() {
const layersList = document.getElementById("layers-list");
if (!layersList) return;
layersList.innerHTML = layers
.map(
(layer) => `
<div class="layer-item ${layer.id === activeLayerId ? "active" : ""}"
data-layer-id="${layer.id}"
onclick="selectLayer('${layer.id}')">
<span class="layer-visibility" onclick="event.stopPropagation(); toggleLayerVisibility('${layer.id}')">${layer.visible ? "👁️" : "👁️‍🗨️"}</span>
<span class="layer-name">${layer.name}</span>
<span class="layer-lock" onclick="event.stopPropagation(); toggleLayerLock('${layer.id}')">${layer.locked ? "🔒" : "🔓"}</span>
</div>
`,
)
.join("");
}
function selectLayer(layerId) {
activeLayerId = layerId;
renderLayers();
}
function toggleLayerVisibility(layerId) {
const layer = layers.find((l) => l.id === layerId);
if (layer) {
layer.visible = !layer.visible;
renderLayers();
render();
}
}
function toggleLayerLock(layerId) {
const layer = layers.find((l) => l.id === layerId);
if (layer) {
layer.locked = !layer.locked;
renderLayers();
}
}
// =============================================================================
// CLIPBOARD & DUPLICATE
// =============================================================================
function duplicateSelected() {
if (!state.selectedElement) {
showNotification("No element selected", "warning");
return;
}
const original = state.selectedElement;
const duplicate = JSON.parse(JSON.stringify(original));
duplicate.id = generateId();
// Offset the duplicate slightly
if (duplicate.x !== undefined) duplicate.x += 20;
if (duplicate.y !== undefined) duplicate.y += 20;
state.elements.push(duplicate);
state.selectedElement = duplicate;
saveToHistory();
render();
showNotification("Element duplicated", "success");
}
function copySelected() {
if (!state.selectedElement) {
showNotification("No element selected", "warning");
return;
}
state.clipboard = JSON.parse(JSON.stringify(state.selectedElement));
showNotification("Element copied", "success");
}
function pasteClipboard() {
if (!state.clipboard) {
showNotification("Nothing to paste", "warning");
return;
}
const pasted = JSON.parse(JSON.stringify(state.clipboard));
pasted.id = generateId();
// Offset the pasted element
if (pasted.x !== undefined) pasted.x += 20;
if (pasted.y !== undefined) pasted.y += 20;
state.elements.push(pasted);
state.selectedElement = pasted;
saveToHistory();
render();
showNotification("Element pasted", "success");
}
// =============================================================================
// ELEMENT ORDERING
// =============================================================================
function bringToFront() {
if (!state.selectedElement) return;
const index = state.elements.findIndex(
(e) => e.id === state.selectedElement.id,
);
if (index !== -1 && index < state.elements.length - 1) {
state.elements.splice(index, 1);
state.elements.push(state.selectedElement);
saveToHistory();
render();
}
}
function sendToBack() {
if (!state.selectedElement) return;
const index = state.elements.findIndex(
(e) => e.id === state.selectedElement.id,
);
if (index > 0) {
state.elements.splice(index, 1);
state.elements.unshift(state.selectedElement);
saveToHistory();
render();
}
}
// =============================================================================
// EXPORT MODAL
// =============================================================================
function showExportModal() {
const modal = document.getElementById("export-modal");
if (modal) {
if (modal.showModal) {
modal.showModal();
} else {
modal.classList.add("open");
modal.style.display = "flex";
}
}
}
function closeExportModal() {
const modal = document.getElementById("export-modal");
if (modal) {
if (modal.close) {
modal.close();
} else {
modal.classList.remove("open");
modal.style.display = "none";
}
}
}
function doExport() {
const formatSelect = document.getElementById("export-format");
const format = formatSelect ? formatSelect.value : "png";
exportCanvas(format);
closeExportModal();
}
// =============================================================================
// UTILITIES
// =============================================================================
function generateId() {
return "el_" + Math.random().toString(36).substr(2, 9);
}
function showNotification(message, type) {
if (typeof window.showNotification === "function") {
window.showNotification(message, type);
} else if (typeof window.GBAlerts !== "undefined") {
if (type === "success") window.GBAlerts.success("Canvas", message);
else if (type === "error") window.GBAlerts.error("Canvas", message);
else window.GBAlerts.info("Canvas", message);
} else {
console.log(`[${type}] ${message}`);
}
}
// =============================================================================
// EXPORT TO WINDOW
// =============================================================================
window.selectTool = selectTool;
window.zoomIn = zoomIn;
window.zoomOut = zoomOut;
window.resetZoom = resetZoom;
window.fitToScreen = fitToScreen;
window.undo = undo;
window.redo = redo;
window.clearCanvas = clearCanvas;
window.setColor = setColor;
window.setFillColor = setFillColor;
window.setStrokeWidth = setStrokeWidth;
window.toggleGrid = toggleGrid;
window.saveCanvas = saveCanvas;
window.exportCanvas = exportCanvas;
window.deleteSelected = deleteSelected;
window.copyElement = copyElement;
window.cutElement = cutElement;
window.pasteElement = pasteElement;
// Sharing & Collaboration
window.shareCanvas = shareCanvas;
// Properties Panel
window.togglePropertiesPanel = togglePropertiesPanel;
// Layers
window.addLayer = addLayer;
window.selectLayer = selectLayer;
window.toggleLayerVisibility = toggleLayerVisibility;
window.toggleLayerLock = toggleLayerLock;
// Clipboard & Duplicate
window.duplicateSelected = duplicateSelected;
window.copySelected = copySelected;
window.pasteClipboard = pasteClipboard;
// Element Ordering
window.bringToFront = bringToFront;
window.sendToBack = sendToBack;
// Export Modal
window.showExportModal = showExportModal;
window.closeExportModal = closeExportModal;
window.doExport = doExport;
// =============================================================================
// INITIALIZE
// =============================================================================
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();