const stampTool = () => toolbar.registerTool( "./res/icons/file-up.svg", "Stamp Image", (state, opt) => { state.loaded = true; // Draw new cursor immediately ovLayer.clear(); state.movecb({...mouse.coords.world.pos}); // Start Listeners mouse.listen.world.onmousemove.on(state.movecb); mouse.listen.world.btn.left.onclick.on(state.drawcb); mouse.listen.world.btn.right.onclick.on(state.cancelcb); // For calls from other tools to paste image if (opt && opt.image) { state.addResource( opt.name || "Clipboard", opt.image, opt.temporary === undefined ? true : opt.temporary, false ); state.ctxmenu.uploadButton.disabled = true; state.back = opt.back || null; toolbar.lock(); } else if (opt) { throw Error( "Pasting from other tools must be in format {image, name?, temporary?, back?}" ); } else { state.ctxmenu.uploadButton.disabled = ""; } }, (state, opt) => { state.loaded = false; // Clear Listeners mouse.listen.world.onmousemove.clear(state.movecb); mouse.listen.world.btn.left.onclick.clear(state.drawcb); mouse.listen.world.btn.right.onclick.clear(state.cancelcb); ovLayer.clear(); }, { init: (state) => { state.loaded = false; state.snapToGrid = true; state.resources = []; state.selected = null; state.back = null; state.lastMouseMove = {x: 0, y: 0}; state.block_res_change = true; state.selectResource = (resource, nolock = true, deselect = true) => { if (nolock && state.ctxmenu.uploadButton.disabled) return; console.debug( `[stamp] Selecting Resource '${resource && resource.name}'[${ resource && resource.id }]` ); const resourceWrapper = resource && resource.dom.wrapper; const wasSelected = resourceWrapper && resourceWrapper.classList.contains("active"); Array.from(state.ctxmenu.resourceList.children).forEach((child) => { child.classList.remove("active"); }); // Select if (!wasSelected) { resourceWrapper && resourceWrapper.classList.add("active"); state.selected = resource; } // If already selected, clear selection (if deselection is enabled) else if (deselect) { resourceWrapper.classList.remove("active"); state.selected = null; } ovLayer.clear(); if (state.loaded) state.redraw(); }; // Open IndexedDB connection const IDBOpenRequest = window.indexedDB.open("stamp", 1); // Synchronizes resources array with the DOM and Local Storage const syncResources = () => { // Saves to IndexedDB /** @type {IDBDatabase} */ const db = state.stampDB; const resources = db .transaction("resources", "readwrite") .objectStore("resources"); try { const FetchKeysQuery = resources.getAllKeys(); FetchKeysQuery.onsuccess = () => { const keys = FetchKeysQuery.result; keys.forEach((key) => { if (!state.resources.find((resource) => resource.id === key)) resources.delete(key); }); }; state.resources .filter((resource) => !resource.temporary && resource.dirty) .forEach((resource) => { const canvas = document.createElement("canvas"); canvas.width = resource.image.width; canvas.height = resource.image.height; const ctx = canvas.getContext("2d"); ctx.drawImage(resource.image, 0, 0); resources.put({ id: resource.id, name: resource.name, src: canvas.toDataURL(), }); resource.dirty = false; }); } catch (e) { console.warn( "[stamp] Failed to synchronize resources with IndexedDB" ); console.warn(e); } // Creates DOM elements when needed state.resources.forEach((resource) => { if ( !state.ctxmenu.resourceList.querySelector( `#resource-${resource.id}` ) ) { console.debug( `[stamp] Creating Resource Element [resource-${resource.id}]` ); const resourceWrapper = document.createElement("div"); resourceWrapper.id = `resource-${resource.id}`; resourceWrapper.title = resource.name; resourceWrapper.classList.add("resource", "list-item"); const resourceTitle = document.createElement("input"); resourceTitle.value = resource.name; resourceTitle.style.pointerEvents = "none"; resourceTitle.addEventListener("change", () => { resource.name = resourceTitle.value; resource.dirty = true; resourceWrapper.title = resourceTitle.value; syncResources(); }); resourceTitle.addEventListener("keyup", function (event) { if (event.key === "Enter") { resourceTitle.blur(); } }); resourceTitle.addEventListener("blur", () => { resourceTitle.style.pointerEvents = "none"; }); resourceTitle.classList.add("resource-title", "title"); resourceWrapper.appendChild(resourceTitle); resourceWrapper.addEventListener("click", () => state.selectResource(resource) ); resourceWrapper.addEventListener("dblclick", () => { resourceTitle.style.pointerEvents = "auto"; resourceTitle.focus(); resourceTitle.select(); }); resourceWrapper.addEventListener("mouseover", () => { state.ctxmenu.previewPane.style.display = "block"; state.ctxmenu.previewPane.style.backgroundImage = `url(${resource.image.src})`; }); resourceWrapper.addEventListener("mouseleave", () => { state.ctxmenu.previewPane.style.display = "none"; }); // Add action buttons const actionArray = document.createElement("div"); actionArray.classList.add("actions"); const saveButton = document.createElement("button"); saveButton.addEventListener( "click", (evn) => { evn.stopPropagation(); const canvas = document.createElement("canvas"); canvas.width = resource.image.width; canvas.height = resource.image.height; canvas.getContext("2d").drawImage(resource.image, 0, 0); downloadCanvas({ canvas, filename: `openOutpaint - resource '${resource.name}'.png`, }); }, {passive: false} ); saveButton.title = "Download Resource"; saveButton.appendChild(document.createElement("div")); saveButton.classList.add("download-btn"); const trashButton = document.createElement("button"); trashButton.addEventListener( "click", (evn) => { evn.stopPropagation(); state.ctxmenu.previewPane.style.display = "none"; state.deleteResource(resource.id); }, {passive: false} ); trashButton.title = "Delete Resource"; trashButton.appendChild(document.createElement("div")); trashButton.classList.add("delete-btn"); actionArray.appendChild(saveButton); actionArray.appendChild(trashButton); resourceWrapper.appendChild(actionArray); state.ctxmenu.resourceList.appendChild(resourceWrapper); resource.dom = {wrapper: resourceWrapper}; } }); // Removes DOM elements when needed const elements = Array.from(state.ctxmenu.resourceList.children); if (elements.length > state.resources.length) elements.forEach((element) => { let remove = true; state.resources.some((resource) => { if (element.id.endsWith(resource.id)) { remove = false; } }); if (remove) { console.debug(`[stamp] Sync Removing Element [${element.id}]`); state.ctxmenu.resourceList.removeChild(element); } }); }; // Adds a image resource (temporary allows only one draw, used for pasting) state.addResource = (name, image, temporary = false, nolock = true) => { const id = guid(); const resource = { id, name, image, dirty: true, temporary, }; console.info(`[stamp] Adding Resource '${name}'[${id}]`); state.resources.push(resource); syncResources(); // Select this resource state.selectResource(resource, nolock, false); return resource; }; // Used for temporary images too state.deleteResource = (id) => { const resourceIndex = state.resources.findIndex((v) => v.id === id); const resource = state.resources[resourceIndex]; if (state.selected === resource) state.selected = null; console.info( `[stamp] Deleting Resource '${resource.name}'[${resource.id}]` ); state.resources.splice(resourceIndex, 1); syncResources(); }; state.movecb = (evn) => { let x = evn.x; let y = evn.y; if (state.snapToGrid) { x += snap(evn.x, 0, 64); y += snap(evn.y, 0, 64); } const vpc = viewport.canvasToView(x, y); uiCtx.clearRect(0, 0, uiCanvas.width, uiCanvas.height); state.erasePrevCursor && state.erasePrevCursor(); uiCtx.save(); state.lastMouseMove = evn; ovLayer.clear(); // Draw selected image if (state.selected) { ovCtx.drawImage(state.selected.image, x, y); } // Draw current cursor location state.erasePrevCursor = _tool._cursor_draw(x, y); uiCtx.restore(); }; state.redraw = () => { state.movecb(state.lastMouseMove); }; state.drawcb = (evn) => { let x = evn.x; let y = evn.y; if (state.snapToGrid) { x += snap(evn.x, 0, 64); y += snap(evn.y, 0, 64); } const resource = state.selected; if (resource) { commands.runCommand("drawImage", "Image Stamp", { image: resource.image, x, y, }); if (resource.temporary) { state.deleteResource(resource.id); } } if (state.back) { toolbar.unlock(); const backfn = state.back; state.back = null; backfn({message: "Returning from stamp", pasted: true}); } }; state.cancelcb = (evn) => { state.selectResource(null); if (state.back) { toolbar.unlock(); const backfn = state.back; state.back = null; backfn({message: "Returning from stamp", pasted: false}); } }; /** * Creates context menu */ if (!state.ctxmenu) { state.ctxmenu = {}; // Snap To Grid Checkbox const array = document.createElement("div"); array.classList.add("checkbox-array"); array.appendChild( _toolbar_input.checkbox( state, "snapToGrid", "Snap To Grid", "icon-grid" ).checkbox ); state.ctxmenu.snapToGridLabel = array; // Create resource list const uploadButtonId = `upload-btn-${guid()}`; const resourceManager = document.createElement("div"); resourceManager.classList.add("resource-manager"); const resourceList = document.createElement("div"); resourceList.classList.add("list"); const previewPane = document.createElement("div"); previewPane.classList.add("preview-pane"); const uploadLabel = document.createElement("label"); uploadLabel.classList.add("upload-button"); uploadLabel.classList.add("button"); uploadLabel.classList.add("tool"); uploadLabel.textContent = "Upload Image"; uploadLabel.htmlFor = uploadButtonId; const uploadButton = document.createElement("input"); uploadButton.id = uploadButtonId; uploadButton.type = "file"; uploadButton.accept = "image/*"; uploadButton.multiple = true; uploadButton.style.display = "none"; uploadButton.addEventListener("change", (evn) => { [...uploadButton.files].forEach((file) => { if (file.type.startsWith("image/")) { console.info("Uploading Image " + file.name); const url = window.URL || window.webkitURL; const image = document.createElement("img"); image.src = url.createObjectURL(file); image.onload = () => state.addResource(file.name, image, false); } }); uploadButton.value = null; }); uploadLabel.appendChild(uploadButton); resourceManager.appendChild(resourceList); resourceManager.appendChild(uploadLabel); resourceManager.appendChild(previewPane); resourceManager.addEventListener( "drop", (evn) => { evn.preventDefault(); resourceManager.classList.remove("dragging"); if (evn.dataTransfer.items) { Array.from(evn.dataTransfer.items).forEach((item) => { if (item.kind === "file" && item.type.startsWith("image/")) { const file = item.getAsFile(); const url = window.URL || window.webkitURL; const image = document.createElement("img"); image.src = url.createObjectURL(file); state.addResource(file.name, image, false); } }); } }, {passive: false} ); resourceManager.addEventListener( "dragover", (evn) => { evn.preventDefault(); }, {passive: false} ); resourceManager.addEventListener("dragover", (evn) => { resourceManager.classList.add("dragging"); }); resourceManager.addEventListener("dragover", (evn) => { resourceManager.classList.remove("dragging"); }); state.ctxmenu.uploadButton = uploadButton; state.ctxmenu.previewPane = previewPane; state.ctxmenu.resourceManager = resourceManager; state.ctxmenu.resourceList = resourceList; // Performs resource fetch from IndexedDB IDBOpenRequest.onerror = (e) => { console.warn("[stamp] Failed to connect to IndexedDB"); console.warn(e); }; IDBOpenRequest.onupgradeneeded = (e) => { const db = e.target.result; console.debug(`[stamp] Setting up database version ${db.version}`); const resourcesStore = db.createObjectStore("resources", { keyPath: "id", }); resourcesStore.createIndex("name", "name", {unique: false}); }; IDBOpenRequest.onsuccess = async (e) => { console.debug("[stamp] Connected to IndexedDB"); state.stampDB = e.target.result; state.stampDB.onerror = (evn) => { console.warn(`[stamp] Database Error:`); console.warn(evn.target.errorCode); }; /** @type {IDBDatabase} */ const db = state.stampDB; /** @type {IDBRequest<{id: string, name: string, src: string}[]>} */ const FetchAllTransaction = db .transaction("resources") .objectStore("resources") .getAll(); FetchAllTransaction.onsuccess = async () => { const data = FetchAllTransaction.result; state.resources.push( ...(await Promise.all( data.map((resource) => { const image = document.createElement("img"); image.src = resource.src; return new Promise((resolve, reject) => { image.onload = () => resolve({ id: resource.id, name: resource.name, image, }); }); }) )) ); syncResources(); }; }; } }, populateContextMenu: (menu, state) => { menu.appendChild(state.ctxmenu.snapToGridLabel); menu.appendChild(state.ctxmenu.resourceManager); }, shortcut: "U", } );