├── Canvas_tab_basic.png ├── Inpaint_Onion.png ├── Inpaint_with_canvas_tab.png ├── LICENSE ├── README.md ├── __init__.py ├── web ├── main.js └── page │ ├── bspaint.js │ ├── cash.min.js │ ├── images │ ├── eraser.png │ ├── fine_eraser.png │ ├── pixels.png │ ├── tip_1.png │ ├── tip_2.png │ ├── tip_3.png │ ├── tip_5.png │ └── tip_9.png │ ├── index.html │ ├── main.js │ ├── paintTools.js │ ├── style.css │ └── support.js └── workflows ├── MultiTarget.mp4 ├── README.md ├── TomatoDoodle.jpg ├── TurboLLiteDepth.svg └── Turbo_canvas.svg /Canvas_tab_basic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lerc/canvas_tab/e04289a6208b43879705e12b641da9fd4d3d27de/Canvas_tab_basic.png -------------------------------------------------------------------------------- /Inpaint_Onion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lerc/canvas_tab/e04289a6208b43879705e12b641da9fd4d3d27de/Inpaint_Onion.png -------------------------------------------------------------------------------- /Inpaint_with_canvas_tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lerc/canvas_tab/e04289a6208b43879705e12b641da9fd4d3d27de/Inpaint_with_canvas_tab.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2023, Neil Graham 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 16 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 17 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 19 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 20 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 21 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 22 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 23 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 24 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # canvas_tab 2 | ComfyUI canvas editor page 3 | 4 | ## Updates 5 | - 2024-1-24 Added multi canvas node support. 6 | - 2024-1-11 Added CSS for prefers-color-scheme:dark 7 | - 2023-12-23 Added tool to scale and rotate layers (Hotkey is T, doubleClick on image to toggle between Rotate and Scale mode) 8 | - 2023-12-4 Added hotkeys for Brush Radius, Added Duplicate Layer button. Ctrl-Click duplicates the target layer. 9 | - 2023-12-2 Added Trigger Queue on change toggle. 10 | - 2023-11-30 Added "Replace Targeted Layer" as an input mode, right click on a layer to set it as target to be replaced 11 | 12 | 13 | This plugin provides two nodes to provide a full page editor that runs in another tab. 14 | 15 | There is an input node `Edit in another Tab ` and an output node `Send to Editor Tab`. 16 | Both are stored in the images submenu. 17 | 18 | The can be set to trigger a queue on change. if there is something in the queue already it will wait until the queue empties before 19 | triggering a new Queue. This should result in only one queued entry triggered by this node at a time. 20 | 21 | ## Installation 22 | You can install either though comfy manager or by cloning this repository into the custom nodes directory. 23 | 24 | ## User Interface 25 | You can edit multiple images at once. 26 | Drag images around with the middle mouse button and scale them with the mouse wheel. 27 | 28 | There is a green Tab on the side of images in the editor, click on that tab to highlight it. 29 | The image with the highlighted tab is sent through to the comfyUI node. 30 | 31 | Multiple Canvas Tab nodes are supported, If the title of the node and the title of the image in the editor are set to the same name 32 | The output of the canvas editor will be sent to that node. 33 | 34 | You can have multiple image layers and you can select generated images to be 35 | added as a new layer, replace an existing layers, or as a new image. 36 | 37 | You can delete layers by clicking on the layer widget with Ctrl-LeftClick. The layer must be visible for this to work as a protection against unwittingly deleting something important. 38 | 39 | Ctrl-click on palette entries reassigns the palette color tho the current color. 40 | Middle-click on palette entries sets the palette color to the current foreground color. 41 | 42 | Both nodes provided by this extension support receiving files by drag and drop to 43 | send images directly to the editor. 44 | 45 | ## Hotkeys 46 | 47 | - B for Brush tool 48 | - E for Erase tool 49 | - Z for pixel editing tool 50 | - P for color Picker 51 | - T for layer transformation 52 | - [ and ] to decrease and increase bush radius 53 | - BackSpace to clear a layer 54 | - ALT_BackSpace to fill the layer with the foreground color 55 | - CTRL_Backspace to fill the layer with the background color 56 | - CTRL_Z undo 57 | - CTRL_SHIFT_Z redo 58 | 59 | 60 | 61 | ## Why would you do such a thing? 62 | 63 | My main motivation for making this was to develop an inpainting workflow, 64 | but I have also found it quite useful for scribble based images, 65 | 66 | This image shows a basic workflow where it simply sends the image back to itself and shows 67 | previews of the image and mask. The workflow is also embedded in this image. 68 | 69 |  70 | 71 | I have been using the controlnet inpaint with a workflow like this. 72 | 73 |  74 | 75 | That workflow should be embedded in this image. 76 | 77 |  78 | 79 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | @author: Lerc 3 | @title: Canvas Tab 4 | @nickname: Canvas Tab 5 | @description: This extension provides a full page image editor with mask support. There are two nodes, one to receive images from the editor and one to send images to the editor. 6 | """ 7 | 8 | 9 | import torch 10 | import base64 11 | import os 12 | import folder_paths 13 | from io import BytesIO 14 | from PIL import Image, ImageOps 15 | from PIL.PngImagePlugin import PngInfo 16 | import numpy as np 17 | 18 | def image_to_data_url(image): 19 | buffered = BytesIO() 20 | image.save(buffered, format="PNG") 21 | img_base64 = base64.b64encode(buffered.getvalue()) 22 | return f"data:image/png;base64,{img_base64.decode()}" 23 | 24 | class Send_To_Editor: 25 | def __init__(self): 26 | self.updateTick = 1 27 | pass 28 | 29 | @classmethod 30 | def INPUT_TYPES(s): 31 | return { 32 | "required": { 33 | }, 34 | "hidden": { 35 | "unique_id":"UNIQUE_ID", 36 | }, 37 | "optional": { 38 | "images": ("IMAGE",), 39 | }, 40 | 41 | } 42 | 43 | RETURN_TYPES = () 44 | 45 | FUNCTION = "collect_images" 46 | 47 | OUTPUT_NODE = True 48 | 49 | CATEGORY = "image" 50 | def IS_CHANGED(self, unique_id, images): 51 | self.updateTick+=1 52 | return hex(self.updateTick) 53 | 54 | def collect_images(self, unique_id, images=None): 55 | 56 | collected_images = list() 57 | if images is not None: 58 | for image in images: 59 | i = 255. * image.cpu().numpy() 60 | img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) 61 | collected_images.append(image_to_data_url(img)) 62 | 63 | 64 | return { "ui": {"collected_images":collected_images}} 65 | 66 | 67 | class Canvas_Tab: 68 | """ 69 | A Image Buffer for handling an editor in another tab. 70 | """ 71 | 72 | def __init__(self): 73 | pass 74 | 75 | @classmethod 76 | def INPUT_TYPES(s): 77 | return { 78 | "required": { 79 | "mask": ("CANVAS",), 80 | "canvas": ("CANVAS",), 81 | }, 82 | "hidden": { 83 | "unique_id":"UNIQUE_ID", 84 | }, 85 | # "optional": { 86 | # "images": ("IMAGE",), 87 | # }, 88 | 89 | } 90 | 91 | RETURN_TYPES = ("IMAGE","MASK") 92 | 93 | FUNCTION = "image_buffer" 94 | 95 | #OUTPUT_NODE = False 96 | 97 | CATEGORY = "image" 98 | 99 | def image_buffer(self, unique_id, mask, canvas, images=None): 100 | 101 | # collected_images = list() 102 | # if images is not None: 103 | # for image in images: 104 | # i = 255. * image.cpu().numpy() 105 | # img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) 106 | # collected_images.append(image_to_data_url(img)) 107 | # 108 | # print(f"Node {unique_id}: images: {images}") 109 | 110 | image_path = folder_paths.get_annotated_filepath(canvas) 111 | i = Image.open(image_path) 112 | i = ImageOps.exif_transpose(i) 113 | 114 | rgb_image = i.convert("RGB") 115 | rgb_image = np.array(rgb_image).astype(np.float32) / 255.0 116 | rgb_image = torch.from_numpy(rgb_image)[None,] 117 | 118 | 119 | mask_path = folder_paths.get_annotated_filepath(mask) 120 | i = Image.open(mask_path) 121 | i = ImageOps.exif_transpose(i) 122 | 123 | if 'A' in i.getbands(): 124 | mask_data = np.array(i.getchannel('A')).astype(np.float32) / 255.0 125 | mask_data = torch.from_numpy(mask_data) 126 | else: 127 | mask_data = torch.zeros((64,64), dtype=torch.float32, device="cpu") 128 | 129 | 130 | 131 | 132 | return (rgb_image, mask_data) 133 | 134 | 135 | WEB_DIRECTORY = "web" 136 | 137 | NODE_CLASS_MAPPINGS = { 138 | "Canvas_Tab": Canvas_Tab, 139 | "Send_To_Editor": Send_To_Editor 140 | } 141 | 142 | NODE_DISPLAY_NAME_MAPPINGS = { 143 | "Canvas_Tab": "Edit In Another Tab", 144 | "Send_To_Editor": "Send to Editor Tab" 145 | } 146 | -------------------------------------------------------------------------------- /web/main.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../scripts/app.js"; 2 | import { api } from "../../scripts/api.js" 3 | import { ComfyWidgets } from "../../scripts/widgets.js"; 4 | 5 | let autoQueueInProgress = false; 6 | let anotherQueueWaiting = false; 7 | 8 | let myticker =0; 9 | let dummyBlob; 10 | let editor= {}; // one image editor for all nodes, otherwise communication is messy when reload happens 11 | // for now that means only one editor node is practical. 12 | // adding a protocol that identifies multiple nodes would allow the one editor 13 | // to serve multiple nodes. 14 | 15 | const plugin_name = "canvas_link"; 16 | const editor_path = "./extensions/canvas_tab/page/index.html" 17 | 18 | function setSignal(key) { 19 | const keyName = plugin_name+":"+key; 20 | localStorage.setItem(keyName, 'true'); 21 | } 22 | 23 | function clearSignal(key) { 24 | const keyName = plugin_name+":"+key; 25 | localStorage.removeItem(keyName); 26 | } 27 | 28 | function getSignal(key) { 29 | const keyName = plugin_name+":"+key; 30 | return localStorage.getItem(keyName) === 'true'; 31 | } 32 | 33 | function checkAndClear(key) { 34 | const keyName = plugin_name+":"+key; 35 | let result=localStorage.getItem(keyName) === 'true'; 36 | if (result) localStorage.removeItem(keyName); 37 | return result; 38 | } 39 | 40 | 41 | function handleStatusUpdate(e) { 42 | const detail = e.detail; 43 | if (detail?.exec_info?.queue_remaining === 0) { 44 | //console.log({anotherQueueWaiting,autoQueueInProgress}) 45 | if (anotherQueueWaiting) { 46 | app.queuePrompt(); 47 | anotherQueueWaiting=false; 48 | } else { 49 | autoQueueInProgress=false; 50 | } 51 | } 52 | } 53 | 54 | app.registerExtension({ 55 | name: "canvas_tab", 56 | async init() { 57 | console.log("init:"+this.name) 58 | checkForExistingEditor(); 59 | const blankImage=document.createElement("canvas"); 60 | blankImage.width=64; 61 | blankImage.height=64; 62 | blankImage.toBlob(a=>dummyBlob=a) 63 | 64 | addEventListener("message",handleWindowMessage) 65 | api.addEventListener("status", handleStatusUpdate) 66 | }, 67 | 68 | async beforeRegisterNodeDef(nodeType, nodeData, app) { 69 | }, 70 | 71 | async getCustomWidgets(app) { 72 | return { 73 | CANVAS(node,inputName,inputData,app) { 74 | return addCanvasWidget(node,inputName,inputData, app) 75 | } 76 | } 77 | }, 78 | 79 | nodeCreated(node) { 80 | const title = node.getTitle(); 81 | // node.type not set at this point? 82 | if (title==="Edit In Another Tab") { 83 | initEditorNode(node); 84 | addDragDropSupport(node); 85 | } 86 | else if (title==="Send to Editor Tab") { 87 | initTransmitNode(node); 88 | addDragDropSupport(node); 89 | } 90 | 91 | } 92 | }); 93 | 94 | 95 | 96 | 97 | function initEditorNode(node) 98 | { 99 | node.collected_images = []; 100 | node.addWidget("button","Edit","bingo", (widget,graphCanvas, node, {x,y}, event) => focusEditor(node)); 101 | node.triggerQueue=node.addWidget("toggle","Queue on change",false,_=>{}); 102 | node.widgets.reverse();// because auto created widgets get put in first 103 | 104 | node.canvasWidget=node.widgets[2]; 105 | node.maskWidget=node.widgets[3]; 106 | 107 | node.maskWidget.onTopOf = node.canvasWidget; 108 | 109 | editor?.channel?.port1.postMessage({retransmit:true}) 110 | 111 | 112 | return; 113 | } 114 | 115 | function addDragDropSupport(node) { 116 | // Add handler to check if an image is being dragged over our node 117 | node.onDragOver = function (e) { 118 | if (e.dataTransfer && e.dataTransfer.items) { 119 | const image = [...e.dataTransfer.items].find((f) => f.kind === "file"); 120 | return !!image; 121 | } 122 | return false; 123 | }; 124 | 125 | // On drop upload files 126 | node.onDragDrop = function (e) { 127 | let handled = false; 128 | for (const file of e.dataTransfer.files) { 129 | if (file.type.startsWith("image/")) { 130 | const url = URL.createObjectURL(file) 131 | transmitImages([url]) 132 | setTimeout(_=>URL.revokeObjectURL(url),1000); 133 | handled = true; 134 | } 135 | } 136 | return handled; 137 | }; 138 | 139 | } 140 | function transmitImages(images) { 141 | if (!editor.window || editor.window.closed) openEditor(); 142 | 143 | if(editor.channel) { 144 | editor.channel.port1.postMessage({images}) 145 | } else { 146 | //try again after half a second just in case we caught it setting up. 147 | setTimeout(_=>{editor?.channel?.port1.postMessage({images})}, 500); 148 | } 149 | } 150 | 151 | function initTransmitNode(node) 152 | { 153 | node.collected_images = []; 154 | 155 | node.onExecuted = (output)=> { 156 | if (output?.collected_images) { 157 | transmitImages(output.collected_images); 158 | } 159 | } 160 | 161 | return; 162 | } 163 | 164 | function openEditor() { 165 | editor = {};// start over with new editor; 166 | if (getSignal('clientPage')) { 167 | //if clientPage is set, there might be a new page to replace the lost one 168 | setSignal('findImageEditor') 169 | setTimeout(_=>{ 170 | if (checkAndClear("findImageEditor")) { 171 | //if the flag is still set by here, assume there's no-one out there 172 | console.log("open window a") 173 | editor.window = window.open(editor_path, plugin_name); 174 | } 175 | } ,1000) 176 | } else { 177 | console.log("open window b") 178 | editor.window = window.open(editor_path, plugin_name); 179 | } 180 | } 181 | 182 | function focusEditor() { 183 | if (!editor.window || editor.window.closed) { 184 | openEditor(); 185 | } else { 186 | editor.window.focus(); 187 | } 188 | } 189 | 190 | 191 | async function uploadBlob(blob,filename="blob") { 192 | try { 193 | // Wrap file in formdata so it includes filename 194 | const body = new FormData(); 195 | body.append("image", blob,filename); 196 | const resp = await api.fetchApi("/upload/image", { 197 | method: "POST", 198 | body, 199 | }); 200 | 201 | if (resp.status === 200) { 202 | return await resp.json();; 203 | } else { 204 | alert(resp.status + " - " + resp.statusText); 205 | } 206 | } catch (error) { 207 | alert(error); 208 | } 209 | } 210 | 211 | function addCanvasWidget(node,name,inputData,app) { 212 | 213 | const widget = { 214 | type: inputData[0], 215 | name, 216 | size: [128,128], 217 | image: null, 218 | sendBlobRequired : true, 219 | uploadedBlobName : "", 220 | _blob: null, 221 | background :"#8888", 222 | _value : "", 223 | updating : false, 224 | get value() { 225 | return this._value 226 | }, 227 | set value(newValue) { 228 | this._value=newValue 229 | }, 230 | get blob() { 231 | return this._blob; 232 | }, 233 | set blob(newValue) { 234 | this._blob=newValue; 235 | this.sendBlobRequired = true; 236 | }, 237 | draw(ctx, node, width, y) { 238 | let [nodeWidth,nodeHeight] = node.size; 239 | if (this.onTopOf) { 240 | ctx.globalAlpha=0.5; 241 | y= this.onTopOf.last_y; 242 | 243 | } else { 244 | ctx.globalAlpha=1; 245 | ctx.fillStyle = this.background; 246 | ctx.fillRect(0,y,width,nodeHeight-y); 247 | } 248 | if (this.image) { 249 | const imageAspect = this.image.width/this.image.height; 250 | let height = nodeHeight-y; 251 | const widgetAspect = width / height; 252 | let targetWidth,targetHeight; 253 | if (imageAspect>widgetAspect) { 254 | targetWidth=width; 255 | targetHeight=width/imageAspect; 256 | } else { 257 | targetHeight=height; 258 | targetWidth=height*imageAspect; 259 | } 260 | ctx.drawImage(this.image, (width-targetWidth)/2,y+(height-targetHeight)/2,targetWidth,targetHeight); 261 | } 262 | }, 263 | computeSize(...args) { 264 | return [128,128]; 265 | }, 266 | async serializeValue(nodeId,widgetIndex) { 267 | let widget = node.widgets[widgetIndex]; 268 | let blob = widget.blob; 269 | if (!(blob instanceof Blob)) blob = dummyBlob; 270 | if (widget.sendBlobRequired) { 271 | let result = await uploadBlob(blob,widget.name+"_Image.png") 272 | if (result) { 273 | widget.uploadedBlobName = result.name; 274 | widget.sendBlobRequired=false; 275 | } 276 | } 277 | return widget.uploadedBlobName; 278 | } 279 | } 280 | node.addCustomWidget(widget); 281 | 282 | return widget; 283 | } 284 | 285 | function initiateCommunication() { 286 | if (editor.window && !editor.window.closed) { 287 | editor.channel= new MessageChannel(); 288 | editor.window.postMessage('Initiate communication', '*', [editor.channel.port2]); 289 | editor.channel.port1.onmessage = messageFromEditor; 290 | } 291 | } 292 | 293 | function loadImage(url) { 294 | return new Promise((resolve, reject) => { 295 | const img = new Image(); 296 | 297 | img.onload = () => resolve(img); 298 | img.onerror = (e) => reject(new Error(`Failed to load image at ${url}`)); 299 | 300 | img.src = url; 301 | }); 302 | } 303 | 304 | async function loadBlobIntoWidget(widget, blob) { 305 | const objectURL = URL.createObjectURL(blob); 306 | const img = await loadImage(objectURL); 307 | widget.blob = blob; 308 | widget.image = img; 309 | app.graph.setDirtyCanvas(true); 310 | URL.revokeObjectURL(objectURL); 311 | } 312 | 313 | function handleWindowMessage(e) { 314 | 315 | if (typeof e.data === "object" && e.data?.category === plugin_name) { 316 | let data = e.data.data; 317 | if (data == "Editor Here") { 318 | editor.window = e.source; 319 | initiateCommunication(); 320 | } else console.log("window message received",e) 321 | } 322 | } 323 | 324 | async function messageFromEditor(event) { 325 | let nodes = app.graph.findNodesByType("Canvas_Tab"); 326 | //send same thing to all of the Canvas_Tab nodes 327 | const {title, selected} = event.data; 328 | 329 | console.log({title,selected}); 330 | if (title) { 331 | 332 | let targetNodes = nodes.filter(node=>node.title==title || (node.title=="Edit In Another Tab" && selected)); 333 | nodes=targetNodes; 334 | } 335 | let queue = false; 336 | if (event.data.image instanceof Blob) { 337 | for (const node of nodes) { 338 | await loadBlobIntoWidget(node.canvasWidget,event.data.image); 339 | if (node.triggerQueue.value) queue=true; 340 | 341 | } 342 | } 343 | if (event.data.mask instanceof Blob) { 344 | for (const node of nodes) { 345 | await loadBlobIntoWidget(node.maskWidget,event.data.mask); 346 | if (node.triggerQueue.value) queue=true; 347 | } 348 | } 349 | if (queue) { 350 | if (autoQueueInProgress) { 351 | anotherQueueWaiting=true; 352 | } else { 353 | app.queuePrompt(); 354 | autoQueueInProgress=true; 355 | } 356 | } 357 | } 358 | 359 | function checkForExistingEditor() { 360 | if (getSignal('clientPage')) { 361 | setSignal("findImageEditor") 362 | // Signal the Image Editor page to identify itself for reattachment 363 | } 364 | } 365 | 366 | 367 | -------------------------------------------------------------------------------- /web/page/bspaint.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | var selectedExport; 3 | var maskStaysOnTop = true; 4 | 5 | if (location.pathname.includes("/page/")) { 6 | //don't init if not in the right path 7 | //this is to stop code running from comfyUI directly 8 | $(initPaint); 9 | } 10 | 11 | var tool=feltTip; 12 | 13 | var tip = { 14 | lastX : 0, 15 | lastY : 0, 16 | x : 0.0, 17 | y : 0.0, 18 | color : "black", 19 | size : 5, 20 | tool : feltTip, 21 | } 22 | 23 | 24 | var hotkeys = {}; 25 | 26 | var initialPalette = [ 27 | "#000000", 28 | "#404040","#808080", 29 | "#a0a0a0","#b8b8b8", 30 | "#d0d0d0","#e0e0e0", 31 | "#ffffff", 32 | 33 | "#a00000","#ff0000", "#ff7070", "#ffb0b0", 34 | "#a06000","#ff8000", "#ffa068", "#ffd0b0", 35 | "#a0a000","#ffff00", "#ffff70", "#ffffc0", 36 | 37 | "#509000","#90f000", "#c0ff68", "#e0ffb8", 38 | 39 | "#008000","#00ff00", "#70ff60", "#c0ffb0", 40 | "#007050","#00f0a0", "#70ffa0", "#c0ffe0", 41 | "#006060","#00e0e0", "#70ffff", "#c0ffff", 42 | "#004080","#0080ff", "#70a0ff", "#c0e0ff", 43 | "#000090","#0000ff", "#7070ff", "#c0c0ff", 44 | "#500078","#7800c8", "#c870d8", "#d8b8ff", 45 | "#a00060","#e00090", "#f070c0", "#ffb0ff", 46 | "#881838","#b03060", "#d07890", "#f0b0c8", 47 | "#703010","#904030", "#b08060", "#e0b0a0", 48 | "#704040","#905050", "#b09070", "#e0c0b0", 49 | 50 | "#00ff80","#0080ff","#50C0ff", "#2030cf", 51 | "#8080ff","#4060a0","#ff00ff", "#ffa0a0",]; 52 | 53 | 54 | var brushSizeControl = brushDiameterControl("tip_diameter"); 55 | 56 | var picStack = []; 57 | 58 | var activePic; 59 | var targetLayer = null; 60 | 61 | function setActivePic(newValue) { 62 | activePic=newValue; 63 | updateLayerList() 64 | if (!activePic) return; 65 | activePic.bringToFront(); 66 | 67 | if (typeof tool.drawUI === "function") tool.drawUI(); 68 | } 69 | 70 | var dragging = false; 71 | var dragButtons = 0; 72 | var dragStartX; 73 | var dragStartY; 74 | 75 | var mouseDownX=0; 76 | var mouseDownY=0; 77 | 78 | 79 | var draggingElement = null; 80 | 81 | 82 | function setExportPic(pic) { 83 | setActivePic(pic); 84 | 85 | selectedExport=pic; 86 | 87 | $(".sidebutton").removeClass("output") 88 | const button=pic.element.querySelector(".sidebutton"); 89 | button.classList.add("output"); 90 | 91 | pic.updateVisualRepresentation(true); 92 | transmitMask(pic.mask.canvas,pic.title,true); 93 | 94 | } 95 | 96 | function closePic(pic) { 97 | if (selectedExport===pic) selectedExport=null; 98 | pic.element.parentElement.removeChild(pic.element); 99 | if (pic === activePic) { 100 | const newActive = document.querySelector("#workspace .pic"); 101 | setActivePic(newActive?.pic); 102 | } 103 | } 104 | 105 | 106 | 107 | 108 | var lastUsedMaskColor = "#402040"; 109 | 110 | class Layer { 111 | canvas = document.createElement("canvas"); 112 | ctx = this.canvas.getContext("2d",{willReadFrequently:true}); 113 | mask = false; 114 | visible = true; 115 | compositeOperation = "source-over"; 116 | opacity = 1; 117 | _maskColor = lastUsedMaskColor; 118 | constructor (pic,title, {width,height}=pic, mask = false) { 119 | this.parentPic = pic; 120 | this.canvas.width=width; 121 | this.canvas.height=height; 122 | this.mask = mask; 123 | this.title= title; 124 | const offsetX=(pic.width-width)/2; 125 | const offsetY=(pic.height-height)/2;0 126 | this.transform = [1,0 ,0,1, offsetX,offsetY]; 127 | this.rotationCenter= {x:width/2,y:height/2}; 128 | if (mask) { 129 | this.compositeOperation="source-over"; 130 | } 131 | } 132 | get maskColor() { 133 | return this._maskColor; 134 | } 135 | get width() { 136 | return this.canvas.width; 137 | } 138 | get height() { 139 | return this.canvas.height; 140 | } 141 | set maskColor(newValue) { 142 | this._maskColor=newValue; 143 | if (this.mask) { 144 | this.ctx.save(); 145 | this.ctx.globalCompositeOperation="source-atop"; 146 | this.ctx.fillStyle=newValue; 147 | this.ctx.fillRect(0,0,this.canvas.width,this.canvas.height); 148 | this.ctx.restore(); 149 | } 150 | } 151 | draw(ctx) { 152 | ctx.save(); 153 | ctx.globalCompositeOperation=this.compositeOperation; 154 | ctx.globalAlpha=this.opacity; 155 | ctx.setTransform(...this.transform); 156 | ctx.drawImage(this.canvas,0,0); 157 | ctx.restore(); 158 | } 159 | convertFromPicCoords(x, y) { 160 | return reverseTransform({x,y},this.transform) 161 | } 162 | // Converts coordinates from this layer's coordinates to the picture's canvas 163 | convertToPicCoords(x, y) { 164 | return applyTransform({x,y},this.transform); 165 | } 166 | 167 | rotate(angle) { 168 | const radians = angle * Math.PI / 180; 169 | const cos = Math.cos(radians); 170 | const sin = Math.sin(radians); 171 | 172 | // Convert relative coordinates to absolute 173 | const centerX = this.rotationCenter.x; 174 | const centerY = this.rotationCenter.y; 175 | 176 | // Create a translation matrix to move the rotation point to (0,0) 177 | const translateToOrigin = [1, 0, 0, 1, -centerX, -centerY]; 178 | 179 | // Create a rotation matrix 180 | const rotation = [cos, sin, -sin, cos, 0, 0]; 181 | 182 | // Create a translation matrix to move back from (0,0) 183 | const translateBack = [1, 0, 0, 1, centerX, centerY]; 184 | 185 | // Concatenate the transforms: translate back -> rotate -> translate to origin 186 | let combined = concatenateTransforms(translateBack, rotation); 187 | combined = concatenateTransforms(combined, translateToOrigin); 188 | 189 | // Apply to the current transform 190 | this.transform = concatenateTransforms(this.transform, combined); 191 | } 192 | 193 | translate(dx, dy) { 194 | const newTransform = [1, 0, 0, 1, dx, dy]; 195 | this.transform = concatenateTransforms(this.transform, newTransform); 196 | } 197 | 198 | scale(sx, sy = sx) { 199 | const newTransform = [sx, 0, 0, sy, 0, 0]; 200 | this.transform = concatenateTransforms(this.transform, newTransform); 201 | } 202 | 203 | undoRecord() { 204 | return { 205 | type: 'layer', 206 | layer: this, 207 | compositeOperation: this.compositeOperation, 208 | transform : [...this.transform], 209 | data: this.ctx.getAllImageData() 210 | } 211 | } 212 | undoTransformRecord() { 213 | return { 214 | type:"layerTransform", 215 | layer:this, 216 | transform:[...this.transform] 217 | } 218 | } 219 | 220 | } 221 | 222 | function updateMirrorGridButtonImage() { 223 | const columns = $(".grid_mirrors #repeat_x").val(); 224 | const rows = $(".grid_mirrors #repeat_y").val(); 225 | const image=gridImage(rows,columns,48); 226 | $("#mirror_grid").css('background-image',`url(${image.toDataURL()})`); 227 | } 228 | 229 | function updateMirrorRotationButtonImage() { 230 | const fins = $(".rotational_mirrors input").val(); 231 | 232 | const image=spiralImage(fins,36); 233 | $("#mirror_rotational").css('background-image',`url(${image.toDataURL()})`); 234 | } 235 | 236 | var mirrorFunction = a=>a; 237 | 238 | function updateMirrors() { 239 | let result = a=>a; 240 | if($("#mirror_rotational.mirror.button").hasClass("down")) { 241 | const ways = $(".rotational_mirrors input").val(); 242 | const rotationFunction = rotationalSymmetry(ways); 243 | result = composeFunction(result,rotationFunction); 244 | } 245 | 246 | if($("#mirror_grid.mirror.button").hasClass("down")) { 247 | const columns = $(".grid_mirrors #repeat_x").val(); 248 | const rows = $(".grid_mirrors #repeat_y").val(); 249 | const gridFunction = cells(columns,rows); 250 | result = composeFunction(result,gridFunction); 251 | } 252 | if($("#mirror_x.mirror.button").hasClass("down")) result = composeFunction(result,mirrorX); 253 | if($("#mirror_y.mirror.button").hasClass("down")) result = composeFunction(result,mirrorY); 254 | if($("#mirror_tlbr.mirror.button").hasClass("down")) result = composeFunction(result,mirrorDiagonalXY); 255 | if($("#mirror_trbl.mirror.button").hasClass("down")) result = composeFunction(result,mirrorDiagonalY1MinusX); 256 | 257 | mirrorFunction=result; 258 | } 259 | 260 | function getDrawAreaAtPoint(uiX, uiY) { 261 | for (let pic of picStack) { 262 | const {x,y} = convertCoordsFromUIToPic(uiX, uiY, pic); 263 | 264 | if (x >= 0 && x <= pic.width && y >= 0 && y <= pic.height) { 265 | return pic; 266 | } 267 | } 268 | return null; // No drawArea found at the point 269 | } 270 | 271 | function createDrawArea(canvas = blankCanvas(),initialTitle="Image") { 272 | const undoDepth = 10; 273 | const element = document.createElement("div") 274 | const ctx=canvas.getContext("2d"); 275 | const eventOverlay = document.createElement("div"); 276 | const sidebar = document.createElement("div"); 277 | const closeButton = document.createElement("div"); 278 | const renameButton = document.createElement("div"); 279 | const titleBar = document.createElement("div"); 280 | const title = document.createElement("div"); 281 | var updatingStroke=false; 282 | sidebar.className = "sidebutton"; 283 | closeButton.className = "closebutton"; 284 | renameButton.className = "renamebutton"; 285 | titleBar.className = "titlebar"; 286 | title.className = "title"; 287 | title.textContent=initialTitle; 288 | element.className="pic"; 289 | eventOverlay.className="eventoverlay fillparent"; 290 | 291 | element.appendChild(sidebar); 292 | element.appendChild(canvas); 293 | element.appendChild(eventOverlay); 294 | element.appendChild(titleBar) 295 | titleBar.appendChild(closeButton); 296 | titleBar.appendChild(title); 297 | titleBar.appendChild(renameButton); 298 | 299 | canvas.ctx=ctx; 300 | 301 | const activeOperationCanvas = document.createElement("canvas"); 302 | activeOperationCanvas.width = canvas.width; 303 | activeOperationCanvas.height = canvas.height; 304 | activeOperationCanvas.ctx = activeOperationCanvas.getContext("2d",{willReadFrequently:true}); 305 | 306 | 307 | const image = canvas.ctx.getAllImageData(); 308 | const undoStack = []; 309 | const redoStack = []; 310 | const layers = []; 311 | var mask; 312 | 313 | eventOverlay.addEventListener("mousedown", handleMouseDown); 314 | eventOverlay.addEventListener("mouseup", handleMouseUp); 315 | eventOverlay.addEventListener("mousemove", handleMouseMove); 316 | eventOverlay.addEventListener("contextmenu",function(e){e.preventDefault(); return false;}); 317 | 318 | 319 | const pic = {element,eventOverlay,image, 320 | get layers() {return layers}, 321 | get canvas() {return canvas}, 322 | get mask() {return mask}, 323 | set mask(m) {mask=m}, 324 | get width() {return canvas.width}, 325 | get height() {return canvas.height}, 326 | get title() {return title.textContent}, 327 | scale:1, 328 | scalefactor:0, 329 | offsetX:0, 330 | offsetY:0, 331 | isDrawing:false, 332 | activeLayer:null, 333 | strokeCoordinates :[], 334 | strokeModifier: a=>a, 335 | setCSSTransform( ) { 336 | const {element,scale,offsetX,offsetY} = this; 337 | element.style.setProperty("--scalefactor",scale) 338 | element.style.setProperty("--translateX",offsetX+"px") 339 | element.style.setProperty("--translateY",offsetY+"px") 340 | }, 341 | setPosition(x,y) { 342 | this.offsetX=x; 343 | this.offsetY=y; 344 | this.setCSSTransform(); 345 | }, 346 | bringToFront() { 347 | const oldPos = picStack.indexOf(this); 348 | if (oldPos >= 0) { 349 | picStack.splice(oldPos,1); 350 | } 351 | picStack.unshift(this); 352 | let z = 100; 353 | for (const {element} of picStack) { 354 | element.style.zIndex=z; 355 | element.setAttribute("data-z",z); 356 | z-=1; 357 | } 358 | }, 359 | 360 | composite(suppressMask=false) { 361 | canvas.ctx.save(); 362 | canvas.ctx.clearRect(0,0,canvas.width,canvas.height) 363 | for (const layer of this.layers) { 364 | if (suppressMask && this.mask==layer) continue; 365 | if (!layer.visible) continue; 366 | canvas.ctx.globalAlpha = layer.opacity; 367 | canvas.ctx.globalCompositeOperation=layer.compositeOperation; 368 | if (this.isDrawing && layer===this.activeLayer) { 369 | canvas.ctx.drawImage(activeOperationCanvas,0,0); 370 | } else { 371 | layer.draw(canvas.ctx); 372 | } 373 | } 374 | canvas.ctx.restore(); 375 | }, 376 | 377 | commit() { 378 | let data = activeOperationCanvas.ctx.getAllImageData() 379 | const undoRecord = { 380 | type : "layer", 381 | layer: this.activeLayer, 382 | transform: [...this.activeLayer.transform], 383 | data: this.activeLayer.ctx.getAllImageData() 384 | } 385 | undoStack.push(undoRecord); 386 | if (undoStack.length > undoDepth) undoStack.shift(); 387 | redoStack.length=0; 388 | 389 | this.activeLayer.ctx.putImageData(data,0,0); 390 | this.activeLayer.transform= [1,0 ,0,1, 0,0]; 391 | 392 | this.isDrawing = false; 393 | //if (selectedExport===this) { 394 | if (this.activeLayer===this.mask) { 395 | transmitMask(this.mask.canvas,this.title,selectedExport===this); 396 | } else { 397 | this.composite(true); 398 | transmitCanvas(canvas,this.title,selectedExport===this); 399 | } 400 | //} 401 | if (activePic===this) { 402 | updateLayerList(); //inefficient to remake all controls on edit, fix this 403 | } 404 | this.updateVisualRepresentation(false); 405 | 406 | }, 407 | clearLayer() { 408 | activeOperationCanvas.ctx.clearRect(0,0,activeOperationCanvas.width,activeOperationCanvas.height); 409 | this.commit(); 410 | }, 411 | fillLayer(color = "#fff") { 412 | activeOperationCanvas.ctx.fillStyle=color; 413 | activeOperationCanvas.ctx.fillRect(0,0,activeOperationCanvas.width,activeOperationCanvas.height); 414 | this.commit(); 415 | }, 416 | updateVisualRepresentation(transmit=true) { 417 | if (transmit) { 418 | this.composite(true); //suppress mask for transmitted canvas 419 | transmitCanvas(canvas,this.title,this===selectedExport); 420 | } 421 | this.composite(false); 422 | if (typeof tool?.drawUI === "function") tool.drawUI(); 423 | }, 424 | 425 | startDraw(x,y) { 426 | if (!this.activeLayer.visible) return; //don't draw on hidden layers. 427 | this.strokeModifier=mirrorFunction; 428 | this.isDrawing=true; 429 | this.strokeCoordinates=[{x:x+0.001,y}]; 430 | tip.x=x; 431 | tip.y=y; 432 | this.draw(x,y); 433 | }, 434 | 435 | stopDraw(x,y) { 436 | const self=this; 437 | if (updatingStroke) { 438 | setTimeout(_=>self.stopDraw(x,y),1) 439 | } else { 440 | this.isDrawing=false; 441 | this.commit(); 442 | } 443 | }, 444 | 445 | draw(x,y) { 446 | this.strokeCoordinates.push({x,y}); 447 | //This is done in a timeout to allow multiple draw movements 448 | //to accumulate rather than slowing things down 449 | if (!updatingStroke) { 450 | updatingStroke=true; 451 | setTimeout(_=>{ 452 | const ctx = activeOperationCanvas.ctx; 453 | ctx.clearRect(0,0,activeOperationCanvas.width,activeOperationCanvas.height); 454 | ctx.save() 455 | ctx.setTransform(...this.activeLayer.transform); 456 | ctx.drawImage(this.activeLayer.canvas,0,0); 457 | ctx.restore(); 458 | const unitRange=this.strokeCoordinates.map(({x,y})=>({x:x/canvas.width,y:y/canvas.height})); 459 | 460 | ctx.save(); 461 | const opacity = $("#brush_opacity input[type=range]").val()/100; 462 | ctx.globalAlpha=opacity; 463 | const strokes = this.strokeModifier([unitRange]) 464 | for (const unitStroke of strokes) { 465 | const stroke=unitStroke.map(({x,y})=>({x:x*canvas.width,y:y*canvas.height})); 466 | tip.tool.drawOperation(activeOperationCanvas.ctx,tip,stroke) 467 | } 468 | ctx.restore(); 469 | if (this.activeLayer.mask) { 470 | const ctx=activeOperationCanvas.ctx; 471 | ctx.save(); 472 | ctx.globalCompositeOperation="source-atop"; 473 | ctx.fillStyle = this.activeLayer.maskColor; 474 | ctx.fillRect(0,0,activeOperationCanvas.width,activeOperationCanvas.height); 475 | ctx.restore(); 476 | } 477 | this.composite(); 478 | updatingStroke=false; 479 | },1) 480 | } 481 | }, 482 | updateLayerList(newList) { 483 | if (maskStaysOnTop && this.mask) { 484 | const maskIndex = newList.indexOf(this.mask); 485 | if (maskIndex !== newList.length - 1) { 486 | newList.splice(maskIndex, 1); 487 | newList.push(this.mask); 488 | } 489 | } 490 | const undoRecord = { 491 | type: 'layerList', 492 | previousList: [...layers] 493 | }; 494 | undoStack.push(undoRecord); 495 | if (undoStack.length > undoDepth) undoStack.shift(); 496 | layers.length = 0; // Clear the existing array 497 | layers.push(...newList); // Fill with new values 498 | if (activePic===this) { 499 | //do UI update if this is the active pic. 500 | updateLayerList() 501 | } 502 | }, 503 | 504 | addEmptyLayer(above = this.activeLayer,name="new layer") { 505 | const newLayer = new Layer(this,name, canvas); 506 | this.insertLayerAbove(newLayer, above); 507 | this.activeLayer = newLayer; 508 | return newLayer; 509 | }, 510 | addLayerFromImage(image,above = this.activeLayer,name="new layer") { 511 | const newLayer = new Layer(this,name, image); 512 | newLayer.ctx.drawImage(image,0,0); 513 | this.insertLayerAbove(newLayer, above); 514 | this.activeLayer = newLayer; 515 | this.updateVisualRepresentation(true); 516 | return newLayer; 517 | 518 | }, 519 | 520 | addDuplicateLayer(layer,above=layer) { 521 | if (!layer) { 522 | return this.addEmptyLayer(); 523 | } else { 524 | if (layers.indexOf(above) < 0) above=this.activeLayer; 525 | // Duplicate the provided layer 526 | const newLayer = new Layer(this, layer.title, {width: layer.canvas.width, height: layer.canvas.height}, layer.mask); 527 | newLayer.ctx.drawImage(layer.canvas, 0, 0); // Copy the content of the original layer 528 | newLayer.transform=[...layer.transform]; 529 | newLayer.visible = layer.visible; 530 | newLayer.compositeOperation = layer.compositeOperation; 531 | newLayer.opacity = layer.opacity; 532 | this.insertLayerAbove(newLayer, above); 533 | this.activeLayer = newLayer; 534 | return newLayer; 535 | } 536 | }, 537 | removeLayer(layer = this.activeLayer) { 538 | const newList = layers.filter(l => l !== layer); 539 | this.updateLayerList(newList); 540 | }, 541 | insertLayerBelow(layer, below = null) { 542 | const newList = [...layers]; 543 | const indexExisting = newList.indexOf(layer); 544 | if (indexExisting !== -1) { 545 | newList.splice(indexExisting, 1); 546 | } 547 | const indexBelow = newList.indexOf(below); 548 | if (indexBelow !== -1) { 549 | newList.splice(indexBelow, 0, layer); 550 | } else { 551 | newList.push(layer); 552 | } 553 | this.updateLayerList(newList); 554 | }, 555 | insertLayerAbove(layer, above = null) { 556 | const newList = [...this.layers]; 557 | const indexExisting = newList.indexOf(layer); 558 | if (indexExisting !== -1) { 559 | newList.splice(indexExisting, 1); 560 | } 561 | const indexAbove = newList.indexOf(above); 562 | if (indexAbove !== -1) { 563 | newList.splice(indexAbove + 1, 0, layer); 564 | } else { 565 | newList.unshift(layer); // Inserts at the top if 'above' layer is not found 566 | } 567 | this.updateLayerList(newList); 568 | }, 569 | addUndoRecord(record) { 570 | undoStack.push(record); 571 | if (undoStack.length > undoDepth) undoStack.shift(); 572 | redoStack.length=0; 573 | }, 574 | undo() { 575 | const lastRecord = undoStack.pop(); 576 | if (lastRecord) { 577 | if (lastRecord.type === 'layerList') { 578 | redoStack.push({type: 'layerList', previousList: [...layers]}); 579 | layers.length = 0; 580 | layers.push(...lastRecord.previousList); 581 | } else if (lastRecord.type === 'layer') { 582 | redoStack.push({ 583 | type: 'layer', 584 | layer: lastRecord.layer, 585 | compositeOperation: lastRecord.layer.compositeOperation, 586 | transform : [...lastRecord.layer.transform], 587 | data: lastRecord.layer.ctx.getAllImageData() 588 | }); 589 | lastRecord.layer.transform=[...lastRecord.transform]; 590 | lastRecord.layer.compositeOperation=lastRecord.compositeOperation; 591 | const ctx = lastRecord.layer.ctx; 592 | ctx.putImageData(lastRecord.data, 0, 0); 593 | } else if (lastRecord.type==="layerTransform") { 594 | redoStack.push(lastRecord.layer.undoTransformRecord()); 595 | lastRecord.layer.transform=[...lastRecord.transform]; 596 | } 597 | //... (handle other types of undoRecords) 598 | } 599 | this.updateVisualRepresentation(true); 600 | updateLayerList(); 601 | }, 602 | 603 | redo() { 604 | const lastRecord = redoStack.pop(); 605 | if (lastRecord) { 606 | if (lastRecord.type === 'layerList') { 607 | undoStack.push({type: 'layerList', previousList: [...layers]}); 608 | layers.length = 0; 609 | layers.push(...lastRecord.previousList); 610 | } else if (lastRecord.type === 'layer') { 611 | undoStack.push({ 612 | type: 'layer', 613 | layer: lastRecord.layer, 614 | compositeOperation: lastRecord.layer.compositeOperation, 615 | transform: [...lastRecord.layer.transform], 616 | data: lastRecord.layer.ctx.getAllImageData() 617 | }); 618 | lastRecord.layer.transform=[...lastRecord.transform]; 619 | lastRecord.layer.compositeOperation=lastRecord.compositeOperation; 620 | lastRecord.layer.ctx.putImageData(lastRecord.data, 0, 0); 621 | } else if (lastRecord.type==="layerTransform") { 622 | redoStack.push(lastRecord.layer.undoTransformRecord()); 623 | lastRecords.layer.transform=[...lastRecord.transform]; 624 | } 625 | //... (handle other types of redoRecords) 626 | } 627 | this.updateVisualRepresentation(true); 628 | updateLayerList(); 629 | }, 630 | } 631 | 632 | layers.push( new Layer(pic,"base", canvas)); 633 | 634 | mask = new Layer(pic,"mask", canvas,true); 635 | mask.opacity = 0.6; 636 | layers.push(mask); 637 | pic.activeLayer= layers[0]; 638 | 639 | layers[0].ctx.putImageData(image,0,0); 640 | activeOperationCanvas.ctx.putImageData(image,0,0); 641 | 642 | 643 | eventOverlay.pic = pic; 644 | sidebar.addEventListener("mousedown",_=> setExportPic(pic)) 645 | closeButton.addEventListener("mousedown",_=> closePic(pic)) 646 | titleBar.addEventListener("mousedown",handleTitleBarMouseDown,false); 647 | renameButton.addEventListener("mousedown",_=>{ 648 | 649 | const originalValue = title.textContent; 650 | const input = $(``)[0]; 651 | titleBar.classList.add("renaming"); 652 | input.value=originalValue; 653 | title.textContent=""; 654 | title.appendChild(input); 655 | input.focus(); 656 | input.addEventListener("keydown", e=>{ 657 | if (e.key === 'Enter') { 658 | title.textContent=(input.value !== "")?input.value:originalValue; 659 | titleBar.classList.remove("renaming"); 660 | e.preventDefault(); 661 | } else if (e.key === 'Escape') { 662 | title.textContent = originalValue; 663 | titleBar.classList.remove("renaming"); 664 | } 665 | },true); 666 | input.addEventListener("blur",_=>{ 667 | title.textContent=(input.value !== "")?input.value:originalValue; 668 | titleBar.classList.remove("renaming"); 669 | 670 | }) 671 | }); 672 | 673 | pic.setCSSTransform(); 674 | setActivePic(pic) 675 | return pic; 676 | 677 | function handleTitleBarMouseDown(e) { 678 | if (e.target.nodeName === "INPUT") return; 679 | 680 | setActivePic(pic); 681 | if (e.button === 1 || e.button === 0) { 682 | dragStartX = pic.offsetX; 683 | dragStartY = pic.offsetY; 684 | mouseDownX = e.clientX; 685 | mouseDownY = e.clientY; 686 | element.style.transition = 'none'; 687 | dragging = true; 688 | dragButtons=e.buttons; 689 | createCaptureOverlay(eventOverlay); 690 | e.preventDefault(); // Prevent text selection during drag 691 | } 692 | } 693 | } 694 | 695 | 696 | 697 | function initPaint(){ 698 | 699 | // Call this function at initialization 700 | window.uiCanvas = initUICanvas(); 701 | 702 | $("#background").val("#ffffff").data("eraser",true); 703 | var palette=$("#palette"); 704 | for (let i of initialPalette) { 705 | const e=`