├── 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 | ![basic usage ](https://raw.githubusercontent.com/Lerc/canvas_tab/main/Canvas_tab_basic.png) 70 | 71 | I have been using the controlnet inpaint with a workflow like this. 72 | 73 | ![inpaint workflow](https://raw.githubusercontent.com/Lerc/canvas_tab/main/Inpaint_with_canvas_tab.png) 74 | 75 | That workflow should be embedded in this image. 76 | 77 | ![Image with embedded Inpaint workflow](https://raw.githubusercontent.com/Lerc/canvas_tab/main/Inpaint_Onion.png) 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=`
`; 706 | palette.append($(e).data("colour",i)); 707 | } 708 | palette.append($(`
Erase
`).data("colour","#0000")) 709 | poulateLayerControl(); 710 | $(".background")[0].addEventListener("wheel",handleMouseWheel); 711 | 712 | $(".paletteentry").on("mousedown", function(e) { 713 | if (![feltTip,pixelTip].includes(tool)) { 714 | setTool(feltTip); 715 | } 716 | let eraser=false; 717 | let c= $(e.currentTarget).data("colour"); 718 | if (c==="#0000") { 719 | c="#ffffff"; 720 | eraser = true; 721 | } 722 | if (e.which == 1) { 723 | if (e.ctrlKey && !eraser) { 724 | c=$("#foreground").val(); 725 | $(e.currentTarget).data("colour",c).css("background-color",c) 726 | } else { 727 | $("#foreground").val(c).data("eraser",eraser); 728 | } 729 | } 730 | if (e.which == 2 && !eraser) { 731 | c=$("#foreground").val(); 732 | $(e.currentTarget).data("colour",c).css("background-color",c) 733 | } 734 | if (e.which == 3 ) { 735 | if (e.ctrlKey && !eraser) { 736 | c=$("#background").val(); 737 | $(e.currentTarget).data("colour",c).css("background-color",c) 738 | } else { 739 | $("#background").val(c).data("eraser",eraser); 740 | } 741 | } 742 | }).on("contextmenu", 743 | function(){return false;} 744 | ); 745 | 746 | $("#pen")[0].tool=feltTip; 747 | $("#pixels")[0].tool=pixelTip; 748 | $("#eraser")[0].tool=eraserTip; 749 | $("#fine_eraser")[0].tool=pixelClear; 750 | $("#eyedropper")[0].tool=eyeDropper; 751 | $("#transform")[0].tool=transformTool; 752 | 753 | $(".tool.button").on("click",function(e) {setTool(e.currentTarget.tool);}); 754 | 755 | $("#clear").on("click",function(e) {activePic?.clearLayer()}); 756 | $("#undo").on("click",function(e) {activePic?.undo()}); 757 | $("#redo").on("click",function(e) {activePic?.redo()}); 758 | 759 | brushSizeControl.addEventListener("changed", e=> { 760 | tip.size=brushSizeControl.diameter; 761 | 762 | for (let p of $(".pic")) { 763 | updateBrushCursor(p); 764 | } 765 | 766 | }); 767 | 768 | addEventListener("keydown", handleKeyDown); 769 | 770 | $(".panel").append(brushSizeControl).append(` 771 |
772 |
773 |
774 |
775 |
776 |
777 |
778 | 779 | 780 | 781 | 782 |
783 |
784 |
785 |
786 | 787 |
788 | `); 789 | 790 | $('.grid_mirrors input').on("change",_=>{updateMirrorGridButtonImage(); updateMirrors()}) 791 | $('.rotational_mirrors input').on("change",_=>{updateMirrorRotationButtonImage(); updateMirrors()}) 792 | 793 | $('.mirror.button').on("mousedown", e=>{ 794 | e.currentTarget.classList.toggle('down'); 795 | updateMirrors(); 796 | }) 797 | updateMirrorGridButtonImage(); 798 | updateMirrorRotationButtonImage(); 799 | 800 | $("#newImageBtn").on("click", function() { 801 | console.log("click #newImageBtn") 802 | $("#newImageModal").addClass("modal-active"); 803 | }); 804 | 805 | $(".close-button").on("click", function() { 806 | $(".modal-active").removeClass("modal-active"); 807 | }); 808 | 809 | $(".cancel-button").on("click", function() { 810 | $(".modal-active").removeClass("modal-active"); 811 | }); 812 | 813 | $(".create-button").on("click", function() { 814 | const width = $("#imageWidth").val(); 815 | const height = $("#imageHeight").val(); 816 | $(".modal-active").removeClass("modal-active"); 817 | let area=createDrawArea(blankCanvas(width, height)) 818 | $("#workspace").append(area.element); 819 | }); 820 | 821 | $(window).on("click", function(event) { 822 | if ($(event.target).hasClass("modal")) { 823 | $(".modal-active").removeClass("modal-active"); 824 | } 825 | }); 826 | 827 | $("#newImageForm").on("submit", function(event) { 828 | event.preventDefault(); 829 | }); 830 | 831 | $(window).on("keydown", function(event) { 832 | if (event.key === "Escape" && $(".modal-active").length) { 833 | $(".modal-active").removeClass("modal-active"); 834 | } 835 | }); 836 | 837 | 838 | $("input.percentage").on("change",processPercentageInput); 839 | $("input.percentage~input").on("input",processPercentageRange); 840 | window.test1=createDrawArea(undefined,"Image A"); 841 | window.test2=createDrawArea(undefined,"Image B"); 842 | test1.setPosition(30,60) ; 843 | test2.setPosition(640,60) ; 844 | test1.activeLayer=test1.addEmptyLayer(); 845 | targetLayer=test2.addEmptyLayer(); 846 | 847 | //setActivePic(test1); 848 | 849 | $("#workspace").append(test1.element); 850 | $("#workspace").append(test2.element); 851 | 852 | 853 | brushSizeControl.diameter=tip.size; 854 | 855 | setExportPic(test1); 856 | setTool(feltTip) 857 | } 858 | 859 | function addNewImage(image) { 860 | console.log( `addNewImage(${image.width},${image.height})`) 861 | const target=blankCanvas(image.width,image.height) 862 | var ctx = target.getContext('2d'); 863 | 864 | ctx.drawImage(image, 0, 0); 865 | 866 | let result =createDrawArea(target); 867 | $("#workspace").append(result.element); 868 | return(result) 869 | } 870 | 871 | function addNewLayer(image) { 872 | if (!selectedExport) { 873 | setExportPic(addNewImage(image)); 874 | return; 875 | } 876 | let pic = selectedExport; 877 | if (pic.canvas.width === image.width && pic.canvas.height==image.height) { 878 | const layer = new Layer(pic,"generation",pic.canvas); 879 | layer.ctx.drawImage(image,0,0); 880 | console.log("new layer", layer) 881 | console.log(layer.canvas.width, layer.canvas.height) 882 | pic.insertLayerBelow(layer) 883 | pic.activeLayer=layer; 884 | updateLayerList(); 885 | pic.updateVisualRepresentation(true); 886 | } 887 | else { 888 | addNewImage(image); 889 | } 890 | } 891 | 892 | function handleKeyDown(e) { 893 | if (document.activeElement.tagName === 'INPUT') { 894 | return; //suppress hotkeys when an input element has focus. 895 | } 896 | if (!e.key) return; 897 | if (e.key === "Dead" ) return; 898 | if (e.key === "Unidentified" ) return; 899 | 900 | 901 | //checking for hotkeys 902 | let keyID = ""; 903 | if (e.ctrlKey) keyID+="CTRL_" 904 | if (e.altKey) keyID+="ALT_" 905 | if (e.shiftKey) keyID+="SHIFT_" 906 | let key = e.key.toUpperCase(); 907 | if (key === " ") key = 'SPACE'; 908 | 909 | const hotkeyCode= keyID+key; 910 | //console.log({hotkeyCode}) 911 | if (hotkeys.hasOwnProperty(hotkeyCode)) { 912 | hotkeys[hotkeyCode](); 913 | } 914 | } 915 | 916 | 917 | function handleMouseDown(e) { 918 | let pic = e.currentTarget.pic; 919 | setActivePic(pic); 920 | 921 | const maskLayer = activePic.activeLayer.mask 922 | if (e.altKey) { 923 | switch (e.button) { 924 | case 0: 925 | 926 | break; 927 | 928 | } 929 | return; 930 | } 931 | 932 | switch (e.button) { 933 | case 0: 934 | tip.tool = $("#foreground").data("eraser")?eraserTip:tool; 935 | tip.colour = maskLayer?"#000":$("#foreground").val(); 936 | pic.startDraw(e.offsetX,e.offsetY); 937 | createCaptureOverlay(e.currentTarget) 938 | break; 939 | case 1: 940 | e.preventDefault(); 941 | dragStartX=pic.offsetX; 942 | dragStartY=pic.offsetY; 943 | mouseDownX=e.clientX; 944 | mouseDownY=e.clientY; 945 | $(e.currentTarget.pic.element).css("transition" , "none"); 946 | dragging = true; 947 | dragButtons = e.buttons; 948 | createCaptureOverlay(e.currentTarget) 949 | 950 | break; 951 | case 2: 952 | tip.colour = $("#background").val(); 953 | tip.tool = (maskLayer || $("#background").data("eraser"))?eraserTip:tool; 954 | pic.startDraw(e.offsetX,e.offsetY); 955 | createCaptureOverlay(e.currentTarget) 956 | 957 | e.preventDefault(); 958 | break; 959 | } 960 | } 961 | function handleMouseLeave(e) { 962 | const bounds=e.currentTarget.getBoundingClientRect(); 963 | let pic = e.currentTarget.pic; 964 | if (dragging) { 965 | 966 | console.log("mouseleave on pic",e); 967 | let cx = e.pageX-bounds.left; 968 | let cy = e.pageY-bounds.top; 969 | console.log({cx,cy}) 970 | 971 | if (e.buttons!==dragButtons) { 972 | stopDragging(); 973 | return; 974 | } 975 | var dx = e.clientX-mouseDownX; 976 | var dy = e.clientY-mouseDownY; 977 | console.log({dx,dy}) 978 | pic.offsetX=dragStartX+dx; 979 | pic.offsetY=dragStartY+dy; 980 | pic.setCSSTransform(); 981 | } 982 | 983 | } 984 | 985 | function handleMouseUp(e) { 986 | let pic = e.currentTarget.pic; 987 | activePic = pic; 988 | 989 | if (dragging) { 990 | if (e.buttons!==dragButtons) { 991 | stopDragging(); 992 | removeCaptureOverlay(); 993 | return; 994 | } 995 | } 996 | 997 | if (pic.isDrawing && e.buttons === 0) { 998 | removeCaptureOverlay(); 999 | pic.stopDraw(e.offsetX,e.offsetY) 1000 | } 1001 | 1002 | } 1003 | function handleMouseMove(e) { 1004 | let pic = e.currentTarget.pic; 1005 | if (pic!==activePic) return; 1006 | if (dragging) { 1007 | if (e.buttons!==dragButtons) { 1008 | stopDragging(); 1009 | removeCaptureOverlay(); 1010 | return; 1011 | } 1012 | var dx = e.clientX-mouseDownX; 1013 | var dy = e.clientY-mouseDownY; 1014 | pic.offsetX=dragStartX+dx; 1015 | pic.offsetY=dragStartY+dy; 1016 | pic.setCSSTransform(); 1017 | } 1018 | 1019 | if (pic.isDrawing && e.buttons === 0) { 1020 | removeCaptureOverlay(); 1021 | pic.stopDraw(e.offsetX,e.offsetY) 1022 | } 1023 | if (pic.isDrawing) pic.draw(e.offsetX,e.offsetY); 1024 | } 1025 | 1026 | 1027 | 1028 | 1029 | function handleMouseWheel(e) { 1030 | let direction = -Math.sign(e.deltaY); 1031 | const scaleAround= {x:e.pageX,y:e.pageY}; 1032 | 1033 | setScale(activePic.scalefactor+direction,scaleAround); 1034 | activePic.setCSSTransform(); 1035 | } 1036 | 1037 | function scalefactorToSize(n) { 1038 | return (Math.pow(2,(n/2))); 1039 | } 1040 | 1041 | function setScale(newfactor, around) { 1042 | const pic = activePic 1043 | const bounds = pic.element.getBoundingClientRect(); 1044 | const parentBounds = pic.element.offsetParent.getBoundingClientRect(); 1045 | const center = {x : bounds.x+bounds.width/2, y:bounds.y+bounds.width/2}; 1046 | if (!insideBounds(around,bounds)) around = center; 1047 | const px = (around.x - bounds.left) /bounds.width; 1048 | const py = (around.y - bounds.top) / bounds.height; 1049 | 1050 | const newScale = scalefactorToSize(newfactor); 1051 | const expectedWidth = pic.element.clientWidth*newScale 1052 | const expectedHeight = pic.element.clientHeight*newScale 1053 | 1054 | 1055 | 1056 | const candidateX = around.x - expectedWidth *px - parentBounds.left; 1057 | const candidateY = around.y - expectedHeight *py - parentBounds.top; 1058 | 1059 | pic.offsetX = candidateX 1060 | pic.offsetY = candidateY 1061 | 1062 | pic.scale=newScale; 1063 | pic.scalefactor=newfactor; 1064 | pic.setCSSTransform(); 1065 | updateBrushCursor(pic.element); 1066 | } 1067 | 1068 | function setTool(newValue) { 1069 | if (newValue !== tool) 1070 | { 1071 | tool=newValue; 1072 | const ctx=uiCanvas.getContext("2d"); 1073 | ctx.clearRect(0,0,1e5,1e5); 1074 | if (typeof tool?.init === "function") tool.init(); 1075 | } 1076 | 1077 | 1078 | for (const pic of picStack) { 1079 | updateBrushCursor(pic.element) 1080 | } 1081 | 1082 | $(".tool.button").removeClass("down"); 1083 | 1084 | for (let e of $(".tool.button")) { 1085 | e.classList.toggle("down",(e.tool === tool)) 1086 | }; 1087 | brushSizeControl.diameter=tip.size; 1088 | uiCanvas.style.pointerEvents=tool?.eventHandlers?"":"none"; 1089 | 1090 | } 1091 | 1092 | 1093 | 1094 | function stopDragging() { 1095 | dragging=false; 1096 | $(activePic.element).css("transition" , ""); 1097 | } 1098 | 1099 | function brushDiameterControl(id="diameter") { 1100 | const element = document.createElement("canvas"); 1101 | element.className="diameter_control" 1102 | element.id=id; 1103 | const ctx = element.getContext("2d"); 1104 | const max_diameter = 48; 1105 | element.width=192; 1106 | element.height=max_diameter; 1107 | //radius divider removes one radius for the half circle at the end of the control 1108 | const radiusDivider = (element.width/(max_diameter/2))-1; 1109 | 1110 | let diameter = 15; 1111 | function redraw() { 1112 | let radius = diameter/2; 1113 | let max_radius = max_diameter/2; 1114 | ctx.clearRect(0,0,element.width,element.height); 1115 | ctx.beginPath(); 1116 | ctx.arc(element.width-max_radius,max_radius,max_radius,-Math.PI/2,Math.PI/2); 1117 | ctx.lineTo(0,max_radius); 1118 | ctx.fillStyle="#8888"; 1119 | ctx.fill(); 1120 | ctx.beginPath(); 1121 | ctx.arc(radius*radiusDivider,max_radius,radius,0,Math.PI*2); 1122 | ctx.fillStyle="#000"; 1123 | ctx.fill(); 1124 | ctx.fillText(diameter,8,10); 1125 | } 1126 | 1127 | Object.defineProperty(element, 'radius', { 1128 | get() { return this.diameter/2; }, 1129 | set(value) {this.diameter=value*2} 1130 | }); 1131 | 1132 | Object.defineProperty(element, 'diameter', { 1133 | get() { return diameter; }, 1134 | set(value) { 1135 | value=Math.round(value); 1136 | if (value<1) value=1; 1137 | if (value>max_diameter) value=max_diameter; 1138 | if (value !== diameter) { 1139 | diameter=value; 1140 | element.dispatchEvent(new Event("changed")); 1141 | } 1142 | redraw(); 1143 | } 1144 | }); 1145 | 1146 | let dragging = false; 1147 | function handleMouseDown(e) { 1148 | if (e.button !==0 ) return 1149 | let {x,y} = containerToCanvas(element,e.clientX,e.clientY) 1150 | e.stopPropagation(); 1151 | element.radius=x/radiusDivider; 1152 | dragging=(y>0) && (y 1185 |
1186 | 1205 |
1206 |
1207 | 1208 |
1209 |
1210 | 1211 | 1212 |
1213 | 1214 |
1215 | 1216 |
1217 |
1218 |
1219 |
1220 |
1221 |
1222 | `) 1223 | 1224 | $(layer_control).append(content); 1225 | 1226 | $('select.composite-mode').on("change", e=>{ 1227 | const mode = e.currentTarget.value; 1228 | activePic.activeLayer.compositeOperation=mode; 1229 | activePic.updateVisualRepresentation(true); 1230 | }) 1231 | $("input.maskColor").on("change", e=>{ 1232 | lastUsedMaskColor = e.currentTarget.value; 1233 | activePic.activeLayer.maskColor=lastUsedMaskColor; 1234 | activePic.updateVisualRepresentation(false); 1235 | updateLayerList(); 1236 | }); 1237 | 1238 | $("input.opacity").on("input", e=> { 1239 | activePic.activeLayer.opacity = e.currentTarget.value/100; 1240 | activePic.updateVisualRepresentation(true); 1241 | //shouldn't need this 1242 | updateLayerList(); 1243 | }); 1244 | 1245 | $(".add_layer").on("click",_=>{ 1246 | activePic.activeLayer = activePic.addEmptyLayer(); 1247 | updateLayerList(); 1248 | }); 1249 | 1250 | $(".duplicate_layer").on("click",e=>{ 1251 | const layer = e.ctrlKey?targetLayer:activePic.activeLayer; 1252 | activePic.activeLayer = activePic.addDuplicateLayer(layer); 1253 | activePic.updateVisualRepresentation(); 1254 | updateLayerList(); 1255 | }); 1256 | 1257 | $(".remove_layer").on("click",_=>{ 1258 | activePic?.removeLayer(activePic.activeLayer) 1259 | activePic?.updateVisualRepresentation(true) 1260 | updateLayerList(); 1261 | }); 1262 | 1263 | let dropzone =$(".layer_list.layer_dropzone"); 1264 | dropzone.on("dragover",e=>{ 1265 | dropzone.children().removeClass("insert_after"); 1266 | dropzone.removeClass("insert_top") 1267 | const insertPoint=findInsertPoint(dropzone[0],e); 1268 | if (insertPoint !== draggingElement) { 1269 | if (insertPoint) { 1270 | insertPoint.classList.add("insert_after"); 1271 | } else { 1272 | dropzone.addClass("insert_top") 1273 | } 1274 | e.preventDefault(); 1275 | } 1276 | }); 1277 | dropzone.on("dragleave", e=>{ 1278 | dropzone.removeClass("insert_top") 1279 | dropzone.children().removeClass("insert_after"); 1280 | }); 1281 | dropzone.on("drop", e=>{ 1282 | dropzone.removeClass("insert_top") 1283 | const insertPoint=findInsertPoint(dropzone[0],e); 1284 | if (insertPoint !== draggingElement) { 1285 | if (draggingElement) { 1286 | const layer = draggingElement.layer; 1287 | const below = insertPoint?.layer; 1288 | activePic.insertLayerBelow(layer,below); 1289 | updateLayerList(); 1290 | activePic.updateVisualRepresentation(true); 1291 | } else { 1292 | //handle file drop maybe? 1293 | } 1294 | 1295 | } 1296 | }) 1297 | } 1298 | 1299 | function findInsertPoint(dropzone, event) { 1300 | const dropzoneRect = dropzone.getBoundingClientRect(); 1301 | const children = Array.from(dropzone.children); 1302 | 1303 | // Normalize the event clientY coordinate to the dropzone's coordinate space 1304 | const eventClientY = event.clientY - dropzoneRect.top; 1305 | 1306 | for (let i = 0; i < children.length; i++) { 1307 | const child = children[i]; 1308 | const childRect = child.getBoundingClientRect(); 1309 | const childTop = childRect.top - dropzoneRect.top; 1310 | const childBottom = childTop + childRect.height; 1311 | 1312 | if (eventClientY >= childTop && eventClientY <= childBottom) { 1313 | if (child === draggingElement) { 1314 | return draggingElement; 1315 | } 1316 | if (eventClientY < childTop + childRect.height / 2) { 1317 | // Event is in the top half of the child element 1318 | return i === 0 ? null : children[i - 1]; 1319 | } else { 1320 | // Event is in the bottom half of the child element 1321 | return child; 1322 | } 1323 | } 1324 | } 1325 | 1326 | return children.length > 0 ? children[children.length - 1] : null; 1327 | } 1328 | 1329 | function updateLayerTitle(newTitle, layer) { 1330 | layer.title = newTitle; 1331 | updateLayerList(); 1332 | activePic.updateVisualRepresentation(true); 1333 | } 1334 | 1335 | function updateLayerList() { 1336 | function makeLayerWidget(layer) { 1337 | const pic=activePic; 1338 | const result = $(`
1339 |
1340 | 1341 |
${layer.title}
1342 | ${layer.mask?`
`:''} 1343 |
1344 | `)[0]; 1345 | 1346 | window.dummyGlobal=result; 1347 | const canvas = result.querySelector(".thumbnail"); 1348 | const ctx=canvas.getContext("2d"); 1349 | const scaleFactor = canvas.width/pic.width; 1350 | ctx.save() 1351 | ctx.scale(scaleFactor,scaleFactor) 1352 | ctx.transform(...layer.transform); 1353 | ctx.drawImage(layer.canvas,0,0); 1354 | ctx.restore(); 1355 | result.layer=layer; 1356 | 1357 | // Renaming handler 1358 | const layerNameDiv = result.querySelector('.layer_name'); 1359 | layerNameDiv.ondblclick = function() { 1360 | const input = document.createElement('input'); 1361 | input.type = 'text'; 1362 | input.value = layer.title; 1363 | input.className = 'layer_name_input'; 1364 | input.onblur = () => {layerNameDiv.removeChild(input);} 1365 | input.onkeydown = function(e) { 1366 | if (e.key === 'Backspace') { 1367 | e.stopPropagation(); 1368 | } 1369 | if (e.key === 'Enter') { 1370 | updateLayerTitle(input.value, layer); 1371 | e.preventDefault(); 1372 | } else if (e.key === 'Escape') { 1373 | layerNameDiv.textContent = layer.title; 1374 | } 1375 | }; 1376 | layerNameDiv.textContent = ''; 1377 | layerNameDiv.appendChild(input); 1378 | input.focus(); 1379 | }; 1380 | 1381 | 1382 | 1383 | result.querySelector(".visibilitybox").onmousedown = e => { 1384 | e.stopPropagation(); 1385 | layer.visible=!layer.visible; 1386 | pic.updateVisualRepresentation(true); 1387 | updateLayerList(); 1388 | } 1389 | result.addEventListener("contextmenu", e=>e.preventDefault()) 1390 | 1391 | result.addEventListener('mousedown', (e) => { 1392 | // Check for non-mask layer, middle mouse button click or left click with Ctrl key 1393 | if (!layer.mask && (e.button === 2) ) { 1394 | targetLayer = (targetLayer === layer) ? null : layer; 1395 | e.preventDefault(); 1396 | updateLayerList(); 1397 | } 1398 | }); 1399 | 1400 | if (layer.mask) { 1401 | result.querySelector(".checkbox").onmousedown = e => { 1402 | e.stopPropagation(); 1403 | pic.mask= (pic.mask===layer) ? null : layer; 1404 | updateLayerList(); 1405 | } 1406 | } 1407 | result.onmousedown = e=> { 1408 | if (e.ctrlKey && e.button==0 && pic.activeLayer?.visible) { 1409 | pic.removeLayer(layer); 1410 | pic.updateVisualRepresentation(true) 1411 | updateLayerList(); 1412 | } else 1413 | if (e.button==0) { 1414 | if (pic.activeLayer !== layer) { 1415 | pic.activeLayer=layer; 1416 | updateLayerList() 1417 | } 1418 | } 1419 | } 1420 | result.ondrag = e=>{ 1421 | draggingElement=e.currentTarget; 1422 | } 1423 | result.ondragend = e=> { 1424 | if (draggingElement === e.currentTarget ) draggingElement=null; 1425 | } 1426 | 1427 | return result; 1428 | }; 1429 | 1430 | 1431 | const layer_control=document.querySelector("#layer_control") 1432 | const layer_list =layer_control.querySelector(".layer_list") 1433 | while(layer_list.firstChild) layer_list.removeChild(layer_list.lastChild) 1434 | 1435 | if (!activePic) return; 1436 | 1437 | const newControls = activePic.layers.map(makeLayerWidget); 1438 | 1439 | newControls.reverse().forEach(element=>layer_list.appendChild(element)) 1440 | 1441 | $(".layer-attributes").toggleClass("mask", activePic.activeLayer.mask) 1442 | 1443 | lastUsedMaskColor=activePic.activeLayer.maskColor; 1444 | $("input.maskColor").val(lastUsedMaskColor) 1445 | $("input.opacity").val((activePic.activeLayer.opacity*100)|0) 1446 | $('select.composite-mode').val(activePic.activeLayer.compositeOperation); 1447 | 1448 | 1449 | } 1450 | 1451 | 1452 | 1453 | function updateBrushCursor(picElement) { 1454 | const p=picElement; 1455 | if (!p) return; 1456 | let scale = parseFloat(p.style.getPropertyValue("--scalefactor")); 1457 | let value ="crosshair"; 1458 | if (tool.cursorFunction) value = tool.cursorFunction(scale); 1459 | p.style.setProperty("cursor",value); 1460 | //set matching cursor for user interface overlay; 1461 | uiCanvas.style.setProperty("cursor",value); 1462 | } 1463 | 1464 | 1465 | 1466 | function createCaptureOverlay(element) { 1467 | var overlay = document.createElement('div'); 1468 | overlay.id = 'global_overlay'; 1469 | overlay.className = 'fillparent'; 1470 | 1471 | document.body.appendChild(overlay); 1472 | 1473 | var forwardMouseEvent = function(e) { 1474 | if (e.buttons==0) removeCaptureOverlay(); 1475 | forwardEvent(e, element); 1476 | }; 1477 | 1478 | ['mousedown', 'mouseup', 'mousemove', 'click', 'dblclick'].forEach(function(eventName) { 1479 | overlay.addEventListener(eventName, forwardMouseEvent); 1480 | }); 1481 | overlay.addEventListener("contextmenu",function(e){e.preventDefault(); return false;}); 1482 | } 1483 | 1484 | function removeCaptureOverlay() { 1485 | $('#global_overlay').remove(); 1486 | } 1487 | 1488 | 1489 | function changeFrameOfReference(fromElement, x,y, toElement) { 1490 | const fromBounds=fromElement.getBoundingClientRect(); 1491 | const toBounds = toElement.getBoundingClientRect(); 1492 | x = x + fromBounds.x - toBounds.x; 1493 | y = y + fromBounds.y - toBounds.y; 1494 | return {x,y} 1495 | } 1496 | 1497 | function fillOrClear(from) { 1498 | if (from.data("eraser")){ 1499 | activePic?.clearLayer() 1500 | } else { 1501 | activePic?.fillLayer(from.val()); 1502 | } 1503 | } 1504 | 1505 | document.addEventListener('paste', async (event) => { 1506 | const items = event.clipboardData.items; 1507 | 1508 | for (const item of items) { 1509 | if (item.type.startsWith('image')) { 1510 | const imageFile = item.getAsFile(); 1511 | const image = new Image(); 1512 | image.src = URL.createObjectURL(imageFile); 1513 | 1514 | image.onload = () => { 1515 | if (activePic) { 1516 | activePic.addLayerFromImage(image,activePic.activeLayer,imageFile.name); 1517 | } 1518 | }; 1519 | } 1520 | } 1521 | }); 1522 | 1523 | function processPercentageInput(e) { 1524 | let element = e.currentTarget; 1525 | if (!element.hasOwnProperty("lastGoodValue")) element.lastGoodValue=100; 1526 | let range = element.parentElement.querySelector("input[type=range]"); 1527 | let value = parseFloat(element.value); 1528 | if (Number.isNaN(value)) { 1529 | value=element.lastGoodValue; 1530 | } 1531 | value=Math.floor(value); 1532 | if (range) { 1533 | range.value=value; 1534 | value=range.value; 1535 | } 1536 | element.value=`${value}%`; 1537 | } 1538 | 1539 | function processPercentageRange(e) { 1540 | const range = e.currentTarget; 1541 | console.log(range.value); 1542 | const input = range.parentNode.querySelector(".percentage") 1543 | input.value = ""+range.value+"%"; 1544 | } 1545 | 1546 | hotkeys["CTRL_Z"] = _=>{activePic?.undo()} 1547 | hotkeys["CTRL_SHIFT_Z"] = _=>{activePic?.redo()} 1548 | hotkeys["BACKSPACE"] = _=>{activePic?.clearLayer()} 1549 | 1550 | hotkeys["ALT_BACKSPACE"] = _=>{fillOrClear($("#foreground"))} 1551 | hotkeys["CTRL_BACKSPACE"] = _=>{fillOrClear($("#background"))} 1552 | 1553 | hotkeys["P"] = _=>{setTool(eyeDropper);} 1554 | hotkeys["B"] = _=>{setTool(feltTip);} 1555 | hotkeys["Z"] = _=>{setTool(pixelTip);} 1556 | hotkeys["E"] = _=>{setTool(eraserTip);} 1557 | hotkeys["T"] = _=>{setTool(transformTool);} 1558 | 1559 | hotkeys["]"] = _=>{brushSizeControl.diameter+=1} 1560 | hotkeys["["] = _=>{brushSizeControl.diameter-=1;} 1561 | 1562 | 1563 | hotkeys["Q"] = _=>{ 1564 | 1565 | drawPicFramesOnUICanvas(uiCanvas, picStack); 1566 | } 1567 | -------------------------------------------------------------------------------- /web/page/cash.min.js: -------------------------------------------------------------------------------- 1 | (function(){"use strict";var C=document,D=window,st=C.documentElement,L=C.createElement.bind(C),ft=L("div"),q=L("table"),Mt=L("tbody"),ot=L("tr"),H=Array.isArray,S=Array.prototype,Dt=S.concat,U=S.filter,at=S.indexOf,ct=S.map,Bt=S.push,ht=S.slice,z=S.some,_t=S.splice,Pt=/^#(?:[\w-]|\\.|[^\x00-\xa0])*$/,Ht=/^\.(?:[\w-]|\\.|[^\x00-\xa0])*$/,$t=/<.+>/,jt=/^\w+$/;function J(t,n){var r=It(n);return!t||!r&&!A(n)&&!c(n)?[]:!r&&Ht.test(t)?n.getElementsByClassName(t.slice(1).replace(/\\/g,"")):!r&&jt.test(t)?n.getElementsByTagName(t):n.querySelectorAll(t)}var dt=function(){function t(n,r){if(n){if(Y(n))return n;var i=n;if(g(n)){var e=r||C;if(i=Pt.test(n)&&A(e)?e.getElementById(n.slice(1).replace(/\\/g,"")):$t.test(n)?yt(n):Y(e)?e.find(n):g(e)?o(e).find(n):J(n,e),!i)return}else if(O(n))return this.ready(n);(i.nodeType||i===D)&&(i=[i]),this.length=i.length;for(var s=0,f=this.length;s]*>/,Gt=/^<(\w+)\s*\/?>(?:<\/\1>)?$/,mt={"*":ft,tr:Mt,td:ot,th:ot,thead:q,tbody:q,tfoot:q};function yt(t){if(!g(t))return[];if(Gt.test(t))return[L(RegExp.$1)];var n=Yt.test(t)&&RegExp.$1,r=mt[n]||mt["*"];return r.innerHTML=t,o(r.childNodes).detach().get()}o.parseHTML=yt,u.has=function(t){var n=g(t)?function(r,i){return J(t,i).length}:function(r,i){return i.contains(t)};return this.filter(n)},u.not=function(t){var n=I(t);return this.filter(function(r,i){return(!g(t)||c(i))&&!n.call(i,r,i)})};function R(t,n,r,i){for(var e=[],s=O(n),f=i&&I(i),a=0,y=t.length;a=0},!0):r.checked=e.indexOf(r.value)>=0}else r.value=v(t)||P(t)?"":t}):this[0]&&bt(this[0])}u.val=Xt,u.is=function(t){var n=I(t);return z.call(this,function(r,i){return n.call(r,i,r)})},o.guid=1;function w(t){return t.length>1?U.call(t,function(n,r,i){return at.call(i,n)===r}):t}o.unique=w,u.add=function(t,n){return o(w(this.get().concat(o(t,n).get())))},u.children=function(t){return x(o(w(R(this,function(n){return n.children}))),t)},u.parent=function(t){return x(o(w(R(this,"parentNode"))),t)},u.index=function(t){var n=t?o(t)[0]:this[0],r=t?this:o(n).parent().children();return at.call(r,n)},u.closest=function(t){var n=this.filter(t);if(n.length)return n;var r=this.parent();return r.length?r.closest(t):n},u.siblings=function(t){return x(o(w(R(this,function(n){return o(n).parent().children().not(n)}))),t)},u.find=function(t){return o(w(R(this,function(n){return J(t,n)})))};var Kt=/^\s*\s*$/g,Qt=/^$|^module$|\/(java|ecma)script/i,Vt=["type","src","nonce","noModule"];function Zt(t,n){var r=o(t);r.filter("script").add(r.find("script")).each(function(i,e){if(Qt.test(e.type)&&st.contains(e)){var s=L("script");s.text=e.textContent.replace(Kt,""),d(Vt,function(f,a){e[a]&&(s[a]=e[a])}),n.head.insertBefore(s,null),n.head.removeChild(s)}})}function kt(t,n,r,i,e){i?t.insertBefore(n,r?t.firstChild:null):t.nodeName==="HTML"?t.parentNode.replaceChild(n,t):t.parentNode.insertBefore(n,r?t:t.nextSibling),e&&Zt(n,t.ownerDocument)}function N(t,n,r,i,e,s,f,a){return d(t,function(y,h){d(o(h),function(p,M){d(o(n),function(b,W){var rt=r?M:W,it=r?W:M,m=r?p:b;kt(rt,m?it.cloneNode(!0):it,i,e,!m)},a)},f)},s),n}u.after=function(){return N(arguments,this,!1,!1,!1,!0,!0)},u.append=function(){return N(arguments,this,!1,!1,!0)};function tn(t){if(!arguments.length)return this[0]&&this[0].innerHTML;if(v(t))return this;var n=/]/.test(t);return this.each(function(r,i){c(i)&&(n?o(i).empty().append(t):i.innerHTML=t)})}u.html=tn,u.appendTo=function(t){return N(arguments,this,!0,!1,!0)},u.wrapInner=function(t){return this.each(function(n,r){var i=o(r),e=i.contents();e.length?e.wrapAll(t):i.append(t)})},u.before=function(){return N(arguments,this,!1,!0)},u.wrapAll=function(t){for(var n=o(t),r=n[0];r.children.length;)r=r.firstElementChild;return this.first().before(n),this.appendTo(r)},u.wrap=function(t){return this.each(function(n,r){var i=o(t)[0];o(r).wrapAll(n?i.cloneNode(!0):i)})},u.insertAfter=function(t){return N(arguments,this,!0,!1,!1,!1,!1,!0)},u.insertBefore=function(t){return N(arguments,this,!0,!0)},u.prepend=function(){return N(arguments,this,!1,!0,!0,!0,!0)},u.prependTo=function(t){return N(arguments,this,!0,!0,!0,!1,!1,!0)},u.contents=function(){return o(w(R(this,function(t){return t.tagName==="IFRAME"?[t.contentDocument]:t.tagName==="TEMPLATE"?t.content.childNodes:t.childNodes})))},u.next=function(t,n,r){return x(o(w(R(this,"nextElementSibling",n,r))),t)},u.nextAll=function(t){return this.next(t,!0)},u.nextUntil=function(t,n){return this.next(n,!0,t)},u.parents=function(t,n){return x(o(w(R(this,"parentElement",!0,n))),t)},u.parentsUntil=function(t,n){return this.parents(n,t)},u.prev=function(t,n,r){return x(o(w(R(this,"previousElementSibling",n,r))),t)},u.prevAll=function(t){return this.prev(t,!0)},u.prevUntil=function(t,n){return this.prev(n,!0,t)},u.map=function(t){return o(Dt.apply([],ct.call(this,function(n,r){return t.call(n,r,n)})))},u.clone=function(){return this.map(function(t,n){return n.cloneNode(!0)})},u.offsetParent=function(){return this.map(function(t,n){for(var r=n.offsetParent;r&&T(r,"position")==="static";)r=r.offsetParent;return r||st})},u.slice=function(t,n){return o(ht.call(this,t,n))};var nn=/-([a-z])/g;function K(t){return t.replace(nn,function(n,r){return r.toUpperCase()})}u.ready=function(t){var n=function(){return setTimeout(t,0,o)};return C.readyState!=="loading"?n():C.addEventListener("DOMContentLoaded",n),this},u.unwrap=function(){return this.parent().each(function(t,n){if(n.tagName!=="BODY"){var r=o(n);r.replaceWith(r.children())}}),this},u.offset=function(){var t=this[0];if(t){var n=t.getBoundingClientRect();return{top:n.top+D.pageYOffset,left:n.left+D.pageXOffset}}},u.position=function(){var t=this[0];if(t){var n=T(t,"position")==="fixed",r=n?t.getBoundingClientRect():this.offset();if(!n){for(var i=t.ownerDocument,e=t.offsetParent||i.documentElement;(e===i.body||e===i.documentElement)&&T(e,"position")==="static";)e=e.parentNode;if(e!==t&&c(e)){var s=o(e).offset();r.top-=s.top+E(e,"borderTopWidth"),r.left-=s.left+E(e,"borderLeftWidth")}}return{top:r.top-E(t,"marginTop"),left:r.left-E(t,"marginLeft")}}};var Et={class:"className",contenteditable:"contentEditable",for:"htmlFor",readonly:"readOnly",maxlength:"maxLength",tabindex:"tabIndex",colspan:"colSpan",rowspan:"rowSpan",usemap:"useMap"};u.prop=function(t,n){if(t){if(g(t))return t=Et[t]||t,arguments.length<2?this[0]&&this[0][t]:this.each(function(i,e){e[t]=n});for(var r in t)this.prop(r,t[r]);return this}},u.removeProp=function(t){return this.each(function(n,r){delete r[Et[t]||t]})};var rn=/^--/;function Q(t){return rn.test(t)}var V={},en=ft.style,un=["webkit","moz","ms"];function sn(t,n){if(n===void 0&&(n=Q(t)),n)return t;if(!V[t]){var r=K(t),i="".concat(r[0].toUpperCase()).concat(r.slice(1)),e="".concat(r," ").concat(un.join("".concat(i," "))).concat(i).split(" ");d(e,function(s,f){if(f in en)return V[t]=f,!1})}return V[t]}var fn={animationIterationCount:!0,columnCount:!0,flexGrow:!0,flexShrink:!0,fontWeight:!0,gridArea:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnStart:!0,gridRow:!0,gridRowEnd:!0,gridRowStart:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,widows:!0,zIndex:!0};function wt(t,n,r){return r===void 0&&(r=Q(t)),!r&&!fn[t]&<(n)?"".concat(n,"px"):n}function on(t,n){if(g(t)){var r=Q(t);return t=sn(t,r),arguments.length<2?this[0]&&T(this[0],t,r):t?(n=wt(t,n,r),this.each(function(e,s){c(s)&&(r?s.style.setProperty(t,n):s.style[t]=n)})):this}for(var i in t)this.css(i,t[i]);return this}u.css=on;function Ct(t,n){try{return t(n)}catch{return n}}var an=/^\s+|\s+$/;function St(t,n){var r=t.dataset[n]||t.dataset[K(n)];return an.test(r)?r:Ct(JSON.parse,r)}function cn(t,n,r){r=Ct(JSON.stringify,r),t.dataset[K(n)]=r}function hn(t,n){if(!t){if(!this[0])return;var r={};for(var i in this[0].dataset)r[i]=St(this[0],i);return r}if(g(t))return arguments.length<2?this[0]&&St(this[0],t):v(n)?this:this.each(function(e,s){cn(s,t,n)});for(var i in t)this.data(i,t[i]);return this}u.data=hn;function Tt(t,n){var r=t.documentElement;return Math.max(t.body["scroll".concat(n)],r["scroll".concat(n)],t.body["offset".concat(n)],r["offset".concat(n)],r["client".concat(n)])}d([!0,!1],function(t,n){d(["Width","Height"],function(r,i){var e="".concat(n?"outer":"inner").concat(i);u[e]=function(s){if(this[0])return B(this[0])?n?this[0]["inner".concat(i)]:this[0].document.documentElement["client".concat(i)]:A(this[0])?Tt(this[0],i):this[0]["".concat(n?"offset":"client").concat(i)]+(s&&n?E(this[0],"margin".concat(r?"Top":"Left"))+E(this[0],"margin".concat(r?"Bottom":"Right")):0)}})}),d(["Width","Height"],function(t,n){var r=n.toLowerCase();u[r]=function(i){if(!this[0])return v(i)?void 0:this;if(!arguments.length)return B(this[0])?this[0].document.documentElement["client".concat(n)]:A(this[0])?Tt(this[0],n):this[0].getBoundingClientRect()[r]-gt(this[0],!t);var e=parseInt(i,10);return this.each(function(s,f){if(c(f)){var a=T(f,"boxSizing");f.style[r]=wt(r,e+(a==="border-box"?gt(f,!t):0))}})}});var Rt="___cd";u.toggle=function(t){return this.each(function(n,r){if(c(r)){var i=vt(r),e=v(t)?i:t;e?(r.style.display=r[Rt]||"",vt(r)&&(r.style.display=Jt(r.tagName))):i||(r[Rt]=T(r,"display"),r.style.display="none")}})},u.hide=function(){return this.toggle(!1)},u.show=function(){return this.toggle(!0)};var xt="___ce",Z=".",k={focus:"focusin",blur:"focusout"},Nt={mouseenter:"mouseover",mouseleave:"mouseout"},dn=/^(mouse|pointer|contextmenu|drag|drop|click|dblclick)/i;function tt(t){return Nt[t]||k[t]||t}function nt(t){var n=t.split(Z);return[n[0],n.slice(1).sort()]}u.trigger=function(t,n){if(g(t)){var r=nt(t),i=r[0],e=r[1],s=tt(i);if(!s)return this;var f=dn.test(s)?"MouseEvents":"HTMLEvents";t=C.createEvent(f),t.initEvent(s,!0,!0),t.namespace=e.join(Z),t.___ot=i}t.___td=n;var a=t.___ot in k;return this.each(function(y,h){a&&O(h[t.___ot])&&(h["___i".concat(t.type)]=!0,h[t.___ot](),h["___i".concat(t.type)]=!1),h.dispatchEvent(t)})};function Lt(t){return t[xt]=t[xt]||{}}function ln(t,n,r,i,e){var s=Lt(t);s[n]=s[n]||[],s[n].push([r,i,e]),t.addEventListener(n,e)}function At(t,n){return!n||!z.call(n,function(r){return t.indexOf(r)<0})}function F(t,n,r,i,e){var s=Lt(t);if(n)s[n]&&(s[n]=s[n].filter(function(f){var a=f[0],y=f[1],h=f[2];if(e&&h.guid!==e.guid||!At(a,r)||i&&i!==y)return!0;t.removeEventListener(n,h)}));else for(n in s)F(t,n,r,i,e)}u.off=function(t,n,r){var i=this;if(v(t))this.each(function(s,f){!c(f)&&!A(f)&&!B(f)||F(f)});else if(g(t))O(n)&&(r=n,n=""),d(j(t),function(s,f){var a=nt(f),y=a[0],h=a[1],p=tt(y);i.each(function(M,b){!c(b)&&!A(b)&&!B(b)||F(b,p,h,n,r)})});else for(var e in t)this.off(e,t[e]);return this},u.remove=function(t){return x(this,t).detach().off(),this},u.replaceWith=function(t){return this.before(t).remove()},u.replaceAll=function(t){return o(t).replaceWith(this),this};function gn(t,n,r,i,e){var s=this;if(!g(t)){for(var f in t)this.on(f,n,r,t[f],e);return this}return g(n)||(v(n)||P(n)?n="":v(r)?(r=n,n=""):(i=r,r=n,n="")),O(i)||(i=r,r=void 0),i?(d(j(t),function(a,y){var h=nt(y),p=h[0],M=h[1],b=tt(p),W=p in Nt,rt=p in k;b&&s.each(function(it,m){if(!(!c(m)&&!A(m)&&!B(m))){var et=function(l){if(l.target["___i".concat(l.type)])return l.stopImmediatePropagation();if(!(l.namespace&&!At(M,l.namespace.split(Z)))&&!(!n&&(rt&&(l.target!==m||l.___ot===b)||W&&l.relatedTarget&&m.contains(l.relatedTarget)))){var ut=m;if(n){for(var _=l.target;!pt(_,n);)if(_===m||(_=_.parentNode,!_))return;ut=_}Object.defineProperty(l,"currentTarget",{configurable:!0,get:function(){return ut}}),Object.defineProperty(l,"delegateTarget",{configurable:!0,get:function(){return m}}),Object.defineProperty(l,"data",{configurable:!0,get:function(){return r}});var bn=i.call(ut,l,l.___td);e&&F(m,b,M,n,et),bn===!1&&(l.preventDefault(),l.stopPropagation())}};et.guid=i.guid=i.guid||o.guid++,ln(m,b,M,n,et)}})}),this):this}u.on=gn;function vn(t,n,r,i){return this.on(t,n,r,i,!0)}u.one=vn;var pn=/\r?\n/g;function mn(t,n){return"&".concat(encodeURIComponent(t),"=").concat(encodeURIComponent(n.replace(pn,`\r 2 | `)))}var yn=/file|reset|submit|button|image/i,Ot=/radio|checkbox/i;u.serialize=function(){var t="";return this.each(function(n,r){d(r.elements||[r],function(i,e){if(!(e.disabled||!e.name||e.tagName==="FIELDSET"||yn.test(e.type)||Ot.test(e.type)&&!e.checked)){var s=bt(e);if(!v(s)){var f=H(s)?s:[s];d(f,function(a,y){t+=mn(e.name,y)})}}})}),t.slice(1)},typeof exports<"u"?module.exports=o:D.cash=D.$=o})(); 3 | -------------------------------------------------------------------------------- /web/page/images/eraser.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lerc/canvas_tab/e04289a6208b43879705e12b641da9fd4d3d27de/web/page/images/eraser.png -------------------------------------------------------------------------------- /web/page/images/fine_eraser.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lerc/canvas_tab/e04289a6208b43879705e12b641da9fd4d3d27de/web/page/images/fine_eraser.png -------------------------------------------------------------------------------- /web/page/images/pixels.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lerc/canvas_tab/e04289a6208b43879705e12b641da9fd4d3d27de/web/page/images/pixels.png -------------------------------------------------------------------------------- /web/page/images/tip_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lerc/canvas_tab/e04289a6208b43879705e12b641da9fd4d3d27de/web/page/images/tip_1.png -------------------------------------------------------------------------------- /web/page/images/tip_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lerc/canvas_tab/e04289a6208b43879705e12b641da9fd4d3d27de/web/page/images/tip_2.png -------------------------------------------------------------------------------- /web/page/images/tip_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lerc/canvas_tab/e04289a6208b43879705e12b641da9fd4d3d27de/web/page/images/tip_3.png -------------------------------------------------------------------------------- /web/page/images/tip_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lerc/canvas_tab/e04289a6208b43879705e12b641da9fd4d3d27de/web/page/images/tip_5.png -------------------------------------------------------------------------------- /web/page/images/tip_9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lerc/canvas_tab/e04289a6208b43879705e12b641da9fd4d3d27de/web/page/images/tip_9.png -------------------------------------------------------------------------------- /web/page/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ComfyUI Canvas Editor 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |
17 | 20 | 26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | 38 | 39 |
40 |
41 |
42 |
43 | 44 | 45 |
46 | 47 |
48 | 49 | 50 |
51 |
52 |
53 |
54 |
55 | 56 |
57 | 58 |
59 | 60 |
61 |
62 |
63 | 64 | 65 | 83 | 84 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /web/page/main.js: -------------------------------------------------------------------------------- 1 | console.log("canvas_tab/web/page/main.js - executing"); 2 | 3 | const plugin_name = "canvas_link"; 4 | 5 | function setSignal(key) { 6 | const keyName = plugin_name+":"+key; 7 | localStorage.setItem(keyName, 'true'); 8 | } 9 | 10 | function clearSignal(key) { 11 | const keyName = plugin_name+":"+key; 12 | localStorage.removeItem(keyName); 13 | } 14 | 15 | function getSignal(key) { 16 | const keyName = plugin_name+":"+key; 17 | return localStorage.getItem(keyName) === 'true'; 18 | } 19 | 20 | function checkAndClear(key) { 21 | const keyName = plugin_name+":"+key; 22 | let result=localStorage.getItem(keyName) === 'true'; 23 | if (result) localStorage.removeItem(keyName); 24 | return result; 25 | } 26 | 27 | 28 | 29 | if (location.pathname.includes("/page/")) { 30 | console.log("client-page-only code running"); 31 | 32 | let portToMain; // variable to hold the MessageChannel port 33 | 34 | function transmitCanvas(canvas,title="",selected=true) { 35 | if (portToMain) { 36 | canvas.toBlob( blob=>{ 37 | portToMain.postMessage( {"image": blob,title,selected} ); 38 | }); 39 | } 40 | } 41 | 42 | function transmitMask(canvas,title="",selected=true) { 43 | if (portToMain) { 44 | canvas.toBlob( blob=>{ 45 | portToMain.postMessage( {"mask": blob,title,selected} ); 46 | }); 47 | } 48 | } 49 | 50 | 51 | 52 | function loadImagefromURL(url) { 53 | var img = new Image(); 54 | img.onload = function() { 55 | console.log("import mode is",$("#import_mode").val()) 56 | switch ($("#import_mode").val()) { 57 | case "layer": 58 | addNewLayer(img) 59 | break; 60 | case "image": 61 | setExportPic(addNewImage(img)); 62 | break; 63 | case "replace_target": 64 | if (targetLayer !== null) { 65 | replaceLayerContent(targetLayer, img); 66 | } 67 | break; 68 | 69 | case "ignore": 70 | default: 71 | } 72 | }; 73 | img.src = url; 74 | } 75 | 76 | function replaceLayerContent(layer, image) { 77 | const ctx = layer.canvas.getContext('2d'); 78 | ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height); 79 | ctx.drawImage(image, 0, 0, layer.canvas.width, layer.canvas.height); 80 | 81 | if (activePic===layer.parentPic) { 82 | updateLayerList(); 83 | } 84 | layer.parentPic.updateVisualRepresentation(false); 85 | } 86 | 87 | window.addEventListener('load', () => { 88 | if (getSignal('clientPage')) { 89 | console.log("but we're the client page, something is amiss") 90 | } else { 91 | setSignal("clientPage"); 92 | } 93 | console.log("at load time our opener was ", window.opener) 94 | if (window.opener) window.opener.postMessage({category:plugin_name,data:"Editor Here"}) 95 | 96 | }); 97 | 98 | window.addEventListener("unload", _=> clearSignal("clientPage")); 99 | 100 | window.addEventListener('storage', (event) => { 101 | if (checkAndClear('findImageEditor')) { 102 | window.opener.postMessage({category:plugin_name,data:"Editor Here"}) 103 | 104 | } 105 | }); 106 | 107 | 108 | window.addEventListener('message', (event) => { 109 | if (event.data === 'Initiate communication') { 110 | portToMain = event.ports[0]; // Save the port for future use 111 | portToMain.postMessage('Hello from Image_editor!'); 112 | 113 | portToMain.onmessage = (messageEvent) => { 114 | if (messageEvent.data instanceof Object) { 115 | const data = messageEvent.data; 116 | const images=data.images; 117 | if (images?.length > 0) { 118 | loadImagefromURL(images[0]); 119 | } 120 | 121 | if (data?.retransmit) { 122 | activePic.updateVisualRepresentation(true); 123 | } 124 | 125 | } else console.log('Message received from main page:', messageEvent.data); 126 | }; 127 | } 128 | }); 129 | 130 | 131 | }; 132 | 133 | -------------------------------------------------------------------------------- /web/page/paintTools.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function convertCoordsFromPicToUI(x,y, pic) { 4 | const picRect = pic.element.getBoundingClientRect(); 5 | const workspaceRect = document.getElementById('workspace').getBoundingClientRect(); 6 | 7 | // Scale the point 8 | const scaledX = x * pic.scale; 9 | const scaledY = y * pic.scale; 10 | 11 | // Translate the point based on the pic's position in the workspace 12 | const uiX = picRect.left - workspaceRect.left + scaledX; 13 | const uiY = picRect.top - workspaceRect.top + scaledY; 14 | 15 | return { x: uiX, y: uiY }; 16 | } 17 | 18 | function convertCoordsFromUIToPic(x,y, pic) { 19 | const picRect = pic.element.getBoundingClientRect(); 20 | const workspaceRect = document.getElementById('workspace').getBoundingClientRect(); 21 | 22 | // Translate the point to the pic's coordinate system 23 | const picX = x - (picRect.left - workspaceRect.left); 24 | const picY = y - (picRect.top - workspaceRect.top); 25 | 26 | // Scale the point based on the pic's scale 27 | const scaledX = picX / pic.scale; 28 | const scaledY = picY / pic.scale; 29 | 30 | return { x: scaledX, y: scaledY }; 31 | } 32 | 33 | 34 | function initUICanvas() { 35 | const uiCanvas = document.createElement('canvas'); 36 | uiCanvas.id = 'uiCanvas'; 37 | uiCanvas.style.position = 'absolute'; 38 | uiCanvas.style.left = '0'; 39 | uiCanvas.style.top = '0'; 40 | uiCanvas.style.zIndex = '101'; // Ensure it's above other elements 41 | 42 | const workspace = document.getElementById('workspace'); 43 | workspace.appendChild(uiCanvas); 44 | 45 | const resizeCanvas = () => { 46 | uiCanvas.width = workspace.clientWidth; 47 | uiCanvas.height = workspace.clientHeight; 48 | }; 49 | 50 | // Initial resize 51 | resizeCanvas(); 52 | 53 | // Resize canvas when window resizes 54 | window.addEventListener('resize', resizeCanvas); 55 | 56 | uiCanvas.addEventListener('mousedown', (event) => handleMouseEvent(event, 'mousedown')); 57 | uiCanvas.addEventListener('mouseup', (event) => handleMouseEvent(event, 'mouseup')); 58 | uiCanvas.addEventListener('mousemove', (event) => handleMouseEvent(event, 'mousemove')); 59 | uiCanvas.addEventListener('contextmenu', (event) => event.preventDefault()); 60 | 61 | function handleMouseEvent(event, eventType) { 62 | if (tool.eventHandlers) { 63 | if ((event.buttons&4)==4) { 64 | passEventToElementBelow(event); 65 | }else { 66 | if (tool.eventHandlers[eventType]) { 67 | // Call the tool's event handler 68 | tool.eventHandlers[eventType](event); 69 | } 70 | } 71 | 72 | } else { 73 | // Pass the event to the element below 74 | passEventToElementBelow(event); 75 | } 76 | event.preventDefault(); 77 | 78 | } 79 | 80 | function passEventToElementBelow(event) { 81 | uiCanvas.style.pointerEvents = 'none'; 82 | 83 | // Find the element below the cursor 84 | let elemBelow = document.elementFromPoint(event.clientX, event.clientY); 85 | 86 | uiCanvas.style.pointerEvents = ''; 87 | 88 | // Dispatch the event to the element below 89 | if (elemBelow) { 90 | forwardEvent(event,elemBelow); 91 | } 92 | } 93 | 94 | return uiCanvas; 95 | } 96 | 97 | function drawPicFramesOnUICanvas(uiCanvas, picList) { 98 | const ctx = uiCanvas.getContext('2d'); 99 | ctx.clearRect(0, 0, uiCanvas.width, uiCanvas.height); // Clear the canvas 100 | 101 | picList.forEach(pic => { 102 | // Convert pic's corners to UI Canvas coordinates 103 | const topLeft = convertCoordsFromPicToUI(0, 0, pic); 104 | const topRight = convertCoordsFromPicToUI(pic.width, 0, pic); 105 | const bottomLeft = convertCoordsFromPicToUI(0, pic.height, pic); 106 | const bottomRight = convertCoordsFromPicToUI(pic.width, pic.height, pic); 107 | 108 | // Draw rectangle 109 | ctx.beginPath(); 110 | ctx.moveTo(topLeft.x,topLeft.y); 111 | ctx.lineTo(topRight.x,topRight.y); 112 | ctx.lineTo(bottomRight.x,bottomRight.y); 113 | ctx.lineTo(bottomLeft.x,bottomLeft.y); 114 | ctx.lineTo(topLeft.x,topLeft.y); 115 | ctx.strokeStyle = 'red'; 116 | ctx.stroke(); 117 | 118 | // Draw cross lines 119 | ctx.beginPath(); 120 | ctx.moveTo(topLeft.x, topLeft.y); 121 | ctx.lineTo(bottomRight.x, bottomRight.y); 122 | ctx.moveTo(topRight.x, topRight.y); 123 | ctx.lineTo(bottomLeft.x, bottomLeft.y); 124 | ctx.stroke(); 125 | }); 126 | } 127 | 128 | 129 | 130 | const pixelTip = { 131 | drawOperation (ctx,toolInfo,strokePath) { 132 | ctx.fillStyle=toolInfo.colour; 133 | for (let {x,y} of strokePath) { 134 | x=Math.floor(x-0.25); 135 | y=Math.floor(y-0.25); 136 | ctx.fillRect(x,y,1,1); 137 | }; 138 | } 139 | } 140 | 141 | const eyeDropper={ 142 | drawOperation(ctx,toolInfo,strokePath) { 143 | const last = strokePath.at(-1); 144 | let {x,y} = last; 145 | x=Math.floor(x-0.25); 146 | y=Math.floor(y-0.25); 147 | let canvas = ctx.canvas; 148 | if (x>=0 && y>=0 && x byte.toString(16).padStart(2, '0'); 151 | const color= "#" + toHex(sample[0]) + toHex(sample[1]) + toHex(sample[2]); 152 | $("#foreground").val(color) 153 | } 154 | }, 155 | eventHandlers: { 156 | mousedown(e) { 157 | const {offsetX,offsetY} = e; 158 | const pic = getDrawAreaAtPoint(offsetX,offsetY); 159 | 160 | if (!pic) return; 161 | const {x,y} = convertCoordsFromUIToPic(offsetX,offsetY,pic); 162 | if (x>=0 && y>=0 && x byte.toString(16).padStart(2, '0'); 165 | const color= "#" + toHex(sample[0]) + toHex(sample[1]) + toHex(sample[2]); 166 | $("#foreground").val(color) 167 | } 168 | console.log({x,y}) 169 | } 170 | } 171 | } 172 | 173 | const feltTip={ 174 | drawOperation(ctx,toolInfo,strokePath) { 175 | ctx.lineWidth=toolInfo.size; 176 | ctx.strokeStyle=toolInfo.colour; 177 | ctx.lineCap="round"; 178 | ctx.lineJoin="round"; 179 | ctx.beginPath(); 180 | for (let {x,y} of strokePath) { 181 | ctx.lineTo(x,y); 182 | } 183 | ctx.stroke(); 184 | }, 185 | cursorFunction : circleBrush 186 | } 187 | 188 | 189 | const eraserTip={ 190 | drawOperation (ctx,toolInfo,strokePath) { 191 | ctx.save(); 192 | ctx.lineWidth=toolInfo.size; 193 | ctx.strokeStyle="white"; 194 | ctx.lineCap="round"; 195 | ctx.lineJoin="round"; 196 | ctx.globalCompositeOperation="destination-out"; 197 | 198 | ctx.beginPath(); 199 | for (let {x,y} of strokePath) { 200 | ctx.lineTo(x,y); 201 | } 202 | ctx.stroke(); 203 | 204 | ctx.restore(); 205 | }, 206 | cursorFunction :circleBrush 207 | } 208 | 209 | 210 | const pixelClear = { 211 | drawOperation(ctx,toolInfo,strokePath) { 212 | let x=Math.floor(toolInfo.x-0.25); 213 | let y=Math.floor(toolInfo.y-0.25); 214 | ctx.clearRect(x,y,1,1); 215 | for (let {x,y} of strokePath) { 216 | x=Math.floor(x-0.25); 217 | y=Math.floor(y-0.25); 218 | ctx.clearRect(x,y,1,1); 219 | }; 220 | } 221 | } 222 | 223 | 224 | const transformTool = (_=> { //closure 225 | let rotateMode = false; 226 | let doubleClickGap=300; 227 | let mouseDownTime =0; 228 | let mouseDownTransform = [1,0, 0,1, 0,0]; 229 | let mouseDownAngle = 0; 230 | let layer; 231 | let dragHandler; 232 | let mouseDownPosition 233 | let preserveAspect = false; 234 | let moved = false; 235 | let scaleHandlers= [ 236 | dragTopLeft,dragTopRight,dragBottomRight,dragBottomLeft,dragTop,dragRight,dragBottom,dragLeft 237 | ]; 238 | 239 | 240 | const tool = { 241 | init() { 242 | rotateMode=false; 243 | this.drawUI(); 244 | }, 245 | drawOperation() { 246 | console.log("transform tool should not draw, this is a bug") 247 | }, 248 | drawUI() { 249 | //drawPicFramesOnUICanvas(uiCanvas,[activePic]); 250 | const ctx=uiCanvas.getContext("2d"); 251 | ctx.clearRect(0,0,1e5,1e5); 252 | let handles=controlPoints(0,0,activePic.activeLayer.width,activePic.activeLayer.height); 253 | ctx.fillStyle="black"; 254 | ctx.strokeStyle="white"; 255 | 256 | 257 | if (rotateMode) { 258 | let {x,y} = activePic.activeLayer.rotationCenter; 259 | const picPoints = activePic.activeLayer.convertToPicCoords(x,y) 260 | const uiPos = convertCoordsFromPicToUI(picPoints.x,picPoints.y,activePic); 261 | ctx.strokeStyle="black"; 262 | ctx.strokeRect(uiPos.x-5,uiPos.y-5,10,10); 263 | ctx.strokeStyle="white"; 264 | ctx.strokeRect(uiPos.x-4,uiPos.y-4,8,8); 265 | for (let{x,y} of handles) { 266 | const picPoints = activePic.activeLayer.convertToPicCoords(x,y) 267 | const uiPos = convertCoordsFromPicToUI(picPoints.x,picPoints.y,activePic); 268 | ctx.beginPath(); 269 | ctx.arc(uiPos.x,uiPos.y,8,0,Math.PI*2); 270 | ctx.fill(); 271 | ctx.beginPath(); 272 | ctx.arc(uiPos.x,uiPos.y,7,0,Math.PI*2); 273 | ctx.stroke(); 274 | } 275 | } else { 276 | for (let{x,y} of handles) { 277 | const picPoints = activePic.activeLayer.convertToPicCoords(x,y) 278 | const uiPos = convertCoordsFromPicToUI(picPoints.x,picPoints.y,activePic); 279 | ctx.fillRect(uiPos.x-5,uiPos.y-5,10,10); 280 | ctx.strokeRect(uiPos.x-4,uiPos.y-4,8,8); 281 | } 282 | } 283 | 284 | } 285 | } 286 | 287 | tool.eventHandlers ={ 288 | mousedown(e) { 289 | if (e.button == 0) { 290 | let now = Date.now(); 291 | let clickSpacing = now-mouseDownTime; 292 | mouseDownTime=now; 293 | if (clickSpacing < doubleClickGap) { 294 | doubleClickHandler(e); 295 | return; 296 | } 297 | 298 | const mousePos = convertCoordsFromUIToPic(e.offsetX,e.offsetY,activePic); 299 | layer = activePic?.activeLayer; 300 | if (!layer) return; 301 | let handles=controlPoints(0,0,layer.width,layer.height); 302 | dragHandler=null; 303 | for (let i=0;i0 && 315 | mousePos.x<=activePic.width && 316 | mousePos.y >0 && 317 | mousePos.y<=activePic.height 318 | ) { 319 | dragHandler=translateHandler; 320 | } 321 | if (rotateMode) { 322 | let {x,y} = layer.convertToPicCoords(layer.rotationCenter.x,layer.rotationCenter.y); 323 | console.log({x,y,mousePos}) 324 | if (max( abs(x-mousePos.x), abs(y-mousePos.y) ) <8 ) { 325 | dragHandler=translateCenterHandler; 326 | } 327 | } 328 | } 329 | if (dragHandler) { 330 | let center = layer.convertToPicCoords(layer.rotationCenter.x,layer.rotationCenter.y) 331 | mouseDownAngle= v2Angle(v2Sub(mousePos,center)); 332 | mouseDownTransform=[...layer.transform]; 333 | mouseDownPosition = {x:e.offsetX,y:e.offsetY} 334 | console.log({mouseDownAngle}) 335 | moved=false; 336 | 337 | } 338 | } 339 | }, 340 | mouseup(e) { 341 | 342 | }, 343 | mousemove(e) { 344 | 345 | if ((e.buttons && 1) !== 1) dragStop(); 346 | if (dragHandler) { 347 | if (!moved) { 348 | activePic.addUndoRecord(layer.undoTransformRecord()); 349 | moved=true; 350 | } 351 | dragHandler(e); 352 | 353 | } 354 | tool.drawUI(); 355 | 356 | }, 357 | } 358 | 359 | function controlPoints (left, top, right, bottom) { 360 | const midX = (left+right)/2; 361 | const midY = (top+bottom)/2; 362 | return [ 363 | {x:left, y:top}, 364 | {x:right, y:top}, 365 | {x:right, y:bottom}, 366 | {x:left, y:bottom}, 367 | {x:midX, y:top}, 368 | {x:right, y:midY}, 369 | {x:midX, y:bottom}, 370 | {x:left, y:midY} 371 | ] 372 | } 373 | 374 | function dragStop() { 375 | dragHandler=null; 376 | updateLayerList(); 377 | } 378 | 379 | function doubleClickHandler(e) { 380 | rotateMode=!rotateMode; 381 | tool.drawUI(); 382 | } 383 | 384 | function scaleHandler(handle,anchor,position,lockX=false,lockY=false) { 385 | const newTransform=scaleTransformByHandle(handle,anchor,position,mouseDownTransform,lockX,lockY); 386 | 387 | layer.transform = newTransform; 388 | 389 | activePic.updateVisualRepresentation(); 390 | 391 | } 392 | 393 | function translateHandler(e) { 394 | let dx = e.offsetX -mouseDownPosition.x 395 | let dy = e.offsetY -mouseDownPosition.y; 396 | 397 | let newTransform = [...mouseDownTransform]; 398 | newTransform[4]+=dx; 399 | newTransform[5]+=dy; 400 | 401 | layer.transform=newTransform; 402 | activePic.updateVisualRepresentation(); 403 | } 404 | 405 | function translateCenterHandler(e) { 406 | const mousePos = convertCoordsFromUIToPic(e.offsetX,e.offsetY,activePic); 407 | layer.rotationCenter= layer.convertFromPicCoords(mousePos.x,mousePos.y); 408 | activePic.updateVisualRepresentation(); 409 | 410 | } 411 | 412 | function dragTopLeft(e) { 413 | const mouseCurrentPosition = convertCoordsFromUIToPic(e.offsetX,e.offsetY,activePic); 414 | scaleHandler({x:0,y:0},{x:layer.width,y:layer.height},mouseCurrentPosition); 415 | } 416 | 417 | 418 | function dragTopRight(e) { 419 | const mouseCurrentPosition = convertCoordsFromUIToPic(e.offsetX,e.offsetY,activePic); 420 | scaleHandler({x:layer.width,y:0},{x:0,y:layer.height},mouseCurrentPosition); 421 | 422 | } 423 | function dragBottomRight(e) { 424 | const mouseCurrentPosition = convertCoordsFromUIToPic(e.offsetX,e.offsetY,activePic); 425 | scaleHandler({x:layer.width,y:layer.height},{x:0,y:0},mouseCurrentPosition); 426 | } 427 | function dragBottomLeft(e) { 428 | const mouseCurrentPosition = convertCoordsFromUIToPic(e.offsetX,e.offsetY,activePic); 429 | scaleHandler({x:0,y:layer.height},{x:layer.width,y:0},mouseCurrentPosition); 430 | } 431 | function dragTop(e) { 432 | const mouseCurrentPosition = convertCoordsFromUIToPic(e.offsetX,e.offsetY,activePic); 433 | scaleHandler({x:layer.width/2,y:0},{x:layer.width,y:layer.height},mouseCurrentPosition,true); 434 | } 435 | function dragRight(e) { 436 | const mouseCurrentPosition = convertCoordsFromUIToPic(e.offsetX,e.offsetY,activePic); 437 | scaleHandler({x:layer.width,y:layer.height/2},{x:0,y:0},mouseCurrentPosition,false,true); 438 | } 439 | function dragLeft(e) { 440 | const mouseCurrentPosition = convertCoordsFromUIToPic(e.offsetX,e.offsetY,activePic); 441 | scaleHandler({x:0,y:layer.height/2},{x:layer.width,y:0},mouseCurrentPosition,false,true); 442 | 443 | } 444 | function dragBottom(e) { 445 | const mouseCurrentPosition = convertCoordsFromUIToPic(e.offsetX,e.offsetY,activePic); 446 | scaleHandler({x:layer.width/2,y:layer.height},{x:layer.width,y:0},mouseCurrentPosition,true); 447 | } 448 | 449 | function rotateHandler(e) { 450 | let center = layer.convertToPicCoords(layer.rotationCenter.x,layer.rotationCenter.y) 451 | let mousePos = convertCoordsFromUIToPic(e.offsetX,e.offsetY,activePic); 452 | 453 | let angle= v2Angle(v2Sub(mousePos,center)); 454 | let angleDelta = angle-mouseDownAngle; 455 | layer.transform=[...mouseDownTransform]; 456 | layer.rotate(angleDelta*RadiansToDegrees); 457 | activePic.updateVisualRepresentation(); 458 | } 459 | 460 | 461 | 462 | return tool; 463 | })(); //end closure for transformTool 464 | -------------------------------------------------------------------------------- /web/page/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | --top-panel-height : 64px; 3 | --left-panel-width : 80px; 4 | --right-panel-width : 210px; 5 | 6 | --label : "image"; 7 | 8 | 9 | --base-color: #f8fcff; 10 | --hover-color: #eee; 11 | --highlight-color: #FFF; 12 | --content-color : #111; 13 | --workspace-color: lightgrey; 14 | 15 | } 16 | 17 | @media (prefers-color-scheme:dark) { 18 | 19 | body{ 20 | --base-color: #333; 21 | --hover-color: #336; 22 | --highlight-color: #448; 23 | --content-color : #ffe; 24 | --workspace-color: #444; 25 | } 26 | 27 | .button, .diameter_control, input[type="text"],input[type="number"], select , button, .layer_actions { 28 | filter: invert(0.8); 29 | } 30 | 31 | .titlebar { 32 | color:#000; 33 | } 34 | } 35 | 36 | 37 | 38 | 39 | body { 40 | margin: 0; 41 | font-family: sans-serif; 42 | 43 | background: var(--base-color); 44 | color: var(--content-color); 45 | width:100vw; 46 | height:100vh; 47 | } 48 | 49 | 50 | @media (max-width: 1250px) { 51 | body { 52 | transform-origin: top left; 53 | transform: scale(0.75); 54 | width:133vw; 55 | height:133vh; 56 | overflow:hidden; 57 | } 58 | } 59 | canvas { 60 | display: block; 61 | box-shadow: 0px 0px 0px 2px #000; 62 | } 63 | .noselect { 64 | -webkit-touch-callout: none; 65 | -webkit-user-select: none; 66 | -khtml-user-select: none; 67 | -moz-user-select: none; 68 | -ms-user-select: none; 69 | user-select: none; 70 | } 71 | 72 | .nodrag { 73 | user-drag: none; 74 | -webkit-user-drag: none; 75 | } 76 | 77 | 78 | .panel { 79 | position: absolute; 80 | left:0px; 81 | right:0px; 82 | top:0px; 83 | height: var(--top-panel-height); 84 | } 85 | 86 | .button { 87 | transition: all 0.05s; 88 | display: inline-block; 89 | border-radius: 4px; 90 | width:32px; 91 | height:32px; 92 | margin:2px; 93 | background-color : #ddd; 94 | box-sizing: border-box; 95 | box-shadow: -1px -1px 1px 1px var(--content-color) inset, 1px 1px 1px 1px var(--highlight-color) inset; 96 | } 97 | .button:hover { 98 | background-color : #eee; 99 | } 100 | .button:active { 101 | background-color : #fff; 102 | box-shadow: 1px 1px 1px 1px var(--content-color) inset, -1px -1px 1px 1px var(--highlight-color) inset; 103 | } 104 | .button.down { 105 | background-color : #fff; 106 | box-shadow: 1px 1px 1px 1px var(--content-color) inset, -1px -1px 1px 1px var(--highlight-color) inset; 107 | } 108 | 109 | .tool.button { 110 | margin-bottom: 9px; 111 | } 112 | 113 | svg.tool.button { 114 | fill:currentColor; 115 | } 116 | 117 | #pen.tool.button { 118 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath d='M18 35 12 29 29 11 36 17ZM37 16 30 10A1 1 0 0137 16M16 36 7 40 11 31Z'/%3E%3C/svg%3E"); 119 | } 120 | 121 | #undo.action.button { 122 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath d='M27 35V17A1 1 0 0019 17V19L23 17 17 27 9 19 13 20V17A1 1 0 0134 17V35Z'/%3E%3C/svg%3E"); 123 | } 124 | 125 | #redo.action.button { 126 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath d='M21 35V17A1 1 90 0129 17V19L25 17 31 27 39 19 35 20V17A1 1 90 0014 17V35Z'/%3E%3C/svg%3E"); 127 | } 128 | 129 | #eyedropper.tool.button { 130 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath d='M34 10A1 1 90 0139 15L34 19 33 20 34 21 33 22 27 16 28 15 29 16 30 15ZM27.4 17.4 12.1 31.4 12.1 33.3 9.3 35.7 9.5 36.9 13.2 33.7 13.2 31.7 27.5 18.9 30.2 21.5 16.1 34.4 14.1 34.6 10.5 37.7 12 37.9 14.6 35.5 16.4 35.4 31.5 21.7'/%3E%3C/svg%3E"); 131 | } 132 | 133 | #transform.tool.button { 134 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-2 -2 32 32'%3E%3Cpath d='m14 2 4 4h-3v7h7v-3l4 4-4 3v-2h-7v7h3l-4 4-4-4h3v-7h-7v3l-4-4 4-4v3h7v-7h-3z'/%3E%3C/svg%3E"); 135 | } 136 | 137 | 138 | 139 | #clear.action.button { 140 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath d='M19 24 12 17 17 12 24 19 31 12 36 17 29 24 36 31 31 36 24 29 17 36 12 31'/%3E%3C/svg%3E"); 141 | } 142 | 143 | 144 | 145 | 146 | .subpanel { 147 | display: inline-block; 148 | border-left: 1px solid var(--content-color); 149 | padding-left: 6px; 150 | height: var(--top-panel-height); 151 | vertical-align: top; 152 | box-sizing:border-box; 153 | } 154 | 155 | .subpanel.actions { 156 | position:absolute; 157 | right:0.5em; 158 | width:200px; 159 | } 160 | .subpanel input { 161 | display:block; 162 | } 163 | 164 | .subpanel.simple_mirrors { 165 | width:70px; 166 | } 167 | 168 | .subpanel.grid_mirrors>span { 169 | height: var(--top-panel-height); 170 | vertical-align: top; 171 | padding-top: 10px; 172 | } 173 | 174 | 175 | .mirror.button { 176 | width:24px; 177 | height:24px; 178 | } 179 | 180 | .mirror.button#mirror_y { 181 | background-image: linear-gradient(to bottom, #0000 45%, #000f 50%, #0000 55%); 182 | } 183 | 184 | .mirror.button#mirror_x { 185 | background-image: linear-gradient(to right, #0000 45%, #000f 50%, #0000 55%); 186 | } 187 | 188 | .mirror.button#mirror_tlbr { 189 | background-image: linear-gradient(to right top, #0000 45%, #000f 50%, #0000 55%); 190 | } 191 | 192 | .mirror.button#mirror_trbl { 193 | background-image: linear-gradient(to right bottom, #0000 45%, #000f 50%, #0000 55%); 194 | } 195 | 196 | .mirror.button#mirror_grid { 197 | width: 48px; 198 | height: 48px; 199 | margin-top: 6px; 200 | } 201 | 202 | .subpanel.rotational_mirrors { 203 | width: 55px; 204 | text-align:center; 205 | } 206 | 207 | .mirror.button#mirror_rotational { 208 | width: 36px; 209 | height: 36px; 210 | margin-bottom:0; 211 | } 212 | 213 | .subpanel.rotational_mirrors input { 214 | width: 42px; 215 | } 216 | 217 | 218 | 219 | #palette { 220 | position:absolute; 221 | left:0px; 222 | top:var(--top-panel-height); 223 | width:var(--left-panel-width); 224 | bottom:0px; 225 | line-height: 19px; 226 | padding:6px; 227 | } 228 | .paletteentry { 229 | width :20px; 230 | height :20px; 231 | display:inline-block; 232 | margin : 0px; 233 | box-shadow:0px 0px 2px 1px black; 234 | } 235 | 236 | .paletteentry.erase { 237 | width: 78px; 238 | height:32px; 239 | text-align: center; 240 | line-height: 32px; 241 | 242 | } 243 | 244 | #workspace{ 245 | position:absolute; 246 | 247 | left: calc( var(--left-panel-width) + 8px); 248 | right: calc( var(--right-panel-width)); 249 | top: calc( var(--top-panel-height) + 4px); 250 | bottom:0px; 251 | overflow:hidden; 252 | background-color:var(--workspace-color); 253 | margin: 4px; 254 | box-shadow: 0px 0px 2px 2px black; 255 | } 256 | .pic { 257 | cursor:crosshair; 258 | background-color:var(--base-color); 259 | background-image: linear-gradient(rgba(32,100,150,.05), transparent 2px), 260 | linear-gradient(90deg, rgba(32,100,150,.05) 2px, transparent 2px), 261 | linear-gradient(rgba(32,100,150,.1) 1px, transparent 1px), 262 | linear-gradient(90deg, rgba(32,100,150,.1) 1px, transparent 1px); 263 | background-size:100px 100px, 100px 100px, 20px 20px, 20px 20px; 264 | background-position:-2px -2px, -2px -2px, -1px -1px, -1px -1px; 265 | 266 | transition: transform 0.5s; 267 | 268 | position:absolute; 269 | margin: auto; 270 | left:0px; 271 | top:0px; 272 | image-rendering:pixelated; 273 | image-rendering:-moz-crisp-edges; 274 | transform : scale(2,2); 275 | transition: transform 0.15s; 276 | 277 | transform-origin: top left; 278 | transform: translate(var(--translateX),var(--translateY)) scale(var(--scalefactor) ) 279 | } 280 | 281 | 282 | input[type="color"] { 283 | width:48px; 284 | height:32px; 285 | padding:0px; 286 | background:none; 287 | border:none; 288 | outline:none; 289 | } 290 | 291 | #brush_opacity { 292 | width:100%; 293 | padding:0; 294 | margin:0; 295 | position:relative; 296 | } 297 | #brush_opacity input { 298 | width:100%; 299 | box-sizing:border-box; 300 | } 301 | #brush_opacity label{ 302 | font-size:small; 303 | } 304 | #brush_opacity output{ 305 | font-size:small; 306 | } 307 | 308 | #foreground { 309 | position: absolute; 310 | bottom : 32px; 311 | left : 12px; 312 | z-index : 1; 313 | } 314 | #background { 315 | position: absolute; 316 | bottom : 20px; 317 | left : 24px; 318 | } 319 | #global_overlay { 320 | z-index: 1111; 321 | } 322 | 323 | .fillparent { 324 | position:absolute; 325 | bottom:0px; 326 | top:0px; 327 | left:0px; 328 | right:0px; 329 | } 330 | #save { 331 | position:absolute; 332 | top:0px; 333 | bottom:0px; 334 | right:0px; 335 | } 336 | .pic>.eventoverlay { 337 | transform: translate(-0.5px,-0.5px); 338 | } 339 | 340 | .pic .titlebar { 341 | position: absolute; 342 | height: 20px; 343 | display: inline-block; 344 | top: -23px; 345 | transform-origin: left; 346 | background-color: rgb(151, 149, 112); 347 | border-radius: 0 11px 0 0; 348 | padding-top: 1px; 349 | padding-left: 32px; 350 | padding-right: 38px; 351 | box-shadow: 0px 0px 0px 2px; 352 | font-family: sans-serif; 353 | } 354 | 355 | .pic .titlebar.renaming { 356 | padding-right: 8px; 357 | } 358 | 359 | .pic .titlebar.renaming .renamebutton { 360 | display:none; 361 | } 362 | 363 | .pic[data-z="100"] .titlebar { 364 | background-color: palegoldenrod; 365 | } 366 | 367 | .pic .closebutton { 368 | position: absolute; 369 | top: 2px; 370 | left: 3px; 371 | width: 16px; 372 | height: 16px; 373 | background: #8008; 374 | border-radius: 16px; 375 | box-sizing: border-box; 376 | border: 1px solid black; 377 | cursor: default; 378 | } 379 | 380 | .pic .closebutton:hover { 381 | background: #f33f; 382 | } 383 | 384 | .pic .closebutton:active { 385 | background: #f00f; 386 | border: 3px solid black; 387 | 388 | } 389 | .pic .title input { 390 | font:inherit; 391 | border: none; 392 | outline-width: 0; 393 | background-color: #0000; 394 | } 395 | 396 | .parameters textarea { 397 | width : 47%; 398 | height :4em; 399 | display: inline-block; 400 | border-top :16px solid #aa42; 401 | height: 100%; 402 | } 403 | 404 | .pic .renamebutton { 405 | position: absolute; 406 | top: 0px; 407 | right: 8px; 408 | width:24px; 409 | height:24px; 410 | box-sizing: border-box; 411 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='m3 7h12 0v1h-11v7h11v1h-12zm14-4v15c0 1-1 2-2 2v1c1 0 3-1 3-2 1 1 2 2 3 2v-1c-1 0-2-1-2-2v-2h5v-9h-5v-4c0-1 1-2 2-2v-1c-1 0-3 1-3 2 0-1-2-2-3-2v1c1 0 2 1 2 2m2 5h4v7h-4z' fill='%2300000080'/%3E%3C/svg%3E"); 412 | } 413 | 414 | .parameters label { 415 | margin-left:8px; 416 | font-family: sans-serif; 417 | font-size:14px; 418 | position: absolute; 419 | 420 | } 421 | .parameters { 422 | z-index:2; 423 | overflow: visible; 424 | width: 50%; 425 | display: inline-block; 426 | position: absolute; 427 | right: 0px; 428 | top: 0px; 429 | } 430 | 431 | .diameter_control { 432 | display:inline-block; 433 | margin-top: 8px; 434 | box-shadow: none; 435 | } 436 | 437 | .sidebutton { 438 | width: 32px; 439 | height: 32px; 440 | border: 2px solid black; 441 | position: absolute; 442 | background-color: green; 443 | right: -36px; 444 | top: 0px; 445 | border-radius: 0px 30px 30px 0px; 446 | } 447 | .sidebutton.output { 448 | background: radial-gradient(ellipse at center, #ffffff 0%,#f4ff60 11%,#f4ff60 11%,#80c217 57%,#008000 100%); 449 | } 450 | 451 | #right_panel{ 452 | position: absolute; 453 | top:var(--top-panel-height); 454 | width:var(--right-panel-width); 455 | right:0px; 456 | bottom:0px; 457 | width:200px; 458 | 459 | } 460 | 461 | .sidepanel_block { 462 | position:absolute; 463 | border-top:1px solid black; 464 | border-bottom:1px solid black; 465 | left:0px; 466 | right:0px; 467 | } 468 | 469 | #layer_control { 470 | --control-height :400px; 471 | bottom:0px; 472 | height:var(--control-height); 473 | } 474 | 475 | .layer_list { 476 | height:calc(var(--control-height) - 100px); 477 | overflow-y: auto; 478 | } 479 | 480 | 481 | .layer-attributes { 482 | position:relative; 483 | } 484 | 485 | #layer_control input, 486 | #layer_control select { 487 | width:95%; 488 | text-align:center; 489 | } 490 | 491 | #layer_control label { 492 | width:95%; 493 | text-align:center; 494 | display: inline-block; 495 | padding-top:5px; 496 | font: 16px sans-serif; 497 | } 498 | 499 | #layer_control .thumbnail { 500 | display:inline-block; 501 | } 502 | 503 | #layer_control .layer_name { 504 | display:inline-block; 505 | vertical-align: top; 506 | line-height: 36px; 507 | width: 60%; 508 | } 509 | 510 | .layer_widget.target .layer_name::before { 511 | color:red; 512 | content: "◎"; 513 | } 514 | 515 | #layer_control .layer_name_input { 516 | width: 80%; 517 | border: none; 518 | background: none; 519 | font-size: inherit; 520 | color: inherit; 521 | line-height: inherit; 522 | padding: 0; 523 | margin: 0; 524 | text-align: left; 525 | } 526 | 527 | 528 | #layer_control .visibilitybox { 529 | display:inline-block; 530 | content:"Q"; 531 | height : 32px; 532 | width:24px; 533 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -18 24 32'%3E%3Cpath d='M 0 5 H 15 V -10 H 0 V 5 Z M 2 3 H 2 V -8 H 13 V 3 Z' fill='%2300000080'/%3E%3C/svg%3E"); 534 | } 535 | 536 | #layer_control .visibilitybox.showing { 537 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-11 -13 14 20'%3E%3Cpath d='M 0.4 -0.2 l 1.1 -1.1 a 1.1 1.1 90 0 0 -11.2 -2.2 q 2.9 4.5 7.7 3.7 M -1.8 -4 A 1.1 1.1 90 0 1 -6.7 -3.6 A 1.1 1.1 90 0 1 -1.4 -3.2 M -1 -3.3 A 1.1 1.1 90 0 0 -8.2 -3.2 A 1.1 1.1 90 0 1 -0.5 -4 M -3 -3 A 1.1 1.1 90 1 0 -3.2 -2.8 z' fill='%2300000080'/%3E%3C/svg%3E"); 538 | } 539 | 540 | #layer_control .layer_widget { 541 | height: 40px; 542 | border: 1px solid #0003; 543 | position:relative; 544 | 545 | } 546 | 547 | #layer_control .checkbox { 548 | width: 16px; 549 | height: 16px; 550 | border: 1px solid black; 551 | display: inline-block; 552 | position: absolute; 553 | top: 8px; 554 | right: 8px; 555 | } 556 | #layer_control .checkbox.checked { 557 | background: radial-gradient(ellipse at center, #ffffff 0%,#e0e6f9 11%,#5b9aff 43%,#3d8cf4 70%,#007fef 100%); 558 | } 559 | 560 | 561 | #layer_control .layer_list.insert_top { 562 | border-top:3px solid red; 563 | } 564 | 565 | #layer_control .layer_widget.insert_after { 566 | border-bottom:3px solid red; 567 | } 568 | 569 | .layer_widget.active { 570 | background: cornsilk; 571 | } 572 | 573 | @media (prefers-color-scheme:dark) { 574 | .layer_widget.active { 575 | background: #639; 576 | color:aquamarine; 577 | } 578 | 579 | .layer_widget.target .layer_name::before { 580 | color: #f88; 581 | content: "◎"; 582 | } 583 | .visibilitybox { 584 | filter:invert(1); 585 | } 586 | } 587 | 588 | 589 | .layer-attributes .imageLayer { 590 | height:24px; 591 | } 592 | 593 | .layer-attributes .maskLayer { 594 | display: none; 595 | } 596 | 597 | .layer-attributes.mask .imageLayer { 598 | display: none; 599 | } 600 | 601 | .layer-attributes.mask .maskLayer { 602 | display: block; 603 | height:24px; 604 | } 605 | 606 | .layer_actions { 607 | position: absolute; 608 | bottom: 2px; 609 | border: 1px solid black; 610 | height: 26px; 611 | left: 0px; 612 | right: 0px; 613 | } 614 | .layer_actions .remove_layer { 615 | position:absolute; 616 | 617 | width:24px; 618 | height:24px; 619 | right:0px; 620 | bottom:0px; 621 | box-sizing: border-box; 622 | 623 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-6 -12 24 24'%3E%3Cpath d='M 0 7 L -1 -6 L 13 -6 L 12 7 Z M 3 4 v -7 a 1 1 0 0 0 -1 0 v 7 a 1 1 0 0 0 1 0 Z M 6.5 4 v -7 a 1 1 0 0 0 -1 0 v 7 a 1 1 0 0 0 1 0 Z M 10 3.8 v -7 a 1 1 0 0 0 -1 0 v 7 a 1 1 0 0 0 1 0 m 4 -10 h -16 a 1 0.2 0 0 1 16 0 m -9 -1.3 h 2 a 1 1 0 0 0 -2 0' fill='%2300000080'/%3E%3C/svg%3E"); 624 | } 625 | 626 | .layer_actions .add_layer { 627 | position:absolute; 628 | 629 | width:24px; 630 | height:24px; 631 | left:0px; 632 | bottom:0px; 633 | box-sizing: border-box; 634 | 635 | background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-12 -12 24 24'%3E%3Cpath d='M -1 7 H 1 V 1 H 7 V -1 H 1 V -7 H -1 V -1 H -7 V 1 H -1' fill='%2300000080'/%3E%3C/svg%3E") 636 | } 637 | 638 | .layer_actions .duplicate_layer { 639 | position:absolute; 640 | 641 | width:24px; 642 | height:24px; 643 | left:32px; 644 | bottom:0px; 645 | box-sizing: border-box; 646 | 647 | background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 24'%3E%3Cpath d='m3 9c0-1 1-2 2-2h8c1 0 2 1 2 2v11c0 1-1 2-2 2h-8c-1 0-2-1-2-2v-11m3-1c-1.5 0-2 .5-2 2v9c0 1.5.5 2 2 2h6c1.5 0 2-.5 2-2v-9c0-1.5-.5-2-2-2zm1-1v-2c0-1 1-2 2-2h8c1 0 2 1 2 2v11c0 1-1 2-2 2h-2v-1h1c1.5 0 2-.5 2-2v-9c0-1.5-.5-2-2-2h-6c-1.5 0-2 .5-2 2v1z' fill='%2300000080'/%3E%3C/svg%3E") 648 | } 649 | 650 | 651 | input.maskColor { 652 | display:inline-block; 653 | position:absolute; 654 | top: -3px; 655 | height:28px; 656 | } 657 | 658 | #import_control { 659 | display: inline-block; 660 | width: 184px; 661 | padding: 0.5em; 662 | text-align: center; 663 | height: var(--top-panel-height); 664 | vertical-align: top; 665 | padding-top: 1em; 666 | border-right: 1px solid black; 667 | } 668 | 669 | #import_control select { 670 | width: 100%; 671 | text-align: center; 672 | } 673 | 674 | .modal { 675 | z-index:5000; 676 | display: none; 677 | position: fixed; 678 | left: 0; 679 | top: 0; 680 | width: 100%; 681 | height: 100%; 682 | overflow: auto; 683 | background-color: rgba(0, 0, 0, 0.4); 684 | 685 | } 686 | 687 | .modal h2 { 688 | margin-top: 10px; 689 | } 690 | .modal-active { 691 | display:block; 692 | } 693 | 694 | .modal-content { 695 | background-color: var(--base-color); 696 | color: var(--content-color); 697 | margin: 15% auto; 698 | padding: 20px; 699 | padding-bottom:60px; 700 | border: 1px solid #888; 701 | width: 300px; 702 | position:relative; 703 | text-align:center; 704 | 705 | } 706 | 707 | .close-button { 708 | color: #aaa; 709 | position:absolute; 710 | right:10px; 711 | top:10px; 712 | font-size: 28px; 713 | font-weight: bold; 714 | } 715 | 716 | .close-button:hover, 717 | .close-button:focus { 718 | color: black; 719 | text-decoration: none; 720 | cursor: pointer; 721 | } 722 | 723 | #newImageModal .cancel-button { 724 | position: absolute; 725 | left:10px; 726 | bottom:10px; 727 | } 728 | #newImageModal .create-button { 729 | position: absolute; 730 | right:10px; 731 | bottom:10px; 732 | } 733 | -------------------------------------------------------------------------------- /web/page/support.js: -------------------------------------------------------------------------------- 1 | // these are support functions for bspaint, 2 | // Nothing in here should rely on global state 3 | 'use strict'; 4 | 5 | const abs=Math.abs; 6 | const min=Math.min; 7 | const max=Math.max; 8 | const median = (a, b, c) => max(min(a, b), min(max(a, b), c)); 9 | 10 | const RadiansToDegrees = 180/Math.PI; 11 | 12 | function v2Distance(a,b) { 13 | return Math.hypot(a.x-b.x,a.y-b.y); 14 | } 15 | 16 | function v2Length({x,y}) { 17 | return Math.hypot(x,y) 18 | } 19 | 20 | function v2Sub(a,b) { 21 | return {x:a.x-b.x,y:a.y-b.y}; 22 | } 23 | function v2Add(a,b) { 24 | return {x:a.x+b.x,y:a.y+b.y}; 25 | } 26 | 27 | function v2Scale(a,scale) { 28 | return {x:a.x*scale,y:a.y*scale} 29 | } 30 | 31 | function v2Normalise(a,length=1) { 32 | const scale = length/v2Length(a); 33 | return v2Scale(a,scale); 34 | } 35 | 36 | function v2Dot(a,b) { 37 | return (a.x*b.x + a.y*b.y); 38 | } 39 | 40 | function v2PurpCW({x, y}) { 41 | return {x: y, y: -x}; 42 | } 43 | 44 | function v2PurpCCW({x, y}) { 45 | return {x: -y, y: x}; 46 | } 47 | function v2Angle({x,y}) { 48 | return Math.atan2(y,x); 49 | } 50 | 51 | var v2Purp=v2PurpCCW; 52 | 53 | 54 | function applyTransform({x,y},transform) { 55 | const [a, b, c, d, e, f] = transform; 56 | 57 | // Apply the layer's transformation matrix 58 | const newX = a * x + c * y + e; 59 | const newY = b * x + d * y + f; 60 | 61 | return { x: newX, y: newY }; 62 | } 63 | 64 | function reverseTransform({x,y},transform) { 65 | const [a, b, c, d, e, f] = transform; 66 | 67 | // Apply the inverse of the layer's transformation matrix 68 | const det = a * d - b * c; 69 | 70 | if (det === 0) { 71 | console.error("Transformation matrix is not invertible"); 72 | return {x,y} 73 | } 74 | 75 | const newX = (d * (x - e) - c * (y - f)) / det; 76 | const newY = (-b * (x - e) + a * (y - f)) / det; 77 | 78 | return { x: newX, y: newY }; 79 | } 80 | 81 | function concatenateTransforms([a1, b1, c1, d1, e1, f1], [a2, b2, c2, d2, e2, f2]) { 82 | return [ 83 | a1 * a2 + c1 * b2, 84 | b1 * a2 + d1 * b2, 85 | a1 * c2 + c1 * d2, 86 | b1 * c2 + d1 * d2, 87 | a1 * e2 + c1 * f2 + e1, 88 | b1 * e2 + d1 * f2 + f1 89 | ]; 90 | } 91 | 92 | function scaleTranformWithSkewFudge(A, B, newTransformedA, originalTransform, skewCompensation=0, lockX=false,lockY=false) { 93 | 94 | const transformedA = applyTransform(A, originalTransform); 95 | const transformedB = applyTransform(B, originalTransform); 96 | // Calculate relative differences 97 | const originalRelDiff = { 98 | x: transformedB.x - transformedA.x, 99 | y: transformedB.y - transformedA.y 100 | }; 101 | const newRelDiff = { 102 | x: transformedB.x - newTransformedA.x, 103 | y: transformedB.y - newTransformedA.y 104 | }; 105 | 106 | const angle = Math.atan2(originalTransform[1], originalTransform[0]); 107 | // Calculate inverse rotation matrix 108 | const cosTheta = Math.cos(-angle); 109 | const sinTheta = Math.sin(-angle); 110 | const inverseRotation = [cosTheta, sinTheta, -sinTheta, cosTheta, 0, 0]; 111 | 112 | // Apply inverse rotation to the relative differences 113 | const rotatedOriginalRelDiff = applyTransform(originalRelDiff, inverseRotation); 114 | const rotatedNewRelDiff = applyTransform(newRelDiff, inverseRotation); 115 | 116 | // Calculate scale factors 117 | let scale = { 118 | x: rotatedNewRelDiff.x / rotatedOriginalRelDiff.x, 119 | y: rotatedNewRelDiff.y / rotatedOriginalRelDiff.y 120 | } 121 | 122 | const divergence =v2Dot( v2Normalise(v2Sub(transformedB,transformedA)), v2Purp(v2Normalise(v2Sub(transformedA,newTransformedA)))); 123 | const lengthAMovement = v2Distance(transformedA,newTransformedA); 124 | 125 | 126 | const scaleTransform= [scale.x + skewCompensation* divergence*lengthAMovement, 0, 0, scale.y, 0, 0]; 127 | 128 | if (lockX) scaleTransform[0]=1; 129 | if (lockY) scaleTransform[3]=1; 130 | 131 | const newTransform = concatenateTransforms(originalTransform,scaleTransform); 132 | const newTransformedB = applyTransform(B,newTransform); 133 | 134 | // Calculate translation to keep B in place 135 | const translateX = transformedB.x - newTransformedB.x; 136 | const translateY = transformedB.y - newTransformedB.y; 137 | 138 | const translation = [1, 0, 0, 1, translateX, translateY]; 139 | 140 | const translated = concatenateTransforms(translation,originalTransform); 141 | const result = concatenateTransforms(translated,scaleTransform); 142 | return result; 143 | } 144 | 145 | function scaleTransformWithDynamicFix(a,b,desiredA,transform,lockX = false,lockY=false) { 146 | function errorVector(skewCandidate) { 147 | const newTransform = scaleTranformWithSkewFudge(a, b, desiredA, transform,skewCandidate,false,false); 148 | const actualA = applyTransform(a,newTransform); 149 | return v2Sub(actualA,desiredA); 150 | } 151 | const candidate1 = 0; 152 | const candidate2 = 110; 153 | var skewAdjustment=0; 154 | 155 | let errorVector1 = errorVector(candidate1); 156 | if (errorVector1.x===0 && errorVector1.y===0) { 157 | //All good to go 158 | skewAdjustment=0; 159 | } else { 160 | let errorVector2 = errorVector(candidate2); 161 | 162 | // Calculate the slopes for x and y components of the error vector 163 | let slopeX = (errorVector2.x - errorVector1.x) / (candidate2 - candidate1); 164 | let slopeY = (errorVector2.y - errorVector1.y) / (candidate2 - candidate1); 165 | 166 | // Calculate where the line crosses zero for both components 167 | let zeroCrossingX = candidate1 - errorVector1.x / slopeX; 168 | let zeroCrossingY = candidate1 - errorVector1.y / slopeY; 169 | 170 | if (slopeY==0) { 171 | skewAdjustment=slopeX; 172 | } 173 | else if (slopeX == 0) { 174 | skewAdjustment=slopeY; 175 | } else { 176 | //not strictly necessary to average here but we're in full-on dodgy math country now. 177 | skewAdjustment = (zeroCrossingX + zeroCrossingY) / 2; 178 | } 179 | } 180 | 181 | return scaleTranformWithSkewFudge(a,b,desiredA,transform,skewAdjustment,lockX,lockY) 182 | } 183 | 184 | 185 | function scaleTransformByHandle(a, stationaryPoint, desiredTransformedA, originalTransform, lockX = false, lockY=false) { 186 | return scaleTransformWithDynamicFix(a,stationaryPoint,desiredTransformedA,originalTransform,lockX,lockY) 187 | } 188 | 189 | 190 | function cells(x=1,y=1) { 191 | let width = 1/x; 192 | let height = 1/y; 193 | return strokes => { 194 | let result = []; 195 | //this is ia bit inefficient, but handles the case where strokes go from one cell to another. 196 | //could be optimized to measure the first point of a stroke set and to do operations relative to 197 | //the cell that the first point occurs in. 198 | for (let tx = -x; txa.map( 201 | function ({x,y}) { 202 | return { 203 | x:(x + tx*width), 204 | y:(y + ty*height), 205 | } 206 | })); 207 | result.push(...cell); 208 | } 209 | } 210 | return result; 211 | } 212 | } 213 | 214 | function mirrorX(strokes) { 215 | return [...strokes, ...strokes.map( a=>a.map(({x,y})=>({x:1-x,y})))]; 216 | } 217 | function mirrorY(strokes) { 218 | return [...strokes, ...strokes.map( a=>a.map(({x,y})=>({x,y:1-y})))]; 219 | } 220 | 221 | function mirrorDiagonalXY(strokes) { 222 | return [...strokes, ...strokes.map( a => a.map(({x, y}) => ({x: y, y: x})))]; 223 | } 224 | 225 | function mirrorDiagonalY1MinusX(strokes) { 226 | return [...strokes, ...strokes.map( a => a.map(({x, y}) => ({x: 1 - y, y: 1 - x})))]; 227 | } 228 | 229 | function rotationalSymmetry(ways) { 230 | const angleIncrement = (Math.PI * 2) / ways; 231 | const originX = 0.5, originY = 0.5; 232 | 233 | return function(strokes) { 234 | let newStrokes = [...strokes]; 235 | 236 | for(let i = 1; i < ways; i++) { 237 | const angle = i * angleIncrement; 238 | const cosAngle = Math.cos(angle); 239 | const sinAngle = Math.sin(angle); 240 | 241 | const rotatedStrokes = strokes.map( a => a.map(({x, y}) => ({ 242 | x: cosAngle * (x - originX) - sinAngle * (y - originY) + originX, 243 | y: sinAngle * (x - originX) + cosAngle * (y - originY) + originY 244 | }))); 245 | 246 | newStrokes = [...newStrokes, ...rotatedStrokes]; 247 | } 248 | 249 | return newStrokes; 250 | }; 251 | } 252 | 253 | 254 | function composeFunction(fnA,fnB) { 255 | return (...args)=>(fnB(fnA(...args))) 256 | } 257 | 258 | const mirrorXY = composeFunction(mirrorX,mirrorY); 259 | 260 | CanvasRenderingContext2D.prototype.getAllImageData = function() { 261 | return this.getImageData(0,0,this.canvas.width,this.canvas.height) 262 | } 263 | 264 | CanvasRenderingContext2D.prototype.setAllImageData = function(imageData) { 265 | return this.putImageData(imageData,0,0) 266 | } 267 | 268 | function blankCanvas(width=512, height=width, filled=true) { 269 | const canvas = document.createElement("canvas"); 270 | canvas.width=width; 271 | canvas.height=height; 272 | const ctx=canvas.getContext("2d"); 273 | if (filled) { 274 | ctx.fillStyle="white"; 275 | ctx.fillRect(0,0,width,height); 276 | } 277 | canvas.ctx=ctx; 278 | return canvas; 279 | } 280 | 281 | function circleImage(diameter) { 282 | 283 | const imageSize=(diameter+4)|0; 284 | let result = blankCanvas(imageSize,imageSize,false); 285 | result.ctx.arc(imageSize/2,imageSize/2,diameter/2,0,Math.PI*2); 286 | result.ctx.lineWidth=3; 287 | result.ctx.strokeStyle="#000"; 288 | result.ctx.stroke(); 289 | result.ctx.lineWidth=1; 290 | result.ctx.strokeStyle="#fff"; 291 | result.ctx.stroke(); 292 | return result; 293 | } 294 | 295 | function gridImage(rows,columns,size=24) { 296 | const result = blankCanvas(size,size,false); 297 | const ctx = result.ctx; 298 | const rowHeight = size/rows; 299 | const columnWidth = size/columns; 300 | for (let tx=0; txbounds.x && point.y>bounds.y && point.x120)) value="crosshair"; 359 | return value; 360 | } 361 | 362 | function cloneEvent(event, modifications = {}) { 363 | let eventProperties = { 364 | "bubbles": event.bubbles, 365 | "cancelBubble": event.cancelBubble, 366 | "cancelable": event.cancelable, 367 | "composed": event.composed, 368 | "currentTarget": event.currentTarget, 369 | "defaultPrevented": event.defaultPrevented, 370 | "eventPhase": event.eventPhase, 371 | "isTrusted": event.isTrusted, 372 | "target": event.target, 373 | "timeStamp": event.timeStamp, 374 | "type": event.type, 375 | }; 376 | 377 | // Checking if the event is a MouseEvent, add mouse event properties 378 | if(event instanceof MouseEvent) { 379 | eventProperties = { 380 | ...eventProperties, 381 | "altKey": event.altKey, 382 | "button": event.button, 383 | "buttons": event.buttons, 384 | "clientX": event.clientX, 385 | "clientY": event.clientY, 386 | "ctrlKey": event.ctrlKey, 387 | "metaKey": event.metaKey, 388 | "movementX": event.movementX, 389 | "movementY": event.movementY, 390 | "offsetX": event.offsetX, 391 | "offsetY": event.offsetY, 392 | "pageX": event.pageX, 393 | "pageY": event.pageY, 394 | "relatedTarget": event.relatedTarget, 395 | "screenX": event.screenX, 396 | "screenY": event.screenY, 397 | "shiftKey": event.shiftKey, 398 | }; 399 | } 400 | 401 | return { ...modifications, ...eventProperties }; 402 | } 403 | 404 | function forwardEvent(mouseEvent, recipient) { 405 | var rect = recipient.getBoundingClientRect(); 406 | 407 | const options = cloneEvent(mouseEvent,{ 408 | clientX:mouseEvent.clientX - rect.left, 409 | clientY:mouseEvent.clientY - rect.top, 410 | }); 411 | 412 | var newEvent = new MouseEvent(mouseEvent.type, options ) 413 | 414 | recipient.dispatchEvent(newEvent); 415 | } 416 | -------------------------------------------------------------------------------- /workflows/MultiTarget.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lerc/canvas_tab/e04289a6208b43879705e12b641da9fd4d3d27de/workflows/MultiTarget.mp4 -------------------------------------------------------------------------------- /workflows/README.md: -------------------------------------------------------------------------------- 1 | # Workflows 2 | 3 | 4 | ## Simple sdxl turbo workflow 5 | 6 | ![turbo workflow](https://raw.githubusercontent.com/Lerc/canvas_tab/main/workflows/Turbo_canvas.svg) 7 | 8 | This is the basic turbo workflow. The default view in the canvas is initialized to provide two 9 | images, Image A is sent to the canvas node. A layer on Image B is set as the target for new 10 | generations. 11 | 12 | You can draw and refine your work by starting with a simple scribble in Image A and when something 13 | you like is generated, CTRL-LeftClick on the duplicate layer button will copy that image 14 | and you can use that as a base drawing. 15 | 16 | ![target info](https://raw.githubusercontent.com/Lerc/canvas_tab/main/workflows/TomatoDoodle.jpg) 17 | 18 | You can get into a good design process by drawing a general outline with a high denoising value, then 19 | successively copying the generated image and drawing on top of it for finer adjustments, gradually reducing 20 | the denoising value as you go. 21 | 22 | Sometimes when iterating generating images bases upon previous generations the image may start to become blurry, 23 | this workflow contains a node for sharpening the image that can reduce this problem, By default the sharpening node 24 | is set to bypass meaning it takes no time and has no effect. If the image becomes blurry you can reactivate this 25 | node for a single generation to get a sharper output to use as a new source image. 26 | 27 | ## Multi Canvas with ControlNet 28 | 29 | ![turbo workflow](https://raw.githubusercontent.com/Lerc/canvas_tab/main/workflows/TurboLLiteDepth.svg) 30 | This workflow uses the ControlLLite depthmap model, It requires creating another image in the canvas editor 31 | with the new image button and setting the title to "Depth". The workflow contains a canvas node with the title "Depth", when an image matches the canvas node title the node will be updated directly from that image. 32 | 33 | This workflow has a small sub-unit for generating a depth map from an existing Image That subunit will sit idle unless you feed it an image, It can be useful to refine a manually drawn depthmap by calulating a depthmap from a generated image. Drop the generated image into this subunit to aquire a calculated depthmap. 34 | 35 | https://private-user-images.githubusercontent.com/139390/299817528-4285f866-c2a5-404b-8407-45d1f3167593.mp4 36 | 37 | https://raw.githubusercontent.com/Lerc/canvas_tab/main/workflows/MultiTarget.mp4 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /workflows/TomatoDoodle.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lerc/canvas_tab/e04289a6208b43879705e12b641da9fd4d3d27de/workflows/TomatoDoodle.jpg --------------------------------------------------------------------------------