/** * onUiLoaded is a function that is called when the UI is loaded. * @param {Function} callback - The function to be called when the UI is loaded. * @returns {void} * @example */ onUiLoaded(async () => { const elementIDs = { img2img: "#img2img_img2img_tab", sketch: "#img2img_sketch", inpaint: "#img2maskimg", inpaintSketch: "#inpaint_sketch", img2imgTabs: "#mode_img2img .tab-nav", rangeGroup: "#img2img_column_size", img2imgPrompt: "#img2img_prompt textarea", img2imgNegPrompt: "#img2img_neg_prompt textarea", img2imgDemRaw: "#img2img_dimensions_row", sendToInpainBtn: "#image_buttons_img2img #inpaint_tab", sendToInpainBtnT2I: "#image_buttons_txt2img #inpaint_tab", sendToInpainBtnNew: "#img2img_send_to_inpaint", sendToInpainBtnT2INew: "#txt2img_send_to_inpaint" }; const tabNameToElementId = { "Inpaint sketch": elementIDs.inpaintSketch, Inpaint: elementIDs.inpaint, Sketch: elementIDs.sketch, }; // Helper functions function checkIsDefault(elements, elemID) { return Object.values(elements).some(value => value.includes(elemID)); } function hasHorizontalScrollbar(element) { return element.scrollWidth > element.clientWidth; } /** * Dispatches a synthetic wheel event to simulate zoom operation. * @param {Element} targetElement - The target element for the event. * @param {string} sign - "+" or "-" sign to determine the zoom direction. * @param {Event} oldEvent - The original event. */ const zoomFakeWheelEvent = (targetElement, sign, oldEvent) => { const rect = targetElement.getBoundingClientRect(); const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; const detailValue = sign === "-" ? 1 : -1; const event = new WheelEvent("wheel", { bubbles: true, cancelable: true, view: window, detail: detailValue, screenX: centerX, screenY: centerY, clientX: centerX, clientY: centerY, ctrlKey: oldEvent.ctrlKey, altKey: oldEvent.altKey, shiftKey: oldEvent.shiftKey, metaKey: oldEvent.ctrlKey, button: 0, buttons: 0, relatedTarget: null, deltaMode: detailValue, deltaX: 0, deltaY: detailValue, deltaZ: 0 }); targetElement.dispatchEvent(event); }; /** * Waits for an element to be present in the DOM. * @param {string} id - The id of the element. * @return {Promise} A promise that resolves with the element when it's present in the DOM. */ const waitForElement = (id) => new Promise(resolve => { const checkForElement = () => { const element = document.querySelector(id); if (element) return resolve(element); setTimeout(checkForElement, 100); }; checkForElement(); }); /** * Retrieves the active tab from a set of tabs. * @param {Object} elements - The element that contains the tabs. * @param {boolean} all - Optional. If true, all tabs are returned. * @return {Element} The active tab element. */ const getActiveTab = (elements, all = false) => { const tabs = elements.img2imgTabs.querySelectorAll("button"); return all ? tabs : Array.from(tabs).find(tab => tab.classList.contains("selected")); }; /** * Retrieves the ID of the active tab. * @param {Object} elements - The element that contains the tabs. * @return {string} The ID of the active tab. */ const getTabId = (elements) => tabNameToElementId[getActiveTab(elements).innerText]; /** * Waits until the opts object is loaded. * @return {Promise} A promise that resolves with the opts object. */ const waitForOpts = async () => { while (!window.opts || !Object.keys(window.opts).length) { await new Promise(resolve => setTimeout(resolve, 100)); } return window.opts; }; /** * Checks if the key event matches a given modifier key. * @param {KeyboardEvent} event - The key event. * @param {string} key - The name of the modifier key. Can be "Ctrl", "Shift", or "Alt". * @return {boolean} True if the event's key is the specified modifier key, false otherwise. */ const isModifierKey = (event, key) => ({ "Ctrl": event.ctrlKey, "Shift": event.shiftKey, "Alt": event.altKey }[key] || false); /** * Checks if a provided hotkey is valid. * @param {string} value - The value to check. * @return {boolean} Whether the provided hotkey is valid. */ const isValidHotkey = value => { const specialKeys = ["Ctrl", "Alt", "Shift", "Disable"]; return /^[a-z]$/i.test(value) || specialKeys.includes(value); }; /** * Normalizes a provided hotkey. * @param {string} hotkey - The hotkey to normalize. * @return {string} The normalized hotkey. */ const normalizeHotkey = hotkey => hotkey.length === 1 ? `Key${hotkey.toUpperCase()}` : hotkey; /** * Formats a hotkey for display. * @param {string} hotkey - The hotkey to format. * @return {string} The formatted hotkey. */ const formatHotkeyForDisplay = hotkey => hotkey.startsWith("Key") ? hotkey.slice(3) : hotkey; /** * Creates a hotkey configuration using the provided options. * @param {Object} defaultHotkeysConfig - The default hotkey configuration. * @param {Object} hotkeysConfigOpts - The user-provided hotkey configuration options. * @return {Object} The resulting hotkey configuration. */ const createHotkeyConfig = (defaultHotkeysConfig, hotkeysConfigOpts) => { const result = {}; const usedKeys = new Set(); const invalidTypes = new Set(["boolean", "object", "number"]); for (const key in defaultHotkeysConfig) { const userValue = hotkeysConfigOpts[key]; const defaultValue = defaultHotkeysConfig[key]; if (key === "canvas_zoom_undo_extra_key") { result[key] = userValue continue } // if(key === "canvas_zoom_inc_brush_size" || key === "canvas_zoom_dec_brush_size") if (userValue === undefined || invalidTypes.has(typeof userValue) || userValue.startsWith("#") || userValue === "disable") { result[key] = userValue === undefined ? defaultValue : userValue; } else if (isValidHotkey(userValue)) { const normalizedUserValue = normalizeHotkey(userValue); if (!usedKeys.has(normalizedUserValue)) { usedKeys.add(normalizedUserValue); result[key] = normalizedUserValue; } else { console.error(`Hotkey: ${formatHotkeyForDisplay(userValue)} for ${key} is repeated and conflicts with another hotkey. The default hotkey is used: ${formatHotkeyForDisplay(defaultValue)}`); result[key] = defaultValue; } } else { if (key === "canvas_zoom_inc_brush_size" || key === "canvas_zoom_dec_brush_size") { result[key] = defaultValue; } else { console.error(`Hotkey: ${formatHotkeyForDisplay(userValue)} for ${key} is not valid. The default hotkey is used: ${formatHotkeyForDisplay(defaultValue)}`); result[key] = defaultValue; } } } return result; }; /** * Disables specified functions in the configuration. * @param {Object} config - The configuration object. * @param {Array} disabledFunctions - The array of functions to be disabled. * @return {Object} The updated configuration object. */ const disableFunctions = (config, disabledFunctions) => { disabledFunctions.forEach(funcName => { const key = functionMap[funcName]; if (key) config[key] = "disable"; }); return config; }; /** * Clears the mask of the inpaint image. */ const clearMask = () => { const closeBtn = document.querySelector("#img2maskimg button[aria-label='Clear']"); closeBtn?.click(); }; /** * Adjusts the opacity value. * @param {number} opacity - The opacity value. * @return {number} The adjusted opacity. */ const adjustOpacity = opacity => opacity === 1 ? 1 : (100 - opacity) / 100; /** * Sets the opacity of the brush. * @param {number} opacity - The opacity value. */ const setBrushOpacity = opacity => { const canvas = document.querySelector(`${elementIDs.inpaint} canvas[key="interface"]`); canvas.getContext("2d").globalAlpha = adjustOpacity(opacity); }; /** * Sets the opacity of the canvas. * @param {number} opacity - The opacity value. */ const setCanvasOpacity = opacity => { const canvas = document.querySelector(`${elementIDs.inpaint} canvas[key="temp"]`); const redrawBtn = document.querySelector(`${elementIDs.inpaint} button[aria-label="Redraw"]`); const ctx = canvas.getContext("2d"); const transparentMask = localStorage.getItem("transparentMask") === "true"; opacity = adjustOpacity(opacity); ctx.globalCompositeOperation = transparentMask ? "source-over" : "xor"; ctx.globalAlpha = transparentMask ? 1 : opacity; localStorage.setItem("transparentMask", !transparentMask); requestAnimationFrame(() => redrawBtn.click()); }; /** * The restoreImgRedMask function displays a red mask around an image to indicate the aspect ratio. * If the image display property is set to 'none', the mask breaks. To fix this, the function * temporarily sets the display property to 'block' and then hides the mask again after 300 milliseconds * to avoid breaking the canvas. Additionally, the function adjusts the mask to work correctly on * very long images. */ /** * Restores the image red mask for a given element. * @param {Object} elements - The object containing references to the different elements. */ const restoreImgRedMask = elements => { const mainTabId = getTabId(elements); if (!mainTabId) return; const mainTab = gradioApp().querySelector(mainTabId); const img = mainTab?.querySelector("img"); const imageARPreview = gradioApp().querySelector("#imageARPreview"); if (!img || !imageARPreview) return; imageARPreview.style.transform = ""; if (parseFloat(mainTab.style.width) > 865) { const scaleMatch = mainTab.style.transform.match(/scale\(([-+]?[0-9]*\.?[0-9]+)\)/); const zoom = scaleMatch && scaleMatch[1] ? Number(scaleMatch[1]) : 1; // default zoom imageARPreview.style.transformOrigin = "0 0"; imageARPreview.style.transform = `scale(${zoom})`; } if (img.style.display !== "none") return; img.style.display = "block"; setTimeout(() => { img.style.display = "none"; }, 400); }; const hotkeysConfigOpts = await waitForOpts(); // Default config const defaultHotkeysConfig = { canvas_hotkey_zoom: "Alt", canvas_hotkey_adjust: "Ctrl", canvas_zoom_undo_extra_key: "Ctrl", canvas_zoom_hotkey_undo: "KeyZ", canvas_hotkey_reset: "KeyR", canvas_hotkey_fullscreen: "KeyS", canvas_hotkey_move: "KeyF", canvas_hotkey_overlap: "KeyO", canvas_zoom_hotkey_open_colorpanel: "KeyQ", canvas_zoom_hotkey_pin_colorpanel: "KeyT", canvas_zoom_hotkey_dropper: "KeyA", canvas_zoom_hotkey_fill: "KeyH", canvas_zoom_hotkey_transparency: "KeyC", canvas_zoom_inc_brush_size: "BracketRight", canvas_zoom_dec_brush_size: "BracketLeft", canvas_zoom_hide_btn: true, canvas_show_tooltip: true, canvas_auto_expand: true, canvas_zoom_mask_clear: true, canvas_zoom_enable_integration: true, canvas_zoom_brush_opacity: false, canvas_blur_prompt: false, canvas_zoom_draw_staight_lines: false, canvas_zoom_brush_outline: false, canvas_zoom_add_buttons: false, canvas_zoom_inpaint_brushcolor: "#000000", canvas_zoom_transparency_level: 70, canvas_zoom_brush_size_change: 5, canvas_zoom_brush_size: 200, canvas_zoom_disabled_functions: [], }; const functionMap = { "Zoom": "canvas_hotkey_zoom", "Adjust brush size": "canvas_hotkey_adjust", "Moving canvas": "canvas_hotkey_move", "Undo": "canvas_zoom_hotkey_undo", "Fullscreen": "canvas_hotkey_fullscreen", "Reset Zoom": "canvas_hotkey_reset", "Overlap": "canvas_hotkey_overlap", "Open color panel": "canvas_zoom_hotkey_open_colorpanel", "Pin color panel": "canvas_zoom_hotkey_pin_colorpanel", "Dropper": "canvas_zoom_hotkey_dropper", "Fill": "canvas_zoom_hotkey_fill", "Transparency Mode": "canvas_zoom_hotkey_transparency", // "Increase brush size": "canvas_hotkey_inc_brush_size", // "Decrease brush size": "canvas_hotkey_dec_brush_size", }; // Disable funcs const preHotkeysConfig = disableFunctions(hotkeysConfigOpts, hotkeysConfigOpts.canvas_zoom_disabled_functions) // Disable unnecessary user functions const hotkeysConfig = createHotkeyConfig(defaultHotkeysConfig, preHotkeysConfig); // Variables for moving and opacity let isMoving = false; let activeElement; let canvasOpacity = 1; let brushOpacity = 1; window.maskOpacity = hotkeysConfig.canvas_zoom_transparency_level / 100 window.maxBrushSize = hotkeysConfig.canvas_zoom_brush_size // Element data holder const elemData = {}; // Retrieve elements const elements = Object.fromEntries(Object.entries(elementIDs).map(([id, selector]) => [id, gradioApp().querySelector(selector)])); // Apply functionality to the range inputs, restore redmask and correct for long images const rangeInputs = elements.rangeGroup ? Array.from(elements.rangeGroup.querySelectorAll("input")) : [gradioApp().querySelector("#img2img_width input[type='range']"), gradioApp().querySelector("#img2img_height input[type='range']")]; rangeInputs.forEach(input => input?.addEventListener("input", () => restoreImgRedMask(elements))); function getTabElFromText(tabName) { const tabMap = { inpaint: elementIDs.inpaint, sketch: elementIDs.sketch, "inpaint sketch": elementIDs.inpaintSketch, }; return tabMap[tabName.trim().toLowerCase()]; } function getSendButtons(elemId) { const tabMap = { [elementIDs.inpaint]: "inpaint", [elementIDs.sketch]: "sketch", [elementIDs.inpaintSketch]: "inpaint_sketch", [elementIDs.img2img]: "img2img", }; const tabString = tabMap[elemId]; return document.querySelectorAll(`#img2img_copy_to_${tabString} button`); } const isGetSizeImgBtnExists = !!document.querySelector("#img2img_detect_image_size_btn"); let getImgDataBtn, clonedDiv; if (!isGetSizeImgBtnExists) { clonedDiv = elements.img2imgDemRaw.children[0].cloneNode(true); clonedDiv.classList.add("get-img-dem"); getImgDataBtn = clonedDiv.querySelector("button"); getImgDataBtn.innerHTML = "📏"; getImgDataBtn.id = "img2img_res_get_btn"; getImgDataBtn.title = "Get the width and height from the picture"; elements.img2imgDemRaw.appendChild(clonedDiv); } const canvasColor = await waitForOpts(); // Initialize localStorage variables localStorage.setItem("brushOutline", hotkeysConfig.canvas_zoom_brush_outline); // console.log(canvasColor.img2img_inpaint_mask_brush_color,canvasColor.img2img_sketch_default_brush_color,canvasColor.canvas_zoom_inpaint_brushcolor) // img2img_sketch_default_brush_color, img2img_inpaint_mask_brush_color. if (canvasColor.img2img_inpaint_mask_brush_color) { localStorage.setItem("brush_color", canvasColor.img2img_inpaint_mask_brush_color); } else { localStorage.setItem("brush_color", hotkeysConfig.canvas_zoom_inpaint_brushcolor); } if (canvasColor.img2img_sketch_default_brush_color) { localStorage.setItem("sketch_brush_color", canvasColor.img2img_sketch_default_brush_color); } function applyZoomAndPan(elemId, isExtension = true) { const targetElement = gradioApp().querySelector(elemId); if (!targetElement) { console.error("Element not found"); return; } targetElement.style.transformOrigin = "0 0"; elemData[elemId] = { zoom: 1, panX: 0, panY: 0, }; const allButtons = [...getSendButtons(elemId), ...getSendButtons(elementIDs.img2img)]; let fullScreenMode = false; //Fix white canvas when change width function setImgDisplayToNone() { const img = targetElement.querySelector(`${elemId} img`); if (img) img.style.display = "none"; } if (hotkeysConfig.canvas_zoom_mask_clear) { allButtons.forEach((button) => { if (button.innerText !== "img2img") { button.addEventListener("click", () => { const sendToTabName = getTabElFromText(button.innerText); if (sendToTabName) { const closeBtn = document.querySelector( `${sendToTabName} button[aria-label='Remove Image']` ) || document.querySelector( `${sendToTabName} button[aria-label='Clear']` ); closeBtn?.click(); } }); } }); if (elements.sendToInpainBtn) { elements.sendToInpainBtn.addEventListener("click", clearMask); elements.sendToInpainBtnT2I.addEventListener("click", clearMask); } else { elements.sendToInpainBtnNew.addEventListener("click", clearMask); elements.sendToInpainBtnT2INew.addEventListener("click", clearMask); } } if (!isGetSizeImgBtnExists) { getImgDataBtn.addEventListener("click", () => { const tabID = getTabId(elements); const canvas = document.querySelector(`${tabID} canvas`); const img = document.querySelector("#img2img_image img"); const imgUpload = document.querySelector("#img_inpaint_base img"); const rightWidth = img?.naturalWidth || canvas?.width || imgUpload?.naturalWidth; const rightHeight = img?.naturalHeight || canvas?.height || imgUpload?.naturalHeight; if (rightWidth && rightHeight) { const [rangeWidth, rangeHeight] = document.querySelectorAll( "#img2img_width input[type='range'], #img2img_height input[type='range']" ); const [inputWidth, inputHeight] = document.querySelectorAll( "#img2img_width input[type='number'], #img2img_height input[type='number']" ); rangeWidth.value = inputWidth.value = rightWidth; rangeHeight.value = inputHeight.value = rightHeight; const changeEvent = new Event("change"); const inputEvent = new Event("input"); for (const el of [rangeWidth, rangeHeight, inputWidth, inputHeight]) { el.dispatchEvent(changeEvent); el.dispatchEvent(inputEvent); } } }); } function createTooltip() { const toolTipElemnt = targetElement.querySelector(".image-container") || targetElement.querySelector("div[data-testid='image']"); const tooltip = document.createElement("div"); tooltip.className = "canvas-tooltip--extension"; const info = document.createElement("i"); info.className = "canvas-tooltip-info--extension"; const tooltipContent = document.createElement("div"); tooltipContent.className = "canvas-tooltip-content--extension"; const hotkeysInfo = [ { configKey: "canvas_hotkey_zoom", action: "Zoom canvas", keySuffix: " + wheel" }, { configKey: "canvas_hotkey_adjust", action: "Adjust brush size", keySuffix: " + wheel" }, { configKey: "canvas_zoom_hotkey_undo", action: "Undo last action", keyPrefix: `${hotkeysConfig.canvas_zoom_undo_extra_key} + ` }, { configKey: "canvas_hotkey_reset", action: "Reset zoom" }, { configKey: "canvas_hotkey_fullscreen", action: "Fullscreen mode" }, { configKey: "canvas_hotkey_move", action: "Move canvas" }, { configKey: "canvas_hotkey_overlap", action: "Overlap" }, { configKey: "canvas_zoom_hotkey_open_colorpanel", action: "Open color panel" }, { configKey: "canvas_zoom_hotkey_pin_colorpanel", action: "Pin color panel" }, { configKey: "canvas_zoom_hotkey_dropper", action: "Toggle dropper" }, { configKey: "canvas_zoom_hotkey_fill", action: "Fill canvas" }, { configKey: "canvas_zoom_hotkey_transparency", action: "Transparency mode" }, ]; const hotkeys = hotkeysInfo.map((info) => { const configValue = hotkeysConfig[info.configKey]; let key = configValue.slice(-1); if (info.keySuffix) { key = `${configValue}${info.keySuffix}`; } if (info.keyPrefix && info.keyPrefix !== "None + ") { key = `${info.keyPrefix}${configValue[3]}`; } return { key, action: info.action, disabled: configValue === "disable", }; }); hotkeys .filter(hotkey => !hotkey.disabled) .forEach(hotkey => { const p = document.createElement("p"); p.innerHTML = `${hotkey.key} - ${hotkey.action}`; tooltipContent.appendChild(p); }); tooltip.append(info, tooltipContent); toolTipElemnt.appendChild(tooltip); } /** * Function to create fullscreen and dropper buttons and attach them to the target element * @returns {void} */ function createFuncButtons() { const buttonContainer = targetElement.querySelector(".image-container") || targetElement.querySelector("div[data-testid='image']"); const fullscreenBtn = document.createElement("button"); fullscreenBtn.className = "fullscreen-btn-main"; fullscreenBtn.innerHTML = `

F

`; fullscreenBtn.addEventListener("click", () => fitToScreen(true)); const dropperBtn = document.createElement("button"); dropperBtn.className = "dropper-btn-main"; dropperBtn.innerHTML = `

P

`; dropperBtn.addEventListener("click", toggleDropper); buttonContainer.append(fullscreenBtn, dropperBtn); } if (hotkeysConfig.canvas_show_tooltip) { createTooltip(); } if (hotkeysConfig.canvas_zoom_add_buttons) { createFuncButtons(); } /** * Function to fix canvas display issues by hiding image when not in "img2img" mode * @returns {void} */ function fixCanvas() { const activeTab = getActiveTab(elements).textContent.trim(); if (activeTab !== "img2img") { const img = targetElement.querySelector(`${elemId} img`); if (img && img.style.display !== "none") { img.style.display = "none"; img.style.visibility = "hidden"; } } } /** * Function to reset the zoom level and pan position of the target element to their initial values * @param {string} _ - ignored parameter * @param {boolean} isMobile - indicates whether the application is running on a mobile device * @returns {void} */ function resetZoom(_ = "", isMobile = false) { elemData[elemId] = { zoomLevel: 1, panX: 0, panY: 0, }; if (isExtension) { targetElement.style.overflow = "hidden"; } targetElement.isZoomed = false; fixCanvas(); targetElement.style.transform = `scale(${elemData[elemId].zoomLevel}) translate(${elemData[elemId].panX}px, ${elemData[elemId].panY}px)`; const canvas = gradioApp().querySelector(`${elemId} canvas[key="interface"]`); toggleOverlap("off"); fullScreenMode = false; const closeBtn = targetElement.querySelector("button[aria-label='Remove Image']"); if (closeBtn) { closeBtn.addEventListener("click", resetZoom); } if (canvas && isExtension) { const parentElement = targetElement.closest('[id^="component-"]'); if ( canvas && parseFloat(canvas.style.width) > parentElement.offsetWidth && parseFloat(targetElement.style.width) > parentElement.offsetWidth ) { fitToElement(); return; } } if ( canvas && !isExtension && parseFloat(canvas.style.width) > 865 && parseFloat(targetElement.style.width) > 865 ) { fitToElement(); return; } if (isMobile) { fitToElement(); return; } targetElement.style.width = ""; if (canvas) { // targetElement.style.height = canvas.style.height; } } // Toggle the zIndex of the target element between two values, allowing it to overlap or be overlapped by other elements function toggleOverlap(forced = "") { const zIndex1 = "0"; const zIndex2 = "998"; targetElement.style.zIndex = targetElement.style.zIndex !== zIndex2 ? zIndex2 : zIndex1; if (forced === "off") { targetElement.style.zIndex = zIndex1; } else if (forced === "on") { targetElement.style.zIndex = zIndex2; } } // Adjust the brush size based on the deltaY value from a mouse wheel event function adjustBrushSize( elemId, deltaY, withoutValue = false, percentage = 5 ) { const input = gradioApp().querySelector( `${elemId} input[aria-label='Brush radius']` ) || gradioApp().querySelector(`${elemId} button[aria-label="Use brush"]`); if (input) { input.click(); if (!withoutValue) { const maxValue = parseFloat(input.getAttribute("max")) || 100; const changeAmount = maxValue * (percentage / 100); const newValue = parseFloat(input.value) + (deltaY > 0 ? -changeAmount : changeAmount); input.value = Math.min(Math.max(newValue, 0), maxValue); input.dispatchEvent(new Event("change")); } } } // Reset zoom when uploading a new image const fileInput = gradioApp().querySelector( `${elemId} input[type="file"][accept="image/*"].svelte-116rqfv` ); fileInput.addEventListener("click", resetZoom); /** * Updates the zoom level of a specified element, adjusts panX and panY coordinates, and applies style transformations. * @param {number} level - The desired zoom level. * @param {number} x - The X-coordinate. * @param {number} y - The Y-coordinate. * @returns {number} The clamped zoom level. */ const updateZoom = (level, x, y) => { const { panX, panY, zoomLevel } = elemData[elemId]; const clampedLevel = Math.max(0.1, Math.min(level, 15)); Object.assign(elemData[elemId], { panX: panX + x - (x * clampedLevel) / zoomLevel, panY: panY + y - (y * clampedLevel) / zoomLevel, }); Object.assign(targetElement.style, { transformOrigin: "0 0", transform: `translate(${elemData[elemId].panX}px, ${elemData[elemId].panY}px) scale(${clampedLevel})`, }); toggleOverlap("on"); setImgDisplayToNone(); if (isExtension) { targetElement.style.overflow = "visible"; } return clampedLevel; }; /** * Changes the zoom level of a specified element based on user interaction and hotkey configuration. * @param {string} operation - Specifies the type of operation ('+' to zoom in, '-' to zoom out). * @param {object} e - The event object. */ const changeZoomLevel = (operation, e) => { if (!isModifierKey(e, hotkeysConfig.canvas_hotkey_zoom)) return; e.preventDefault(); let { zoomLevel } = elemData[elemId]; if (!zoomLevel) { zoomLevel = 1; elemData[elemId].zoomLevel = zoomLevel; } const delta = zoomLevel > 7 ? 0.9 : zoomLevel > 2 ? 0.6 : 0.2; const zoomPos = { x: e.clientX, y: e.clientY, }; fullScreenMode = false; const rect = targetElement.getBoundingClientRect(); elemData[elemId].zoomLevel = updateZoom( zoomLevel + (operation === "+" ? delta : -delta), zoomPos.x - rect.left, zoomPos.y - rect.top ); targetElement.isZoomed = true; }; /** * This function fits the target element to the screen by calculating * the required scale and offsets. It also updates the global variables * zoomLevel, panX, and panY to reflect the new state. */ function fitToElement() { //Reset Zoom targetElement.style.transform = `translate(${0}px, ${0}px) scale(${1})`; let parentElement; if (isExtension) { parentElement = targetElement.closest('[id^="component-"]'); } else { parentElement = targetElement.parentElement; } // Get element and screen dimensions const elementWidth = targetElement.offsetWidth; const elementHeight = targetElement.offsetHeight; // const parentElement = targetElement.parentElement; const screenWidth = parentElement.clientWidth; const screenHeight = parentElement.clientHeight; // Get element's coordinates relative to the parent element const elementRect = targetElement.getBoundingClientRect(); const parentRect = parentElement.getBoundingClientRect(); const elementX = elementRect.x - parentRect.x; // Calculate scale and offsets const scaleX = screenWidth / elementWidth; const scaleY = screenHeight / elementHeight; const scale = Math.min(scaleX, scaleY); const transformOrigin = window.getComputedStyle(targetElement).transformOrigin; const [originX, originY] = transformOrigin.split(" "); const originXValue = parseFloat(originX); const originYValue = parseFloat(originY); const offsetX = (screenWidth - elementWidth * scale) / 2 - originXValue * (1 - scale); const offsetY = (screenHeight - elementHeight * scale) / 2.5 - originYValue * (1 - scale); // Apply scale and offsets to the element targetElement.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; // Update global variables elemData[elemId].zoomLevel = scale; elemData[elemId].panX = offsetX; elemData[elemId].panY = offsetY; fullScreenMode = false; toggleOverlap("off"); } /** * This function fits the target element to the screen by calculating * the required scale and offsets. It also updates the global variables * zoomLevel, panX, and panY to reflect the new state. */ // Fullscreen mode function fitToScreen(isMobile = false) { const canvas = gradioApp().querySelector( `${elemId} canvas[key="interface"]` ); if (!canvas) return; if (canvas.offsetWidth > 862 || isExtension || isMobile) { targetElement.style.width = (canvas.offsetWidth + 2) + "px"; } if (isExtension) { targetElement.style.overflow = "visible"; } if (fullScreenMode) { if (isMobile) { resetZoom("", true); } else { resetZoom(); } fullScreenMode = false; return; } //Reset Zoom targetElement.style.transform = `translate(${0}px, ${0}px) scale(${1})`; // Get scrollbar width to right-align the image const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; // Get element and screen dimensions const elementWidth = targetElement.offsetWidth; const elementHeight = targetElement.offsetHeight; const screenWidth = window.innerWidth - scrollbarWidth; const screenHeight = window.innerHeight; // Get element's coordinates relative to the page const elementRect = targetElement.getBoundingClientRect(); const elementY = elementRect.y; const elementX = elementRect.x; // Calculate scale and offsets const scaleX = screenWidth / elementWidth; const scaleY = screenHeight / elementHeight; const scale = Math.min(scaleX, scaleY); // Get the current transformOrigin const computedStyle = window.getComputedStyle(targetElement); const transformOrigin = computedStyle.transformOrigin; const [originX, originY] = transformOrigin.split(" "); const originXValue = parseFloat(originX); const originYValue = parseFloat(originY); // Calculate offsets with respect to the transformOrigin const offsetX = (screenWidth - elementWidth * scale) / 2 - elementX - originXValue * (1 - scale); const offsetY = (screenHeight - elementHeight * scale) / 2 - elementY - originYValue * (1 - scale); // Apply scale and offsets to the element targetElement.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; // Update global variables elemData[elemId].zoomLevel = scale; elemData[elemId].panX = offsetX; elemData[elemId].panY = offsetY; fullScreenMode = true; toggleOverlap("on"); } /** * Toggles the opacity mode for the canvas and brush. */ const toggleOpacityMode = () => { if (getActiveTab(elements).innerText !== "Inpaint") return; canvasOpacity = canvasOpacity === 1 ? hotkeysConfig.canvas_zoom_transparency_level : 1; setCanvasOpacity(canvasOpacity); setBrushOpacity(canvasOpacity); }; /** * Restores the source of an image to its original state, in case of an undo operation. */ //Restore undo func function restoreUndo() { const img = targetElement.querySelector(`${elemId} img`); const imgDataSource = img.getAttribute("data-source"); const isUpload = img.getAttribute("data-isupload"); if (imgDataSource && img.src !== imgDataSource && isUpload === "false") { img.style.display = "none"; img.src = imgDataSource; } } /** * Undo the last action performed by the user, considering keypress and mouse events. * @param {object} e - The event object. */ // Undo last action function undoLastAction(e) { let isCtrlPressed = isModifierKey(e, hotkeysConfig.canvas_zoom_undo_extra_key) const isAuxButton = e.button >= 3; const activeTab = activeElement; if (hotkeysConfig.canvas_zoom_undo_extra_key === "None" || isAuxButton) { isCtrlPressed = true } else { if (!isModifierKey(e, hotkeysConfig.canvas_zoom_undo_extra_key)) return; } // Move undoBtn query outside the if statement to avoid unnecessary queries const undoBtn = document.querySelector(`${activeTab} button[aria-label="Undo"]`); // Set opacity to 1, to avoid bugs if (canvasOpacity > 1 && "Inpaint" === getActiveTab(elements).innerText) { setCanvasOpacity(1); setBrushOpacity(1); canvasOpacity = 1; brushOpacity = 1; } if ((isCtrlPressed) && undoBtn && activeTab === elemId) { e.preventDefault(); restoreUndo(); undoBtn.click(); } } // Toggle dropper function toggleDropper() { const colorPickerEnabled = localStorage.getItem("colorPickerEnable") === "true"; localStorage.setItem("colorPickerEnable", !colorPickerEnabled); } /** * Toggles the visibility of certain canvas buttons and tooltips. * @param {boolean} hide - Specifies whether to hide (true) or show (false) the elements. */ function hideCanvasButtons(hide = false) { const funcBtns = document.querySelector(`${activeElement} button[aria-label='Undo']`)?.parentElement; const artistsBtns = document.querySelector(`${activeElement} .brush`)?.parentElement; const tooltip = document.querySelector(`${activeElement} .canvas-tooltip-info--extension`); const elements = [funcBtns, artistsBtns, tooltip]; elements.forEach(element => { if (hide) { element.classList.remove("canvas-buttons-visible"); element.classList.add("canvas-buttons-hidden"); } else { element.classList.remove("canvas-buttons-hidden"); element.classList.add("canvas-buttons-visible"); } }); } /** * Moves a color input panel to the current mouse position. * @param {HTMLElement} colorInput - The color input panel to move. * @returns {Promise} A promise that resolves when the next frame is requested. */ const pinColorPanelToMouse = (colorInput) => new Promise((resolve) => { colorInput.style.position = "absolute"; colorInput.style.visibility = "hidden"; const canvas = document.querySelector(`${elemId} canvas[key="interface"]`); const isMouseOverCanvas = localStorage.getItem("overCanvas") === "true"; const marginLeft = parseInt(window.getComputedStyle(canvas).getPropertyValue("margin-left")) || 0; colorInput.style.left = `${mouseX - targetElement.clientWidth + marginLeft * (isMouseOverCanvas ? 1 : 0)}px`; colorInput.style.top = `${mouseY - 40 - colorInput.offsetHeight}px`; requestAnimationFrame(resolve); }); /** * Opens the brush panel under the mouse pointer and optionally triggers the color menu. * @param {boolean} openUnderMouse - Whether to open the brush panel under the mouse. * @param {boolean} openColorMenu - Whether to trigger the color menu. */ const openBrushPanel = async (openUnderMouse = false, openColorMenu = true) => { const colorBtn = document.querySelector(`${elemId} button[aria-label="Select brush color"]`); if (!document.querySelector(`${elemId} input[aria-label="Brush color"]`)) colorBtn?.click(); setTimeout(async () => { const colorInput = document.querySelector(`${elemId} input[aria-label="Brush color"]`); if (openUnderMouse) await pinColorPanelToMouse(colorInput); if (openColorMenu) colorInput?.click(); }, 0); }; /** * Fills the canvas with the currently selected color. */ const fillCanvasWithColor = () => { openBrushPanel(false, false); localStorage.setItem("fillCanvasBrushColor", "true"); const canvas = document.querySelector(`${elemId} canvas[key="interface"]`); canvas.dispatchEvent(new MouseEvent('mousemove', { clientX: 1, clientY: 1 })); }; /** * Handles key down events to trigger corresponding canvas actions. * @param {KeyboardEvent} event - The keyboard event object. */ const handleKeyDown = (event) => { const allowedKeys = ['KeyV', 'KeyC', 'F5']; const isCtrlPressed = event.ctrlKey; const isTyping = ['TEXTAREA', 'INPUT'].includes(event.target.nodeName); if (isTyping && !hotkeysConfig.canvas_blur_prompt || isCtrlPressed && allowedKeys.includes(event.code)) return; const hotkeyActions = { [hotkeysConfig.canvas_hotkey_reset]: resetZoom, [hotkeysConfig.canvas_hotkey_overlap]: toggleOverlap, [hotkeysConfig.canvas_hotkey_fullscreen]: fitToScreen, [hotkeysConfig.canvas_zoom_hotkey_open_colorpanel]: () => openBrushPanel(false, true), [hotkeysConfig.canvas_zoom_hotkey_pin_colorpanel]: () => openBrushPanel(true, true), [hotkeysConfig.canvas_zoom_hotkey_dropper]: toggleDropper, [hotkeysConfig.canvas_zoom_hotkey_fill]: fillCanvasWithColor, [hotkeysConfig.canvas_zoom_hotkey_transparency]: toggleOpacityMode, [hotkeysConfig.canvas_zoom_hotkey_undo]: undoLastAction, [hotkeysConfig.canvas_zoom_dec_brush_size]: () => adjustBrushSize(elemId, 100, false,hotkeysConfig.canvas_zoom_brush_size_change), [hotkeysConfig.canvas_zoom_inc_brush_size]: () => adjustBrushSize(elemId, -100, false,hotkeysConfig.canvas_zoom_brush_size_change), }; const action = hotkeyActions[event.code]; if (action) { event.preventDefault(); action(event); } else if (["Equal", "NumpadAdd", "Minus", "NumpadSubtract"].includes(event.code)) { event.preventDefault(); zoomFakeWheelEvent(targetElement, event.code.startsWith('Minus') ? '-' : '+', event); } if (isModifierKey(event, hotkeysConfig.canvas_hotkey_zoom) || isModifierKey(event, hotkeysConfig.canvas_hotkey_adjust)) event.preventDefault(); if (event.code === "ShiftLeft" && hotkeysConfig.canvas_zoom_draw_staight_lines) window.drawStraightLine = true; }; // Get mouse position const getMousePosition = ({ offsetX, offsetY }) => { mouseX = offsetX; mouseY = offsetY; }; // We make the transparency of the brush and the transparency of the mask the same function matchBrushOpacity() { const canvas = document.querySelector(`${elementIDs.inpaint} canvas[key="interface"]`); if (canvas && brushOpacity !== hotkeysConfig.canvas_zoom_transparency_level) { setBrushOpacity(100 - hotkeysConfig.canvas_zoom_transparency_level) brushOpacity = hotkeysConfig.canvas_zoom_transparency_level } } if (hotkeysConfig.canvas_zoom_brush_opacity) { targetElement.addEventListener("mousemove", matchBrushOpacity) } // Simulation of the function to put a long image into the screen. // We detect if an image has a scroll bar or not, make a fullscreen to reveal the image, then reduce it to fit into the element. // We hide the image and show it to the user when it is ready. targetElement.isExpanded = false; function autoExpand() { const canvas = document.querySelector(`${elemId} canvas[key="interface"]`); if (canvas) { if (hasHorizontalScrollbar(targetElement) && targetElement.isExpanded === false) { targetElement.style.visibility = "hidden"; setTimeout(() => { fitToScreen(); resetZoom(); targetElement.style.visibility = "visible"; targetElement.isExpanded = true; }, 10); } } } //observers // Creating an observer with a callback function to handle DOM changes const observer = new MutationObserver((mutationsList, observer) => { for (let mutation of mutationsList) { // If the style attribute of the canvas has changed, by observation it happens only when the picture changes if (mutation.type === 'attributes' && mutation.attributeName === 'style' && mutation.target.tagName.toLowerCase() === 'canvas') { targetElement.isExpanded = false; setTimeout(resetZoom, 10); } } }); // Apply auto expand if enabled if (hotkeysConfig.canvas_auto_expand && (!window.applyZoomAndPan || isExtension)) { targetElement.addEventListener("mousemove", autoExpand); // Set up an observer to track attribute changes observer.observe(targetElement, { attributes: true, childList: true, subtree: true }); } targetElement.addEventListener("mousemove", getMousePosition); targetElement.addEventListener("auxclick", undoLastAction); // Handle events only inside the targetElement let isKeyDownHandlerAttached = false; let hideTimeout; let buttonsHidden = false; /** * Handles mouse move events to control canvas operations. */ const handleMouseMove = () => { if (!isKeyDownHandlerAttached) { document.addEventListener("keydown", handleKeyDown); isKeyDownHandlerAttached = true; activeElement = elemId; } const shouldHideButtons = window.isDrawing && hotkeysConfig.canvas_zoom_hide_btn; try { if (shouldHideButtons !== buttonsHidden) { hideCanvasButtons(shouldHideButtons); buttonsHidden = shouldHideButtons; } } catch (error) { // A small hack, as it is necessary that this check works like this // There's no need to report a problem because there isn't one. } }; targetElement.addEventListener("mouseout", () => { const canvas = targetElement.querySelector(`${elemId} canvas`) if (canvas) { hideCanvasButtons(false) } }) /** * Handles mouse leave events to remove key down events and reset active element. */ const handleMouseLeave = () => { if (!isKeyDownHandlerAttached) return; document.removeEventListener("keydown", handleKeyDown); isKeyDownHandlerAttached = false; activeElement = null; }; // Add mouse event handlers targetElement.addEventListener("mousemove", handleMouseMove); targetElement.addEventListener("mouseleave", handleMouseLeave); // Reset zoom when click on another tab elements.img2imgTabs.addEventListener("click", resetZoom); elements.img2imgTabs.addEventListener("click", () => { // targetElement.style.width = ""; if (parseInt(targetElement.style.width) > 865) { setTimeout(fitToElement, 0); } }); /** * Event listener for wheel event on the target element to handle zoom and brush size adjustment. */ targetElement.addEventListener("wheel", (e) => { const isDefaultCanvas = checkIsDefault(elementIDs, elemId); if (!isDefaultCanvas || !window.applyZoomAndPan) { const operation = e.deltaY > 0 ? "-" : "+"; changeZoomLevel(operation, e); } if (!isModifierKey(e, hotkeysConfig.canvas_hotkey_adjust)) return; e.preventDefault(); adjustBrushSize(elemId, e.deltaY, false, hotkeysConfig.canvas_zoom_brush_size_change); }); /** * Handles key down events to trigger moving operations on the canvas. * @param {KeyboardEvent} event - The keyboard event object. */ const handleMoveKeyDown = (e) => { const allowedKeys = ['KeyV', 'KeyC', 'F5']; const isCtrlPressed = e.ctrlKey || e.metaKey; // Combine Ctrl and Meta (Cmd) keys const isTyping = ['TEXTAREA', 'INPUT'].includes(e.target.nodeName); const shouldPreventDefault = (isTyping && !hotkeysConfig.canvas_blur_prompt) || (isCtrlPressed && allowedKeys.includes(e.code)); if (shouldPreventDefault || e.code !== hotkeysConfig.canvas_hotkey_move || !isKeyDownHandlerAttached) { return; } e.preventDefault(); document.activeElement.blur(); isMoving = true; }; /** * Handles key up events to stop moving operations on the canvas. * @param {KeyboardEvent} event - The keyboard event object. */ const handleMoveKeyUp = (e) => { if (e.code === hotkeysConfig.canvas_hotkey_move) isMoving = false; if (e.code === "ShiftLeft") window.drawStraightLine = false; }; document.addEventListener("keydown", handleMoveKeyDown); document.addEventListener("keyup", handleMoveKeyUp); /** * Updates pan position based on mouse movements and redraws the target element. * @param {number} movementX - The amount of horizontal movement. * @param {number} movementY - The amount of vertical movement. */ const updatePanPosition = (movementX, movementY) => { let { zoomLevel, panX, panY } = elemData[elemId]; if (typeof zoomLevel === 'undefined' || typeof panX === 'undefined' || typeof panY === 'undefined') { zoomLevel = 1; panX = 0; panY = 0; elemData[elemId].zoomLevel = zoomLevel; elemData[elemId].panX = panX; elemData[elemId].panY = panY; } const panSpeed = elemData[elemId].zoomLevel > 8 ? 3.5 : 2; elemData[elemId].panX += movementX * panSpeed; elemData[elemId].panY += movementY * panSpeed; requestAnimationFrame(() => { targetElement.style.transform = `translate(${elemData[elemId].panX}px, ${elemData[elemId].panY}px) scale(${elemData[elemId].zoomLevel})`; toggleOverlap("on"); }); }; /** * Handles the movement of the canvas element by keyboard input. * @param {KeyboardEvent} e - The keyboard event object. */ function handleMoveByKey(e) { if (isMoving && elemId === activeElement) { updatePanPosition(e.movementX, e.movementY); targetElement.style.pointerEvents = "none"; if (isExtension) { targetElement.style.overflow = "visible"; } } else { targetElement.style.pointerEvents = "auto"; } } /** * Handles the end of the canvas movement operation. */ const handleEnd = () => { document.removeEventListener("mousemove", handleMoveByMouse); document.removeEventListener("mouseup", handleEnd); targetElement.style.pointerEvents = "auto"; }; /** * Handles the movement of the canvas element by mouse input. * @param {MouseEvent} e - The mouse event object. */ const handleMoveByMouse = (e) => { if (e.shiftKey && elemId === activeElement) { e.preventDefault(); updatePanPosition(e.movementX, e.movementY); targetElement.style.pointerEvents = "none"; if (isExtension) { targetElement.style.overflow = "visible"; } } }; function targetElementHandler(e) { if (e.shiftKey) { e.preventDefault(); document.addEventListener("mousemove", handleMoveByMouse); document.addEventListener("mouseup", handleEnd); } } // Checks for extension function checkForOutBox() { const parentElement = targetElement.closest('[id^="component-"]'); if (parentElement.offsetWidth < targetElement.offsetWidth && !targetElement.isExpanded) { resetZoom(); targetElement.isExpanded = true; } if (parentElement.offsetWidth < targetElement.offsetWidth && elemData[elemId].zoomLevel == 1) { resetZoom(); } if (parentElement.offsetWidth < targetElement.offsetWidth && targetElement.offsetWidth * elemData[elemId].zoomLevel > parentElement.offsetWidth && elemData[elemId].zoomLevel < 1 && !targetElement.isZoomed) { resetZoom(); } } if (isExtension) { targetElement.addEventListener("mousemove", checkForOutBox); } // Prevents sticking to the mouse window.onblur = function () { isMoving = false; }; window.addEventListener('resize', (e) => { resetZoom(); if (isExtension) { targetElement.isExpanded = false; targetElement.isZoomed = false; } }); gradioApp().addEventListener("mousemove", handleMoveByKey); // targetElement.addEventListener("mousedown", targetElementHandler); // Additional trigger if the main trigger has failed targetElement.addEventListener("mouseup", (e) => { window.isDrawing = false }) } applyZoomAndPan(elementIDs.sketch, false); applyZoomAndPan(elementIDs.inpaint, false); applyZoomAndPan(elementIDs.inpaintSketch, false); // Make Canvas zoom func global, like in build in extension // Temp disable have bugs :( // if (!window.applyZoomAndPan) { // window.applyZoomAndPan = applyZoomAndPan // } /** * Adds event listeners to elements for enabling zoom and pan integration. * @param {string} id - The id of the main element. * @param {Array} elementIDs - An array of element IDs to which the event listeners should be added. */ // Make the function global so that other extensions can take advantage of this solution const applyZoomAndPanIntegration = async (id, elementIDs) => { const mainEl = document.querySelector(id); if (id.toLocaleLowerCase() === "none") { for (const elementID of elementIDs) { const el = await waitForElement(elementID); if (!el) break; applyZoomAndPan(elementID); } return; } if (!mainEl) return; mainEl.addEventListener("click", async () => { for (const elementID of elementIDs) { const el = await waitForElement(elementID); if (!el) break; applyZoomAndPan(elementID); } }, { once: true }); }; // Enable Extensions Integration const integrateControlNet = hotkeysConfig.canvas_zoom_enable_integration; if (integrateControlNet) { // Add integration with ControlNet txt2img One TAB applyZoomAndPanIntegration("#txt2img_controlnet", ["#txt2img_controlnet_ControlNet_input_image"]); // Add integration with ControlNet img2img One TAB applyZoomAndPanIntegration("#img2img_controlnet", ["#img2img_controlnet_ControlNet_input_image"]); // Add integration with Regional Prompter applyZoomAndPanIntegration("#RP_main", ["#polymask"]); // Add integration with Latent Couple txt2img applyZoomAndPanIntegration("div#tab_txt2img div#script_twoshot_tabs", ["div#tab_txt2img div#script_twoshot_tabs div#twoshot_canvas_sketch"]); // Add integration with Latent Couple img2img applyZoomAndPanIntegration("div#tab_img2img div#script_twoshot_tabs", ["div#tab_img2img div#script_twoshot_tabs div#twoshot_canvas_sketch"]); // Add integration with Inpaint Anything applyZoomAndPanIntegration("None", ["#ia_sam_image", "#ia_sel_mask"]); // Add by template, if you have a tab then add its class as the first argument, if you have a tab then add none at the beginning // Add your integration and open PR 😊 } // The controlNet author has implemented this functionality in new versions if (!window.applyZoomAndPanIntegration && integrateControlNet) { // Add integration with ControlNet txt2img Tabs applyZoomAndPanIntegration("#txt2img_controlnet", Array.from({ length: 10 }, (_, i) => `#txt2img_controlnet_ControlNet-${i}_input_image`)); // Add integration with ControlNet img2img Tabs applyZoomAndPanIntegration("#img2img_controlnet", Array.from({ length: 10 }, (_, i) => `#img2img_controlnet_ControlNet-${i}_input_image`)); } // if(window.applyZoomAndPanIntegration){ window.applyZoomAndPanIntegration = applyZoomAndPanIntegration // } });