diff --git a/ui/suite/admin/admin-functions.js b/ui/suite/admin/admin-functions.js
new file mode 100644
index 0000000..4dfaef4
--- /dev/null
+++ b/ui/suite/admin/admin-functions.js
@@ -0,0 +1,726 @@
+/* =============================================================================
+ ADMIN MODULE - Missing Function Handlers
+ These functions are called by onclick handlers in admin HTML files
+ ============================================================================= */
+
+(function() {
+ 'use strict';
+
+ // =============================================================================
+ // MODAL HELPERS
+ // =============================================================================
+
+ function showModal(modalId) {
+ const modal = document.getElementById(modalId);
+ if (modal) {
+ if (modal.showModal) {
+ modal.showModal();
+ } else {
+ modal.classList.add('open');
+ modal.style.display = 'flex';
+ }
+ }
+ }
+
+ function hideModal(modalId) {
+ const modal = document.getElementById(modalId);
+ if (modal) {
+ if (modal.close) {
+ modal.close();
+ } else {
+ modal.classList.remove('open');
+ modal.style.display = 'none';
+ }
+ }
+ }
+
+ 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('Admin', message);
+ else if (type === 'error') window.GBAlerts.error('Admin', message);
+ else if (type === 'warning') window.GBAlerts.warning('Admin', message);
+ else window.GBAlerts.info('Admin', message);
+ } else {
+ console.log(`[${type}] ${message}`);
+ }
+ }
+
+ // =============================================================================
+ // ACCOUNTS.HTML FUNCTIONS
+ // =============================================================================
+
+ function showSmtpModal() {
+ showModal('smtp-modal');
+ }
+
+ function closeSmtpModal() {
+ hideModal('smtp-modal');
+ }
+
+ function testSmtpConnection() {
+ const host = document.getElementById('smtp-host')?.value;
+ const port = document.getElementById('smtp-port')?.value;
+ const username = document.getElementById('smtp-username')?.value;
+
+ if (!host || !port) {
+ showNotification('Please fill in SMTP host and port', 'error');
+ return;
+ }
+
+ showNotification('Testing SMTP connection...', 'info');
+
+ fetch('/api/settings/smtp/test', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ host, port: parseInt(port), username })
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ showNotification('SMTP connection successful!', 'success');
+ } else {
+ showNotification('SMTP connection failed: ' + (data.error || 'Unknown error'), 'error');
+ }
+ })
+ .catch(err => {
+ showNotification('Connection test failed: ' + err.message, 'error');
+ });
+ }
+
+ function connectAccount(provider) {
+ showNotification(`Connecting to ${provider}...`, 'info');
+ // OAuth flow would redirect to provider
+ window.location.href = `/api/auth/oauth/${provider}?redirect=/admin/accounts`;
+ }
+
+ function disconnectAccount(provider) {
+ if (!confirm(`Disconnect ${provider} account?`)) return;
+
+ fetch(`/api/settings/accounts/${provider}/disconnect`, { method: 'POST' })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ showNotification(`${provider} disconnected`, 'success');
+ location.reload();
+ } else {
+ showNotification('Failed to disconnect: ' + data.error, 'error');
+ }
+ })
+ .catch(err => showNotification('Error: ' + err.message, 'error'));
+ }
+
+ // =============================================================================
+ // ADMIN-DASHBOARD.HTML FUNCTIONS
+ // =============================================================================
+
+ function showInviteMemberModal() {
+ showModal('invite-member-modal');
+ }
+
+ function closeInviteMemberModal() {
+ hideModal('invite-member-modal');
+ }
+
+ function showBulkInviteModal() {
+ showModal('bulk-invite-modal');
+ }
+
+ function closeBulkInviteModal() {
+ hideModal('bulk-invite-modal');
+ }
+
+ function sendInvitation() {
+ const email = document.getElementById('invite-email')?.value;
+ const role = document.getElementById('invite-role')?.value || 'member';
+
+ if (!email) {
+ showNotification('Please enter an email address', 'error');
+ return;
+ }
+
+ fetch('/api/admin/invitations', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email, role })
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ showNotification('Invitation sent to ' + email, 'success');
+ closeInviteMemberModal();
+ } else {
+ showNotification('Failed to send invitation: ' + data.error, 'error');
+ }
+ })
+ .catch(err => showNotification('Error: ' + err.message, 'error'));
+ }
+
+ function sendBulkInvitations() {
+ const emailsText = document.getElementById('bulk-emails')?.value || '';
+ const role = document.getElementById('bulk-role')?.value || 'member';
+ const emails = emailsText.split(/[\n,;]+/).map(e => e.trim()).filter(e => e);
+
+ if (emails.length === 0) {
+ showNotification('Please enter at least one email address', 'error');
+ return;
+ }
+
+ fetch('/api/admin/invitations/bulk', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ emails, role })
+ })
+ .then(response => response.json())
+ .then(data => {
+ showNotification(`${data.sent || emails.length} invitations sent`, 'success');
+ closeBulkInviteModal();
+ })
+ .catch(err => showNotification('Error: ' + err.message, 'error'));
+ }
+
+ function resendInvitation(invitationId) {
+ fetch(`/api/admin/invitations/${invitationId}/resend`, { method: 'POST' })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ showNotification('Invitation resent', 'success');
+ } else {
+ showNotification('Failed to resend: ' + data.error, 'error');
+ }
+ })
+ .catch(err => showNotification('Error: ' + err.message, 'error'));
+ }
+
+ function cancelInvitation(invitationId) {
+ if (!confirm('Cancel this invitation?')) return;
+
+ fetch(`/api/admin/invitations/${invitationId}`, { method: 'DELETE' })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ showNotification('Invitation cancelled', 'success');
+ location.reload();
+ }
+ })
+ .catch(err => showNotification('Error: ' + err.message, 'error'));
+ }
+
+ // =============================================================================
+ // BILLING-DASHBOARD.HTML FUNCTIONS
+ // =============================================================================
+
+ function updateBillingPeriod(period) {
+ const params = new URLSearchParams({ period });
+
+ // Update dashboard stats via HTMX or fetch
+ if (typeof htmx !== 'undefined') {
+ htmx.ajax('GET', `/api/admin/billing/stats?${params}`, '#billing-stats');
+ } else {
+ fetch(`/api/admin/billing/stats?${params}`)
+ .then(r => r.json())
+ .then(data => updateBillingStats(data))
+ .catch(err => console.error('Failed to update billing period:', err));
+ }
+ }
+
+ function updateBillingStats(data) {
+ if (data.totalRevenue) {
+ const el = document.getElementById('total-revenue');
+ if (el) el.textContent = formatCurrency(data.totalRevenue);
+ }
+ if (data.activeSubscriptions) {
+ const el = document.getElementById('active-subscriptions');
+ if (el) el.textContent = data.activeSubscriptions;
+ }
+ }
+
+ function exportBillingReport() {
+ const period = document.getElementById('billingPeriod')?.value || 'current';
+ showNotification('Generating billing report...', 'info');
+
+ fetch(`/api/admin/billing/export?period=${period}`)
+ .then(response => {
+ if (response.ok) return response.blob();
+ throw new Error('Export failed');
+ })
+ .then(blob => {
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `billing-report-${period}.csv`;
+ a.click();
+ URL.revokeObjectURL(url);
+ showNotification('Report downloaded', 'success');
+ })
+ .catch(err => showNotification('Export failed: ' + err.message, 'error'));
+ }
+
+ function toggleBreakdownView() {
+ const chart = document.getElementById('breakdown-chart');
+ const table = document.getElementById('breakdown-table');
+
+ if (chart && table) {
+ const showingChart = !chart.classList.contains('hidden');
+ chart.classList.toggle('hidden', showingChart);
+ table.classList.toggle('hidden', !showingChart);
+ }
+ }
+
+ function showQuotaSettings() {
+ showModal('quota-settings-modal');
+ }
+
+ function closeQuotaSettings() {
+ hideModal('quota-settings-modal');
+ }
+
+ function saveQuotaSettings() {
+ const form = document.getElementById('quota-form');
+ if (!form) return;
+
+ const formData = new FormData(form);
+ const quotas = Object.fromEntries(formData);
+
+ fetch('/api/admin/billing/quotas', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(quotas)
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ showNotification('Quota settings saved', 'success');
+ closeQuotaSettings();
+ } else {
+ showNotification('Failed to save: ' + data.error, 'error');
+ }
+ })
+ .catch(err => showNotification('Error: ' + err.message, 'error'));
+ }
+
+ function configureAlerts() {
+ showModal('alerts-config-modal');
+ }
+
+ function closeAlertsConfig() {
+ hideModal('alerts-config-modal');
+ }
+
+ function saveAlertSettings() {
+ const form = document.getElementById('alerts-form');
+ if (!form) return;
+
+ const formData = new FormData(form);
+ const settings = Object.fromEntries(formData);
+
+ fetch('/api/admin/billing/alerts', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(settings)
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ showNotification('Alert settings saved', 'success');
+ closeAlertsConfig();
+ }
+ })
+ .catch(err => showNotification('Error: ' + err.message, 'error'));
+ }
+
+ // =============================================================================
+ // BILLING.HTML FUNCTIONS
+ // =============================================================================
+
+ function showUpgradeModal() {
+ showModal('upgrade-modal');
+ }
+
+ function closeUpgradeModal() {
+ hideModal('upgrade-modal');
+ }
+
+ function showCancelModal() {
+ showModal('cancel-modal');
+ }
+
+ function closeCancelModal() {
+ hideModal('cancel-modal');
+ }
+
+ function showAddPaymentModal() {
+ showModal('add-payment-modal');
+ }
+
+ function closeAddPaymentModal() {
+ hideModal('add-payment-modal');
+ }
+
+ function showEditAddressModal() {
+ showModal('edit-address-modal');
+ }
+
+ function closeEditAddressModal() {
+ hideModal('edit-address-modal');
+ }
+
+ function exportInvoices() {
+ showNotification('Exporting invoices...', 'info');
+
+ fetch('/api/billing/invoices/export')
+ .then(response => {
+ if (response.ok) return response.blob();
+ throw new Error('Export failed');
+ })
+ .then(blob => {
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'invoices.csv';
+ a.click();
+ URL.revokeObjectURL(url);
+ showNotification('Invoices exported', 'success');
+ })
+ .catch(err => showNotification('Export failed: ' + err.message, 'error'));
+ }
+
+ function contactSales() {
+ window.open('mailto:sales@example.com?subject=Enterprise Plan Inquiry', '_blank');
+ }
+
+ function showDowngradeOptions() {
+ closeCancelModal();
+ showUpgradeModal();
+ // Focus on lower-tier plans
+ const planSelector = document.querySelector('.plan-options');
+ if (planSelector) {
+ planSelector.scrollIntoView({ behavior: 'smooth' });
+ }
+ }
+
+ function selectPlan(planId) {
+ document.querySelectorAll('.plan-option').forEach(el => {
+ el.classList.toggle('selected', el.dataset.plan === planId);
+ });
+ }
+
+ function confirmUpgrade() {
+ const selectedPlan = document.querySelector('.plan-option.selected');
+ if (!selectedPlan) {
+ showNotification('Please select a plan', 'error');
+ return;
+ }
+
+ const planId = selectedPlan.dataset.plan;
+
+ fetch('/api/billing/subscription/upgrade', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ plan_id: planId })
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ showNotification('Plan upgraded successfully!', 'success');
+ closeUpgradeModal();
+ location.reload();
+ } else {
+ showNotification('Upgrade failed: ' + data.error, 'error');
+ }
+ })
+ .catch(err => showNotification('Error: ' + err.message, 'error'));
+ }
+
+ function confirmCancellation() {
+ const reason = document.getElementById('cancel-reason')?.value;
+
+ fetch('/api/billing/subscription/cancel', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ reason })
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ showNotification('Subscription cancelled', 'success');
+ closeCancelModal();
+ location.reload();
+ } else {
+ showNotification('Cancellation failed: ' + data.error, 'error');
+ }
+ })
+ .catch(err => showNotification('Error: ' + err.message, 'error'));
+ }
+
+ // =============================================================================
+ // COMPLIANCE-DASHBOARD.HTML FUNCTIONS
+ // =============================================================================
+
+ function updateFramework(framework) {
+ // Update dashboard for selected compliance framework
+ if (typeof htmx !== 'undefined') {
+ htmx.ajax('GET', `/api/compliance/dashboard?framework=${framework}`, '#compliance-content');
+ } else {
+ fetch(`/api/compliance/dashboard?framework=${framework}`)
+ .then(r => r.json())
+ .then(data => updateComplianceDashboard(data))
+ .catch(err => console.error('Failed to update framework:', err));
+ }
+ }
+
+ function updateComplianceDashboard(data) {
+ // Update various dashboard elements
+ if (data.score) {
+ const el = document.getElementById('compliance-score');
+ if (el) el.textContent = data.score + '%';
+ }
+ }
+
+ function generateComplianceReport() {
+ const framework = document.getElementById('complianceFramework')?.value || 'soc2';
+ showNotification('Generating compliance report...', 'info');
+
+ fetch(`/api/compliance/report?framework=${framework}`)
+ .then(response => {
+ if (response.ok) return response.blob();
+ throw new Error('Report generation failed');
+ })
+ .then(blob => {
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `compliance-report-${framework}.pdf`;
+ a.click();
+ URL.revokeObjectURL(url);
+ showNotification('Report generated', 'success');
+ })
+ .catch(err => showNotification('Report failed: ' + err.message, 'error'));
+ }
+
+ function startAuditPrep() {
+ showModal('audit-prep-modal');
+ }
+
+ function closeAuditPrep() {
+ hideModal('audit-prep-modal');
+ }
+
+ function showEvidenceUpload() {
+ showModal('evidence-upload-modal');
+ }
+
+ function closeEvidenceUpload() {
+ hideModal('evidence-upload-modal');
+ }
+
+ function uploadEvidence() {
+ const fileInput = document.getElementById('evidence-file');
+ const category = document.getElementById('evidence-category')?.value;
+
+ if (!fileInput?.files?.length) {
+ showNotification('Please select a file', 'error');
+ return;
+ }
+
+ const formData = new FormData();
+ formData.append('file', fileInput.files[0]);
+ formData.append('category', category);
+
+ fetch('/api/compliance/evidence', {
+ method: 'POST',
+ body: formData
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ showNotification('Evidence uploaded', 'success');
+ closeEvidenceUpload();
+ } else {
+ showNotification('Upload failed: ' + data.error, 'error');
+ }
+ })
+ .catch(err => showNotification('Error: ' + err.message, 'error'));
+ }
+
+ function filterLogs() {
+ const category = document.getElementById('logCategory')?.value || 'all';
+
+ if (typeof htmx !== 'undefined') {
+ htmx.ajax('GET', `/api/compliance/audit-log?category=${category}`, '#audit-log-list');
+ }
+ }
+
+ function exportAuditLog() {
+ const category = document.getElementById('logCategory')?.value || 'all';
+ showNotification('Exporting audit log...', 'info');
+
+ fetch(`/api/compliance/audit-log/export?category=${category}`)
+ .then(response => {
+ if (response.ok) return response.blob();
+ throw new Error('Export failed');
+ })
+ .then(blob => {
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'audit-log.csv';
+ a.click();
+ URL.revokeObjectURL(url);
+ showNotification('Audit log exported', 'success');
+ })
+ .catch(err => showNotification('Export failed: ' + err.message, 'error'));
+ }
+
+ // =============================================================================
+ // GROUPS.HTML FUNCTIONS
+ // =============================================================================
+
+ function closeDetailPanel() {
+ const panel = document.getElementById('detail-panel');
+ if (panel) {
+ panel.classList.remove('open');
+ }
+ }
+
+ function openDetailPanel(groupId) {
+ const panel = document.getElementById('detail-panel');
+ if (panel) {
+ panel.classList.add('open');
+ // Load group details
+ if (typeof htmx !== 'undefined') {
+ htmx.ajax('GET', `/api/admin/groups/${groupId}`, '#panel-content');
+ }
+ }
+ }
+
+ function createGroup() {
+ showModal('create-group-modal');
+ }
+
+ function closeCreateGroup() {
+ hideModal('create-group-modal');
+ }
+
+ function saveGroup() {
+ const name = document.getElementById('group-name')?.value;
+ const description = document.getElementById('group-description')?.value;
+
+ if (!name) {
+ showNotification('Please enter a group name', 'error');
+ return;
+ }
+
+ fetch('/api/admin/groups', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name, description })
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ showNotification('Group created', 'success');
+ closeCreateGroup();
+ location.reload();
+ } else {
+ showNotification('Failed to create group: ' + data.error, 'error');
+ }
+ })
+ .catch(err => showNotification('Error: ' + err.message, 'error'));
+ }
+
+ function deleteGroup(groupId) {
+ if (!confirm('Delete this group? This action cannot be undone.')) return;
+
+ fetch(`/api/admin/groups/${groupId}`, { method: 'DELETE' })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ showNotification('Group deleted', 'success');
+ closeDetailPanel();
+ location.reload();
+ }
+ })
+ .catch(err => showNotification('Error: ' + err.message, 'error'));
+ }
+
+ // =============================================================================
+ // UTILITY FUNCTIONS
+ // =============================================================================
+
+ function formatCurrency(amount, currency = 'USD') {
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: currency
+ }).format(amount);
+ }
+
+ // =============================================================================
+ // EXPORT TO WINDOW
+ // =============================================================================
+
+ // Accounts
+ window.showSmtpModal = showSmtpModal;
+ window.closeSmtpModal = closeSmtpModal;
+ window.testSmtpConnection = testSmtpConnection;
+ window.connectAccount = connectAccount;
+ window.disconnectAccount = disconnectAccount;
+
+ // Admin Dashboard
+ window.showInviteMemberModal = showInviteMemberModal;
+ window.closeInviteMemberModal = closeInviteMemberModal;
+ window.showBulkInviteModal = showBulkInviteModal;
+ window.closeBulkInviteModal = closeBulkInviteModal;
+ window.sendInvitation = sendInvitation;
+ window.sendBulkInvitations = sendBulkInvitations;
+ window.resendInvitation = resendInvitation;
+ window.cancelInvitation = cancelInvitation;
+
+ // Billing Dashboard
+ window.updateBillingPeriod = updateBillingPeriod;
+ window.exportBillingReport = exportBillingReport;
+ window.toggleBreakdownView = toggleBreakdownView;
+ window.showQuotaSettings = showQuotaSettings;
+ window.closeQuotaSettings = closeQuotaSettings;
+ window.saveQuotaSettings = saveQuotaSettings;
+ window.configureAlerts = configureAlerts;
+ window.closeAlertsConfig = closeAlertsConfig;
+ window.saveAlertSettings = saveAlertSettings;
+
+ // Billing
+ window.showUpgradeModal = showUpgradeModal;
+ window.closeUpgradeModal = closeUpgradeModal;
+ window.showCancelModal = showCancelModal;
+ window.closeCancelModal = closeCancelModal;
+ window.showAddPaymentModal = showAddPaymentModal;
+ window.closeAddPaymentModal = closeAddPaymentModal;
+ window.showEditAddressModal = showEditAddressModal;
+ window.closeEditAddressModal = closeEditAddressModal;
+ window.exportInvoices = exportInvoices;
+ window.contactSales = contactSales;
+ window.showDowngradeOptions = showDowngradeOptions;
+ window.selectPlan = selectPlan;
+ window.confirmUpgrade = confirmUpgrade;
+ window.confirmCancellation = confirmCancellation;
+
+ // Compliance Dashboard
+ window.updateFramework = updateFramework;
+ window.generateComplianceReport = generateComplianceReport;
+ window.startAuditPrep = startAuditPrep;
+ window.closeAuditPrep = closeAuditPrep;
+ window.showEvidenceUpload = showEvidenceUpload;
+ window.closeEvidenceUpload = closeEvidenceUpload;
+ window.uploadEvidence = uploadEvidence;
+ window.filterLogs = filterLogs;
+ window.exportAuditLog = exportAuditLog;
+
+ // Groups
+ window.closeDetailPanel = closeDetailPanel;
+ window.openDetailPanel = openDetailPanel;
+ window.createGroup = createGroup;
+ window.closeCreateGroup = closeCreateGroup;
+ window.saveGroup = saveGroup;
+ window.deleteGroup = deleteGroup;
+
+})();
diff --git a/ui/suite/admin/index.html b/ui/suite/admin/index.html
index 817676b..024ea60 100644
--- a/ui/suite/admin/index.html
+++ b/ui/suite/admin/index.html
@@ -719,4 +719,3 @@
-
diff --git a/ui/suite/canvas/canvas.js b/ui/suite/canvas/canvas.js
new file mode 100644
index 0000000..23b5a3a
--- /dev/null
+++ b/ui/suite/canvas/canvas.js
@@ -0,0 +1,1120 @@
+/* =============================================================================
+ 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);
+ }
+ }
+
+ // =============================================================================
+ // 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;
+
+ // =============================================================================
+ // INITIALIZE
+ // =============================================================================
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", init);
+ } else {
+ init();
+ }
+})();
diff --git a/ui/suite/chat/chat.js b/ui/suite/chat/chat.js
index 3c9eff2..2e3cd0c 100644
--- a/ui/suite/chat/chat.js
+++ b/ui/suite/chat/chat.js
@@ -740,6 +740,33 @@ function parseMarkdown(text) {
.replace(/`([^`]+)`/gim, "$1")
.replace(/\n/gim, "
");
}
+// Export projector functions for onclick handlers in projector.html
+window.openProjector = openProjector;
+window.closeProjector = closeProjector;
+window.closeProjectorOnOverlay = closeProjectorOnOverlay;
+window.toggleFullscreen = toggleFullscreen;
+window.downloadContent = downloadContent;
+window.shareContent = shareContent;
+window.togglePlayPause = togglePlayPause;
+window.mediaSeekBack = mediaSeekBack;
+window.mediaSeekForward = mediaSeekForward;
+window.toggleMute = toggleMute;
+window.setVolume = setVolume;
+window.toggleLoop = toggleLoop;
+window.prevSlide = prevSlide;
+window.nextSlide = nextSlide;
+window.goToSlide = goToSlide;
+window.zoomIn = zoomIn;
+window.zoomOut = zoomOut;
+window.prevImage = prevImage;
+window.nextImage = nextImage;
+window.rotateImage = rotateImage;
+window.fitToScreen = fitToScreen;
+window.toggleLineNumbers = toggleLineNumbers;
+window.toggleWordWrap = toggleWordWrap;
+window.setCodeTheme = setCodeTheme;
+window.copyCode = copyCode;
+
if (window.htmx) {
htmx.on("htmx:wsMessage", function (event) {
try {
diff --git a/ui/suite/dashboards/dashboards.js b/ui/suite/dashboards/dashboards.js
new file mode 100644
index 0000000..6f51173
--- /dev/null
+++ b/ui/suite/dashboards/dashboards.js
@@ -0,0 +1,744 @@
+/* =============================================================================
+ DASHBOARDS MODULE - Business Intelligence Dashboards
+ ============================================================================= */
+
+(function () {
+ "use strict";
+
+ // =============================================================================
+ // STATE
+ // =============================================================================
+
+ const state = {
+ dashboards: [],
+ dataSources: [],
+ currentDashboard: null,
+ selectedWidgetType: null,
+ isEditing: false,
+ widgets: [],
+ filters: {
+ category: "all",
+ search: "",
+ },
+ };
+
+ // =============================================================================
+ // INITIALIZATION
+ // =============================================================================
+
+ function init() {
+ loadDashboards();
+ loadDataSources();
+ bindEvents();
+ console.log("Dashboards module initialized");
+ }
+
+ function bindEvents() {
+ // Search input
+ const searchInput = document.getElementById("dashboard-search");
+ if (searchInput) {
+ searchInput.addEventListener("input", (e) => {
+ state.filters.search = e.target.value;
+ filterDashboards();
+ });
+ }
+
+ // Category filter
+ const categorySelect = document.getElementById("category-filter");
+ if (categorySelect) {
+ categorySelect.addEventListener("change", (e) => {
+ state.filters.category = e.target.value;
+ filterDashboards();
+ });
+ }
+
+ // Dashboard card clicks
+ document.addEventListener("click", (e) => {
+ const card = e.target.closest(".dashboard-card");
+ if (card && !e.target.closest("button")) {
+ const dashboardId = card.dataset.id;
+ if (dashboardId) {
+ openDashboard(dashboardId);
+ }
+ }
+ });
+ }
+
+ // =============================================================================
+ // DASHBOARD CRUD
+ // =============================================================================
+
+ async function loadDashboards() {
+ try {
+ const response = await fetch("/api/dashboards");
+ if (response.ok) {
+ const data = await response.json();
+ state.dashboards = data.dashboards || [];
+ renderDashboardList();
+ }
+ } catch (e) {
+ console.error("Failed to load dashboards:", e);
+ }
+ }
+
+ function renderDashboardList() {
+ const container = document.getElementById("dashboards-grid");
+ if (!container) return;
+
+ const filtered = state.dashboards.filter((d) => {
+ const matchesSearch =
+ !state.filters.search ||
+ d.name.toLowerCase().includes(state.filters.search.toLowerCase());
+ const matchesCategory =
+ state.filters.category === "all" ||
+ d.category === state.filters.category;
+ return matchesSearch && matchesCategory;
+ });
+
+ if (filtered.length === 0) {
+ container.innerHTML = `
+
Create your first dashboard to visualize your data
+ +${escapeHtml(dashboard.description || "No description")}
+ +Create your first objective to start tracking goals
+ +${escapeHtml(objective.description || "No description")}
+${escapeHtml(objective.owner_name || "Unassigned")}
+${formatDate(objective.start_date)} - ${formatDate(objective.end_date)}
+