/***************************************************************************** # # # KVMD - The main PiKVM daemon. # # # # Copyright (C) 2018-2024 Maxim Devaev # # # # This program is free software: you can redistribute it and/or modify # # it under the terms of the GNU General Public License as published by # # the Free Software Foundation, either version 3 of the License, or # # (at your option) any later version. # # # # This program is distributed in the hope that it will be useful, # # but WITHOUT ANY WARRANTY; without even the implied warranty of # # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # # GNU General Public License for more details. # # # # You should have received a copy of the GNU General Public License # # along with this program. If not, see . # # # *****************************************************************************/ "use strict"; import {tools, $, $$, $$$} from "./tools.js"; export var wm; export function initWindowManager() { wm = new __WindowManager(); } function __WindowManager() { var self = this; /************************************************************************/ var __top_z_index = 0; var __windows = []; var __menu_buttons = []; var __catch_menu_esc = false; var __init__ = function() { for (let el of $$("menu-button")) { el.parentElement.querySelector(".menu").tabIndex = -1; tools.el.setOnDown(el, () => __toggleMenu(el)); __menu_buttons.push(el); } if (!window.ResizeObserver) { tools.error("ResizeObserver not supported"); } for (let el_win of $$("window")) { el_win.tabIndex = -1; __makeWindowMovable(el_win); __windows.push(el_win); if (el_win.classList.contains("window-resizable") && window.ResizeObserver) { el_win.__observer_timer = null; new ResizeObserver(function() { // Таймер нужен чтобы остановить дребезг ресайза: observer вызывает // __organizeWindow(), который сам по себе триггерит observer. if (el_win.__observer_timer === null || el_win.__manual_resizing) { __organizeWindow(el_win, !el_win.__manual_resizing); if (el_win.__observer_timer !== null) { clearTimeout(el_win.__observer_timer); } el_win.__observer_timer = setTimeout(function() { el_win.__observer_timer = null; }, 100); } }).observe(el_win); el_win.addEventListener("pointerrawupdate", function(ev) { // События pointerdown и touchdown не генерируются при ресайзе за уголок, // поэтому отлавливаем pointerrawupdate для тач-событий. let events = ev.getCoalescedEvents(); for (ev of events) { if ( ev.target === el_win && ev.pointerType === "touch" && ev.buttons && Math.abs(el_win.clientWidth - ev.offsetX) < 20 && Math.abs(el_win.clientHeight - ev.offsetY) < 20 ) { __setWindowMca(el_win, false, null, true); break; } } }); el_win.addEventListener("mousedown", function(ev) { if ( ev.target === el_win && Math.abs(el_win.clientWidth - ev.offsetX) < 20 && Math.abs(el_win.clientHeight - ev.offsetY) < 20 ) { el_win.__manual_resizing = true; } }); document.addEventListener("mouseup", function() { if (el_win.__manual_resizing) { __organizeWindow(el_win); } el_win.__manual_resizing = false; }); document.addEventListener("mousemove", function(ev) { if (el_win.__manual_resizing) { __setWindowMca(el_win, false, null, true); if (!ev.buttons) { __organizeWindow(el_win); el_win.__manual_resizing = false; } } }); } { let el = el_win.querySelector(".window-header .window-button-close"); if (el) { el.title = "Close window"; tools.el.setOnClick(el, () => self.closeWindow(el_win)); } } { let el = el_win.querySelector(".window-header .window-button-maximize"); if (el) { el.title = "Maximize window"; tools.el.setOnClick(el, function() { __setWindowMca(el_win, true, false, false); __organizeWindow(el_win); __activateWindow(el_win); }); } } { let el = el_win.querySelector(".window-header .window-button-original"); if (el) { el.title = "Reduce window to its original size and center it"; tools.el.setOnClick(el, function() { __setWindowMca(el_win, false, true, false); el_win.style.width = ""; el_win.style.height = ""; __organizeWindow(el_win); __activateWindow(el_win); }); } } { let el_enter = el_win.querySelector(".window-header .window-button-enter-full-tab"); let el_exit = el_win.querySelector(".window-button-exit-full-tab"); if (el_enter && el_exit) { el_enter.title = "Stretch to the entire tab"; tools.el.setOnClick(el_enter, () => self.setFullTabWindow(el_win, true)); tools.el.setOnClick(el_exit, () => self.setFullTabWindow(el_win, false)); } } { let el = el_win.querySelector(".window-header .window-button-full-screen"); if (el && el_win.requestFullscreen) { el.title = "Go to full-screen mode"; tools.el.setOnClick(el, function() { __setFullScreenWindow(el_win); __activateWindow(el_win); }); } } } for (let el of $$$("button[data-show-window]")) { tools.el.setOnClick(el, () => self.showWindow($(el.getAttribute("data-show-window")))); } window.addEventListener("mouseup", __globalMouseButtonHandler); window.addEventListener("touchend", __globalMouseButtonHandler); window.addEventListener("focusin", (ev) => __focusInOut(ev.target, true)); window.addEventListener("focusout", (ev) => __focusInOut(ev.target, false)); // Окна с iframe нуждаются в особенной логике для подсветки, // потому что из iframe не приходят события фокуса. // Мы можешь лишь следить за focus/blur на окне и проверять // активный элемент, и если это iframe - назодить его окно, // и подсвечивать его. Или наоборот, тушить все окна, // в которых есть другие iframe. window.addEventListener("focus", function() { let el_active = document.activeElement; for (let el of document.getElementsByTagName("iframe")) { if (el !== el_active) { __focusInOut(el, false); } } }); window.addEventListener("blur", function() { // При переходе в iframe, в хромиуме прилетает два блура: // с первым активный элемент становится body, со вторым - iframe. // В фоксе оба раза это будет body, но если проверить чуть позже - // то станет iframe. Таймаут решает проблему. setTimeout(function() { let el = document.activeElement; if (el && el.tagName.toLowerCase() === "iframe") { let el_parent = __focusInOut(el, true); if (el_parent !== null) { __activateWindow(el_parent); } } }, 100); }); window.addEventListener("resize", __organizeWindowsOnBrowserResize); window.addEventListener("orientationchange", __organizeWindowsOnBrowserResize); document.addEventListener("fullscreenchange", __onFullScreenChange); document.addEventListener("keyup", function(ev) { if (__catch_menu_esc && ev.code === "Escape") { ev.preventDefault(); __closeAllMenues(); __activateLastWindow(); } }); }; /************************************************************************/ self.info = (html, ...args) => __modalCodeDialog("Info", html, args.join("\n"), true, false); self.error = (html, ...args) => __modalCodeDialog("Error", html, args.join("\n"), true, false); self.confirm = (html, ...args) => __modalCodeDialog("Question", html, args.join("\n"), true, true); self.modal = (header, html, ok, cancel) => __modalDialog(header, html, ok, cancel); var __modalCodeDialog = function(header, html, code, ok, cancel) { let create_content = function(el_content) { if (code) { html += `

${tools.escape(code)}
`; } el_content.innerHTML = html; }; return __modalDialog(header, create_content, ok, cancel); }; var __modalDialog = function(header, html, ok, cancel, el_parent=null) { let el_active_menu = (document.activeElement && document.activeElement.closest(".menu")); let el_modal = document.createElement("div"); el_modal.className = "modal"; el_modal.style.visibility = "visible"; let el_win = document.createElement("div"); el_win.className = "modal-window"; el_win.tabIndex = -1; el_modal.appendChild(el_win); let el_header = document.createElement("div"); el_header.className = "modal-header"; el_header.innerText = header; el_win.appendChild(el_header); let el_content = document.createElement("div"); el_content.className = "modal-content"; el_win.appendChild(el_content); let el_buttons = document.createElement("div"); el_buttons.classList.add("modal-buttons", "buttons-row"); el_win.appendChild(el_buttons); let el_cancel_bt = null; let el_ok_bt = null; if (cancel) { el_cancel_bt = document.createElement("button"); el_cancel_bt.className = "row100"; el_cancel_bt.innerText = "Cancel"; el_buttons.appendChild(el_cancel_bt); } if (ok) { el_ok_bt = document.createElement("button"); el_ok_bt.className = "row100"; el_ok_bt.innerText = "OK"; el_buttons.appendChild(el_ok_bt); } if (ok && cancel) { el_ok_bt.className = "row50"; el_cancel_bt.className = "row50"; } el_win.addEventListener("keyup", function (ev) { ev.preventDefault(); if (ok && ev.code === "Enter") { el_ok_bt.click(); } else if (cancel && ev.code === "Escape") { el_cancel_bt.click(); } }); let promise = null; if (ok || cancel) { promise = new Promise(function(resolve) { function close(retval) { __closeWindow(el_win); let index = __windows.indexOf(el_modal); if (index !== -1) { __windows.splice(index, 1); } if (el_active_menu && el_active_menu.style.visibility === "visible") { el_active_menu.focus(); } else { __activateLastWindow(el_modal); } resolve(retval); // Так как resolve() асинхронный, надо выполнить в эвентлупе после него setTimeout(function() { el_modal.outerHTML = ""; }, 0); } if (cancel) { tools.el.setOnClick(el_cancel_bt, () => close(false)); } if (ok) { tools.el.setOnClick(el_ok_bt, () => close(true)); } }); } __windows.push(el_modal); (el_parent || document.fullscreenElement || document.body).appendChild(el_modal); if (typeof html === "function") { // Это должно быть здесь, потому что элемент должен иметь родителя чтобы существовать html(el_content, el_ok_bt); } else { el_content.innerHTML = html; } __activateWindow(el_modal); return promise; }; var __setWindowMca = function(el_win, maximized, centered, adjusted) { if (maximized !== null) { el_win.toggleAttribute("data-maximized", maximized); if (maximized) { el_win.removeAttribute("data-centered"); } } if (centered !== null) { el_win.toggleAttribute("data-centered", centered); if (centered) { el_win.removeAttribute("data-maximized"); } } if (adjusted !== null) { el_win.toggleAttribute("data-adjusted", adjusted); if (adjusted) { el_win.removeAttribute("data-maximized"); } } }; self.showWindow = function(el_win) { let showed = false; if (!self.isWindowVisible(el_win)) { showed = true; } if (!el_win.hasAttribute("data-adjusted")) { if (el_win.hasAttribute("data-show-maximized") && !el_win.hasAttribute("data-centered")) { __setWindowMca(el_win, true, false, false); } else if (el_win.hasAttribute("data-show-centered") && !el_win.hasAttribute("data-maximized")) { __setWindowMca(el_win, false, true, false); } } __organizeWindow(el_win); el_win.style.visibility = "visible"; __activateWindow(el_win); if (showed && el_win.show_hook) { el_win.show_hook(); } }; self.isWindowVisible = function(el_win) { return (window.getComputedStyle(el_win, null).visibility !== "hidden"); }; self.getViewGeometry = function() { let el_navbar = $("navbar"); return { "top": (el_navbar ? el_navbar.clientHeight : 0), // Navbar height "bottom": Math.max(document.documentElement.clientHeight, window.innerHeight || 0), "left": 0, "right": Math.max(document.documentElement.clientWidth, window.innerWidth || 0), }; }; self.closeWindow = function(el_win) { __closeWindow(el_win); __activateLastWindow(el_win); }; self.setFullTabWindow = function(el_win, enabled) { el_win.classList.toggle("window-full-tab", enabled); let el_navbar = $("navbar"); if (el_navbar) { tools.hidden.setVisible(el_navbar, !enabled); } setTimeout(() => __activateWindow(el_win), 100); }; self.setAspectRatio = function(el_win, width, height) { // XXX: Values from CSS width += 9 + 9 + 2 + 2; height += 30 + 9 + 2 + 2; el_win.__aspect_ratio_width = width; el_win.__aspect_ratio_height = height; el_win.style.maxWidth = "fit-content"; el_win.style.maxHeight = "fit-content"; el_win.style.aspectRatio = `${width} / ${height}`; __organizeWindow(el_win, true, false); }; var __closeWindow = function(el_win) { el_win.focus(); el_win.blur(); el_win.style.visibility = "hidden"; if (el_win.close_hook) { el_win.close_hook(); } }; var __toggleMenu = function(el_a) { let all_hidden = true; for (let el_bt of __menu_buttons) { let el_menu = el_bt.parentElement.querySelector(".menu"); if (el_bt === el_a && window.getComputedStyle(el_menu, null).visibility === "hidden") { let rect = el_menu.getBoundingClientRect(); let offset = self.getViewGeometry().right - (rect.left + el_menu.clientWidth + 2); // + 2 is ugly hack if (offset < 0) { el_menu.style.right = "0px"; } else { el_menu.style.removeProperty("right"); } el_bt.classList.add("menu-button-pressed"); el_menu.style.visibility = "visible"; let el_focus = el_menu.querySelector("[data-focus]"); (el_focus !== null ? el_focus : el_menu).focus(); all_hidden &= false; } else { el_bt.classList.remove("menu-button-pressed"); el_menu.style.visibility = "hidden"; el_menu.style.removeProperty("right"); } } if (all_hidden) { __catch_menu_esc = false; __activateLastWindow(); } else { __catch_menu_esc = true; } }; var __closeAllMenues = function() { __catch_menu_esc = false; for (let el_bt of __menu_buttons) { let el_menu = el_bt.parentElement.querySelector(".menu"); el_bt.classList.remove("menu-button-pressed"); el_menu.style.visibility = "hidden"; el_menu.style.removeProperty("right"); } }; var __focusInOut = function(el, focus_in) { let el_parent = null; if ((el_parent = el.closest(".modal-window")) !== null) { el_parent.classList.toggle("window-active", focus_in); } else if ((el_parent = el.closest(".window")) !== null) { el_parent.classList.toggle("window-active", focus_in); } else if ((el_parent = el.closest(".menu")) !== null) { el_parent.classList.toggle("menu-active", focus_in); } tools.debug(`UI: Focus ${focus_in ? "IN" : "OUT"}:`, el_parent); return el_parent; }; var __globalMouseButtonHandler = function(ev) { if ( ev.target.closest && !ev.target.closest(".menu-button") && !ev.target.closest(".modal") ) { for (let el = ev.target; el && el !== document; el = el.parentNode) { if (el.classList.contains("menu")) { return; } else if (el.hasAttribute("data-force-hide-menu")) { break; } } setTimeout(function() { // Тач-событие на хроме не долетает при data-force-hide-menu, // судя по всему оно прерывается при закрытии меню. // Откладываем обработку. __closeAllMenues(); __activateLastWindow(); }, 10); } }; var __organizeWindowsOnBrowserResize = function() { for (let el_win of $$("window")) { if (el_win.style.visibility === "visible") { __organizeWindow(el_win); } } }; var __organizeWindow = function(el_win, auto_shrink=true, organize_hook=true) { if (organize_hook && el_win.organize_hook) { el_win.organize_hook(); } if (auto_shrink && el_win.classList.contains("window-resizable")) { // При переполнении рабочей области сократить размер окна let view = self.getViewGeometry(); let rect = el_win.getBoundingClientRect(); if ((rect.bottom - rect.top) > (view.bottom - view.top)) { let ratio = (rect.bottom - rect.top) / (view.bottom - view.top); el_win.style.height = view.bottom - view.top + "px"; el_win.style.width = Math.round((rect.right - rect.left) / ratio) + "px"; } if ((rect.right - rect.left) > (view.right - view.left)) { el_win.style.width = view.right - view.left + "px"; } } if (el_win.hasAttribute("data-maximized")) { __organizeMaximizeWindow(el_win); } else if (el_win.hasAttribute("data-centered")) { __organizeCenterWindow(el_win); } else { __organizeFitWindow(el_win); } }; var __organizeCenterWindow = function(el_win) { let view = self.getViewGeometry(); let rect = el_win.getBoundingClientRect(); el_win.style.top = Math.max(view.top, Math.round((view.bottom - rect.height) / 2)) + "px"; el_win.style.left = Math.round((view.right - rect.width) / 2) + "px"; }; var __organizeMaximizeWindow = function(el_win) { let view = self.getViewGeometry(); el_win.style.top = view.top + "px"; let aw = el_win.__aspect_ratio_width; let ah = el_win.__aspect_ratio_height; let gw = view.right - view.left; let gh = view.bottom - view.top; if (aw && ah) { // Умная машинерия только для aspect-ratio if (aw / gw < ah / gh) { el_win.style.width = ""; el_win.style.height = gh + "px"; } else { el_win.style.left = ""; el_win.style.height = ""; el_win.style.width = gw + "px"; } } else if (!el_win.hasAttribute("data-organize-hook")) { // FIXME: Можно было бы проверять наличие organize_hook, // но эвент от обзервера приходит раньше чем настроятся хуки. // По идее это надо бы глобально исправить. el_win.style.width = gw + "px"; el_win.style.height = gh + "px"; } let rect = el_win.getBoundingClientRect(); el_win.style.left = Math.round((view.right - rect.width) / 2) + "px"; }; var __organizeFitWindow = function(el_win) { let view = self.getViewGeometry(); let rect = el_win.getBoundingClientRect(); if (rect.top <= view.top) { el_win.style.top = view.top + "px"; } else if (rect.bottom > view.bottom) { el_win.style.top = view.bottom - rect.height + "px"; } if (rect.left <= view.left) { el_win.style.left = view.left + "px"; } else if (rect.right > view.right) { el_win.style.left = view.right - rect.width + "px"; } }; var __activateLastWindow = function(el_except_win=null) { let el_last_win = null; let el_active = document.activeElement; if (el_active) { el_last_win = (el_active.closest(".modal-window") || el_active.closest(".window")); if (el_last_win && window.getComputedStyle(el_last_win, null).visibility === "hidden") { el_last_win = null; } } if (!el_last_win || el_last_win === el_except_win) { let max_z_index = 0; for (let el_win of __windows) { let z_index = parseInt(window.getComputedStyle(el_win, null).zIndex) || 0; let visibility = window.getComputedStyle(el_win, null).visibility; if (max_z_index < z_index && visibility !== "hidden" && el_win !== el_except_win) { el_last_win = el_win; max_z_index = z_index; } } } if (el_last_win) { tools.debug("UI: Activating last window:", el_last_win); __activateWindow(el_last_win); } else { tools.debug("UI: No last window to activation"); } }; var __activateWindow = function(el_win) { if (window.getComputedStyle(el_win, null).visibility !== "hidden") { let el_to_focus; let el_focused; // A window which contains a focus let el_active = document.activeElement; if (el_win.className === "modal") { el_to_focus = el_win.querySelector(".modal-window"); el_focused = (el_active && el_active.closest(".modal-window")); } else { // .window el_to_focus = el_win; el_focused = (el_active && el_active.closest(".window")); } if (el_win.className !== "modal" && parseInt(el_win.style.zIndex) !== __top_z_index) { __top_z_index += 1; el_win.style.zIndex = __top_z_index; tools.debug("UI: Activated window:", el_win); } if (el_win !== el_focused) { el_to_focus.focus(); tools.debug("UI: Focused window:", el_win); } } }; var __makeWindowMovable = function(el_win) { let el_header = el_win.querySelector(".window-header"); let el_grab = el_win.querySelector(".window-header .window-grab"); if (el_header === null || el_grab === null) { // Для псевдоокна OCR return; } let prev_pos = {"x": 0, "y": 0}; let moving = false; function startMoving(ev) { // При перетаскивании resizable-окна за правый кран экрана оно ужимается. // Этот костыль фиксит это. el_win.style.width = el_win.offsetWidth + "px"; __closeAllMenues(); __activateWindow(el_win); ev = (ev || window.ev); ev.preventDefault(); if (!ev.touches || ev.touches.length === 1) { el_header.classList.add("window-header-grabbed"); prev_pos = getEventPosition(ev); moving = true; } } function doMoving(ev) { if (!moving) { return; } __setWindowMca(el_win, false, false, true); ev = (ev || window.ev); ev.preventDefault(); let ev_pos = getEventPosition(ev); let x = prev_pos.x - ev_pos.x; let y = prev_pos.y - ev_pos.y; el_win.style.top = (el_win.offsetTop - y) + "px"; el_win.style.left = (el_win.offsetLeft - x) + "px"; prev_pos = ev_pos; } function stopMoving() { el_header.classList.remove("window-header-grabbed"); moving = false; } function getEventPosition(ev) { if (ev.touches) { return {"x": ev.touches[0].clientX, "y": ev.touches[0].clientY}; } else { return {"x": ev.clientX, "y": ev.clientY}; } } document.addEventListener("mousemove", doMoving); document.addEventListener("mouseup", stopMoving); document.addEventListener("touchmove", doMoving); document.addEventListener("touchend", stopMoving); el_win.addEventListener("mousedown", () => __activateWindow(el_win)); el_win.addEventListener("touchstart", () => __activateWindow(el_win)); el_grab.addEventListener("mousedown", startMoving); el_grab.addEventListener("touchstart", startMoving); }; var __onFullScreenChange = function(ev) { let el_win = ev.target; if (!document.fullscreenElement) { let rect = el_win.__before_full_screen_rect; if (rect) { el_win.style.width = rect.width + "px"; el_win.style.height = rect.height + "px"; el_win.style.top = rect.top + "px"; el_win.style.left = rect.left + "px"; } } }; var __setFullScreenWindow = function(el_win) { el_win.__before_full_screen_rect = el_win.getBoundingClientRect(); el_win.requestFullscreen(); if (navigator.keyboard && navigator.keyboard.lock) { navigator.keyboard.lock(); } else { let msg = ( "Shortcuts like Alt+Tab, Ctrl+W, Ctrl+N might not be captured.
" + "For best keyboard handling use any browser with
keyboard lock support from this list.

" + "In Chrome use HTTPS and enable system-keyboard-lock
" + "by putting at URL chrome://flags/#system-keyboard-lock" ); __modalDialog("Keyboard lock is unsupported", msg, true, false, el_win); } el_win.focus(el_win); // Почему-то теряется фокус }; __init__(); }