zoom.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640
  1. onUiLoaded(async() => {
  2. const elementIDs = {
  3. img2imgTabs: "#mode_img2img .tab-nav",
  4. inpaint: "#img2maskimg",
  5. inpaintSketch: "#inpaint_sketch",
  6. rangeGroup: "#img2img_column_size",
  7. sketch: "#img2img_sketch",
  8. };
  9. const tabNameToElementId = {
  10. "Inpaint sketch": elementIDs.inpaintSketch,
  11. "Inpaint": elementIDs.inpaint,
  12. "Sketch": elementIDs.sketch,
  13. };
  14. // Helper functions
  15. // Get active tab
  16. function getActiveTab(elements, all = false) {
  17. const tabs = elements.img2imgTabs.querySelectorAll("button");
  18. if (all) return tabs;
  19. for (let tab of tabs) {
  20. if (tab.classList.contains("selected")) {
  21. return tab;
  22. }
  23. }
  24. }
  25. // Get tab ID
  26. function getTabId(elements) {
  27. const activeTab = getActiveTab(elements);
  28. return tabNameToElementId[activeTab.innerText];
  29. }
  30. // Wait until opts loaded
  31. async function waitForOpts() {
  32. for (;;) {
  33. if (window.opts && Object.keys(window.opts).length) {
  34. return window.opts;
  35. }
  36. await new Promise(resolve => setTimeout(resolve, 100));
  37. }
  38. }
  39. // Check is hotkey valid
  40. function isSingleLetter(value) {
  41. return (
  42. typeof value === "string" && value.length === 1 && /[a-z]/i.test(value)
  43. );
  44. }
  45. // Create hotkeyConfig from opts
  46. function createHotkeyConfig(defaultHotkeysConfig, hotkeysConfigOpts) {
  47. const result = {};
  48. const usedKeys = new Set();
  49. for (const key in defaultHotkeysConfig) {
  50. if (typeof hotkeysConfigOpts[key] === "boolean") {
  51. result[key] = hotkeysConfigOpts[key];
  52. continue;
  53. }
  54. if (
  55. hotkeysConfigOpts[key] &&
  56. isSingleLetter(hotkeysConfigOpts[key]) &&
  57. !usedKeys.has(hotkeysConfigOpts[key].toUpperCase())
  58. ) {
  59. // If the property passed the test and has not yet been used, add 'Key' before it and save it
  60. result[key] = "Key" + hotkeysConfigOpts[key].toUpperCase();
  61. usedKeys.add(hotkeysConfigOpts[key].toUpperCase());
  62. } else {
  63. // If the property does not pass the test or has already been used, we keep the default value
  64. console.error(
  65. `Hotkey: ${hotkeysConfigOpts[key]} for ${key} is repeated and conflicts with another hotkey or is not 1 letter. The default hotkey is used: ${defaultHotkeysConfig[key][3]}`
  66. );
  67. result[key] = defaultHotkeysConfig[key];
  68. }
  69. }
  70. return result;
  71. }
  72. /**
  73. * The restoreImgRedMask function displays a red mask around an image to indicate the aspect ratio.
  74. * If the image display property is set to 'none', the mask breaks. To fix this, the function
  75. * temporarily sets the display property to 'block' and then hides the mask again after 300 milliseconds
  76. * to avoid breaking the canvas. Additionally, the function adjusts the mask to work correctly on
  77. * very long images.
  78. */
  79. function restoreImgRedMask(elements) {
  80. const mainTabId = getTabId(elements);
  81. if (!mainTabId) return;
  82. const mainTab = gradioApp().querySelector(mainTabId);
  83. const img = mainTab.querySelector("img");
  84. const imageARPreview = gradioApp().querySelector("#imageARPreview");
  85. if (!img || !imageARPreview) return;
  86. imageARPreview.style.transform = "";
  87. if (parseFloat(mainTab.style.width) > 865) {
  88. const transformString = mainTab.style.transform;
  89. const scaleMatch = transformString.match(/scale\(([-+]?[0-9]*\.?[0-9]+)\)/);
  90. let zoom = 1; // default zoom
  91. if (scaleMatch && scaleMatch[1]) {
  92. zoom = Number(scaleMatch[1]);
  93. }
  94. imageARPreview.style.transformOrigin = "0 0";
  95. imageARPreview.style.transform = `scale(${zoom})`;
  96. }
  97. if (img.style.display !== "none") return;
  98. img.style.display = "block";
  99. setTimeout(() => {
  100. img.style.display = "none";
  101. }, 400);
  102. }
  103. const hotkeysConfigOpts = await waitForOpts();
  104. // Default config
  105. const defaultHotkeysConfig = {
  106. canvas_hotkey_reset: "KeyR",
  107. canvas_hotkey_fullscreen: "KeyS",
  108. canvas_hotkey_move: "KeyF",
  109. canvas_hotkey_overlap: "KeyO",
  110. canvas_show_tooltip: true,
  111. canvas_swap_controls: false
  112. };
  113. // swap the actions for ctr + wheel and shift + wheel
  114. const hotkeysConfig = createHotkeyConfig(
  115. defaultHotkeysConfig,
  116. hotkeysConfigOpts
  117. );
  118. let isMoving = false;
  119. let mouseX, mouseY;
  120. let activeElement;
  121. const elements = Object.fromEntries(Object.keys(elementIDs).map((id) => [
  122. id,
  123. gradioApp().querySelector(elementIDs[id]),
  124. ]));
  125. const elemData = {};
  126. // Apply functionality to the range inputs. Restore redmask and correct for long images.
  127. const rangeInputs = elements.rangeGroup ? Array.from(elements.rangeGroup.querySelectorAll("input")) :
  128. [
  129. gradioApp().querySelector("#img2img_width input[type='range']"),
  130. gradioApp().querySelector("#img2img_height input[type='range']")
  131. ];
  132. for (const input of rangeInputs) {
  133. input?.addEventListener("input", () => restoreImgRedMask(elements));
  134. }
  135. function applyZoomAndPan(elemId) {
  136. const targetElement = gradioApp().querySelector(elemId);
  137. if (!targetElement) {
  138. console.log("Element not found");
  139. return;
  140. }
  141. targetElement.style.transformOrigin = "0 0";
  142. elemData[elemId] = {
  143. zoom: 1,
  144. panX: 0,
  145. panY: 0
  146. };
  147. let fullScreenMode = false;
  148. // Create tooltip
  149. function createTooltip() {
  150. const toolTipElemnt =
  151. targetElement.querySelector(".image-container");
  152. const tooltip = document.createElement("div");
  153. tooltip.className = "tooltip";
  154. // Creating an item of information
  155. const info = document.createElement("i");
  156. info.className = "tooltip-info";
  157. info.textContent = "";
  158. // Create a container for the contents of the tooltip
  159. const tooltipContent = document.createElement("div");
  160. tooltipContent.className = "tooltip-content";
  161. // Add info about hotkeys
  162. const zoomKey = hotkeysConfig.canvas_swap_controls ? "Ctrl" : "Shift";
  163. const adjustKey = hotkeysConfig.canvas_swap_controls ? "Shift" : "Ctrl";
  164. const hotkeys = [
  165. {key: `${zoomKey} + wheel`, action: "Zoom canvas"},
  166. {key: `${adjustKey} + wheel`, action: "Adjust brush size"},
  167. {
  168. key: hotkeysConfig.canvas_hotkey_reset.charAt(hotkeysConfig.canvas_hotkey_reset.length - 1),
  169. action: "Reset zoom"
  170. },
  171. {
  172. key: hotkeysConfig.canvas_hotkey_fullscreen.charAt(hotkeysConfig.canvas_hotkey_fullscreen.length - 1),
  173. action: "Fullscreen mode"
  174. },
  175. {
  176. key: hotkeysConfig.canvas_hotkey_move.charAt(hotkeysConfig.canvas_hotkey_move.length - 1),
  177. action: "Move canvas"
  178. }
  179. ];
  180. for (const hotkey of hotkeys) {
  181. const p = document.createElement("p");
  182. p.innerHTML = `<b>${hotkey.key}</b> - ${hotkey.action}`;
  183. tooltipContent.appendChild(p);
  184. }
  185. // Add information and content elements to the tooltip element
  186. tooltip.appendChild(info);
  187. tooltip.appendChild(tooltipContent);
  188. // Add a hint element to the target element
  189. toolTipElemnt.appendChild(tooltip);
  190. }
  191. //Show tool tip if setting enable
  192. if (hotkeysConfig.canvas_show_tooltip) {
  193. createTooltip();
  194. }
  195. // In the course of research, it was found that the tag img is very harmful when zooming and creates white canvases. This hack allows you to almost never think about this problem, it has no effect on webui.
  196. function fixCanvas() {
  197. const activeTab = getActiveTab(elements).textContent.trim();
  198. if (activeTab !== "img2img") {
  199. const img = targetElement.querySelector(`${elemId} img`);
  200. if (img && img.style.display !== "none") {
  201. img.style.display = "none";
  202. img.style.visibility = "hidden";
  203. }
  204. }
  205. }
  206. // Reset the zoom level and pan position of the target element to their initial values
  207. function resetZoom() {
  208. elemData[elemId] = {
  209. zoomLevel: 1,
  210. panX: 0,
  211. panY: 0
  212. };
  213. fixCanvas();
  214. targetElement.style.transform = `scale(${elemData[elemId].zoomLevel}) translate(${elemData[elemId].panX}px, ${elemData[elemId].panY}px)`;
  215. const canvas = gradioApp().querySelector(
  216. `${elemId} canvas[key="interface"]`
  217. );
  218. toggleOverlap("off");
  219. fullScreenMode = false;
  220. if (
  221. canvas &&
  222. parseFloat(canvas.style.width) > 865 &&
  223. parseFloat(targetElement.style.width) > 865
  224. ) {
  225. fitToElement();
  226. return;
  227. }
  228. targetElement.style.width = "";
  229. if (canvas) {
  230. targetElement.style.height = canvas.style.height;
  231. }
  232. }
  233. // Toggle the zIndex of the target element between two values, allowing it to overlap or be overlapped by other elements
  234. function toggleOverlap(forced = "") {
  235. const zIndex1 = "0";
  236. const zIndex2 = "998";
  237. targetElement.style.zIndex =
  238. targetElement.style.zIndex !== zIndex2 ? zIndex2 : zIndex1;
  239. if (forced === "off") {
  240. targetElement.style.zIndex = zIndex1;
  241. } else if (forced === "on") {
  242. targetElement.style.zIndex = zIndex2;
  243. }
  244. }
  245. // Adjust the brush size based on the deltaY value from a mouse wheel event
  246. function adjustBrushSize(
  247. elemId,
  248. deltaY,
  249. withoutValue = false,
  250. percentage = 5
  251. ) {
  252. const input =
  253. gradioApp().querySelector(
  254. `${elemId} input[aria-label='Brush radius']`
  255. ) ||
  256. gradioApp().querySelector(
  257. `${elemId} button[aria-label="Use brush"]`
  258. );
  259. if (input) {
  260. input.click();
  261. if (!withoutValue) {
  262. const maxValue =
  263. parseFloat(input.getAttribute("max")) || 100;
  264. const changeAmount = maxValue * (percentage / 100);
  265. const newValue =
  266. parseFloat(input.value) +
  267. (deltaY > 0 ? -changeAmount : changeAmount);
  268. input.value = Math.min(Math.max(newValue, 0), maxValue);
  269. input.dispatchEvent(new Event("change"));
  270. }
  271. }
  272. }
  273. // Reset zoom when uploading a new image
  274. const fileInput = gradioApp().querySelector(
  275. `${elemId} input[type="file"][accept="image/*"].svelte-116rqfv`
  276. );
  277. fileInput.addEventListener("click", resetZoom);
  278. // Update the zoom level and pan position of the target element based on the values of the zoomLevel, panX and panY variables
  279. function updateZoom(newZoomLevel, mouseX, mouseY) {
  280. newZoomLevel = Math.max(0.5, Math.min(newZoomLevel, 15));
  281. elemData[elemId].panX +=
  282. mouseX - (mouseX * newZoomLevel) / elemData[elemId].zoomLevel;
  283. elemData[elemId].panY +=
  284. mouseY - (mouseY * newZoomLevel) / elemData[elemId].zoomLevel;
  285. targetElement.style.transformOrigin = "0 0";
  286. targetElement.style.transform = `translate(${elemData[elemId].panX}px, ${elemData[elemId].panY}px) scale(${newZoomLevel})`;
  287. toggleOverlap("on");
  288. return newZoomLevel;
  289. }
  290. // Change the zoom level based on user interaction
  291. function changeZoomLevel(operation, e) {
  292. if (
  293. (!hotkeysConfig.canvas_swap_controls && e.shiftKey) ||
  294. (hotkeysConfig.canvas_swap_controls && e.ctrlKey)
  295. ) {
  296. e.preventDefault();
  297. let zoomPosX, zoomPosY;
  298. let delta = 0.2;
  299. if (elemData[elemId].zoomLevel > 7) {
  300. delta = 0.9;
  301. } else if (elemData[elemId].zoomLevel > 2) {
  302. delta = 0.6;
  303. }
  304. zoomPosX = e.clientX;
  305. zoomPosY = e.clientY;
  306. fullScreenMode = false;
  307. elemData[elemId].zoomLevel = updateZoom(
  308. elemData[elemId].zoomLevel +
  309. (operation === "+" ? delta : -delta),
  310. zoomPosX - targetElement.getBoundingClientRect().left,
  311. zoomPosY - targetElement.getBoundingClientRect().top
  312. );
  313. }
  314. }
  315. /**
  316. * This function fits the target element to the screen by calculating
  317. * the required scale and offsets. It also updates the global variables
  318. * zoomLevel, panX, and panY to reflect the new state.
  319. */
  320. function fitToElement() {
  321. //Reset Zoom
  322. targetElement.style.transform = `translate(${0}px, ${0}px) scale(${1})`;
  323. // Get element and screen dimensions
  324. const elementWidth = targetElement.offsetWidth;
  325. const elementHeight = targetElement.offsetHeight;
  326. const parentElement = targetElement.parentElement;
  327. const screenWidth = parentElement.clientWidth;
  328. const screenHeight = parentElement.clientHeight;
  329. // Get element's coordinates relative to the parent element
  330. const elementRect = targetElement.getBoundingClientRect();
  331. const parentRect = parentElement.getBoundingClientRect();
  332. const elementX = elementRect.x - parentRect.x;
  333. // Calculate scale and offsets
  334. const scaleX = screenWidth / elementWidth;
  335. const scaleY = screenHeight / elementHeight;
  336. const scale = Math.min(scaleX, scaleY);
  337. const transformOrigin =
  338. window.getComputedStyle(targetElement).transformOrigin;
  339. const [originX, originY] = transformOrigin.split(" ");
  340. const originXValue = parseFloat(originX);
  341. const originYValue = parseFloat(originY);
  342. const offsetX =
  343. (screenWidth - elementWidth * scale) / 2 -
  344. originXValue * (1 - scale);
  345. const offsetY =
  346. (screenHeight - elementHeight * scale) / 2.5 -
  347. originYValue * (1 - scale);
  348. // Apply scale and offsets to the element
  349. targetElement.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
  350. // Update global variables
  351. elemData[elemId].zoomLevel = scale;
  352. elemData[elemId].panX = offsetX;
  353. elemData[elemId].panY = offsetY;
  354. fullScreenMode = false;
  355. toggleOverlap("off");
  356. }
  357. /**
  358. * This function fits the target element to the screen by calculating
  359. * the required scale and offsets. It also updates the global variables
  360. * zoomLevel, panX, and panY to reflect the new state.
  361. */
  362. // Fullscreen mode
  363. function fitToScreen() {
  364. const canvas = gradioApp().querySelector(
  365. `${elemId} canvas[key="interface"]`
  366. );
  367. if (!canvas) return;
  368. if (canvas.offsetWidth > 862) {
  369. targetElement.style.width = canvas.offsetWidth + "px";
  370. }
  371. if (fullScreenMode) {
  372. resetZoom();
  373. fullScreenMode = false;
  374. return;
  375. }
  376. //Reset Zoom
  377. targetElement.style.transform = `translate(${0}px, ${0}px) scale(${1})`;
  378. // Get scrollbar width to right-align the image
  379. const scrollbarWidth =
  380. window.innerWidth - document.documentElement.clientWidth;
  381. // Get element and screen dimensions
  382. const elementWidth = targetElement.offsetWidth;
  383. const elementHeight = targetElement.offsetHeight;
  384. const screenWidth = window.innerWidth - scrollbarWidth;
  385. const screenHeight = window.innerHeight;
  386. // Get element's coordinates relative to the page
  387. const elementRect = targetElement.getBoundingClientRect();
  388. const elementY = elementRect.y;
  389. const elementX = elementRect.x;
  390. // Calculate scale and offsets
  391. const scaleX = screenWidth / elementWidth;
  392. const scaleY = screenHeight / elementHeight;
  393. const scale = Math.min(scaleX, scaleY);
  394. // Get the current transformOrigin
  395. const computedStyle = window.getComputedStyle(targetElement);
  396. const transformOrigin = computedStyle.transformOrigin;
  397. const [originX, originY] = transformOrigin.split(" ");
  398. const originXValue = parseFloat(originX);
  399. const originYValue = parseFloat(originY);
  400. // Calculate offsets with respect to the transformOrigin
  401. const offsetX =
  402. (screenWidth - elementWidth * scale) / 2 -
  403. elementX -
  404. originXValue * (1 - scale);
  405. const offsetY =
  406. (screenHeight - elementHeight * scale) / 2 -
  407. elementY -
  408. originYValue * (1 - scale);
  409. // Apply scale and offsets to the element
  410. targetElement.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
  411. // Update global variables
  412. elemData[elemId].zoomLevel = scale;
  413. elemData[elemId].panX = offsetX;
  414. elemData[elemId].panY = offsetY;
  415. fullScreenMode = true;
  416. toggleOverlap("on");
  417. }
  418. // Handle keydown events
  419. function handleKeyDown(event) {
  420. const hotkeyActions = {
  421. [hotkeysConfig.canvas_hotkey_reset]: resetZoom,
  422. [hotkeysConfig.canvas_hotkey_overlap]: toggleOverlap,
  423. [hotkeysConfig.canvas_hotkey_fullscreen]: fitToScreen
  424. };
  425. const action = hotkeyActions[event.code];
  426. if (action) {
  427. event.preventDefault();
  428. action(event);
  429. }
  430. }
  431. // Get Mouse position
  432. function getMousePosition(e) {
  433. mouseX = e.offsetX;
  434. mouseY = e.offsetY;
  435. }
  436. targetElement.addEventListener("mousemove", getMousePosition);
  437. // Handle events only inside the targetElement
  438. let isKeyDownHandlerAttached = false;
  439. function handleMouseMove() {
  440. if (!isKeyDownHandlerAttached) {
  441. document.addEventListener("keydown", handleKeyDown);
  442. isKeyDownHandlerAttached = true;
  443. activeElement = elemId;
  444. }
  445. }
  446. function handleMouseLeave() {
  447. if (isKeyDownHandlerAttached) {
  448. document.removeEventListener("keydown", handleKeyDown);
  449. isKeyDownHandlerAttached = false;
  450. activeElement = null;
  451. }
  452. }
  453. // Add mouse event handlers
  454. targetElement.addEventListener("mousemove", handleMouseMove);
  455. targetElement.addEventListener("mouseleave", handleMouseLeave);
  456. // Reset zoom when click on another tab
  457. elements.img2imgTabs.addEventListener("click", resetZoom);
  458. elements.img2imgTabs.addEventListener("click", () => {
  459. // targetElement.style.width = "";
  460. if (parseInt(targetElement.style.width) > 865) {
  461. setTimeout(fitToElement, 0);
  462. }
  463. });
  464. targetElement.addEventListener("wheel", e => {
  465. // change zoom level
  466. const operation = e.deltaY > 0 ? "-" : "+";
  467. changeZoomLevel(operation, e);
  468. // Handle brush size adjustment with ctrl key pressed
  469. if (
  470. (hotkeysConfig.canvas_swap_controls && e.shiftKey) ||
  471. (!hotkeysConfig.canvas_swap_controls &&
  472. (e.ctrlKey || e.metaKey))
  473. ) {
  474. e.preventDefault();
  475. // Increase or decrease brush size based on scroll direction
  476. adjustBrushSize(elemId, e.deltaY);
  477. }
  478. });
  479. // Handle the move event for pan functionality. Updates the panX and panY variables and applies the new transform to the target element.
  480. function handleMoveKeyDown(e) {
  481. if (e.code === hotkeysConfig.canvas_hotkey_move) {
  482. if (!e.ctrlKey && !e.metaKey && isKeyDownHandlerAttached) {
  483. e.preventDefault();
  484. document.activeElement.blur();
  485. isMoving = true;
  486. }
  487. }
  488. }
  489. function handleMoveKeyUp(e) {
  490. if (e.code === hotkeysConfig.canvas_hotkey_move) {
  491. isMoving = false;
  492. }
  493. }
  494. document.addEventListener("keydown", handleMoveKeyDown);
  495. document.addEventListener("keyup", handleMoveKeyUp);
  496. // Detect zoom level and update the pan speed.
  497. function updatePanPosition(movementX, movementY) {
  498. let panSpeed = 2;
  499. if (elemData[elemId].zoomLevel > 8) {
  500. panSpeed = 3.5;
  501. }
  502. elemData[elemId].panX += movementX * panSpeed;
  503. elemData[elemId].panY += movementY * panSpeed;
  504. // Delayed redraw of an element
  505. requestAnimationFrame(() => {
  506. targetElement.style.transform = `translate(${elemData[elemId].panX}px, ${elemData[elemId].panY}px) scale(${elemData[elemId].zoomLevel})`;
  507. toggleOverlap("on");
  508. });
  509. }
  510. function handleMoveByKey(e) {
  511. if (isMoving && elemId === activeElement) {
  512. updatePanPosition(e.movementX, e.movementY);
  513. targetElement.style.pointerEvents = "none";
  514. } else {
  515. targetElement.style.pointerEvents = "auto";
  516. }
  517. }
  518. // Prevents sticking to the mouse
  519. window.onblur = function() {
  520. isMoving = false;
  521. };
  522. gradioApp().addEventListener("mousemove", handleMoveByKey);
  523. }
  524. applyZoomAndPan(elementIDs.sketch);
  525. applyZoomAndPan(elementIDs.inpaint);
  526. applyZoomAndPan(elementIDs.inpaintSketch);
  527. // Make the function global so that other extensions can take advantage of this solution
  528. window.applyZoomAndPan = applyZoomAndPan;
  529. });