Files
WASasquatch-ComfyUI_Viewer/web/views/canvas.js
T

3595 lines
154 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Canvas View - Infinite canvas image compositor with layers, transforms, and drawing tools
*/
import { BaseView, escapeHtml } from "./base_view.js";
class CanvasView extends BaseView {
static id = "canvas";
static displayName = "Canvas";
static isUI = true;
static priority = 95;
static CANVAS_MARKER = "$WAS_CANVAS$";
/**
* Canvas provides its own complete styling - opt out of base iframe styles
*/
static usesBaseStyles() {
return false;
}
static detect(content) {
try {
// Check for marker prefix
let jsonContent = content;
if (content.startsWith(this.CANVAS_MARKER)) {
jsonContent = content.slice(this.CANVAS_MARKER.length);
}
const parsed = JSON.parse(jsonContent);
if (parsed.type === "canvas_composer" && Array.isArray(parsed.images)) {
return 200;
}
} catch {}
return 0;
}
static render(content, theme) {
let data;
try {
// Strip marker prefix if present
let jsonContent = content;
if (content.startsWith(this.CANVAS_MARKER)) {
jsonContent = content.slice(this.CANVAS_MARKER.length);
}
data = JSON.parse(jsonContent);
} catch {
return `<pre>Invalid canvas data</pre>`;
}
// Use single quotes for attribute since JSON contains double quotes
// Only need to escape single quotes and < > & in the JSON
const imagesJson = JSON.stringify(data.images || [])
.replace(/&/g, '&amp;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// Pass saved canvas state if available
const savedStateJson = data.savedState ? JSON.stringify(data.savedState)
.replace(/&/g, '&amp;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;') : '';
// Pass origin from parent context since iframe can't access it due to cross-origin policy
const origin = typeof window !== 'undefined' ? window.location.origin : '';
return `
<script></script>
<div id="canvas-composer" data-images='${imagesJson}' data-origin="${origin}" data-saved-state='${savedStateJson}'>
<div class="canvas-toolbar">
<div class="toolbar-group tools-group">
<button class="tool-btn active" data-tool="select" title="Select (V)">↖</button>
<button class="tool-btn" data-tool="pan" title="Pan (H)">✋</button>
<button class="tool-btn" data-tool="brush" title="Brush (B)">🖌</button>
</div>
<div class="toolbar-separator"></div>
<div class="toolbar-group bg-group">
<label>BG:</label>
<input type="color" id="bg-color" value="#000000" title="Background Color">
<label class="checkbox-label"><input type="checkbox" id="bg-transparent" checked> Transparent</label>
</div>
<div class="toolbar-separator"></div>
<div class="toolbar-group layer-options">
<label>Layer Opacity:</label>
<input type="range" id="layer-opacity" min="0" max="100" value="100">
</div>
<div class="toolbar-separator"></div>
<div class="toolbar-group export-size-group">
<label>Size:</label>
<input type="number" id="export-width" min="0" max="8192" value="0" placeholder="W" title="Export Width (0 = auto)">
<span>×</span>
<input type="number" id="export-height" min="0" max="8192" value="0" placeholder="H" title="Export Height (0 = auto)">
<label class="checkbox-label"><input type="checkbox" id="snap-enabled"> Snap</label>
</div>
<div class="toolbar-spacer"></div>
<div class="toolbar-group actions-group">
<button id="send-output-btn" class="action-btn primary" title="Send to Node Output">📤 Send to Output</button>
<button id="export-btn" class="action-btn" title="Download PNG">📥 Download</button>
<button id="reset-view-btn" class="action-btn icon-only" title="Reset View">⟲</button>
</div>
</div>
<div class="brush-toolbar" id="brush-toolbar" style="display:none;">
<div class="toolbar-group">
<label>Tool:</label>
<select id="brush-tool">
<option value="brush">Brush</option>
<option value="eraser">Eraser</option>
<option value="clone">Clone Stamp</option>
</select>
</div>
<div class="toolbar-group">
<label>Size:</label>
<input type="range" id="brush-size" min="1" max="100" value="10">
<span id="brush-size-val">10</span>
</div>
<div class="toolbar-group">
<label>Hardness:</label>
<input type="range" id="brush-hardness" min="0" max="100" value="100">
</div>
<div class="toolbar-group">
<label>Opacity:</label>
<input type="range" id="brush-opacity" min="1" max="100" value="100">
<span id="brush-opacity-val">100%</span>
</div>
<div class="toolbar-group">
<label>Blend:</label>
<select id="brush-blend">
<option value="source-over">Normal</option>
<option value="multiply">Multiply</option>
<option value="screen">Screen</option>
<option value="overlay">Overlay</option>
<option value="hard-light">Hard Light</option>
</select>
</div>
<div class="toolbar-group">
<input type="color" id="brush-color" value="#ffffff" title="Brush Color">
</div>
<div class="toolbar-group clone-options" id="clone-options" style="display:none;">
<label class="checkbox-label"><input type="checkbox" id="clone-all-layers"> Sample All</label>
<span id="clone-status" style="opacity:0.6;font-size:10px;">Alt+Click to set source</span>
</div>
</div>
<div class="canvas-workspace">
<div class="canvas-container">
<canvas id="main-canvas"></canvas>
</div>
<div class="layers-drawer" id="layers-drawer">
<div class="drawer-toggle" id="drawer-toggle" title="Toggle Layers Panel"></div>
<div class="drawer-content">
<div class="drawer-section layer-controls-section">
<div class="layer-control-row">
<label>Opacity:</label>
<input type="range" id="drawer-layer-opacity" min="0" max="100" value="100">
<span id="drawer-opacity-val">100%</span>
</div>
<div class="layer-control-row">
<label>Blend:</label>
<select id="layer-blend-mode">
<option value="source-over">Normal</option>
<option value="multiply">Multiply</option>
<option value="screen">Screen</option>
<option value="overlay">Overlay</option>
<option value="darken">Darken</option>
<option value="lighten">Lighten</option>
<option value="color-dodge">Color Dodge</option>
<option value="color-burn">Color Burn</option>
<option value="hard-light">Hard Light</option>
<option value="soft-light">Soft Light</option>
<option value="difference">Difference</option>
<option value="exclusion">Exclusion</option>
<option value="hue">Hue</option>
<option value="saturation">Saturation</option>
<option value="color">Color</option>
<option value="luminosity">Luminosity</option>
</select>
</div>
</div>
<div class="drawer-section">
<div class="drawer-header">
<span>Layers</span>
<div class="drawer-header-buttons">
<button id="save-state-btn" title="Save Canvas State">💾</button>
<button id="load-state-btn" title="Load Canvas State">📂</button>
<button id="add-new-layer" title="Add New Drawing Layer">+</button>
</div>
</div>
<div id="layers-list" class="layers-list"></div>
</div>
<div class="drawer-section">
<div class="drawer-header">
<span>Source Images</span>
<button id="refresh-images-btn" title="Refresh Images">🔄</button>
</div>
<div id="images-list" class="images-list"></div>
</div>
<div class="drawer-section mini-map-section">
<div class="drawer-header">Navigator</div>
<div class="mini-map-container">
<canvas id="mini-map-canvas"></canvas>
</div>
</div>
</div>
</div>
</div>
<div id="layer-effects-modal" class="modal-overlay" style="display:none;">
<div class="modal-content">
<div class="modal-header">
<span id="modal-layer-name">Layer Effects</span>
<button id="modal-close" class="modal-close-btn">×</button>
</div>
<div class="modal-body">
<div class="effect-section">
<div class="effect-header">
<label class="checkbox-label"><input type="checkbox" id="effect-stroke-enabled"> Stroke</label>
</div>
<div class="effect-controls" id="stroke-controls">
<div class="effect-row">
<label>Width:</label>
<input type="range" id="effect-stroke-width" min="1" max="50" value="2">
<span id="effect-stroke-width-val">2</span>px
</div>
<div class="effect-row">
<label>Color:</label>
<input type="color" id="effect-stroke-color" value="#000000">
</div>
</div>
</div>
<div class="effect-section">
<div class="effect-header">
<label class="checkbox-label"><input type="checkbox" id="effect-shadow-enabled"> Shadow</label>
</div>
<div class="effect-controls" id="shadow-controls">
<div class="effect-row">
<label>Blur:</label>
<input type="range" id="effect-shadow-blur" min="0" max="50" value="10">
<span id="effect-shadow-blur-val">10</span>px
</div>
<div class="effect-row">
<label>Offset X:</label>
<input type="range" id="effect-shadow-x" min="-50" max="50" value="5">
<span id="effect-shadow-x-val">5</span>px
</div>
<div class="effect-row">
<label>Offset Y:</label>
<input type="range" id="effect-shadow-y" min="-50" max="50" value="5">
<span id="effect-shadow-y-val">5</span>px
</div>
<div class="effect-row">
<label>Color:</label>
<input type="color" id="effect-shadow-color" value="#000000">
<label>Opacity:</label>
<input type="range" id="effect-shadow-opacity" min="0" max="100" value="50">
<span id="effect-shadow-opacity-val">50</span>%
</div>
</div>
</div>
<div class="effect-section">
<div class="effect-header">Transform</div>
<div class="effect-controls">
<div class="effect-row">
<label>Rotation:</label>
<input type="range" id="effect-rotation" min="-180" max="180" value="0">
<span id="effect-rotation-val">0</span>°
</div>
<div class="effect-row">
<label>Scale X:</label>
<input type="range" id="effect-scale-x" min="10" max="300" value="100">
<span id="effect-scale-x-val">100</span>%
</div>
<div class="effect-row">
<label>Scale Y:</label>
<input type="range" id="effect-scale-y" min="10" max="300" value="100">
<span id="effect-scale-y-val">100</span>%
</div>
<div class="effect-row">
<label>Skew X:</label>
<input type="range" id="effect-skew-x" min="-100" max="100" value="0">
<span id="effect-skew-x-val">0</span>
</div>
<div class="effect-row">
<label>Skew Y:</label>
<input type="range" id="effect-skew-y" min="-100" max="100" value="0">
<span id="effect-skew-y-val">0</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
}
static getStyles(theme) {
// Use CSS variables from ComfyUI theme for consistent styling
return `
*, *::before, *::after {
box-sizing: border-box;
}
html, body {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
#canvas-composer {
display: flex;
flex-direction: column;
height: 100%;
background: var(--theme-bg);
color: var(--theme-fg);
font-family: system-ui, sans-serif;
font-size: 12px;
}
.canvas-toolbar {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: var(--theme-panel-header, var(--theme-bg-dark));
border-bottom: 1px solid var(--theme-border);
flex-wrap: wrap;
min-height: 42px;
}
.brush-toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 12px;
background: var(--theme-bg);
border-bottom: 1px solid var(--theme-border);
min-height: 36px;
}
.brush-toolbar .toolbar-group {
display: flex;
align-items: center;
gap: 6px;
}
.brush-toolbar label { font-size: 11px; color: var(--theme-fg-muted); white-space: nowrap; }
.brush-toolbar input[type="range"] { width: 60px; accent-color: var(--theme-accent); }
.brush-toolbar select {
background: var(--theme-input-bg);
color: var(--theme-fg);
border: 1px solid var(--theme-input-border);
border-radius: 4px;
padding: 3px 6px;
font-size: 11px;
}
.brush-toolbar select:focus { border-color: var(--theme-input-focus); outline: none; }
.brush-toolbar span { font-size: 10px; color: var(--theme-fg-muted); min-width: 24px; }
.brush-toolbar .checkbox-label { cursor: pointer; font-size: 11px; }
.brush-toolbar .checkbox-label input[type="checkbox"] { accent-color: var(--theme-accent); }
.toolbar-group {
display: flex;
align-items: center;
gap: 6px;
}
.toolbar-separator {
width: 1px;
height: 24px;
background: var(--theme-border);
margin: 0 4px;
}
.toolbar-spacer {
flex: 1;
}
.tool-btn {
width: 32px;
height: 32px;
border: 1px solid var(--theme-input-border);
background: var(--theme-input-bg);
color: var(--theme-fg);
border-radius: 6px;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
}
.tool-btn:hover { background: var(--theme-accent-bg); border-color: var(--theme-accent); }
.tool-btn.active { background: var(--theme-accent); color: #fff; border-color: var(--theme-accent); box-shadow: 0 2px 4px var(--theme-shadow); }
.canvas-toolbar input[type="range"] { width: 60px; accent-color: var(--theme-accent); }
.canvas-toolbar input[type="number"] { width: 50px; background: var(--theme-input-bg); color: var(--theme-fg); border: 1px solid var(--theme-input-border); border-radius: 4px; padding: 4px; text-align: center; }
.canvas-toolbar input[type="number"]::-webkit-inner-spin-button { opacity: 0; }
.canvas-toolbar input[type="number"]:focus { border-color: var(--theme-input-focus); outline: none; }
.export-size-group span { color: var(--theme-fg-muted); font-size: 11px; }
.canvas-toolbar select, .layer-control-row select {
background: var(--theme-input-bg);
color: var(--theme-fg);
border: 1px solid var(--theme-input-border);
border-radius: 4px;
padding: 4px 6px;
transform-origin: top left;
}
.canvas-toolbar select:focus, .layer-control-row select:focus { border-color: var(--theme-input-focus); outline: none; }
.canvas-toolbar label { display: flex; align-items: center; gap: 4px; white-space: nowrap; font-size: 11px; color: var(--theme-fg-muted); }
.checkbox-label { cursor: pointer; }
.checkbox-label input[type="checkbox"] { accent-color: var(--theme-accent); }
.action-btn {
padding: 6px 12px;
background: var(--theme-input-bg);
color: var(--theme-fg);
border: 1px solid var(--theme-input-border);
border-radius: 6px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
display: flex;
align-items: center;
gap: 4px;
transition: all 0.15s ease;
}
.action-btn:hover { background: var(--theme-accent-bg); border-color: var(--theme-accent); }
.action-btn.primary { background: var(--theme-accent); color: #fff; border-color: var(--theme-accent); }
.action-btn.primary:hover { background: var(--theme-accent-hover); box-shadow: 0 2px 6px var(--theme-shadow); }
.action-btn.icon-only { padding: 6px 8px; min-width: 32px; justify-content: center; }
.canvas-workspace {
display: flex;
flex: 1;
overflow: hidden;
position: relative;
}
.canvas-container {
flex: 1;
position: relative;
overflow: hidden;
background: repeating-conic-gradient(#333 0% 25%, #444 0% 50%) 50% / 20px 20px;
min-width: 0;
min-height: 0;
}
#main-canvas {
display: block;
}
.layers-drawer {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 200px;
background: var(--theme-panel-bg, var(--theme-bg));
border-left: 1px solid var(--theme-border);
display: flex;
transition: transform 0.2s ease;
z-index: 100;
}
.layers-drawer.collapsed {
transform: translateX(200px);
}
.drawer-toggle {
position: absolute;
left: -24px;
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 48px;
background: var(--theme-panel-bg, var(--theme-bg));
border: 1px solid var(--theme-border);
border-right: none;
border-radius: 6px 0 0 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: var(--theme-fg);
}
.drawer-toggle:hover { background: var(--theme-accent-bg); }
.layers-drawer.collapsed .drawer-toggle {
transform: translateY(-50%) translateX(-200px);
}
.layers-drawer.collapsed .drawer-toggle::after { content: '▶'; }
.drawer-toggle::after { content: '◀'; }
.drawer-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.drawer-section {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
border-bottom: 1px solid var(--theme-border);
}
.drawer-section:last-child { border-bottom: none; }
.drawer-section.layer-controls-section {
flex: 0 0 auto;
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.layer-control-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
position: relative;
}
.layer-control-row label {
color: var(--theme-fg-muted);
white-space: nowrap;
min-width: 50px;
}
.layer-control-row select {
flex: 1;
padding: 4px 6px;
font-size: 11px;
background: var(--theme-input-bg);
color: var(--theme-fg);
border: 1px solid var(--theme-border);
border-radius: 3px;
min-width: 0;
}
.layer-control-row input[type="range"] {
flex: 1;
min-width: 0;
accent-color: var(--theme-accent);
}
.layer-control-row span {
font-size: 10px;
min-width: 32px;
text-align: right;
}
.drawer-header {
padding: 8px 10px;
font-weight: bold;
font-size: 11px;
text-transform: uppercase;
border-bottom: 1px solid var(--theme-border);
background: var(--theme-panel-header, var(--theme-bg-dark));
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.drawer-header-buttons {
display: flex;
gap: 4px;
}
.drawer-header button {
font-size: 10px;
padding: 2px 6px;
background: var(--theme-accent);
color: #fff;
border: none;
border-radius: 3px;
cursor: pointer;
}
.drawer-header button:hover { opacity: 0.8; }
.layers-list, .images-list {
flex: 1;
overflow-y: auto;
padding: 6px;
}
.layers-list::-webkit-scrollbar, .images-list::-webkit-scrollbar {
width: 6px;
}
.layers-list::-webkit-scrollbar-thumb, .images-list::-webkit-scrollbar-thumb {
background: var(--theme-scrollbar-thumb);
border-radius: 3px;
}
.layers-list::-webkit-scrollbar-track, .images-list::-webkit-scrollbar-track {
background: var(--theme-scrollbar-track);
}
.mini-map-section {
flex: 0 !important;
min-height: auto !important;
}
.mini-map-container {
padding: 6px;
background: #000;
}
#mini-map-canvas {
width: 100%;
height: 80px;
display: block;
cursor: crosshair;
}
.layer-item, .image-item {
padding: 6px 8px;
margin: 2px 0;
background: var(--theme-bg-light);
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
user-select: none;
}
.layer-item:hover, .image-item:hover { background: var(--theme-accent-bg); }
.layer-item.selected { background: var(--theme-selection); outline: 1px solid var(--theme-accent); }
.layer-item.drag-over { border-top: 2px solid var(--theme-accent); }
.layer-item.dragging { opacity: 0.5; }
.layer-thumb {
width: 28px;
height: 28px;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
background-color: var(--theme-bg);
border: 1px solid var(--theme-border);
border-radius: 3px;
flex-shrink: 0;
}
.layer-thumb.draw-layer {
background: linear-gradient(45deg, #666 25%, transparent 25%), linear-gradient(-45deg, #666 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #666 75%), linear-gradient(-45deg, transparent 75%, #666 75%);
background-size: 8px 8px;
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
}
.layer-info { flex: 1; overflow: hidden; min-width: 0; }
.layer-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 11px; }
.layer-type { font-size: 9px; color: var(--theme-fg-muted); }
.layer-controls {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.layer-btn {
width: 20px;
height: 20px;
border: none;
background: transparent;
color: var(--theme-fg);
cursor: pointer;
border-radius: 3px;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.layer-btn:hover { background: var(--theme-accent-bg); }
.layer-btn.visibility-off { opacity: 0.4; }
.layer-btn.effects-btn { color: var(--theme-fg-muted); }
.layer-btn.effects-btn:hover { color: var(--theme-accent); }
.layer-btn.effects-btn.has-effects { color: var(--theme-accent); background: var(--theme-accent-bg); }
.layer-btn.mask-add-btn { color: var(--theme-fg-disabled); font-size: 10px; }
.layer-btn.mask-add-btn:hover { color: var(--theme-warning); }
.layer-btn.mask-edit-btn { color: var(--theme-fg-muted); }
.layer-btn.mask-edit-btn:hover { color: var(--theme-warning); }
.layer-btn.mask-edit-btn.editing-mask { color: #ff6600; background: #ff660033; }
.layer-item.has-mask { border-left: 2px solid var(--theme-warning); }
.image-item {
cursor: grab;
}
.image-item:active { cursor: grabbing; }
/* Modal styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(2px);
}
.modal-content {
background: var(--theme-panel-bg, var(--theme-bg));
border: 1px solid var(--theme-border);
border-radius: 12px;
width: 360px;
max-width: 90vw;
max-height: 80vh;
overflow: hidden;
box-shadow: 0 8px 32px var(--theme-shadow);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--theme-border);
background: var(--theme-panel-header, var(--theme-bg-dark));
font-weight: 600;
}
.modal-close-btn {
width: 28px;
height: 28px;
border: none;
background: transparent;
color: var(--theme-fg);
font-size: 20px;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.modal-close-btn:hover { background: var(--theme-accent-bg); }
.modal-body {
padding: 12px 16px;
overflow-y: auto;
max-height: calc(80vh - 60px);
}
.effect-section {
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--theme-border-light);
}
.effect-section:last-child { border-bottom: none; margin-bottom: 0; }
.effect-header {
font-weight: 600;
font-size: 12px;
margin-bottom: 10px;
color: var(--theme-fg);
}
.effect-controls {
padding-left: 8px;
}
.effect-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: 11px;
}
.effect-row:last-child { margin-bottom: 0; }
.effect-row label { min-width: 60px; color: var(--theme-fg-muted); }
.effect-row input[type="range"] { flex: 1; accent-color: var(--theme-accent); }
.effect-row input[type="color"] { width: 32px; height: 24px; border: 1px solid var(--theme-input-border); border-radius: 4px; cursor: pointer; }
.effect-row span { min-width: 30px; text-align: right; font-family: monospace; }
/* Transform handles cursor styles */
.canvas-container.cursor-nwse { cursor: nwse-resize; }
.canvas-container.cursor-nesw { cursor: nesw-resize; }
.canvas-container.cursor-rotate { cursor: grab; }
.canvas-container.cursor-move { cursor: move; }
`;
}
static getScripts() {
return `
<script>
(function() {
try {
const container = document.getElementById('canvas-composer');
if (!container) return;
// Get origin from data attribute (passed from parent context)
const origin = container.dataset.origin || '';
// Helper to convert file info to ComfyUI /view URL
function getImageUrl(fileInfo) {
if (typeof fileInfo === 'string') {
// Legacy base64 URL
return fileInfo;
}
// File reference - construct absolute /view URL
const params = new URLSearchParams({
filename: fileInfo.filename,
subfolder: fileInfo.subfolder || '',
type: fileInfo.type || 'temp'
});
return origin + '/view?' + params.toString();
}
// Load image - simple approach without crossOrigin/fetch to avoid CORS in srcdoc iframe
function loadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (e) => {
console.error('[Canvas] Failed to load image:', url, e);
reject(e);
};
img.src = url;
});
}
// Read image data from data attribute (HTML-escaped JSON)
let imagesData = [];
try {
const jsonStr = container.dataset.images;
if (jsonStr) {
imagesData = JSON.parse(jsonStr);
}
} catch (e) {}
const canvas = document.getElementById('main-canvas');
if (!canvas) {
console.error('[Canvas] Main canvas not found!');
return;
}
const ctx = canvas.getContext('2d');
// State
const state = {
tool: 'select',
zoom: 1,
panX: 0,
panY: 0,
layers: [],
selectedLayerId: null,
isDragging: false,
dragStart: { x: 0, y: 0 },
dragType: null,
activeHandle: null,
handleStartData: null,
brushColor: '#ffffff',
brushSize: 10,
brushHardness: 100,
brushOpacity: 100,
brushTool: 'brush',
brushBlend: 'source-over',
cloneSource: null,
cloneAllLayers: false,
editingMask: false,
bgColor: '#000000',
bgTransparent: true,
isPainting: false,
lastPaintPos: null,
modalLayerId: null,
renderPending: false,
layerListDirty: true,
exportWidth: 0,
exportHeight: 0,
snapEnabled: false,
snapThreshold: 10,
};
let layerIdCounter = 0;
// Undo/Redo history - saves canvas pixel data as ImageData
const history = {
states: [],
index: -1,
maxStates: 30,
save() {
// Remove any states after current index
this.states = this.states.slice(0, this.index + 1);
// Save layer state with canvas data
const snapshot = state.layers.map(l => {
const saved = {
id: l.id,
type: l.type,
name: l.name,
visible: l.visible,
x: l.x,
y: l.y,
width: l.width,
height: l.height,
rotation: l.rotation,
scaleX: l.scaleX,
scaleY: l.scaleY,
skewX: l.skewX,
skewY: l.skewY,
opacity: l.opacity,
blendMode: l.blendMode,
strokeWidth: l.strokeWidth,
strokeColor: l.strokeColor,
shadow: l.shadow,
shadowBlur: l.shadowBlur,
shadowOffsetX: l.shadowOffsetX,
shadowOffsetY: l.shadowOffsetY,
shadowColor: l.shadowColor,
maskEnabled: l.maskEnabled,
imageUrl: l.imageUrl,
drawData: null,
maskData: null,
tainted: false
};
// Save draw canvas data (may fail if canvas is tainted by cross-origin images)
if (l.drawCanvas) {
try {
saved.drawData = l.drawCtx.getImageData(0, 0, l.drawCanvas.width, l.drawCanvas.height);
} catch (e) {
// Canvas is tainted - save as data URL instead (or mark as tainted)
saved.tainted = true;
try {
saved.drawDataUrl = l.drawCanvas.toDataURL();
} catch (e2) {
// Can't save tainted canvas at all
saved.drawDataUrl = null;
}
}
}
// Save mask canvas data
if (l.maskCanvas) {
try {
saved.maskData = l.maskCtx.getImageData(0, 0, l.maskCanvas.width, l.maskCanvas.height);
} catch (e) {
// Mask canvas tainted (unlikely but handle it)
saved.maskData = null;
}
}
return saved;
});
this.states.push(snapshot);
if (this.states.length > this.maxStates) {
this.states.shift();
} else {
this.index++;
}
},
undo() {
if (this.index > 0) {
this.index--;
this.restore();
}
},
redo() {
if (this.index < this.states.length - 1) {
this.index++;
this.restore();
}
},
restore() {
const snapshot = this.states[this.index];
if (!snapshot) return;
// Get IDs from snapshot
const snapshotIds = new Set(snapshot.map(s => s.id));
const currentIds = new Set(state.layers.map(l => l.id));
// Remove layers that don't exist in snapshot
state.layers = state.layers.filter(l => snapshotIds.has(l.id));
// Process each saved layer
snapshot.forEach(saved => {
let layer = state.layers.find(l => l.id === saved.id);
// If layer doesn't exist, recreate it
if (!layer) {
if (saved.type === 'draw') {
layer = createDrawLayer();
layer.id = saved.id;
} else {
// Recreate image layer
layer = {
id: saved.id,
type: saved.type,
name: saved.name,
image: null,
imageUrl: saved.imageUrl,
x: saved.x,
y: saved.y,
width: saved.width,
height: saved.height,
rotation: saved.rotation || 0,
scaleX: saved.scaleX || 1,
scaleY: saved.scaleY || 1,
skewX: saved.skewX || 0,
skewY: saved.skewY || 0,
opacity: saved.opacity ?? 100,
blendMode: saved.blendMode || 'source-over',
visible: saved.visible !== false,
strokeWidth: saved.strokeWidth || 0,
strokeColor: saved.strokeColor || '#000000',
shadow: saved.shadow || false,
shadowBlur: saved.shadowBlur || 10,
shadowOffsetX: saved.shadowOffsetX || 5,
shadowOffsetY: saved.shadowOffsetY || 5,
shadowColor: saved.shadowColor || 'rgba(0,0,0,0.5)',
maskEnabled: saved.maskEnabled || false,
maskCanvas: null,
maskCtx: null,
};
// Reload image if needed
if (saved.imageUrl) {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
layer.image = img;
scheduleRender();
};
img.src = saved.imageUrl;
}
}
state.layers.push(layer);
}
// Restore properties
Object.keys(saved).forEach(key => {
if (key !== 'drawData' && key !== 'maskData' && key !== 'drawDataUrl' && key !== 'id' && key !== 'tainted') {
layer[key] = saved[key];
}
});
// Restore draw canvas data
if (saved.drawData && layer.drawCanvas) {
layer.drawCtx.putImageData(saved.drawData, 0, 0);
} else if (saved.tainted && saved.drawDataUrl && layer.drawCanvas) {
// Restore tainted canvas from data URL
const img = new Image();
img.onload = () => {
layer.drawCtx.clearRect(0, 0, layer.drawCanvas.width, layer.drawCanvas.height);
layer.drawCtx.drawImage(img, 0, 0);
scheduleRender();
};
img.src = saved.drawDataUrl;
}
// Restore mask canvas data
if (saved.maskData && layer.maskCanvas) {
layer.maskCtx.putImageData(saved.maskData, 0, 0);
}
});
// Reorder layers to match snapshot order
const orderedLayers = [];
snapshot.forEach(saved => {
const layer = state.layers.find(l => l.id === saved.id);
if (layer) orderedLayers.push(layer);
});
state.layers = orderedLayers;
// Reset selection if selected layer no longer exists
if (state.selectedLayerId && !state.layers.find(l => l.id === state.selectedLayerId)) {
state.selectedLayerId = state.layers.length > 0 ? state.layers[0].id : null;
}
// Reset clone source as it may be invalid
state.cloneSource = null;
const cloneStatus = document.getElementById('clone-status');
if (cloneStatus) cloneStatus.textContent = 'Alt+Click to set source';
// Reset painting state
state.isPainting = false;
state.lastPaintPos = null;
markLayerListDirty();
scheduleRender();
}
};
function createLayerMask(layerId) {
const layer = state.layers.find(l => l.id === layerId);
if (!layer || layer.maskCanvas) return;
const maskCanvas = document.createElement('canvas');
maskCanvas.width = layer.width;
maskCanvas.height = layer.height;
const maskCtx = maskCanvas.getContext('2d');
// Start with fully opaque white mask (fully visible)
maskCtx.fillStyle = 'white';
maskCtx.fillRect(0, 0, maskCanvas.width, maskCanvas.height);
layer.maskCanvas = maskCanvas;
layer.maskCtx = maskCtx;
layer.maskEnabled = true;
markLayerListDirty();
scheduleRender();
}
function deleteLayerMask(layerId) {
const layer = state.layers.find(l => l.id === layerId);
if (!layer || !layer.maskCanvas) return;
layer.maskCanvas = null;
layer.maskCtx = null;
layer.maskEnabled = false;
state.editingMask = false;
markLayerListDirty();
scheduleRender();
}
function toggleMaskEditing() {
const layer = state.layers.find(l => l.id === state.selectedLayerId);
if (!layer || !layer.maskCanvas) return;
state.editingMask = !state.editingMask;
markLayerListDirty();
scheduleRender();
}
function duplicateLayer(layerId) {
const layer = state.layers.find(l => l.id === layerId);
if (!layer) return null;
const newLayer = {
...layer,
id: ++layerIdCounter,
name: layer.name + ' Copy',
x: layer.x + 20,
y: layer.y + 20
};
// Clone draw canvas if it's a draw layer
if (layer.type === 'draw' && layer.drawCanvas) {
newLayer.drawCanvas = document.createElement('canvas');
newLayer.drawCanvas.width = layer.drawCanvas.width;
newLayer.drawCanvas.height = layer.drawCanvas.height;
newLayer.drawCtx = newLayer.drawCanvas.getContext('2d');
newLayer.drawCtx.drawImage(layer.drawCanvas, 0, 0);
newLayer.image = newLayer.drawCanvas;
}
// Clone mask if exists
if (layer.maskCanvas) {
newLayer.maskCanvas = document.createElement('canvas');
newLayer.maskCanvas.width = layer.maskCanvas.width;
newLayer.maskCanvas.height = layer.maskCanvas.height;
newLayer.maskCtx = newLayer.maskCanvas.getContext('2d');
newLayer.maskCtx.drawImage(layer.maskCanvas, 0, 0);
}
state.layers.push(newLayer);
state.selectedLayerId = newLayer.id;
markLayerListDirty();
history.save();
scheduleRender();
return newLayer;
}
async function createLayer(imageUrl, x = 100, y = 100, sourceImageIndex = -1) {
const img = await loadImage(imageUrl);
const layer = {
id: ++layerIdCounter,
type: 'image',
name: 'Image ' + layerIdCounter,
visible: true,
image: img,
imageUrl: imageUrl,
sourceImageIndex: sourceImageIndex,
x: x,
y: y,
width: img.width,
height: img.height,
sourceWidth: img.width,
sourceHeight: img.height,
trimOffsetX: 0,
trimOffsetY: 0,
rotation: 0,
scaleX: 1,
scaleY: 1,
skewX: 0,
skewY: 0,
opacity: 1,
strokeWidth: 0,
strokeColor: '#000000',
shadow: false,
shadowBlur: 10,
shadowOffsetX: 5,
shadowOffsetY: 5,
shadowColor: 'rgba(0,0,0,0.5)',
blendMode: 'source-over',
};
state.layers.push(layer);
markLayerListDirty();
return layer;
}
function createDrawLayer(width = 512, height = 512) {
const drawCanvas = document.createElement('canvas');
drawCanvas.width = width;
drawCanvas.height = height;
const layer = {
id: ++layerIdCounter,
type: 'draw',
name: 'Draw ' + layerIdCounter,
visible: true,
drawCanvas: drawCanvas,
drawCtx: drawCanvas.getContext('2d'),
image: drawCanvas,
imageUrl: null,
x: 50,
y: 50,
width: width,
height: height,
rotation: 0,
scaleX: 1,
scaleY: 1,
skewX: 0,
skewY: 0,
opacity: 1,
strokeWidth: 0,
strokeColor: '#000000',
shadow: false,
shadowBlur: 10,
shadowOffsetX: 5,
shadowOffsetY: 5,
shadowColor: 'rgba(0,0,0,0.5)',
blendMode: 'source-over',
};
state.layers.push(layer);
markLayerListDirty();
return layer;
}
function resizeCanvas() {
const container = canvas.parentElement;
// Use clientWidth/Height to get actual content area without scrollbars
const width = container.clientWidth;
const height = container.clientHeight;
// Set both the canvas buffer size and CSS size to match exactly
canvas.width = width;
canvas.height = height;
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
render();
}
function scheduleRender() {
if (state.renderPending) return;
state.renderPending = true;
requestAnimationFrame(() => {
state.renderPending = false;
render();
});
}
// Cached offscreen canvas for compositing (avoid creating new canvas every frame)
let cachedOffCanvas = null;
let cachedOffCtx = null;
// Cached temp canvas for mask compositing
let cachedMaskCanvas = null;
let cachedMaskCtx = null;
let lastMiniMapUpdate = 0;
const MINIMAP_UPDATE_INTERVAL = 100; // Update minimap at most every 100ms
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Background
if (!state.bgTransparent) {
ctx.fillStyle = state.bgColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
// Calculate bounds for offscreen canvas
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const layer of state.layers) {
if (!layer.visible) continue;
const lw = layer.width * layer.scaleX;
const lh = layer.height * layer.scaleY;
minX = Math.min(minX, layer.x);
minY = Math.min(minY, layer.y);
maxX = Math.max(maxX, layer.x + lw);
maxY = Math.max(maxY, layer.y + lh);
}
// Reuse cached offscreen canvas for layer compositing (prevents blending with checkered bg)
const hasLayers = state.layers.some(l => l.visible);
let offCanvas, offCtx;
if (hasLayers && isFinite(minX)) {
const padding = 100;
const offWidth = Math.max(1, Math.ceil(maxX - minX + padding * 2));
const offHeight = Math.max(1, Math.ceil(maxY - minY + padding * 2));
// Reuse cached canvas if large enough, otherwise create new one
if (!cachedOffCanvas || cachedOffCanvas.width < offWidth || cachedOffCanvas.height < offHeight) {
cachedOffCanvas = document.createElement('canvas');
cachedOffCanvas.width = Math.max(offWidth, 2048);
cachedOffCanvas.height = Math.max(offHeight, 2048);
cachedOffCtx = cachedOffCanvas.getContext('2d');
}
offCanvas = cachedOffCanvas;
offCtx = cachedOffCtx;
offCtx.setTransform(1, 0, 0, 1, 0, 0);
offCtx.clearRect(0, 0, offCanvas.width, offCanvas.height);
offCtx.translate(-minX + padding, -minY + padding);
}
// Render layers to offscreen canvas
for (const layer of state.layers) {
if (!layer.visible) continue;
if (!offCtx) continue;
offCtx.save();
offCtx.translate(layer.x + layer.width * layer.scaleX * 0.5, layer.y + layer.height * layer.scaleY * 0.5);
offCtx.rotate(layer.rotation * Math.PI / 180);
offCtx.transform(1, layer.skewY, layer.skewX, 1, 0, 0);
offCtx.scale(layer.scaleX, layer.scaleY);
offCtx.translate(-layer.width * 0.5, -layer.height * 0.5);
offCtx.globalAlpha = layer.opacity;
offCtx.globalCompositeOperation = layer.blendMode || 'source-over';
// Apply shadow if enabled
if (layer.shadow) {
offCtx.shadowBlur = layer.shadowBlur;
offCtx.shadowOffsetX = layer.shadowOffsetX;
offCtx.shadowOffsetY = layer.shadowOffsetY;
offCtx.shadowColor = layer.shadowColor;
}
// Draw stroke behind image using canvas shadow trick
if (layer.strokeWidth > 0) {
offCtx.save();
offCtx.shadowColor = layer.strokeColor;
offCtx.shadowBlur = layer.strokeWidth;
offCtx.shadowOffsetX = 0;
offCtx.shadowOffsetY = 0;
offCtx.drawImage(layer.image, 0, 0, layer.width, layer.height);
offCtx.restore();
if (layer.shadow) {
offCtx.shadowBlur = layer.shadowBlur;
offCtx.shadowOffsetX = layer.shadowOffsetX;
offCtx.shadowOffsetY = layer.shadowOffsetY;
offCtx.shadowColor = layer.shadowColor;
} else {
offCtx.shadowColor = 'transparent';
}
}
// Draw the image with mask if enabled
if (layer.maskCanvas && layer.maskEnabled) {
// Reuse cached mask canvas to avoid creating new canvas every frame
if (!cachedMaskCanvas || cachedMaskCanvas.width < layer.width || cachedMaskCanvas.height < layer.height) {
cachedMaskCanvas = document.createElement('canvas');
cachedMaskCanvas.width = Math.max(layer.width, 1024);
cachedMaskCanvas.height = Math.max(layer.height, 1024);
cachedMaskCtx = cachedMaskCanvas.getContext('2d');
}
cachedMaskCtx.clearRect(0, 0, layer.width, layer.height);
cachedMaskCtx.globalCompositeOperation = 'source-over';
cachedMaskCtx.drawImage(layer.image, 0, 0, layer.width, layer.height);
cachedMaskCtx.globalCompositeOperation = 'destination-in';
cachedMaskCtx.drawImage(layer.maskCanvas, 0, 0);
offCtx.drawImage(cachedMaskCanvas, 0, 0, layer.width, layer.height);
} else {
offCtx.drawImage(layer.image, 0, 0, layer.width, layer.height);
}
// Clear shadow state
offCtx.shadowColor = 'transparent';
// Show mask overlay when editing - white areas are visible, erased areas show red
if (state.editingMask && layer.id === state.selectedLayerId && layer.maskCanvas) {
offCtx.globalAlpha = 0.5;
offCtx.drawImage(layer.maskCanvas, 0, 0, layer.width, layer.height);
offCtx.globalAlpha = layer.opacity;
}
offCtx.restore();
}
// Draw composited layers onto main canvas
ctx.save();
ctx.translate(state.panX, state.panY);
ctx.scale(state.zoom, state.zoom);
if (offCanvas && hasLayers && isFinite(minX)) {
const padding = 100;
ctx.drawImage(offCanvas, minX - padding, minY - padding);
}
// Draw selection UI on main canvas
for (const layer of state.layers) {
if (!layer.visible) continue;
// Selection box
if (layer.id === state.selectedLayerId && state.tool === 'select') {
ctx.save();
ctx.strokeStyle = '#00aaff';
ctx.translate(layer.x + layer.width * layer.scaleX * 0.5, layer.y + layer.height * layer.scaleY * 0.5);
ctx.rotate(layer.rotation * Math.PI / 180);
ctx.transform(1, layer.skewY, layer.skewX, 1, 0, 0);
ctx.scale(layer.scaleX, layer.scaleY);
ctx.translate(-layer.width * 0.5, -layer.height * 0.5);
// Compensate line width and dash for layer scale so they appear consistent
const avgScale = (layer.scaleX + layer.scaleY) / 2;
ctx.lineWidth = 2 / state.zoom / avgScale;
ctx.setLineDash([5 / state.zoom / avgScale, 5 / state.zoom / avgScale]);
// Compensate padding for scale
const padding = 5 / state.zoom / avgScale;
ctx.strokeRect(-padding, -padding, layer.width + padding * 2, layer.height + padding * 2);
// Corner handles for scale - compensate size for layer scale
ctx.fillStyle = '#00aaff';
const hsX = 8 / state.zoom / layer.scaleX;
const hsY = 8 / state.zoom / layer.scaleY;
const hshX = hsX * 0.5;
const hshY = hsY * 0.5;
ctx.fillRect(-hshX, -hshY, hsX, hsY);
ctx.fillRect(layer.width - hshX, -hshY, hsX, hsY);
ctx.fillRect(-hshX, layer.height - hshY, hsX, hsY);
ctx.fillRect(layer.width - hshX, layer.height - hshY, hsX, hsY);
// Rotation handle - compensate for scale
const rotHandleOffset = 20 / state.zoom / layer.scaleY;
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(layer.width * 0.5, 0);
ctx.lineTo(layer.width * 0.5, -rotHandleOffset);
ctx.stroke();
ctx.beginPath();
ctx.arc(layer.width * 0.5, -rotHandleOffset, Math.min(hsX, hsY) * 0.5, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
ctx.restore();
// Draw export bbox overlay if size is set
if (state.exportWidth > 0 && state.exportHeight > 0) {
ctx.save();
ctx.translate(state.panX, state.panY);
ctx.scale(state.zoom, state.zoom);
// Darken area outside export bbox
ctx.fillStyle = 'rgba(0,0,0,0.4)';
const bw = state.exportWidth;
const bh = state.exportHeight;
const vw = canvas.width / state.zoom;
const vh = canvas.height / state.zoom;
const vx = -state.panX / state.zoom;
const vy = -state.panY / state.zoom;
// Top
ctx.fillRect(vx, vy, vw, -vy);
// Bottom
ctx.fillRect(vx, bh, vw, vh - bh + vy);
// Left
ctx.fillRect(vx, 0, -vx, bh);
// Right
ctx.fillRect(bw, 0, vw - bw + vx, bh);
// Draw export bbox border
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2 / state.zoom;
ctx.setLineDash([8 / state.zoom, 4 / state.zoom]);
ctx.strokeRect(0, 0, bw, bh);
// Draw corner markers
ctx.setLineDash([]);
ctx.fillStyle = '#fff';
const ms = 6 / state.zoom;
ctx.fillRect(-ms/2, -ms/2, ms, ms);
ctx.fillRect(bw - ms/2, -ms/2, ms, ms);
ctx.fillRect(-ms/2, bh - ms/2, ms, ms);
ctx.fillRect(bw - ms/2, bh - ms/2, ms, ms);
ctx.restore();
}
// Draw snap guides when dragging
if (state.isDragging && state.snapEnabled && state.snapGuides) {
ctx.save();
ctx.strokeStyle = '#0af';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
for (const guide of state.snapGuides) {
ctx.beginPath();
if (guide.type === 'vertical') {
const sx = guide.x * state.zoom + state.panX;
ctx.moveTo(sx, 0);
ctx.lineTo(sx, canvas.height);
} else {
const sy = guide.y * state.zoom + state.panY;
ctx.moveTo(0, sy);
ctx.lineTo(canvas.width, sy);
}
ctx.stroke();
}
ctx.restore();
}
if (state.layerListDirty) {
state.layerListDirty = false;
updateLayersList();
}
// Throttle minimap updates to avoid performance issues
const now = performance.now();
if (now - lastMiniMapUpdate > MINIMAP_UPDATE_INTERVAL) {
lastMiniMapUpdate = now;
updateMiniMap();
}
}
// Cache minimap canvas reference and dimensions
let miniMapCanvas = null;
let miniMapCtx = null;
let miniMapWidth = 0;
let miniMapHeight = 0;
function updateMiniMap() {
if (!miniMapCanvas) {
miniMapCanvas = document.getElementById('mini-map-canvas');
if (!miniMapCanvas) return;
}
if (!miniMapCtx) {
miniMapCtx = miniMapCanvas.getContext('2d');
if (!miniMapCtx) return;
}
// Only recalculate dimensions occasionally (not every frame)
const rect = miniMapCanvas.getBoundingClientRect();
if (rect.width !== miniMapWidth || rect.height !== miniMapHeight) {
miniMapWidth = rect.width;
miniMapHeight = rect.height;
miniMapCanvas.width = miniMapWidth;
miniMapCanvas.height = miniMapHeight;
}
const mctx = miniMapCtx;
mctx.fillStyle = '#222';
mctx.fillRect(0, 0, miniMapWidth, miniMapHeight);
// Calculate content bounds (include export bbox if set)
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
// Include export bbox in bounds calculation
if (state.exportWidth > 0 && state.exportHeight > 0) {
minX = Math.min(minX, 0);
minY = Math.min(minY, 0);
maxX = Math.max(maxX, state.exportWidth);
maxY = Math.max(maxY, state.exportHeight);
}
for (const layer of state.layers) {
if (!layer.visible) continue;
const lw = layer.width * layer.scaleX;
const lh = layer.height * layer.scaleY;
minX = Math.min(minX, layer.x);
minY = Math.min(minY, layer.y);
maxX = Math.max(maxX, layer.x + lw);
maxY = Math.max(maxY, layer.y + lh);
}
if (!isFinite(minX)) {
minX = 0; minY = 0; maxX = 512; maxY = 512;
}
// Add padding
const pad = 100;
minX -= pad; minY -= pad;
maxX += pad; maxY += pad;
const contentW = maxX - minX;
const contentH = maxY - minY;
// Calculate scale to fit content in mini-map
const scale = Math.min(miniMapWidth / contentW, miniMapHeight / contentH) * 0.9;
const offsetX = (miniMapWidth - contentW * scale) / 2;
const offsetY = (miniMapHeight - contentH * scale) / 2;
// Draw layers as rectangles
mctx.save();
mctx.translate(offsetX, offsetY);
mctx.scale(scale, scale);
mctx.translate(-minX, -minY);
// Draw export bbox if set
if (state.exportWidth > 0 && state.exportHeight > 0) {
mctx.strokeStyle = '#f80';
mctx.lineWidth = 2 / scale;
mctx.setLineDash([4 / scale, 2 / scale]);
mctx.strokeRect(0, 0, state.exportWidth, state.exportHeight);
mctx.setLineDash([]);
}
for (const layer of state.layers) {
if (!layer.visible) continue;
mctx.fillStyle = layer.id === state.selectedLayerId ? '#6cf' : '#888';
mctx.globalAlpha = 0.5;
mctx.fillRect(layer.x, layer.y, layer.width * layer.scaleX, layer.height * layer.scaleY);
}
// Draw viewport rectangle
const viewX = -state.panX / state.zoom;
const viewY = -state.panY / state.zoom;
const viewW = canvas.width / state.zoom;
const viewH = canvas.height / state.zoom;
mctx.globalAlpha = 1;
mctx.strokeStyle = '#fff';
mctx.lineWidth = 2 / scale;
mctx.strokeRect(viewX, viewY, viewW, viewH);
mctx.restore();
// Store transform info for click handling
miniMapCanvas._miniMapTransform = { minX, minY, scale, offsetX, offsetY };
}
function markLayerListDirty() {
state.layerListDirty = true;
}
function calculateSnap(layer, newX, newY) {
if (!state.snapEnabled) return { x: newX, y: newY, guides: [] };
const threshold = state.snapThreshold / state.zoom;
const guides = [];
let snapX = newX;
let snapY = newY;
const layerW = layer.width * layer.scaleX;
const layerH = layer.height * layer.scaleY;
// Layer edges
const layerLeft = newX;
const layerRight = newX + layerW;
const layerTop = newY;
const layerBottom = newY + layerH;
const layerCenterX = newX + layerW / 2;
const layerCenterY = newY + layerH / 2;
// Collect snap targets
const snapTargetsX = [];
const snapTargetsY = [];
// Export bbox edges (if set)
if (state.exportWidth > 0 && state.exportHeight > 0) {
snapTargetsX.push({ val: 0, label: 'bbox-left' });
snapTargetsX.push({ val: state.exportWidth, label: 'bbox-right' });
snapTargetsX.push({ val: state.exportWidth / 2, label: 'bbox-centerX' });
snapTargetsY.push({ val: 0, label: 'bbox-top' });
snapTargetsY.push({ val: state.exportHeight, label: 'bbox-bottom' });
snapTargetsY.push({ val: state.exportHeight / 2, label: 'bbox-centerY' });
}
// Other layer edges
for (const other of state.layers) {
if (other.id === layer.id || !other.visible) continue;
const ow = other.width * other.scaleX;
const oh = other.height * other.scaleY;
snapTargetsX.push({ val: other.x, label: 'layer-left' });
snapTargetsX.push({ val: other.x + ow, label: 'layer-right' });
snapTargetsX.push({ val: other.x + ow / 2, label: 'layer-centerX' });
snapTargetsY.push({ val: other.y, label: 'layer-top' });
snapTargetsY.push({ val: other.y + oh, label: 'layer-bottom' });
snapTargetsY.push({ val: other.y + oh / 2, label: 'layer-centerY' });
}
// Find closest snap for X (check left, right, center)
let bestSnapX = null;
let bestDistX = threshold;
for (const target of snapTargetsX) {
// Left edge
const distL = Math.abs(layerLeft - target.val);
if (distL < bestDistX) {
bestDistX = distL;
bestSnapX = { offset: target.val - layerLeft, guide: target.val };
}
// Right edge
const distR = Math.abs(layerRight - target.val);
if (distR < bestDistX) {
bestDistX = distR;
bestSnapX = { offset: target.val - layerRight, guide: target.val };
}
// Center
const distC = Math.abs(layerCenterX - target.val);
if (distC < bestDistX) {
bestDistX = distC;
bestSnapX = { offset: target.val - layerCenterX, guide: target.val };
}
}
// Find closest snap for Y (check top, bottom, center)
let bestSnapY = null;
let bestDistY = threshold;
for (const target of snapTargetsY) {
// Top edge
const distT = Math.abs(layerTop - target.val);
if (distT < bestDistY) {
bestDistY = distT;
bestSnapY = { offset: target.val - layerTop, guide: target.val };
}
// Bottom edge
const distB = Math.abs(layerBottom - target.val);
if (distB < bestDistY) {
bestDistY = distB;
bestSnapY = { offset: target.val - layerBottom, guide: target.val };
}
// Center
const distC = Math.abs(layerCenterY - target.val);
if (distC < bestDistY) {
bestDistY = distC;
bestSnapY = { offset: target.val - layerCenterY, guide: target.val };
}
}
// Apply snaps
if (bestSnapX) {
snapX = newX + bestSnapX.offset;
guides.push({ type: 'vertical', x: bestSnapX.guide });
}
if (bestSnapY) {
snapY = newY + bestSnapY.offset;
guides.push({ type: 'horizontal', y: bestSnapY.guide });
}
return { x: snapX, y: snapY, guides };
}
function getLayerAtPoint(x, y) {
const wx = (x - state.panX) / state.zoom;
const wy = (y - state.panY) / state.zoom;
function isPointInLayer(layer) {
const lx = layer.x;
const ly = layer.y;
const lw = layer.width * layer.scaleX;
const lh = layer.height * layer.scaleY;
return wx >= lx && wx <= lx + lw && wy >= ly && wy <= ly + lh;
}
// Prioritize currently selected layer - allows dragging even if behind others
if (state.selectedLayerId) {
const selectedLayer = state.layers.find(l => l.id === state.selectedLayerId);
if (selectedLayer && selectedLayer.visible && isPointInLayer(selectedLayer)) {
return selectedLayer;
}
}
// Fall back to top-to-bottom search for other layers
for (let i = state.layers.length - 1; i >= 0; i--) {
const layer = state.layers[i];
if (!layer.visible) continue;
if (isPointInLayer(layer)) {
return layer;
}
}
return null;
}
function getHandleAtPoint(x, y) {
if (!state.selectedLayerId) return null;
const layer = state.layers.find(l => l.id === state.selectedLayerId);
if (!layer) return null;
const wx = (x - state.panX) / state.zoom;
const wy = (y - state.panY) / state.zoom;
const hs = 24 / state.zoom; // Hit detection radius (larger than visual size)
// Transform world point to layer's local coordinate system
// Reverse the transform chain: translate to center, rotate, skew, scale, translate to corner
const cx = layer.x + layer.width * layer.scaleX * 0.5;
const cy = layer.y + layer.height * layer.scaleY * 0.5;
// Translate to center
let lx = wx - cx;
let ly = wy - cy;
// Inverse rotation
const rad = -layer.rotation * Math.PI / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const rx = lx * cos - ly * sin;
const ry = lx * sin + ly * cos;
lx = rx;
ly = ry;
// Inverse skew (approximate for small skew values)
const skewX = layer.skewX || 0;
const skewY = layer.skewY || 0;
const preLx = lx;
lx = lx - ly * skewX;
ly = ly - preLx * skewY;
// Now lx, ly is in the rotated/skewed local space centered at layer center
// Handle positions in local space (relative to layer center, in unscaled coords)
const hw = layer.width * 0.5;
const hh = layer.height * 0.5;
const handles = [
{ name: 'nw', x: -hw * layer.scaleX, y: -hh * layer.scaleY },
{ name: 'ne', x: hw * layer.scaleX, y: -hh * layer.scaleY },
{ name: 'sw', x: -hw * layer.scaleX, y: hh * layer.scaleY },
{ name: 'se', x: hw * layer.scaleX, y: hh * layer.scaleY },
{ name: 'rotate', x: 0, y: -hh * layer.scaleY - 20 / state.zoom }
];
for (const h of handles) {
if (Math.abs(lx - h.x) < hs && Math.abs(ly - h.y) < hs) {
return h.name;
}
}
return null;
}
let draggedLayerId = null;
function updateLayersList() {
const list = document.getElementById('layers-list');
const layerOrder = state.layers.slice().reverse();
list.innerHTML = layerOrder.map(function(layer, displayIdx) {
var thumbStyle = layer.imageUrl ? 'style="background-image: url(' + "'" + layer.imageUrl + "'" + ')"' : '';
var selectedClass = layer.id === state.selectedLayerId ? 'selected' : '';
var drawClass = layer.type === 'draw' ? 'draw-layer' : '';
var visClass = !layer.visible ? 'visibility-off' : '';
var visIcon = layer.visible ? '👁' : '👁‍🗨';
var typeText = layer.type === 'draw' ? 'Drawing' : 'Image';
var hasEffects = layer.strokeWidth > 0 || layer.shadow;
var effectsClass = hasEffects ? 'has-effects' : '';
var hasMask = layer.maskCanvas ? 'has-mask' : '';
var maskEditClass = (state.editingMask && layer.id === state.selectedLayerId) ? 'editing-mask' : '';
var maskBtn = layer.maskCanvas
? '<button class="layer-btn mask-edit-btn ' + maskEditClass + '" data-id="' + layer.id + '" title="Edit Mask">🎭</button>'
: '<button class="layer-btn mask-add-btn" data-id="' + layer.id + '" title="Add Mask">➕🎭</button>';
return '<div class="layer-item ' + selectedClass + ' ' + hasMask + '" data-id="' + layer.id + '" data-order="' + displayIdx + '" draggable="true">' +
'<div class="layer-thumb ' + drawClass + '" ' + thumbStyle + '></div>' +
'<div class="layer-info">' +
'<div class="layer-name">' + layer.name + '</div>' +
'<div class="layer-type">' + typeText + '</div>' +
'</div>' +
'<div class="layer-controls">' +
maskBtn +
'<button class="layer-btn effects-btn ' + effectsClass + '" data-id="' + layer.id + '" title="Layer Effects">⚙</button>' +
'<button class="layer-btn visibility-btn ' + visClass + '" data-id="' + layer.id + '" title="Toggle Visibility">' + visIcon + '</button>' +
'<button class="layer-btn delete-btn" data-id="' + layer.id + '" title="Delete Layer">🗑</button>' +
'</div>' +
'</div>';
}).join('');
}
// Event delegation for layers list - attach once, not on every update
(function() {
const list = document.getElementById('layers-list');
list.addEventListener('click', (e) => {
const item = e.target.closest('.layer-item');
if (!item) return;
const id = parseInt(item.dataset.id);
if (e.target.closest('.effects-btn')) {
e.stopPropagation();
openEffectsModal(id);
} else if (e.target.closest('.visibility-btn')) {
e.stopPropagation();
const layer = state.layers.find(l => l.id === id);
if (layer) {
layer.visible = !layer.visible;
markLayerListDirty();
scheduleRender();
}
} else if (e.target.closest('.delete-btn')) {
e.stopPropagation();
state.layers = state.layers.filter(l => l.id !== id);
if (state.selectedLayerId === id) state.selectedLayerId = null;
markLayerListDirty();
scheduleRender();
} else if (e.target.closest('.mask-add-btn')) {
e.stopPropagation();
createLayerMask(id);
state.selectedLayerId = id;
state.editingMask = true;
document.querySelector('[data-tool="brush"]').click();
} else if (e.target.closest('.mask-edit-btn')) {
e.stopPropagation();
state.selectedLayerId = id;
toggleMaskEditing();
if (state.editingMask) {
document.querySelector('[data-tool="brush"]').click();
}
} else {
state.selectedLayerId = id;
updateLayerControls();
markLayerListDirty();
scheduleRender();
}
});
list.addEventListener('dragstart', (e) => {
const item = e.target.closest('.layer-item');
if (!item) return;
draggedLayerId = parseInt(item.dataset.id);
item.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
list.addEventListener('dragend', (e) => {
const item = e.target.closest('.layer-item');
if (item) item.classList.remove('dragging');
draggedLayerId = null;
list.querySelectorAll('.layer-item').forEach(i => i.classList.remove('drag-over'));
});
list.addEventListener('dragover', (e) => {
e.preventDefault();
const item = e.target.closest('.layer-item');
if (item && draggedLayerId && parseInt(item.dataset.id) !== draggedLayerId) {
item.classList.add('drag-over');
}
});
list.addEventListener('dragleave', (e) => {
const item = e.target.closest('.layer-item');
if (item) item.classList.remove('drag-over');
});
list.addEventListener('drop', (e) => {
e.preventDefault();
const item = e.target.closest('.layer-item');
if (!item) return;
item.classList.remove('drag-over');
const targetId = parseInt(item.dataset.id);
if (draggedLayerId && draggedLayerId !== targetId) {
const fromIdx = state.layers.findIndex(l => l.id === draggedLayerId);
const toIdx = state.layers.findIndex(l => l.id === targetId);
if (fromIdx !== -1 && toIdx !== -1) {
const [moved] = state.layers.splice(fromIdx, 1);
// Layer list is displayed reversed, so swap the insert logic
const insertIdx = fromIdx > toIdx ? toIdx + 1 : toIdx;
state.layers.splice(insertIdx, 0, moved);
markLayerListDirty();
scheduleRender();
}
}
});
})();
function updateImagesList() {
const list = document.getElementById('images-list');
list.innerHTML = imagesData.map(function(fileInfo, idx) {
var imgUrl = getImageUrl(fileInfo);
return '<div class="image-item" data-idx="' + idx + '" draggable="true">' +
'<div class="layer-thumb" style="background-image: url(' + "'" + imgUrl + "'" + ')"></div>' +
'<div class="layer-info">' +
'<div class="layer-name">Image ' + (idx + 1) + '</div>' +
'</div>' +
'</div>';
}).join('');
list.querySelectorAll('.image-item').forEach(item => {
item.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', item.dataset.idx);
});
item.addEventListener('dblclick', async () => {
const idx = parseInt(item.dataset.idx);
const layer = await createLayer(getImageUrl(imagesData[idx]), 50 + state.layers.length * 20, 50 + state.layers.length * 20, idx);
state.selectedLayerId = layer.id;
render();
});
});
}
function updateLayerControls() {
const layer = state.layers.find(l => l.id === state.selectedLayerId);
if (!layer) return;
const opacityVal = Math.round(layer.opacity * 100);
document.getElementById('layer-opacity').value = opacityVal;
document.getElementById('drawer-layer-opacity').value = opacityVal;
document.getElementById('drawer-opacity-val').textContent = opacityVal + '%';
document.getElementById('layer-blend-mode').value = layer.blendMode || 'source-over';
}
function openEffectsModal(layerId) {
const layer = state.layers.find(l => l.id === layerId);
if (!layer) return;
state.modalLayerId = layerId;
const modal = document.getElementById('layer-effects-modal');
document.getElementById('modal-layer-name').textContent = layer.name + ' - Effects';
document.getElementById('effect-stroke-enabled').checked = layer.strokeWidth > 0;
document.getElementById('effect-stroke-width').value = layer.strokeWidth || 2;
document.getElementById('effect-stroke-width-val').textContent = layer.strokeWidth || 2;
document.getElementById('effect-stroke-color').value = layer.strokeColor;
document.getElementById('effect-shadow-enabled').checked = layer.shadow;
document.getElementById('effect-shadow-blur').value = layer.shadowBlur;
document.getElementById('effect-shadow-blur-val').textContent = layer.shadowBlur;
document.getElementById('effect-shadow-x').value = layer.shadowOffsetX;
document.getElementById('effect-shadow-x-val').textContent = layer.shadowOffsetX;
document.getElementById('effect-shadow-y').value = layer.shadowOffsetY;
document.getElementById('effect-shadow-y-val').textContent = layer.shadowOffsetY;
let shadowOpacity = 50;
try {
const parts = layer.shadowColor.split(',');
if (parts.length >= 4) {
shadowOpacity = Math.round(parseFloat(parts[3]) * 100);
}
} catch(e) {}
document.getElementById('effect-shadow-opacity').value = shadowOpacity;
document.getElementById('effect-shadow-opacity-val').textContent = shadowOpacity;
document.getElementById('effect-shadow-color').value = '#000000';
document.getElementById('effect-rotation').value = layer.rotation;
document.getElementById('effect-rotation-val').textContent = Math.round(layer.rotation);
document.getElementById('effect-scale-x').value = layer.scaleX * 100;
document.getElementById('effect-scale-x-val').textContent = Math.round(layer.scaleX * 100);
document.getElementById('effect-scale-y').value = layer.scaleY * 100;
document.getElementById('effect-scale-y-val').textContent = Math.round(layer.scaleY * 100);
document.getElementById('effect-skew-x').value = layer.skewX * 100;
document.getElementById('effect-skew-x-val').textContent = Math.round(layer.skewX * 100);
document.getElementById('effect-skew-y').value = layer.skewY * 100;
document.getElementById('effect-skew-y-val').textContent = Math.round(layer.skewY * 100);
modal.style.display = 'flex';
}
function closeEffectsModal() {
document.getElementById('layer-effects-modal').style.display = 'none';
state.modalLayerId = null;
}
function updateModalLayer(property, value) {
const layer = state.layers.find(l => l.id === state.modalLayerId);
if (!layer) return;
layer[property] = value;
markLayerListDirty();
scheduleRender();
}
// Prevent context menu on canvas for right-click panning
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
// Cache canvas rect to avoid expensive getBoundingClientRect calls
let cachedCanvasRect = null;
let cachedCanvasRectTime = 0;
function getCanvasRect() {
const now = performance.now();
if (!cachedCanvasRect || now - cachedCanvasRectTime > 100) {
cachedCanvasRect = canvas.getBoundingClientRect();
cachedCanvasRectTime = now;
}
return cachedCanvasRect;
}
// Event handlers
canvas.addEventListener('mousedown', (e) => {
const rect = getCanvasRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Right-click drag to pan (works in any tool mode)
if (e.button === 2) {
state.isDragging = true;
state.dragType = 'pan';
state.dragStart = { x: x - state.panX, y: y - state.panY };
return;
}
if (state.tool === 'select') {
const handle = getHandleAtPoint(x, y);
if (handle) {
const layer = state.layers.find(l => l.id === state.selectedLayerId);
state.isDragging = true;
state.dragType = 'transform';
state.activeHandle = handle;
state.handleStartData = {
x: layer.x,
y: layer.y,
width: layer.width,
height: layer.height,
scaleX: layer.scaleX,
scaleY: layer.scaleY,
skewX: layer.skewX || 0,
skewY: layer.skewY || 0,
rotation: layer.rotation,
mouseX: x,
mouseY: y
};
} else {
const layer = getLayerAtPoint(x, y);
if (layer) {
state.selectedLayerId = layer.id;
state.isDragging = true;
state.dragType = 'move';
state.dragStart = { x: x - (layer.x * state.zoom + state.panX), y: y - (layer.y * state.zoom + state.panY) };
markLayerListDirty();
updateLayerControls();
} else {
state.selectedLayerId = null;
markLayerListDirty();
}
}
scheduleRender();
} else if (state.tool === 'pan') {
state.isDragging = true;
state.dragType = 'pan';
state.dragStart = { x: x - state.panX, y: y - state.panY };
} else if (state.tool === 'brush') {
// Reset any stale painting state before starting new stroke
state.isPainting = false;
state.isDragging = false;
if (state.brushTool === 'clone' && e.altKey) {
// Alt+click sets clone source
const wx = (x - state.panX) / state.zoom;
const wy = (y - state.panY) / state.zoom;
const sourceLayer = getLayerAtPoint(x, y);
state.cloneSource = {
sourceX: wx,
sourceY: wy,
sourceLayer: sourceLayer,
startWorldX: 0,
startWorldY: 0
};
document.getElementById('clone-status').textContent = 'Source set ✓';
return;
}
// For clone, ALWAYS record the starting WORLD position at start of each stroke
if (state.brushTool === 'clone' && state.cloneSource) {
const wx = (x - state.panX) / state.zoom;
const wy = (y - state.panY) / state.zoom;
state.cloneSource.startWorldX = wx;
state.cloneSource.startWorldY = wy;
}
history.save();
state.isPainting = true;
state.lastPaintPos = { x, y };
paintAt(x, y, true);
}
});
canvas.addEventListener('mousemove', (e) => {
const rect = getCanvasRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (state.isDragging) {
if (state.dragType === 'move' && state.selectedLayerId) {
const layer = state.layers.find(l => l.id === state.selectedLayerId);
if (layer) {
const rawX = (x - state.dragStart.x - state.panX) / state.zoom;
const rawY = (y - state.dragStart.y - state.panY) / state.zoom;
const snap = calculateSnap(layer, rawX, rawY);
layer.x = snap.x;
layer.y = snap.y;
state.snapGuides = snap.guides;
scheduleRender();
}
} else if (state.dragType === 'transform' && state.selectedLayerId) {
const layer = state.layers.find(l => l.id === state.selectedLayerId);
if (layer && state.handleStartData) {
const dx = (x - state.handleStartData.mouseX) / state.zoom;
const dy = (y - state.handleStartData.mouseY) / state.zoom;
const startData = state.handleStartData;
const shiftKey = e.shiftKey;
const ctrlKey = e.ctrlKey || e.metaKey;
if (state.activeHandle === 'se') {
if (ctrlKey) {
// Ctrl+corner: skew
layer.skewX = startData.skewX + dx / (layer.height * layer.scaleY);
layer.skewY = startData.skewY + dy / (layer.width * layer.scaleX);
} else {
let newScaleX = Math.max(0.1, startData.scaleX + dx / layer.width);
let newScaleY = Math.max(0.1, startData.scaleY + dy / layer.height);
if (shiftKey) {
const avgScale = (newScaleX / startData.scaleX + newScaleY / startData.scaleY) / 2;
newScaleX = startData.scaleX * avgScale;
newScaleY = startData.scaleY * avgScale;
}
layer.scaleX = newScaleX;
layer.scaleY = newScaleY;
}
} else if (state.activeHandle === 'nw') {
if (ctrlKey) {
layer.skewX = startData.skewX - dx / (layer.height * layer.scaleY);
layer.skewY = startData.skewY - dy / (layer.width * layer.scaleX);
} else {
let newScaleX = Math.max(0.1, startData.scaleX - dx / layer.width);
let newScaleY = Math.max(0.1, startData.scaleY - dy / layer.height);
if (shiftKey) {
const avgScale = (newScaleX / startData.scaleX + newScaleY / startData.scaleY) / 2;
newScaleX = startData.scaleX * avgScale;
newScaleY = startData.scaleY * avgScale;
}
layer.x = startData.x + (startData.scaleX - newScaleX) * layer.width;
layer.y = startData.y + (startData.scaleY - newScaleY) * layer.height;
layer.scaleX = newScaleX;
layer.scaleY = newScaleY;
}
} else if (state.activeHandle === 'ne') {
if (ctrlKey) {
layer.skewX = startData.skewX + dx / (layer.height * layer.scaleY);
layer.skewY = startData.skewY - dy / (layer.width * layer.scaleX);
} else {
let newScaleX = Math.max(0.1, startData.scaleX + dx / layer.width);
let newScaleY = Math.max(0.1, startData.scaleY - dy / layer.height);
if (shiftKey) {
const avgScale = (newScaleX / startData.scaleX + newScaleY / startData.scaleY) / 2;
newScaleX = startData.scaleX * avgScale;
newScaleY = startData.scaleY * avgScale;
}
layer.y = startData.y + (startData.scaleY - newScaleY) * layer.height;
layer.scaleX = newScaleX;
layer.scaleY = newScaleY;
}
} else if (state.activeHandle === 'sw') {
if (ctrlKey) {
layer.skewX = startData.skewX - dx / (layer.height * layer.scaleY);
layer.skewY = startData.skewY + dy / (layer.width * layer.scaleX);
} else {
let newScaleX = Math.max(0.1, startData.scaleX - dx / layer.width);
let newScaleY = Math.max(0.1, startData.scaleY + dy / layer.height);
if (shiftKey) {
const avgScale = (newScaleX / startData.scaleX + newScaleY / startData.scaleY) / 2;
newScaleX = startData.scaleX * avgScale;
newScaleY = startData.scaleY * avgScale;
}
layer.x = startData.x + (startData.scaleX - newScaleX) * layer.width;
layer.scaleX = newScaleX;
layer.scaleY = newScaleY;
}
} else if (state.activeHandle === 'rotate') {
const cx = layer.x + layer.width * layer.scaleX * 0.5;
const cy = layer.y + layer.height * layer.scaleY * 0.5;
const startAngle = Math.atan2(startData.mouseY / state.zoom - cy + state.panY / state.zoom, startData.mouseX / state.zoom - cx + state.panX / state.zoom);
const currentAngle = Math.atan2(y / state.zoom - cy + state.panY / state.zoom, x / state.zoom - cx + state.panX / state.zoom);
let newRotation = startData.rotation + (currentAngle - startAngle) * 180 / Math.PI;
if (shiftKey) {
// Snap to 45-degree increments
newRotation = Math.round(newRotation / 45) * 45;
}
layer.rotation = newRotation;
}
scheduleRender();
}
} else if (state.dragType === 'pan') {
state.panX = x - state.dragStart.x;
state.panY = y - state.dragStart.y;
scheduleRender();
}
} else if (state.isPainting) {
paintLine(state.lastPaintPos.x, state.lastPaintPos.y, x, y);
state.lastPaintPos = { x, y };
}
});
canvas.addEventListener('mouseup', () => {
state.isDragging = false;
state.isPainting = false;
state.lastPaintPos = null;
state.activeHandle = null;
state.handleStartData = null;
state.snapGuides = null;
scheduleRender();
});
canvas.addEventListener('mouseleave', () => {
state.isDragging = false;
state.isPainting = false;
state.snapGuides = null;
});
// Window-level mouseup to catch releases outside canvas
window.addEventListener('mouseup', () => {
state.isDragging = false;
state.isPainting = false;
state.lastPaintPos = null;
state.activeHandle = null;
state.handleStartData = null;
state.snapGuides = null;
});
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const rect = getCanvasRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
const newZoom = Math.max(0.1, Math.min(10, state.zoom * zoomFactor));
state.panX = x - (x - state.panX) * (newZoom / state.zoom);
state.panY = y - (y - state.panY) * (newZoom / state.zoom);
state.zoom = newZoom;
scheduleRender();
});
// Drop handler
canvas.addEventListener('dragover', (e) => e.preventDefault());
canvas.addEventListener('drop', async (e) => {
e.preventDefault();
const idx = parseInt(e.dataTransfer.getData('text/plain'));
if (!isNaN(idx) && imagesData[idx]) {
const rect = getCanvasRect();
const x = (e.clientX - rect.left - state.panX) / state.zoom;
const y = (e.clientY - rect.top - state.panY) / state.zoom;
const layer = await createLayer(getImageUrl(imagesData[idx]), x - 50, y - 50, idx);
state.selectedLayerId = layer.id;
render();
}
});
function screenToLayerCoords(x, y, layer) {
// Convert screen coords to world coords
const wx = (x - state.panX) / state.zoom;
const wy = (y - state.panY) / state.zoom;
// Get layer center
const cx = layer.x + layer.width * layer.scaleX * 0.5;
const cy = layer.y + layer.height * layer.scaleY * 0.5;
// Translate to layer center
let lx = wx - cx;
let ly = wy - cy;
// Inverse rotation
const rad = -layer.rotation * Math.PI / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const rx = lx * cos - ly * sin;
const ry = lx * sin + ly * cos;
lx = rx;
ly = ry;
// Inverse skew
const skewX = layer.skewX || 0;
const skewY = layer.skewY || 0;
const preLx = lx;
lx = lx - ly * skewX;
ly = ly - preLx * skewY;
// Inverse scale and translate to layer origin
lx = lx / layer.scaleX + layer.width * 0.5;
ly = ly / layer.scaleY + layer.height * 0.5;
return { x: lx, y: ly };
}
function paintAt(x, y, isFirst = false) {
const layer = state.layers.find(l => l.id === state.selectedLayerId);
if (!layer) return;
// If editing mask, paint on mask canvas
if (state.editingMask && layer.maskCanvas) {
const local = screenToLayerCoords(x, y, layer);
const localX = local.x;
const localY = local.y;
const mctx = layer.maskCtx;
const radius = state.brushSize * 0.5;
const hardness = state.brushHardness / 100;
// Mask uses alpha: opaque white=visible, transparent=hidden
// Brush paints to HIDE (erase mask), Eraser paints to REVEAL (restore mask)
if (state.brushTool === 'eraser') {
// Eraser reveals (paints white to restore visibility)
const gradient = mctx.createRadialGradient(localX, localY, 0, localX, localY, radius);
gradient.addColorStop(0, 'rgba(255,255,255,1)');
gradient.addColorStop(hardness, 'rgba(255,255,255,1)');
gradient.addColorStop(1, 'rgba(255,255,255,0)');
mctx.fillStyle = gradient;
mctx.beginPath();
mctx.arc(localX, localY, radius, 0, Math.PI * 2);
mctx.fill();
} else {
// Brush hides (erases mask to make transparent)
mctx.globalCompositeOperation = 'destination-out';
const gradient = mctx.createRadialGradient(localX, localY, 0, localX, localY, radius);
gradient.addColorStop(0, 'rgba(0,0,0,1)');
gradient.addColorStop(hardness, 'rgba(0,0,0,1)');
gradient.addColorStop(1, 'rgba(0,0,0,0)');
mctx.fillStyle = gradient;
mctx.beginPath();
mctx.arc(localX, localY, radius, 0, Math.PI * 2);
mctx.fill();
mctx.globalCompositeOperation = 'source-over';
}
scheduleRender();
return;
}
// For eraser on image layers, convert to editable canvas
if (layer.type === 'image' && state.brushTool === 'eraser') {
// Convert image layer to editable canvas if not already
if (!layer.drawCanvas) {
const drawCanvas = document.createElement('canvas');
drawCanvas.width = layer.width;
drawCanvas.height = layer.height;
const dctx = drawCanvas.getContext('2d');
dctx.drawImage(layer.image, 0, 0, layer.width, layer.height);
layer.drawCanvas = drawCanvas;
layer.drawCtx = dctx;
layer.image = drawCanvas;
}
}
// Painting requires a draw canvas
if (!layer.drawCanvas) return;
// Always get fresh context reference in case it was invalidated
if (!layer.drawCtx || layer.drawCtx.canvas !== layer.drawCanvas) {
layer.drawCtx = layer.drawCanvas.getContext('2d');
}
const local = screenToLayerCoords(x, y, layer);
const localX = local.x;
const localY = local.y;
const dctx = layer.drawCtx;
if (!dctx) return;
const radius = state.brushSize * 0.5;
// Apply brush opacity
const brushAlpha = state.brushOpacity / 100;
if (state.brushTool === 'eraser') {
dctx.save();
dctx.globalCompositeOperation = 'destination-out';
dctx.globalAlpha = brushAlpha;
const gradient = dctx.createRadialGradient(localX, localY, 0, localX, localY, radius);
const hardness = state.brushHardness / 100;
gradient.addColorStop(0, 'rgba(0,0,0,1)');
gradient.addColorStop(hardness, 'rgba(0,0,0,1)');
gradient.addColorStop(1, 'rgba(0,0,0,0)');
dctx.fillStyle = gradient;
dctx.beginPath();
dctx.arc(localX, localY, radius, 0, Math.PI * 2);
dctx.fill();
dctx.restore();
} else if (state.brushTool === 'clone' && state.cloneSource) {
const src = state.cloneSource;
// Calculate current world position
const wx = (x - state.panX) / state.zoom;
const wy = (y - state.panY) / state.zoom;
// Offset from where we started painting (in world coords)
const offsetX = wx - src.startWorldX;
const offsetY = wy - src.startWorldY;
// Source sample position in world coords
const srcWorldX = src.sourceX + offsetX;
const srcWorldY = src.sourceY + offsetY;
// Create a brush stamp canvas with hardness gradient
const stampCanvas = document.createElement('canvas');
const stampSize = Math.ceil(radius * 2) + 2;
stampCanvas.width = stampSize;
stampCanvas.height = stampSize;
const sctx = stampCanvas.getContext('2d');
const stampCenter = stampSize / 2;
// Get source layer (refresh reference in case layers array changed)
let sourceLayer = null;
if (src.sourceLayer && src.sourceLayer.id) {
sourceLayer = state.layers.find(l => l.id === src.sourceLayer.id);
}
let drewSomething = false;
if (state.cloneAllLayers) {
// Create a composite of all visible layers at world coordinates
const sampleSize = Math.max(layer.drawCanvas.width, layer.drawCanvas.height) * 2;
const tempCanvas = document.createElement('canvas');
tempCanvas.width = sampleSize;
tempCanvas.height = sampleSize;
const tctx = tempCanvas.getContext('2d');
// Center the composite around the source sample point
const compositeOffsetX = sampleSize / 2 - srcWorldX;
const compositeOffsetY = sampleSize / 2 - srcWorldY;
// Render all visible layers INCLUDING current with their transforms
for (const l of state.layers) {
if (!l.visible) continue;
tctx.save();
tctx.globalAlpha = l.opacity;
tctx.globalCompositeOperation = l.blendMode || 'source-over';
tctx.translate(compositeOffsetX + l.x + l.width * l.scaleX * 0.5,
compositeOffsetY + l.y + l.height * l.scaleY * 0.5);
tctx.rotate(l.rotation * Math.PI / 180);
tctx.transform(1, l.skewY || 0, l.skewX || 0, 1, 0, 0);
tctx.scale(l.scaleX, l.scaleY);
tctx.translate(-l.width * 0.5, -l.height * 0.5);
tctx.drawImage(l.image, 0, 0, l.width, l.height);
tctx.restore();
drewSomething = true;
}
if (drewSomething) {
// Draw source onto stamp canvas
sctx.drawImage(tempCanvas, stampCenter - sampleSize / 2, stampCenter - sampleSize / 2);
}
} else if (sourceLayer && sourceLayer.image) {
// Sample from single source layer
const sl = sourceLayer;
const srcLocalX = srcWorldX - sl.x;
const srcLocalY = srcWorldY - sl.y;
sctx.drawImage(sl.image, stampCenter - srcLocalX, stampCenter - srcLocalY);
drewSomething = true;
}
if (drewSomething) {
// Apply hardness gradient as mask
sctx.globalCompositeOperation = 'destination-in';
const hardness = state.brushHardness / 100;
const gradient = sctx.createRadialGradient(stampCenter, stampCenter, 0, stampCenter, stampCenter, radius);
gradient.addColorStop(0, 'rgba(0,0,0,1)');
gradient.addColorStop(hardness, 'rgba(0,0,0,1)');
gradient.addColorStop(1, 'rgba(0,0,0,0)');
sctx.fillStyle = gradient;
sctx.fillRect(0, 0, stampSize, stampSize);
// Draw stamp onto layer with opacity and blend mode
dctx.save();
dctx.globalCompositeOperation = state.brushBlend;
dctx.globalAlpha = brushAlpha;
dctx.drawImage(stampCanvas, localX - stampCenter, localY - stampCenter);
dctx.restore();
}
} else {
// Regular brush
dctx.save();
dctx.globalCompositeOperation = state.brushBlend;
dctx.globalAlpha = brushAlpha;
const gradient = dctx.createRadialGradient(localX, localY, 0, localX, localY, radius);
const hardness = state.brushHardness / 100;
gradient.addColorStop(0, state.brushColor);
gradient.addColorStop(hardness, state.brushColor);
gradient.addColorStop(1, state.brushColor + '00');
dctx.fillStyle = gradient;
dctx.beginPath();
dctx.arc(localX, localY, radius, 0, Math.PI * 2);
dctx.fill();
dctx.restore();
}
scheduleRender();
}
function paintLine(x1, y1, x2, y2) {
const dist = Math.sqrt((x2-x1)**2 + (y2-y1)**2);
const stepSize = state.brushSize * 0.25;
const steps = Math.max(1, Math.floor(dist / stepSize));
for (let i = 0; i <= steps; i++) {
const t = i / steps;
paintAt(x1 + (x2-x1)*t, y1 + (y2-y1)*t);
}
}
// Toolbar handlers
document.querySelectorAll('.tool-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
state.tool = btn.dataset.tool;
document.getElementById('brush-toolbar').style.display = state.tool === 'brush' ? 'flex' : 'none';
});
});
document.getElementById('bg-color').addEventListener('input', (e) => {
state.bgColor = e.target.value;
scheduleRender();
});
document.getElementById('bg-transparent').addEventListener('change', (e) => {
state.bgTransparent = e.target.checked;
scheduleRender();
});
document.getElementById('export-width').addEventListener('input', (e) => {
state.exportWidth = Math.max(0, parseInt(e.target.value) || 0);
scheduleRender();
});
document.getElementById('export-height').addEventListener('input', (e) => {
state.exportHeight = Math.max(0, parseInt(e.target.value) || 0);
scheduleRender();
});
document.getElementById('snap-enabled').addEventListener('change', (e) => {
state.snapEnabled = e.target.checked;
});
document.getElementById('brush-size').addEventListener('input', (e) => {
state.brushSize = parseInt(e.target.value);
document.getElementById('brush-size-val').textContent = state.brushSize;
});
document.getElementById('brush-hardness').addEventListener('input', (e) => {
state.brushHardness = parseInt(e.target.value);
});
document.getElementById('brush-opacity').addEventListener('input', (e) => {
state.brushOpacity = parseInt(e.target.value);
document.getElementById('brush-opacity-val').textContent = state.brushOpacity + '%';
});
document.getElementById('brush-tool').addEventListener('change', (e) => {
state.brushTool = e.target.value;
document.getElementById('clone-options').style.display = state.brushTool === 'clone' ? 'inline' : 'none';
if (state.brushTool === 'clone') {
state.cloneSource = null;
document.getElementById('clone-status').textContent = 'Alt+Click to set source';
}
});
document.getElementById('clone-all-layers').addEventListener('change', (e) => {
state.cloneAllLayers = e.target.checked;
});
document.getElementById('brush-blend').addEventListener('change', (e) => {
state.brushBlend = e.target.value;
});
document.getElementById('brush-color').addEventListener('input', (e) => {
state.brushColor = e.target.value;
});
document.getElementById('layer-opacity').addEventListener('input', (e) => {
const layer = state.layers.find(l => l.id === state.selectedLayerId);
if (layer) {
layer.opacity = parseInt(e.target.value) / 100;
document.getElementById('drawer-layer-opacity').value = e.target.value;
document.getElementById('drawer-opacity-val').textContent = e.target.value + '%';
scheduleRender();
}
});
document.getElementById('drawer-layer-opacity').addEventListener('input', (e) => {
const layer = state.layers.find(l => l.id === state.selectedLayerId);
if (layer) {
layer.opacity = parseInt(e.target.value) / 100;
document.getElementById('layer-opacity').value = e.target.value;
document.getElementById('drawer-opacity-val').textContent = e.target.value + '%';
scheduleRender();
}
});
document.getElementById('layer-blend-mode').addEventListener('change', (e) => {
const layer = state.layers.find(l => l.id === state.selectedLayerId);
if (layer) {
layer.blendMode = e.target.value;
scheduleRender();
}
});
// Modal event handlers
document.getElementById('modal-close').addEventListener('click', closeEffectsModal);
document.getElementById('layer-effects-modal').addEventListener('click', (e) => {
if (e.target.id === 'layer-effects-modal') closeEffectsModal();
});
document.getElementById('effect-stroke-enabled').addEventListener('change', (e) => {
updateModalLayer('strokeWidth', e.target.checked ? parseInt(document.getElementById('effect-stroke-width').value) : 0);
});
document.getElementById('effect-stroke-width').addEventListener('input', (e) => {
document.getElementById('effect-stroke-width-val').textContent = e.target.value;
if (document.getElementById('effect-stroke-enabled').checked) {
updateModalLayer('strokeWidth', parseInt(e.target.value));
}
});
document.getElementById('effect-stroke-color').addEventListener('input', (e) => {
updateModalLayer('strokeColor', e.target.value);
});
document.getElementById('effect-shadow-enabled').addEventListener('change', (e) => {
updateModalLayer('shadow', e.target.checked);
});
document.getElementById('effect-shadow-blur').addEventListener('input', (e) => {
document.getElementById('effect-shadow-blur-val').textContent = e.target.value;
updateModalLayer('shadowBlur', parseInt(e.target.value));
});
document.getElementById('effect-shadow-x').addEventListener('input', (e) => {
document.getElementById('effect-shadow-x-val').textContent = e.target.value;
updateModalLayer('shadowOffsetX', parseInt(e.target.value));
});
document.getElementById('effect-shadow-y').addEventListener('input', (e) => {
document.getElementById('effect-shadow-y-val').textContent = e.target.value;
updateModalLayer('shadowOffsetY', parseInt(e.target.value));
});
document.getElementById('effect-shadow-opacity').addEventListener('input', (e) => {
document.getElementById('effect-shadow-opacity-val').textContent = e.target.value;
const opacity = parseInt(e.target.value) / 100;
const color = document.getElementById('effect-shadow-color').value;
const r = parseInt(color.slice(1,3), 16);
const g = parseInt(color.slice(3,5), 16);
const b = parseInt(color.slice(5,7), 16);
updateModalLayer('shadowColor', 'rgba(' + r + ',' + g + ',' + b + ',' + opacity + ')');
});
document.getElementById('effect-shadow-color').addEventListener('input', (e) => {
const opacity = parseInt(document.getElementById('effect-shadow-opacity').value) / 100;
const color = e.target.value;
const r = parseInt(color.slice(1,3), 16);
const g = parseInt(color.slice(3,5), 16);
const b = parseInt(color.slice(5,7), 16);
updateModalLayer('shadowColor', 'rgba(' + r + ',' + g + ',' + b + ',' + opacity + ')');
});
document.getElementById('effect-rotation').addEventListener('input', (e) => {
document.getElementById('effect-rotation-val').textContent = e.target.value;
updateModalLayer('rotation', parseInt(e.target.value));
});
document.getElementById('effect-scale-x').addEventListener('input', (e) => {
document.getElementById('effect-scale-x-val').textContent = e.target.value;
updateModalLayer('scaleX', parseInt(e.target.value) / 100);
});
document.getElementById('effect-scale-y').addEventListener('input', (e) => {
document.getElementById('effect-scale-y-val').textContent = e.target.value;
updateModalLayer('scaleY', parseInt(e.target.value) / 100);
});
document.getElementById('effect-skew-x').addEventListener('input', (e) => {
document.getElementById('effect-skew-x-val').textContent = e.target.value;
updateModalLayer('skewX', parseInt(e.target.value) / 100);
});
document.getElementById('effect-skew-y').addEventListener('input', (e) => {
document.getElementById('effect-skew-y-val').textContent = e.target.value;
updateModalLayer('skewY', parseInt(e.target.value) / 100);
});
// Helper to create export canvas from current layers
function createExportCanvas() {
const visibleLayers = state.layers.filter(l => l.visible);
let minX, minY, maxX, maxY, padding;
// Use export size if set, otherwise auto-calculate from layers
if (state.exportWidth > 0 && state.exportHeight > 0) {
minX = 0;
minY = 0;
maxX = state.exportWidth;
maxY = state.exportHeight;
padding = 0;
} else {
minX = Infinity; minY = Infinity; maxX = -Infinity; maxY = -Infinity;
for (const layer of visibleLayers) {
minX = Math.min(minX, layer.x);
minY = Math.min(minY, layer.y);
maxX = Math.max(maxX, layer.x + layer.width * layer.scaleX);
maxY = Math.max(maxY, layer.y + layer.height * layer.scaleY);
}
if (visibleLayers.length === 0) {
minX = 0; minY = 0; maxX = 512; maxY = 512;
}
padding = 20;
}
const exportCanvas = document.createElement('canvas');
exportCanvas.width = maxX - minX + padding * 2;
exportCanvas.height = maxY - minY + padding * 2;
const ectx = exportCanvas.getContext('2d');
if (!state.bgTransparent) {
ectx.fillStyle = state.bgColor;
ectx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
}
ectx.translate(-minX + padding, -minY + padding);
for (const layer of visibleLayers) {
ectx.save();
ectx.translate(layer.x + layer.width * layer.scaleX * 0.5, layer.y + layer.height * layer.scaleY * 0.5);
ectx.rotate(layer.rotation * Math.PI / 180);
ectx.transform(1, layer.skewY, layer.skewX, 1, 0, 0);
ectx.scale(layer.scaleX, layer.scaleY);
ectx.translate(-layer.width * 0.5, -layer.height * 0.5);
ectx.globalAlpha = layer.opacity;
if (layer.shadow) {
ectx.shadowBlur = layer.shadowBlur;
ectx.shadowOffsetX = layer.shadowOffsetX;
ectx.shadowOffsetY = layer.shadowOffsetY;
ectx.shadowColor = layer.shadowColor;
}
ectx.drawImage(layer.image, 0, 0, layer.width, layer.height);
ectx.restore();
}
return exportCanvas;
}
// Prepare export data for parent window (avoids tainted canvas issue)
function getExportData() {
const visibleLayers = state.layers.filter(l => l.visible);
let minX, minY, maxX, maxY, padding;
// Use export size if set, otherwise auto-calculate from layers
if (state.exportWidth > 0 && state.exportHeight > 0) {
minX = 0;
minY = 0;
maxX = state.exportWidth;
maxY = state.exportHeight;
padding = 0;
} else {
minX = Infinity; minY = Infinity; maxX = -Infinity; maxY = -Infinity;
for (const layer of visibleLayers) {
minX = Math.min(minX, layer.x);
minY = Math.min(minY, layer.y);
maxX = Math.max(maxX, layer.x + layer.width * layer.scaleX);
maxY = Math.max(maxY, layer.y + layer.height * layer.scaleY);
}
if (visibleLayers.length === 0) {
minX = 0; minY = 0; maxX = 512; maxY = 512;
}
padding = 20;
}
return {
layers: visibleLayers.map(l => ({
imageIndex: l.sourceImageIndex, // Index into imagesData for restoration
imageUrl: l.imageUrl, // Current URL for export
x: l.x, y: l.y,
width: l.width, height: l.height,
scaleX: l.scaleX, scaleY: l.scaleY,
rotation: l.rotation,
skewX: l.skewX, skewY: l.skewY,
opacity: l.opacity,
blendMode: l.blendMode,
shadow: l.shadow,
shadowBlur: l.shadowBlur,
shadowOffsetX: l.shadowOffsetX,
shadowOffsetY: l.shadowOffsetY,
shadowColor: l.shadowColor,
isDrawLayer: l.type === 'draw',
hasDrawCanvas: !!l.drawCanvas,
drawDataUrl: l.drawCanvas ? (() => { try { return l.drawCanvas.toDataURL(); } catch(e) { return null; } })() : null,
maskEnabled: l.maskEnabled || false,
maskDataUrl: (l.maskEnabled && l.maskCanvas) ? (() => { try { return l.maskCanvas.toDataURL(); } catch(e) { return null; } })() : null
})),
bounds: { minX, minY, maxX, maxY, padding },
bgTransparent: state.bgTransparent,
bgColor: state.bgColor
};
}
// Export canvas composite as base64 PNG
function exportCompositeBase64() {
const visibleLayers = state.layers.filter(l => l.visible);
if (visibleLayers.length === 0) return null;
// Calculate bounds
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const layer of visibleLayers) {
const lw = layer.width * layer.scaleX;
const lh = layer.height * layer.scaleY;
minX = Math.min(minX, layer.x);
minY = Math.min(minY, layer.y);
maxX = Math.max(maxX, layer.x + lw);
maxY = Math.max(maxY, layer.y + lh);
}
const width = Math.ceil(maxX - minX);
const height = Math.ceil(maxY - minY);
if (width <= 0 || height <= 0) return null;
// Create export canvas
const exportCanvas = document.createElement('canvas');
exportCanvas.width = width;
exportCanvas.height = height;
const ectx = exportCanvas.getContext('2d');
// Render layers (same logic as main render but without UI elements)
for (const layer of visibleLayers) {
ectx.save();
// Translate to layer position relative to bounds
ectx.translate(layer.x - minX + layer.width * layer.scaleX * 0.5, layer.y - minY + layer.height * layer.scaleY * 0.5);
ectx.rotate(layer.rotation * Math.PI / 180);
ectx.transform(1, layer.skewY, layer.skewX, 1, 0, 0);
ectx.scale(layer.scaleX, layer.scaleY);
ectx.translate(-layer.width * 0.5, -layer.height * 0.5);
ectx.globalAlpha = layer.opacity;
ectx.globalCompositeOperation = layer.blendMode || 'source-over';
if (layer.shadow) {
ectx.shadowBlur = layer.shadowBlur;
ectx.shadowOffsetX = layer.shadowOffsetX;
ectx.shadowOffsetY = layer.shadowOffsetY;
ectx.shadowColor = layer.shadowColor;
}
// Draw with mask if enabled
if (layer.maskCanvas && layer.maskEnabled) {
const tempCanvas = document.createElement('canvas');
tempCanvas.width = layer.width;
tempCanvas.height = layer.height;
const tctx = tempCanvas.getContext('2d');
tctx.drawImage(layer.image, 0, 0, layer.width, layer.height);
tctx.globalCompositeOperation = 'destination-in';
tctx.drawImage(layer.maskCanvas, 0, 0);
ectx.drawImage(tempCanvas, 0, 0, layer.width, layer.height);
} else {
ectx.drawImage(layer.image, 0, 0, layer.width, layer.height);
}
ectx.restore();
}
try {
return exportCanvas.toDataURL('image/png');
} catch (e) {
console.warn('[Canvas] Export failed - canvas may be tainted by cross-origin images:', e);
return null;
}
}
// Send to Output - sends layer data to parent for reconstruction (avoids CORS issues)
document.getElementById('send-output-btn').addEventListener('click', () => {
const data = getExportData();
if (!data.layers || data.layers.length === 0) {
alert('No visible layers to export');
return;
}
// Send layer data to parent - parent will reconstruct canvas and set manual_content
window.parent.postMessage({
type: 'canvas-export-request',
action: 'output',
data: data,
nodeId: window.WAS_NODE_ID
}, '*');
// Visual feedback
const btn = document.getElementById('send-output-btn');
const originalText = btn.textContent;
btn.textContent = '✓ Sent!';
btn.style.background = '#22c55e';
setTimeout(() => {
btn.textContent = originalText;
btn.style.background = '';
}, 1500);
});
// Download button - sends to parent for export
document.getElementById('export-btn').addEventListener('click', () => {
const data = getExportData();
window.parent.postMessage({
type: 'canvas-export-request',
action: 'download',
data: data,
nodeId: window.WAS_NODE_ID
}, '*');
});
document.getElementById('reset-view-btn').addEventListener('click', () => {
state.zoom = 1;
state.panX = 0;
state.panY = 0;
render();
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
const ctrl = e.ctrlKey || e.metaKey;
// Undo: Ctrl+Z
if (ctrl && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
history.undo();
return;
}
// Redo: Ctrl+Y or Ctrl+Shift+Z
if ((ctrl && e.key === 'y') || (ctrl && e.shiftKey && e.key === 'z')) {
e.preventDefault();
history.redo();
return;
}
// Duplicate: Ctrl+D
if (ctrl && e.key === 'd') {
e.preventDefault();
if (state.selectedLayerId) {
duplicateLayer(state.selectedLayerId);
}
return;
}
// Delete layer
if (e.key === 'Delete' || e.key === 'Backspace') {
if (state.selectedLayerId) {
history.save();
state.layers = state.layers.filter(l => l.id !== state.selectedLayerId);
state.selectedLayerId = null;
markLayerListDirty();
render();
}
return;
}
// Tool shortcuts
if (e.key === 'v' || e.key === 'V') {
document.querySelector('[data-tool="select"]').click();
} else if (e.key === 'h' || e.key === 'H') {
document.querySelector('[data-tool="pan"]').click();
} else if (e.key === 'b' || e.key === 'B') {
document.querySelector('[data-tool="brush"]').click();
} else if (e.key === 'e' || e.key === 'E') {
// E for eraser
document.querySelector('[data-tool="brush"]').click();
document.getElementById('brush-tool').value = 'eraser';
state.brushTool = 'eraser';
}
});
// Drawer toggle
document.getElementById('drawer-toggle').addEventListener('click', () => {
document.getElementById('layers-drawer').classList.toggle('collapsed');
});
// Mini-map click to navigate
// miniMapCanvas is already declared at line 1262 and initialized in updateMiniMap()
if (!miniMapCanvas) miniMapCanvas = document.getElementById('mini-map-canvas');
miniMapCanvas.addEventListener('mousedown', (e) => {
const transform = miniMapCanvas._miniMapTransform;
if (!transform) return;
const rect = miniMapCanvas.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const clickY = e.clientY - rect.top;
// Convert click to world coordinates
const worldX = (clickX - transform.offsetX) / transform.scale + transform.minX;
const worldY = (clickY - transform.offsetY) / transform.scale + transform.minY;
// Center viewport on clicked point
state.panX = -worldX * state.zoom + canvas.width / 2;
state.panY = -worldY * state.zoom + canvas.height / 2;
scheduleRender();
});
// Add new drawing layer
document.getElementById('add-new-layer').addEventListener('click', () => {
// Calculate bounding box of all existing layers
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const l of state.layers) {
const lw = l.width * Math.abs(l.scaleX || 1);
const lh = l.height * Math.abs(l.scaleY || 1);
minX = Math.min(minX, l.x);
minY = Math.min(minY, l.y);
maxX = Math.max(maxX, l.x + lw);
maxY = Math.max(maxY, l.y + lh);
}
// Default size if no layers exist
let newWidth = 512, newHeight = 512, newX = 50, newY = 50;
if (state.layers.length > 0 && isFinite(minX)) {
newWidth = Math.max(64, Math.round(maxX - minX));
newHeight = Math.max(64, Math.round(maxY - minY));
newX = minX;
newY = minY;
}
const layer = createDrawLayer(newWidth, newHeight);
layer.x = newX;
layer.y = newY;
state.selectedLayerId = layer.id;
// Save history for undo support
history.save();
// Switch to select/pointer mode directly (not brush mode)
state.tool = 'select';
document.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('active'));
document.querySelector('[data-tool="select"]').classList.add('active');
document.getElementById('brush-toolbar').style.display = 'none';
markLayerListDirty();
render();
});
// Save canvas state to file
document.getElementById('save-state-btn').addEventListener('click', () => {
const exportData = {
version: 1,
layers: state.layers.map(l => {
const layerData = {
type: l.type,
name: l.name,
visible: l.visible,
x: l.x, y: l.y,
width: l.width, height: l.height,
rotation: l.rotation,
scaleX: l.scaleX, scaleY: l.scaleY,
skewX: l.skewX, skewY: l.skewY,
opacity: l.opacity,
blendMode: l.blendMode,
sourceImageIndex: l.sourceImageIndex,
imageUrl: l.imageUrl
};
// Save draw layer content as data URL
if (l.type === 'draw' && l.drawCanvas) {
try {
layerData.drawDataUrl = l.drawCanvas.toDataURL();
} catch (e) {
layerData.drawDataUrl = null;
}
}
// Save mask if exists
if (l.maskCanvas) {
try {
layerData.maskDataUrl = l.maskCanvas.toDataURL();
layerData.maskEnabled = l.maskEnabled;
} catch (e) {}
}
return layerData;
}),
bgColor: state.bgColor,
bgTransparent: state.bgTransparent
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.download = 'canvas-state.json';
link.href = url;
link.click();
URL.revokeObjectURL(url);
});
// Load canvas state from file
document.getElementById('load-state-btn').addEventListener('click', () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const savedState = JSON.parse(text);
if (!savedState.layers) {
alert('Invalid canvas state file');
return;
}
// Clear existing layers
state.layers = [];
state.selectedLayerId = null;
// Restore layers
for (const layerData of savedState.layers) {
if (layerData.type === 'draw' && layerData.drawDataUrl) {
// Restore draw layer
const img = await loadImage(layerData.drawDataUrl);
const layer = createDrawLayer(img.width, img.height);
layer.drawCtx.drawImage(img, 0, 0);
Object.assign(layer, {
name: layerData.name || layer.name,
visible: layerData.visible !== false,
x: layerData.x || 0, y: layerData.y || 0,
rotation: layerData.rotation || 0,
scaleX: layerData.scaleX || 1, scaleY: layerData.scaleY || 1,
skewX: layerData.skewX || 0, skewY: layerData.skewY || 0,
opacity: layerData.opacity !== undefined ? layerData.opacity : 1,
blendMode: layerData.blendMode || 'source-over'
});
} else if (layerData.sourceImageIndex !== undefined && imagesData[layerData.sourceImageIndex]) {
// Restore image layer from source images
const layer = await createLayer(getImageUrl(imagesData[layerData.sourceImageIndex]), layerData.x || 0, layerData.y || 0, layerData.sourceImageIndex);
Object.assign(layer, {
name: layerData.name || layer.name,
visible: layerData.visible !== false,
rotation: layerData.rotation || 0,
scaleX: layerData.scaleX || 1, scaleY: layerData.scaleY || 1,
skewX: layerData.skewX || 0, skewY: layerData.skewY || 0,
opacity: layerData.opacity !== undefined ? layerData.opacity : 1,
blendMode: layerData.blendMode || 'source-over'
});
}
}
// Restore background settings
if (savedState.bgColor) state.bgColor = savedState.bgColor;
if (savedState.bgTransparent !== undefined) state.bgTransparent = savedState.bgTransparent;
document.getElementById('bg-color').value = state.bgColor;
document.getElementById('bg-transparent').checked = state.bgTransparent;
if (state.layers.length > 0) {
state.selectedLayerId = state.layers[state.layers.length - 1].id;
}
markLayerListDirty();
render();
console.log('[Canvas] Loaded state with', state.layers.length, 'layers');
} catch (err) {
console.error('[Canvas] Failed to load state:', err);
alert('Failed to load canvas state: ' + err.message);
}
};
input.click();
});
// Refresh source images from parent
document.getElementById('refresh-images-btn').addEventListener('click', () => {
window.parent.postMessage({ type: 'requestImages' }, '*');
});
// Listen for updated images from parent
window.addEventListener('message', (e) => {
if (e.data && e.data.type === 'updateImages' && Array.isArray(e.data.images)) {
imagesData = e.data.images;
updateImagesList();
console.log('[Canvas] Updated source images:', imagesData.length);
}
});
// Initialize
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
updateImagesList();
// Try to restore saved canvas state
let savedState = null;
try {
const savedStateStr = container.dataset.savedState;
if (savedStateStr) {
savedState = JSON.parse(savedStateStr);
}
} catch (e) {
console.warn('[Canvas] Failed to parse saved state:', e);
}
// Check if saved state matches current imagesData (same session/files)
// If mismatch, discard saved state and start fresh
if (savedState && savedState.layers && imagesData.length > 0) {
const savedImageUrls = savedState.layers
.filter(l => l.imageUrl)
.map(l => l.imageUrl);
const currentSubfolder = imagesData[0]?.subfolder || '';
const savedHasDifferentSession = savedImageUrls.length > 0 &&
!savedImageUrls.some(url => url.includes(currentSubfolder));
if (savedHasDifferentSession) {
console.log('[Canvas] Saved state references different session, starting fresh');
savedState = null;
}
}
if (savedState && savedState.layers && savedState.layers.length > 0) {
// Restore layers from saved state
console.log('[Canvas] Restoring', savedState.layers.length, 'layers from saved state');
// Restore background settings
if (savedState.bgTransparent !== undefined) state.bgTransparent = savedState.bgTransparent;
if (savedState.bgColor) state.bgColor = savedState.bgColor;
document.getElementById('bg-transparent').checked = state.bgTransparent;
document.getElementById('bg-color').value = state.bgColor;
// Restore each layer
const restorePromises = savedState.layers.map(async (layerData, idx) => {
try {
if (layerData.isDrawLayer && layerData.drawDataUrl) {
// Restore draw layer
const layer = createDrawLayer(layerData.width || 512, layerData.height || 512);
const img = await loadImage(layerData.drawDataUrl);
layer.drawCtx.drawImage(img, 0, 0);
// Restore properties
Object.assign(layer, {
x: layerData.x || 0,
y: layerData.y || 0,
rotation: layerData.rotation || 0,
scaleX: layerData.scaleX || 1,
scaleY: layerData.scaleY || 1,
skewX: layerData.skewX || 0,
skewY: layerData.skewY || 0,
opacity: layerData.opacity || 1,
blendMode: layerData.blendMode || 'source-over',
});
return layer;
} else if (layerData.imageIndex !== undefined && layerData.imageIndex >= 0 && imagesData[layerData.imageIndex]) {
// Restore image layer using fresh URL from imagesData
const imgIdx = layerData.imageIndex;
const layer = await createLayer(getImageUrl(imagesData[imgIdx]), layerData.x || 0, layerData.y || 0, imgIdx);
Object.assign(layer, {
rotation: layerData.rotation || 0,
scaleX: layerData.scaleX || 1,
scaleY: layerData.scaleY || 1,
skewX: layerData.skewX || 0,
skewY: layerData.skewY || 0,
opacity: layerData.opacity || 1,
blendMode: layerData.blendMode || 'source-over',
shadow: layerData.shadow || false,
shadowBlur: layerData.shadowBlur || 10,
shadowOffsetX: layerData.shadowOffsetX || 5,
shadowOffsetY: layerData.shadowOffsetY || 5,
shadowColor: layerData.shadowColor || 'rgba(0,0,0,0.5)',
});
return layer;
}
} catch (e) {
console.error('[Canvas] Failed to restore layer:', e);
}
return null;
});
Promise.all(restorePromises).then(layers => {
const validLayers = layers.filter(l => l);
if (validLayers.length > 0) {
state.selectedLayerId = validLayers[validLayers.length - 1].id;
markLayerListDirty();
render();
console.log('[Canvas] Restored', validLayers.length, 'layers');
// Save initial state for undo support
history.save();
} else if (imagesData.length > 0) {
// Restore failed (e.g. temp files deleted), fall back to fresh images
console.log('[Canvas] Restore failed, loading fresh images');
createLayer(getImageUrl(imagesData[0]), 50, 50, 0).then(layer => {
state.selectedLayerId = layer.id;
markLayerListDirty();
render();
// Save initial state for undo support
history.save();
}).catch(e => console.error('[Canvas] Failed to load fresh image:', e));
} else {
markLayerListDirty();
render();
}
});
} else if (imagesData.length > 0) {
// Auto-add first image if available and no saved state
createLayer(getImageUrl(imagesData[0]), 50, 50, 0).then(layer => {
state.selectedLayerId = layer.id;
render();
// Save initial state for undo support
history.save();
}).catch(e => console.error('[Canvas] Failed to auto-add first image:', e));
} else {
// No layers - save empty initial state for undo support
history.save();
}
} catch (e) {
console.error('[Canvas] Script error:', e);
}
})();
<\/script>
`;
}
/**
* Content marker prefix for canvas view
*/
static getContentMarker() {
return this.CANVAS_MARKER;
}
/**
* Get message types this view handles
*/
static getMessageTypes() {
return ["canvas-export-request", "requestImages"];
}
/**
* Get view-specific state from node widgets
*/
static getStateFromWidget(node) {
const viewStateWidget = node.widgets?.find(w => w.name === "view_state");
if (viewStateWidget?.value) {
try {
const viewState = JSON.parse(viewStateWidget.value);
return viewState.canvasState || null;
} catch {}
}
return null;
}
/**
* Inject saved state into content before rendering
*/
static injectState(content, state) {
if (!state || !content) return content;
try {
let jsonContent = content;
if (content.startsWith(this.CANVAS_MARKER)) {
jsonContent = content.slice(this.CANVAS_MARKER.length);
}
const canvasData = JSON.parse(jsonContent);
canvasData.savedState = state;
return this.CANVAS_MARKER + JSON.stringify(canvasData);
} catch (e) {
console.warn("[Canvas View] Failed to inject saved state:", e);
return content;
}
}
/**
* Handle messages from iframe (called by view_loader.handleViewMessage)
*/
static handleMessage(messageType, data, node, app, iframeSource) {
if (messageType === "requestImages") {
return this._handleRequestImages(data, node, app, iframeSource);
}
if (messageType !== "canvas-export-request") return false;
const { action, data: exportData } = data;
const { layers, bounds, bgTransparent, bgColor } = exportData;
const { minX, minY, maxX, maxY, padding } = bounds;
const exportCanvas = document.createElement('canvas');
exportCanvas.width = maxX - minX + padding * 2;
exportCanvas.height = maxY - minY + padding * 2;
const ctx = exportCanvas.getContext('2d');
if (!bgTransparent) {
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
}
ctx.translate(-minX + padding, -minY + padding);
if (layers.length === 0) {
this._finishExport(action, exportCanvas, exportData, node);
return true;
}
// Count total items to load (layer images + mask images)
let totalToLoad = 0;
let loadedCount = 0;
const loadedImages = [];
const loadedMasks = [];
layers.forEach((layer, idx) => {
// Use drawDataUrl if available (for draw layers or image layers edited with eraser/brush)
const imgSrc = (layer.isDrawLayer || layer.hasDrawCanvas) ? layer.drawDataUrl : layer.imageUrl;
if (imgSrc) totalToLoad++;
if (layer.maskEnabled && layer.maskDataUrl) totalToLoad++;
});
if (totalToLoad === 0) {
this._finishExport(action, exportCanvas, exportData, node);
return true;
}
const checkComplete = () => {
if (loadedCount === totalToLoad) {
this._renderLayers(ctx, layers, loadedImages, loadedMasks);
this._finishExport(action, exportCanvas, exportData, node);
}
};
layers.forEach((layer, idx) => {
// Use drawDataUrl if available (for draw layers or image layers edited with eraser/brush)
const imgSrc = (layer.isDrawLayer || layer.hasDrawCanvas) ? layer.drawDataUrl : layer.imageUrl;
// Skip layers without valid image source
if (!imgSrc) {
console.warn('[Canvas Export] Layer', idx, 'has no valid image source, skipping');
loadedImages[idx] = null;
loadedMasks[idx] = null;
return;
}
// Load layer image
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
loadedImages[idx] = img;
loadedCount++;
checkComplete();
};
img.onerror = () => {
console.warn('[Canvas Export] Failed to load layer', idx, layer.isDrawLayer ? '(draw layer)' : '(image layer)');
loadedImages[idx] = null;
loadedCount++;
checkComplete();
};
img.src = imgSrc;
// Load mask image if enabled
if (layer.maskEnabled && layer.maskDataUrl) {
const maskImg = new Image();
maskImg.crossOrigin = 'anonymous';
maskImg.onload = () => {
loadedMasks[idx] = maskImg;
loadedCount++;
checkComplete();
};
maskImg.onerror = () => {
console.warn('[Canvas Export] Failed to load mask for layer', idx);
loadedMasks[idx] = null;
loadedCount++;
checkComplete();
};
maskImg.src = layer.maskDataUrl;
} else {
loadedMasks[idx] = null;
}
});
return true;
}
/**
* Render layers onto canvas context
*/
static _renderLayers(ctx, layers, loadedImages, loadedMasks = []) {
layers.forEach((layer, idx) => {
const img = loadedImages[idx];
if (!img) return;
ctx.save();
ctx.translate(layer.x + layer.width * layer.scaleX * 0.5, layer.y + layer.height * layer.scaleY * 0.5);
ctx.rotate(layer.rotation * Math.PI / 180);
ctx.transform(1, layer.skewY, layer.skewX, 1, 0, 0);
ctx.scale(layer.scaleX, layer.scaleY);
ctx.translate(-layer.width * 0.5, -layer.height * 0.5);
ctx.globalAlpha = layer.opacity;
ctx.globalCompositeOperation = layer.blendMode || 'source-over';
if (layer.shadow) {
ctx.shadowBlur = layer.shadowBlur;
ctx.shadowOffsetX = layer.shadowOffsetX;
ctx.shadowOffsetY = layer.shadowOffsetY;
ctx.shadowColor = layer.shadowColor;
}
// Apply mask if enabled
const maskImg = loadedMasks[idx];
if (layer.maskEnabled && maskImg) {
// Composite image with mask using a temporary canvas
const tempCanvas = document.createElement('canvas');
tempCanvas.width = layer.width;
tempCanvas.height = layer.height;
const tctx = tempCanvas.getContext('2d');
tctx.drawImage(img, 0, 0, layer.width, layer.height);
tctx.globalCompositeOperation = 'destination-in';
tctx.drawImage(maskImg, 0, 0, layer.width, layer.height);
ctx.drawImage(tempCanvas, 0, 0, layer.width, layer.height);
} else {
ctx.drawImage(img, 0, 0, layer.width, layer.height);
}
ctx.restore();
});
}
/**
* Handle requestImages message from iframe
* Note: The viewer system automatically extracts view-specific content from multiview
* payloads and provides it via data._viewContent
*/
static _handleRequestImages(data, node, app, iframeSource) {
// Use view-specific content provided by the viewer system (handles multiview automatically)
let content = data._viewContent || "";
if (!content) return false;
try {
let jsonContent = content;
if (content.startsWith(this.CANVAS_MARKER)) {
jsonContent = content.slice(this.CANVAS_MARKER.length);
}
const parsed = JSON.parse(jsonContent);
if (parsed.images && Array.isArray(parsed.images) && iframeSource) {
iframeSource.postMessage({
type: 'updateImages',
images: parsed.images
}, '*');
return true;
}
} catch (e) {
console.warn("[Canvas View] Failed to parse content for requestImages:", e);
}
return false;
}
/**
* Finish export - download or save to widget
*/
static _finishExport(action, exportCanvas, exportData, node) {
let dataUrl;
try {
dataUrl = exportCanvas.toDataURL('image/png');
} catch (e) {
console.error("[Canvas View] Export failed - canvas may be tainted by cross-origin images:", e);
alert('Export failed: Canvas contains cross-origin images that cannot be exported.');
return;
}
if (action === 'download') {
const link = document.createElement('a');
link.download = 'canvas-export.png';
link.href = dataUrl;
link.click();
} else if (action === 'output') {
const viewStateWidget = node.widgets?.find(w => w.name === "view_state");
if (viewStateWidget) {
try {
const viewState = JSON.parse(viewStateWidget.value || "{}");
viewState.canvas_output = "$WAS_CANVAS_OUTPUT$" + dataUrl;
// Note: Canvas state is no longer saved to widget to avoid quota errors
// Use the save/load buttons in the Layers panel to export/import canvas state
viewStateWidget.value = JSON.stringify(viewState);
} catch (e) {
console.error("[Canvas View] Failed to save view state:", e);
}
}
console.log("[Canvas View] Canvas output set, dataUrl length:", dataUrl.length);
node.setDirtyCanvas?.(true, true);
}
}
}
export default CanvasView;