/*eslint-disable*/ ((exports) => { // TODO: E\("([a-z]+)"\) -> "<$1>" or get rid of jQuery as a dependency function E(tagName) { return document.createElement(tagName); } function element_to_string(element) { // returns a CSS-selector-like string for the given element // if (element instanceof Element) { // doesn't work with different window.Element from iframes if (typeof element === "object" && "tagName" in element) { return element.tagName.toLowerCase() + (element.id ? "#" + element.id : "") + (element.className ? "." + element.className.split(" ").join(".") : "") + (element.src ? `[src="${element.src}"]` : "") + // Note: not escaped; may not actually work as a selector (but this is for debugging) (element.srcdoc ? "[srcdoc]" : "") + // (srcdoc can be long) (element.href ? `[href="${element.href}"]` : ""); } else if (element) { return element.constructor.name; } else { return `${element}`; } } function find_tabstops(container_el) { const $el = $(container_el); // This function finds focusable controls, but not necessarily all of them; // for radio elements, it only gives one: either the checked one, or the first one if none are checked. // Note: for audio[controls], Chrome at least has two tabstops (the audio element and three dots menu button). // It might be possible to detect this in the shadow DOM, I don't know, I haven't worked with the shadow DOM. // But it might be more reliable to make a dummy tabstop element to detect when you tab out of the first/last element. // Also for iframes! // Assuming that doesn't mess with screen readers. // Right now you can't tab to the three dots menu if it's the last element. // @TODO: see what ally.js does. Does it handle audio[controls]? https://allyjs.io/api/query/tabsequence.html let $controls = $el.find(` input:enabled, textarea:enabled, select:enabled, button:enabled, a[href], [tabIndex='0'], details summary, iframe, object, embed, video[controls], audio[controls], [contenteditable]:not([contenteditable='false']) `).filter(":visible"); // const $controls = $el.find(":tabbable"); // https://api.jqueryui.com/tabbable-selector/ // Radio buttons should be treated as a group with one tabstop. // If there's no selected ("checked") radio, it should still visit the group, // but if there is a selected radio in the group, it should skip all unselected radios in the group. const radios = {}; // best radio found so far, per group const to_skip = []; for (const el of $controls.toArray()) { if (el.nodeName.toLowerCase() === "input" && el.type === "radio") { if (radios[el.name]) { if (el.checked) { to_skip.push(radios[el.name]); radios[el.name] = el; } else { to_skip.push(el); } } else { radios[el.name] = el; } } } const $tabstops = $controls.not(to_skip); // debug viz: // $tabstops.css({boxShadow: "0 0 2px 2px green"}); // $(to_skip).css({boxShadow: "0 0 2px 2px gray"}) return $tabstops; } var $G = $(window); $Window.Z_INDEX = 5; var minimize_slots = []; // for if there's no taskbar // @TODO: make this a class, // instead of a weird pseudo-class function $Window(options) { options = options || {}; // @TODO: handle all option defaults here // and validate options. var $w = $(E("div")).addClass("window os-window").appendTo("body"); $w[0].$window = $w; $w.element = $w[0]; $w[0].id = `os-window-${Math.random().toString(36).substr(2, 9)}`; $w.$titlebar = $(E("div")).addClass("window-titlebar").appendTo($w); $w.$title_area = $(E("div")).addClass("window-title-area").appendTo($w.$titlebar); $w.$title = $(E("span")).addClass("window-title").appendTo($w.$title_area); if (options.toolWindow) { options.minimizeButton = false; options.maximizeButton = false; } if (options.minimizeButton !== false) { $w.$minimize = $(E("button")).addClass("window-minimize-button window-action-minimize window-button").appendTo($w.$titlebar); $w.$minimize.attr("aria-label", "Minimize window"); // @TODO: for taskbarless minimized windows, "restore" $w.$minimize.append(""); } if (options.maximizeButton !== false) { $w.$maximize = $(E("button")).addClass("window-maximize-button window-action-maximize window-button").appendTo($w.$titlebar); $w.$maximize.attr("aria-label", "Maximize or restore window"); // @TODO: specific text for the state if (!options.resizable) { $w.$maximize.attr("disabled", true); } $w.$maximize.append(""); } if (options.closeButton !== false) { $w.$x = $(E("button")).addClass("window-close-button window-action-close window-button").appendTo($w.$titlebar); $w.$x.attr("aria-label", "Close window"); $w.$x.append(""); } $w.$content = $(E("div")).addClass("window-content").appendTo($w); $w.$content.attr("tabIndex", "-1"); $w.$content.css("outline", "none"); if (options.toolWindow) { $w.addClass("tool-window"); } if (options.parentWindow) { options.parentWindow.addChildWindow($w); // semantic parent logic is currently only suited for tool windows // for dialog windows, it would make the dialog window not show as focused // (alternatively, I could simply, when following the semantic parent chain, look for windows that are not tool windows) if (options.toolWindow) { $w[0].dataset.semanticParent = options.parentWindow[0].id; } } var $component = options.$component; if (typeof options.icon === "object" && "tagName" in options.icon) { options.icons = { any: options.icon }; } else if (options.icon) { // old terrible API using globals that you have to define console.warn("DEPRECATED: use options.icons instead of options.icon, e.g. new $Window({icons: {16: 'app-16x16.png', any: 'app-icon.svg'}})"); if (typeof $Icon !== "undefined" && typeof TITLEBAR_ICON_SIZE !== "undefined") { $w.icon_name = options.icon; $w.$icon = $Icon(options.icon, TITLEBAR_ICON_SIZE).prependTo($w.$titlebar); } else { throw new Error("Use {icon: img_element} or {icons: {16: url_or_img_element}} options"); } } $w.icons = options.icons || {}; let iconSize = 16; $w.setTitlebarIconSize = function (target_icon_size) { if ($w.icons) { $w.$icon?.remove(); $w.$icon = $($w.getIconAtSize(target_icon_size)); $w.$icon.prependTo($w.$titlebar); } iconSize = target_icon_size; $w.trigger("icon-change"); }; $w.getTitlebarIconSize = function () { return iconSize; }; // @TODO: this could be a static method, like OSGUI.getIconAtSize(icons, targetSize) $w.getIconAtSize = function (target_icon_size) { let icon_size; if ($w.icons[target_icon_size]) { icon_size = target_icon_size; } else if ($w.icons["any"]) { icon_size = "any"; } else { const sizes = Object.keys($w.icons).filter(size => isFinite(size) && isFinite(parseFloat(size))); sizes.sort((a, b) => Math.abs(a - target_icon_size) - Math.abs(b - target_icon_size)); icon_size = sizes[0]; } if (icon_size) { const icon = $w.icons[icon_size]; let icon_element; if (icon.nodeType !== undefined) { icon_element = icon.cloneNode(true); } else { icon_element = E("img"); const $icon = $(icon_element); if (icon.srcset) { $icon.attr("srcset", icon.srcset); } else { $icon.attr("src", icon.src || icon); } $icon.attr({ width: icon_size, height: icon_size, draggable: false, }); $icon.css({ width: target_icon_size, height: target_icon_size, }); } return icon_element; } return null; }; // @TODO: automatically update icon size based on theme (with a CSS variable) $w.setTitlebarIconSize(iconSize); $w.getIconName = () => { console.warn("DEPRECATED: use $w.icons object instead of $w.icon_name"); return $w.icon_name; }; $w.setIconByID = (icon_name) => { console.warn("DEPRECATED: use $w.setIcons(icons) instead of $w.setIconByID(icon_name)"); var old_$icon = $w.$icon; $w.$icon = $Icon(icon_name, TITLEBAR_ICON_SIZE); old_$icon.replaceWith($w.$icon); $w.icon_name = icon_name; $w.task?.updateIcon(); $w.trigger("icon-change"); return $w; }; $w.setIcons = (icons) => { $w.icons = icons; $w.setTitlebarIconSize(iconSize); $w.task?.updateIcon(); // icon-change already sent by setTitlebarIconSize }; if ($component) { $w.addClass("component-window"); } setTimeout(() => { if (get_direction() == "rtl") { $w.addClass("rtl"); // for reversing the titlebar gradient } }, 0); // returns writing/layout direction, "ltr" or "rtl" function get_direction() { return window.get_direction ? window.get_direction() : getComputedStyle($w[0]).direction; } // This is very silly, using jQuery's event handling to implement simpler event handling. // But I'll implement it in a non-silly way at least when I remove jQuery. Maybe sooner. const $event_target = $({}); const make_simple_listenable = (name) => { return (callback) => { const fn = () => { callback(); }; $event_target.on(name, fn); const dispose = () => { $event_target.off(name, fn); }; return dispose; }; }; $w.onFocus = make_simple_listenable("focus"); $w.onBlur = make_simple_listenable("blur"); $w.onClosed = make_simple_listenable("closed"); $w.setDimensions = ({ innerWidth, innerHeight, outerWidth, outerHeight }) => { let width_from_frame, height_from_frame; // It's good practice to make all measurements first, then update the DOM. // Once you update the DOM, the browser has to recalculate layout, which can be slow. if (innerWidth) { width_from_frame = $w.outerWidth() - $w.$content.outerWidth(); } if (innerHeight) { height_from_frame = $w.outerHeight() - $w.$content.outerHeight(); const $menu_bar = $w.$content.find(".menus"); // only if inside .content; might move to a slot outside .content later if ($menu_bar.length) { // maybe this isn't technically part of the frame, per se? but it's part of the non-client area, which is what I technically mean. height_from_frame += $menu_bar.outerHeight(); } } if (outerWidth) { $w.outerWidth(outerWidth); } if (outerHeight) { $w.outerHeight(outerHeight); } if (innerWidth) { $w.outerWidth(innerWidth + width_from_frame); } if (innerHeight) { $w.outerHeight(innerHeight + height_from_frame); } }; $w.setDimensions(options); let child_$windows = []; $w.addChildWindow = ($child_window) => { child_$windows.push($child_window); }; const showAsFocused = () => { if ($w.hasClass("focused")) { return; } $w.addClass("focused"); $event_target.triggerHandler("focus"); }; const stopShowingAsFocused = () => { if (!$w.hasClass("focused")) { return; } $w.removeClass("focused"); $event_target.triggerHandler("blur"); }; $w.focus = () => { // showAsFocused(); $w.bringToFront(); refocus(); }; $w.blur = () => { stopShowingAsFocused(); if (document.activeElement && document.activeElement.closest(".window") == $w[0]) { document.activeElement.blur(); } }; if (options.toolWindow) { if (options.parentWindow) { options.parentWindow.onFocus(showAsFocused); options.parentWindow.onBlur(stopShowingAsFocused); // TODO: also show as focused if focus is within the window // initial state // might need a setTimeout, idk... if (document.activeElement && document.activeElement.closest(".window") == options.parentWindow[0]) { showAsFocused(); } } else { // the browser window is the parent window // show focus whenever the browser window is focused $(window).on("focus", showAsFocused); $(window).on("blur", stopShowingAsFocused); // initial state if (document.hasFocus()) { showAsFocused(); } } } /*else - PATCHED; I want focus tracking in a tool window; @TODO: dissolve the concept of a "tool window" */ { // global focusout is needed, to continue showing as focused while child windows or menu popups are focused (@TODO: Is this redundant with focusin?) // global focusin is needed, to show as focused when a child window becomes focused (when perhaps nothing was focused before, so no focusout event) // global blur is needed, to show as focused when an iframe gets focus, because focusin/out doesn't fire at all in that case // global focus is needed, to stop showing as focused when an iframe loses focus // pretty ridiculous!! // but it still doesn't handle the case where the browser window is not focused, and the user clicks an iframe directly. // for that, we need to listen inside the iframe, because no events are fired at all outside in that case, // and :focus/:focus-within doesn't work with iframes so we can't even do a hack with transitionstart. // @TODO: simplify the strategy; I ended up piling a few strategies on top of each other, and the earlier ones may be redundant. // In particular, 1. I ended up making it proactively inject into iframes, rather than when focused since there's a case where focus can't be detected otherwise. // 2. I ended up simulating focusin events for iframes. // I may want to rely on that, or, I may want to remove that and set up a refocus chain directly instead, // avoiding refocus() which may interfere with drag operations in an iframe when focusing the iframe (e.g. clicking into Paint to draw or drag a sub-window). // console.log("adding global focusin/focusout/blur/focus for window", $w[0].id); const global_focus_update_handler = make_focus_in_out_handler($w[0], true); // must be $w and not $content so semantic parent chain works, with [data-semantic-parent] pointing to the window not the content window.addEventListener("focusin", global_focus_update_handler); window.addEventListener("focusout", global_focus_update_handler); window.addEventListener("blur", global_focus_update_handler); window.addEventListener("focus", global_focus_update_handler); function setupIframe(iframe) { if (!focus_update_handlers_by_container.has(iframe)) { const iframe_update_focus = make_focus_in_out_handler(iframe, false); // this also operates as a flag to prevent multiple handlers from being added, or waiting for the iframe to load duplicately focus_update_handlers_by_container.set(iframe, iframe_update_focus); // @TODO: try removing setTimeout(s) setTimeout(() => { // for iframe src to be set? I forget. // Note: try must be INSIDE setTimeout, not outside, to work. try { const wait_for_iframe_load = (callback) => { // Note: error may occur accessing iframe.contentDocument; this must be handled by the caller. // To that end, this function must access it synchronously, to allow the caller to handle the error. if (iframe.contentDocument.readyState == "complete") { callback(); } else { // iframe.contentDocument.addEventListener("readystatechange", () => { // if (iframe.contentDocument.readyState == "complete") { // callback(); // } // }); setTimeout(() => { wait_for_iframe_load(callback); }, 100); } }; wait_for_iframe_load(() => { // console.log("adding focusin/focusout/blur/focus for iframe", iframe); iframe.contentWindow.addEventListener("focusin", iframe_update_focus); iframe.contentWindow.addEventListener("focusout", iframe_update_focus); iframe.contentWindow.addEventListener("blur", iframe_update_focus); iframe.contentWindow.addEventListener("focus", iframe_update_focus); observeIframes(iframe.contentDocument); }); } catch (error) { warn_iframe_access(iframe, error); } }, 100); } } function observeIframes(container_node) { const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.tagName == "IFRAME") { setupIframe(node); } } } }); observer.observe(container_node, { childList: true, subtree: true }); // needed in recursive calls (for iframes inside iframes) // (for the window, it shouldn't be able to have iframes yet) for (const iframe of container_node.querySelectorAll("iframe")) { setupIframe(iframe); } } observeIframes($w.$content[0]); function make_focus_in_out_handler(logical_container_el, is_root) { // In case of iframes, logical_container_el is the iframe, and container_node is the iframe's contentDocument. // container_node is not a parameter here because it can change over time, may be an empty document before the iframe is loaded. return function handle_focus_in_out(event) { const container_node = logical_container_el.tagName == "IFRAME" ? logical_container_el.contentDocument : logical_container_el; const document = container_node.ownerDocument ?? container_node; // is this equivalent? // const document = logical_container_el.tagName == "IFRAME" ? logical_container_el.contentDocument : logical_container_el.ownerDocument; // console.log(`handling ${event.type} for container`, container_el); let newly_focused = event ? (event.type === "focusout" || event.type === "blur") ? event.relatedTarget : event.target : document.activeElement; if (event?.type === "blur") { newly_focused = null; // only handle iframe } // console.log(`[${$w.title()}] (is_root=${is_root})`, `newly_focused is (preliminarily)`, element_to_string(newly_focused), `\nlogical_container_el`, logical_container_el, `\ncontainer_node`, container_node, `\ndocument.activeElement`, document.activeElement, `\ndocument.hasFocus()`, document.hasFocus(), `\ndocument`, document); // Iframes are stingy about focus events, so we need to check if focus is actually within an iframe. if ( document.activeElement && document.activeElement.tagName === "IFRAME" && (event?.type === "focusout" || event?.type === "blur") && !newly_focused // doesn't exist for security reasons in this case ) { newly_focused = document.activeElement; // console.log(`[${$w.title()}] (is_root=${is_root})`, `newly_focused is (actually)`, element_to_string(newly_focused)); } const outside_or_at_exactly = !newly_focused || // contains() only works with DOM nodes (elements and documents), not window objects. // Since container_node is a DOM node, it will never have a Window inside of it (ignoring iframes). newly_focused.window === newly_focused || // is a Window object (cross-frame test) !container_node.contains(newly_focused); // Note: node.contains(node) === true const firmly_outside = outside_or_at_exactly && container_node !== newly_focused; // console.log(`[${$w.title()}] (is_root=${is_root})`, `outside_or_at_exactly=${outside_or_at_exactly}`, `firmly_outside=${firmly_outside}`); if (firmly_outside && is_root) { if (!options.toolWindow) { // PATCHED stopShowingAsFocused(); } } if ( !outside_or_at_exactly && newly_focused.tagName !== "HTML" && newly_focused.tagName !== "BODY" && newly_focused !== container_node && !newly_focused.matches(".window-content") && !newly_focused.closest(".menus") && !newly_focused.closest(".window-titlebar") ) { last_focus_by_container.set(logical_container_el, newly_focused); // overwritten for iframes below debug_focus_tracking(document, container_node, newly_focused, is_root); } if ( !outside_or_at_exactly && newly_focused.tagName === "IFRAME" ) { const iframe = newly_focused; // console.log("iframe", iframe, onfocusin_by_container.has(iframe)); try { const focus_in_iframe = iframe.contentDocument.activeElement; if ( focus_in_iframe && focus_in_iframe.tagName !== "HTML" && focus_in_iframe.tagName !== "BODY" && !focus_in_iframe.closest(".menus") ) { // last_focus_by_container.set(logical_container_el, iframe); // done above last_focus_by_container.set(iframe, focus_in_iframe); debug_focus_tracking(iframe.contentDocument, iframe.contentDocument, focus_in_iframe, is_root); } } catch (e) { warn_iframe_access(iframe, e); } } // For child windows and menu popups, follow "semantic parent" chain. // Menu popups and child windows aren't descendants of the window they belong to, // but should keep the window shown as focused. // (In principle this sort of feature could be useful for focus tracking*, // but right now it's only for child windows and menu popups, which should not be tracked for refocus, // so I'm doing this after last_focus_by_container.set, for now anyway.) // ((*: and it may even be surprising if it doesn't work, if one sees the attribute on menus and attempts to use it. // But who's going to see that? The menus close so it's a pain to see the DOM structure! :P **)) // (((**: without window.debugKeepMenusOpen))) if (is_root) { do { // if (!newly_focused?.closest) { // console.warn("what is this?", newly_focused); // break; // } const waypoint = newly_focused?.closest?.("[data-semantic-parent]"); if (waypoint) { const id = waypoint.dataset.semanticParent; const parent = waypoint.ownerDocument.getElementById(id); // console.log("following semantic parent, from", newly_focused, "\nto", parent, "\nvia", waypoint); newly_focused = parent; if (!parent) { console.warn("semantic parent not found with id", id); break; } } else { break; } } while (true); } // Note: allowing showing window as focused from listeners inside iframe (non-root) too, // in order to handle clicking an iframe when the browser window was not previously focused (e.g. after reload) if ( newly_focused && newly_focused.window !== newly_focused && // cross-frame test for Window object container_node.contains(newly_focused) ) { if (!options.toolWindow) { // PATCHED showAsFocused(); } $w.bringToFront(); if (!is_root) { // trigger focusin events for iframes // @TODO: probably don't need showAsFocused() here since it'll be handled externally (on this simulated focusin), // and might not need a lot of other logic frankly if I'm simulating focusin events let el = logical_container_el; while (el) { // console.log("dispatching focusin event for", el); el.dispatchEvent(new Event("focusin", { bubbles: true, target: el, view: el.ownerDocument.defaultView, })); el = el.currentView?.frameElement; } } } else if (is_root) { if (!options.toolWindow) { // PATCHED stopShowingAsFocused(); } } } } // initial state is unfocused } $w.css("touch-action", "none"); let minimize_target_el = null; // taskbar button (optional) $w.setMinimizeTarget = function (new_taskbar_button_el) { minimize_target_el = new_taskbar_button_el; }; let task; Object.defineProperty($w, "task", { get() { return task; }, set(new_task) { console.warn("DEPRECATED: use $w.setMinimizeTarget(taskbar_button_el) instead of setting $window.task object"); task = new_task; }, }); let before_minimize; $w.minimize = () => { minimize_target_el = minimize_target_el || task?.$task[0]; if (animating_titlebar) { when_done_animating_titlebar.push($w.minimize); return; } if ($w.is(":visible")) { if (minimize_target_el && !$w.hasClass("minimized-without-taskbar")) { const before_rect = $w.$titlebar[0].getBoundingClientRect(); const after_rect = minimize_target_el.getBoundingClientRect(); $w.animateTitlebar(before_rect, after_rect, () => { $w.hide(); $w.blur(); }); } else { // no taskbar // @TODO: make this metrically similar to what Windows 98 does // @TODO: DRY! This is copied heavily from maximize() // @TODO: after minimize (without taskbar) and maximize, restore should restore original position before minimize // OR should it not maximize but restore the unmaximized state? I think I tested it but I forget. const to_width = 150; const spacing = 10; if ($w.hasClass("minimized-without-taskbar")) { // unminimizing minimize_slots[$w._minimize_slot_index] = null; } else { // minimizing let i = 0; while (minimize_slots[i]) { i++; } $w._minimize_slot_index = i; minimize_slots[i] = $w; } const to_x = $w._minimize_slot_index * (to_width + spacing) + 10; const titlebar_height = $w.$titlebar.outerHeight(); let before_unminimize; const instantly_minimize = () => { before_minimize = { position: $w.css("position"), left: $w.css("left"), top: $w.css("top"), width: $w.css("width"), height: $w.css("height"), }; $w.addClass("minimized-without-taskbar"); if ($w.hasClass("maximized")) { $w.removeClass("maximized"); $w.addClass("was-maximized"); $w.$maximize.removeClass("window-action-restore"); $w.$maximize.addClass("window-action-maximize"); } $w.$minimize.removeClass("window-action-minimize"); $w.$minimize.addClass("window-action-restore"); if (before_unminimize) { $w.css({ position: before_unminimize.position, left: before_unminimize.left, top: before_unminimize.top, width: before_unminimize.width, height: before_unminimize.height, }); } else { $w.css({ position: "fixed", top: `calc(100% - ${titlebar_height + 5}px)`, left: to_x, width: to_width, height: titlebar_height, }); } }; const instantly_unminimize = () => { before_unminimize = { position: $w.css("position"), left: $w.css("left"), top: $w.css("top"), width: $w.css("width"), height: $w.css("height"), }; $w.removeClass("minimized-without-taskbar"); if ($w.hasClass("was-maximized")) { $w.removeClass("was-maximized"); $w.addClass("maximized"); $w.$maximize.removeClass("window-action-maximize"); $w.$maximize.addClass("window-action-restore"); } $w.$minimize.removeClass("window-action-restore"); $w.$minimize.addClass("window-action-minimize"); $w.css({ width: "", height: "" }); if (before_minimize) { $w.css({ position: before_minimize.position, left: before_minimize.left, top: before_minimize.top, width: before_minimize.width, height: before_minimize.height, }); } }; const before_rect = $w.$titlebar[0].getBoundingClientRect(); let after_rect; $w.css("transform", ""); if ($w.hasClass("minimized-without-taskbar")) { instantly_unminimize(); after_rect = $w.$titlebar[0].getBoundingClientRect(); instantly_minimize(); } else { instantly_minimize(); after_rect = $w.$titlebar[0].getBoundingClientRect(); instantly_unminimize(); } $w.animateTitlebar(before_rect, after_rect, () => { if ($w.hasClass("minimized-without-taskbar")) { instantly_unminimize(); } else { instantly_minimize(); $w.blur(); } }); } } }; $w.unminimize = () => { if (animating_titlebar) { when_done_animating_titlebar.push($w.unminimize); return; } if ($w.hasClass("minimized-without-taskbar")) { $w.minimize(); return; } if ($w.is(":hidden")) { const before_rect = minimize_target_el.getBoundingClientRect(); $w.show(); const after_rect = $w.$titlebar[0].getBoundingClientRect(); $w.hide(); $w.animateTitlebar(before_rect, after_rect, () => { $w.show(); $w.bringToFront(); $w.focus(); }); } }; let before_maximize; $w.maximize = () => { if (!options.resizable) { return; } if (animating_titlebar) { when_done_animating_titlebar.push($w.maximize); return; } if ($w.hasClass("minimized-without-taskbar")) { $w.minimize(); return; } const instantly_maximize = () => { before_maximize = { position: $w.css("position"), left: $w.css("left"), top: $w.css("top"), width: $w.css("width"), height: $w.css("height"), }; $w.addClass("maximized"); const $taskbar = $(".taskbar"); const scrollbar_width = window.innerWidth - $(window).width(); const scrollbar_height = window.innerHeight - $(window).height(); const taskbar_height = $taskbar.length ? $taskbar.outerHeight() + 1 : 0; $w.css({ position: "fixed", top: 0, left: 0, width: `calc(100vw - ${scrollbar_width}px)`, height: `calc(100vh - ${scrollbar_height}px - ${taskbar_height}px)`, }); }; const instantly_unmaximize = () => { $w.removeClass("maximized"); $w.css({ width: "", height: "" }); if (before_maximize) { $w.css({ position: before_maximize.position, left: before_maximize.left, top: before_maximize.top, width: before_maximize.width, height: before_maximize.height, }); } }; const before_rect = $w.$titlebar[0].getBoundingClientRect(); let after_rect; $w.css("transform", ""); const restoring = $w.hasClass("maximized"); if (restoring) { instantly_unmaximize(); after_rect = $w.$titlebar[0].getBoundingClientRect(); instantly_maximize(); } else { instantly_maximize(); after_rect = $w.$titlebar[0].getBoundingClientRect(); instantly_unmaximize(); } $w.animateTitlebar(before_rect, after_rect, () => { if (restoring) { instantly_unmaximize(); // finalize in some way $w.$maximize.removeClass("window-action-restore"); $w.$maximize.addClass("window-action-maximize"); } else { instantly_maximize(); // finalize in some way $w.$maximize.removeClass("window-action-maximize"); $w.$maximize.addClass("window-action-restore"); } }); }; $w.restore = () => { if ($w.is(".minimized-without-taskbar, .minimized")) { $w.unminimize(); } else if ($w.is(".maximized")) { $w.maximize(); } }; // must not pass event to functions by accident; also methods may not be defined yet $w.$minimize?.on("click", (e) => { $w.minimize(); }); $w.$maximize?.on("click", (e) => { $w.maximize(); }); $w.$x?.on("click", (e) => { $w.close(); }); $w.$title_area.on("dblclick", (e) => { $w.maximize(); }); $w.css({ position: "absolute", zIndex: $Window.Z_INDEX++ }); $w.bringToFront = () => { $w.css({ zIndex: $Window.Z_INDEX++ }); for (const $childWindow of child_$windows) { $childWindow.bringToFront(); } }; // Keep track of last focused elements per container, // where containers include: // - window (global focus tracking) // - $w[0] (window-local, for restoring focus when refocusing window) // - any iframes that are same-origin (for restoring focus when refocusing window) // @TODO: should these be WeakMaps? probably. // @TODO: share this Map between all windows? but clean it up when destroying windows? or would a WeakMap take care of that? var last_focus_by_container = new Map(); // element to restore focus to, by container var focus_update_handlers_by_container = new Map(); // event handlers by container; note use as a flag to avoid adding multiple handlers var debug_svg_by_container = new Map(); // visualization var debug_svgs_in_window = []; // visualization var warned_iframes = new WeakSet(); // prevent spamming console const warn_iframe_access = (iframe, error) => { const log_template = (message) => [`OS-GUI.js failed to access an iframe (${element_to_string(iframe)}) for focus integration. ${message} Original error: `, error]; let cross_origin; if (iframe.srcdoc) { cross_origin = false; } else { try { const url = new URL(iframe.src); cross_origin = url.origin !== window.location.origin; // shouldn't need to use iframe.ownerDocument.location.origin because intermediate iframes must be same-origin } catch (parse_error) { console.error(...log_template(`This may be a bug in OS-GUI. Is this a cross-origin iframe? Failed to parse URL (${parse_error}).`)); return; } } if (cross_origin) { if (options.iframes?.ignoreCrossOrigin && !warned_iframes.has(iframe)) { console.warn(...log_template(`Only same-origin iframes can work with focus integration (showing window as focused, refocusing last focused controls). If you can re-host the content on the same origin, you can resolve this and enable focus integration. You can also disable this warning by passing {iframes: {ignoreCrossOrigin: true}} to $Window.`)); warned_iframes.add(iframe); } } else { console.error(...log_template(`This may be a bug in OS-GUI, since it doesn't appear to be a cross-origin iframe.`)); } }; const debug_focus_tracking = (document, container_el, descendant_el, is_root) => { if (!$Window.DEBUG_FOCUS) { return; } let svg = debug_svg_by_container.get(container_el); if (!svg) { svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.style.position = "fixed"; svg.style.top = "0"; svg.style.left = "0"; svg.style.width = "100%"; svg.style.height = "100%"; svg.style.pointerEvents = "none"; svg.style.zIndex = "100000000"; svg.style.direction = "ltr"; // position labels correctly debug_svg_by_container.set(container_el, svg); debug_svgs_in_window.push(svg); document.body.appendChild(svg); } svg._container_el = container_el; svg._descendant_el = descendant_el; svg._is_root = is_root; animate_debug_focus_tracking(); }; const update_debug_focus_tracking = (svg) => { const container_el = svg._container_el; const descendant_el = svg._descendant_el; const is_root = svg._is_root; while (svg.lastChild) { svg.removeChild(svg.lastChild); } const descendant_rect = descendant_el.getBoundingClientRect?.() ?? { left: 0, top: 0, width: innerWidth, height: innerHeight, right: innerWidth, bottom: innerHeight }; const container_rect = container_el.getBoundingClientRect?.() ?? { left: 0, top: 0, width: innerWidth, height: innerHeight, right: innerWidth, bottom: innerHeight }; // draw rectangles with labels for (const rect of [descendant_rect, container_rect]) { const rect_el = document.createElementNS("http://www.w3.org/2000/svg", "rect"); rect_el.setAttribute("x", rect.left); rect_el.setAttribute("y", rect.top); rect_el.setAttribute("width", rect.width); rect_el.setAttribute("height", rect.height); rect_el.setAttribute("stroke", rect === descendant_rect ? "#f44" : "#f44"); rect_el.setAttribute("stroke-width", "2"); rect_el.setAttribute("fill", "none"); if (!is_root) { rect_el.setAttribute("stroke-dasharray", "5,5"); } svg.appendChild(rect_el); const text_el = document.createElementNS("http://www.w3.org/2000/svg", "text"); text_el.setAttribute("x", rect.left); text_el.setAttribute("y", rect.top + (rect === descendant_rect ? 20 : 0)); // align container text on outside, descendant text on inside text_el.setAttribute("fill", rect === descendant_rect ? "#f44" : "aqua"); text_el.setAttribute("font-size", "20"); text_el.style.textShadow = "1px 1px 1px black, 0 0 10px black"; text_el.textContent = element_to_string(rect === descendant_rect ? descendant_el : container_el); svg.appendChild(text_el); } // draw lines connecting the two rects const lines = [ [descendant_rect.left, descendant_rect.top, container_rect.left, container_rect.top], [descendant_rect.right, descendant_rect.top, container_rect.right, container_rect.top], [descendant_rect.left, descendant_rect.bottom, container_rect.left, container_rect.bottom], [descendant_rect.right, descendant_rect.bottom, container_rect.right, container_rect.bottom], ]; for (const line of lines) { const line_el = document.createElementNS("http://www.w3.org/2000/svg", "line"); line_el.setAttribute("x1", line[0]); line_el.setAttribute("y1", line[1]); line_el.setAttribute("x2", line[2]); line_el.setAttribute("y2", line[3]); line_el.setAttribute("stroke", "green"); line_el.setAttribute("stroke-width", "2"); svg.appendChild(line_el); } }; let debug_animation_frame_id; const animate_debug_focus_tracking = () => { cancelAnimationFrame(debug_animation_frame_id); if (!$Window.DEBUG_FOCUS) { clean_up_debug_focus_tracking(); return; } debug_animation_frame_id = requestAnimationFrame(animate_debug_focus_tracking); for (const svg of debug_svgs_in_window) { update_debug_focus_tracking(svg); } }; const clean_up_debug_focus_tracking = () => { cancelAnimationFrame(debug_animation_frame_id); for (const svg of debug_svgs_in_window) { svg.remove(); } debug_svgs_in_window.length = 0; debug_svg_by_container.clear(); }; const refocus = (container_el = $w.$content[0]) => { const logical_container_el = container_el.matches(".window-content") ? $w[0] : container_el; const last_focus = last_focus_by_container.get(logical_container_el); if (last_focus) { last_focus.focus({ preventScroll: true }); if (last_focus.tagName === "IFRAME") { try { refocus(last_focus); } catch (e) { warn_iframe_access(last_focus, e); } } return; } const $tabstops = find_tabstops(container_el); const $default = $tabstops.filter(".default"); if ($default.length) { $default[0].focus({ preventScroll: true }); return; } if ($tabstops.length) { if ($tabstops[0].tagName === "IFRAME") { try { refocus($tabstops[0]); // not .contentDocument.body because we want the container tracked by last_focus_by_container } catch (e) { warn_iframe_access($tabstops[0], e); } } else { $tabstops[0].focus({ preventScroll: true }); } return; } if (options.toolWindow && options.parentWindow) { options.parentWindow.triggerHandler("refocus-window"); return; } container_el.focus({ preventScroll: true }); if (container_el.tagName === "IFRAME") { try { refocus(container_el.contentDocument.body); } catch (e) { warn_iframe_access(container_el, e); } } }; $w.on("refocus-window", () => { refocus(); }); // redundant events are for handling synthetic events, // which may be sent individually, rather than in tandem $w.on("pointerdown mousedown", handle_pointer_activation); // Note that jQuery treats some events differently, and can't listen for some synthetic events // but pointerdown and mousedown seem to be supported. That said, if you trigger() either, // addEventListener() handlers will not be called. So if I remove the dependency on jQuery, // it will not be possible to listen for some .trigger() events. // https://jsfiddle.net/1j01/ndvwts9y/1/ // Assumption: focusin comes after pointerdown/mousedown // This is probably guaranteed, because you can prevent the default of focusing from pointerdown/mousedown $G.on("focusin", (e) => { last_focus_by_container.set(window, e.target); // debug_focus_tracking(document, window, e.target); }); function handle_pointer_activation(event) { // console.log("handle_pointer_activation", event.type, event.target); $w.bringToFront(); // Test cases where it should refocus the last focused control in the window: // - Click in the blank space of the window // - Click in blank space again now that something's focused // - Click on the window title bar // - Click on title bar buttons // - Closing a second window should focus the first window // - Open a dialog window from an app window that has a tool window, then close the dialog window // - @TODO: Even if the tool window has controls, it should focus the parent window, I think // - Clicking on a control in the window should focus said control // - Clicking on a disabled control in the window should focus the window // - Make sure to test this with another window previously focused // - Simulated clicks (important for JS Paint's eye gaze and speech recognition modes) // - (@TODO: Should clicking a child window focus the parent window?) // - After potentially selecting text but not selecting anything // It should NOT refocus when: // - Clicking on a control in a different window // - When other event handlers set focus // - Using the keyboard to focus something outside the window, such as a menu popup // - Clicking a control that focuses something outside the window // - Button that opens another window (e.g. Recursive Dialog button in tests) // - Button that focuses a control in another window (e.g. Focus Other button in tests) // - Trying to select text // Wait for other pointerdown handlers and default behavior, and focusin events. requestAnimationFrame(() => { const last_focus_global = last_focus_by_container.get(window); // const last_focus_in_window = last_focus_by_container.get($w.$content[0]); // console.log("a tick after", event.type, { last_focus_in_window, last_focus_global, activeElement: document.activeElement, win_elem: $w[0] }); // console.log("did focus change?", document.activeElement !== last_focus_global); // If something programmatically got focus, don't refocus. if ( document.activeElement && document.activeElement !== document && document.activeElement !== document.body && document.activeElement !== $w.$content[0] && document.activeElement !== last_focus_global ) { return; } // If menus got focus, don't refocus. if (document.activeElement?.closest?.(".menus, .menu-popup")) { // console.log("click in menus"); return; } // If the element is selectable, wait until the click is done and see if anything was selected first. // This is a bit of a weird compromise, for now. const target_style = getComputedStyle(event.target); if (target_style.userSelect !== "none") { // Immediately show the window as focused, just don't refocus a specific control. $w.$content.focus(); $w.one("pointerup pointercancel", () => { requestAnimationFrame(() => { // this seems to make it more reliable in regards to double clicking if (!getSelection().toString().trim()) { refocus(); } }); }); return; } // Set focus to the last focused control, which should be updated if a click just occurred. refocus(); }); } $w.on("keydown", (e) => { if (e.isDefaultPrevented()) { return; } if (e.ctrlKey || e.altKey || e.metaKey) { return; } // console.log("keydown", e.key, e.target); if (e.target.closest(".menus")) { // console.log("keydown in menus"); return; } const $buttons = $w.$content.find("button"); const $focused = $(document.activeElement); const focused_index = $buttons.index($focused); switch (e.keyCode) { case 40: // Down case 39: // Right if ($focused.is("button") && !e.shiftKey) { if (focused_index < $buttons.length - 1) { $buttons[focused_index + 1].focus(); e.preventDefault(); } } break; case 38: // Up case 37: // Left if ($focused.is("button") && !e.shiftKey) { if (focused_index > 0) { $buttons[focused_index - 1].focus(); e.preventDefault(); } } break; case 32: // Space case 13: // Enter (doesn't actually work in chrome because the button gets clicked immediately) if ($focused.is("button") && !e.shiftKey) { $focused.addClass("pressed"); const release = () => { $focused.removeClass("pressed"); $focused.off("focusout", release); $(window).off("keyup", keyup); }; const keyup = (e) => { if (e.keyCode === 32 || e.keyCode === 13) { release(); } }; $focused.on("focusout", release); $(window).on("keyup", keyup); } break; case 9: { // Tab // wrap around when tabbing through controls in a window const $controls = find_tabstops($w.$content[0]); if ($controls.length > 0) { const focused_control_index = $controls.index($focused); if (e.shiftKey) { if (focused_control_index === 0) { e.preventDefault(); $controls[$controls.length - 1].focus(); } } else { if (focused_control_index === $controls.length - 1) { e.preventDefault(); $controls[0].focus(); } } } break; } case 27: // Escape // @TODO: make this optional, and probably default false $w.close(); break; } }); $w.applyBounds = () => { // TODO: outerWidth vs width? not sure const bound_width = Math.max(document.body.scrollWidth, innerWidth); const bound_height = Math.max(document.body.scrollHeight, innerHeight); $w.css({ left: Math.max(0, Math.min(bound_width - $w.width(), $w.position().left)), top: Math.max(0, Math.min(bound_height - $w.height(), $w.position().top)), }); }; $w.bringTitleBarInBounds = () => { // Try to make the titlebar always accessible const bound_width = Math.max(document.body.scrollWidth, innerWidth); const bound_height = Math.max(document.body.scrollHeight, innerHeight); const min_horizontal_pixels_on_screen = 40; // enough for space past a close button $w.css({ left: Math.max( min_horizontal_pixels_on_screen - $w.outerWidth(), Math.min( bound_width - min_horizontal_pixels_on_screen, $w.position().left ) ), top: Math.max(0, Math.min( bound_height - $w.$titlebar.outerHeight() - 5, $w.position().top )), }); }; $w.center = () => { $w.css({ left: (innerWidth - $w.width()) / 2 + window.scrollX, top: (innerHeight - $w.height()) / 2 + window.scrollY, }); $w.applyBounds(); }; $G.on("resize", $w.bringTitleBarInBounds); var drag_offset_x, drag_offset_y, drag_pointer_x, drag_pointer_y, drag_pointer_id; var update_drag = (e) => { const pointerId = e.pointerId ?? e.originalEvent?.pointerId; // originalEvent doesn't exist for triggerHandler() if ( drag_pointer_id === pointerId || pointerId === undefined || // (allowing synthetic events to affect the drag without pointerId) drag_pointer_id === undefined || // (allowing real events to affect a drag started with a synthetic event without a pointerId, for jspaint's Eye Gaze Mode... uh...) drag_pointer_id === 1234567890 // allowing real events to affect a drag started with a synthetic event with this fake pointerId, for jspaint's Eye Gaze Mode!! // @TODO: find a better way to support synthetic events (could make the fake pointerId a formal part of the API contract at least...) ) { drag_pointer_x = e.clientX ?? drag_pointer_x; drag_pointer_y = e.clientY ?? drag_pointer_y; } $w.css({ left: drag_pointer_x + scrollX - drag_offset_x, top: drag_pointer_y + scrollY - drag_offset_y, }); }; $w.$titlebar.css("touch-action", "none"); $w.$titlebar.on("selectstart", (e) => { // preventing mousedown would break :active state, I'm not sure if just selectstart is enough... e.preventDefault(); }); $w.$titlebar.on("mousedown", "button", (e) => { // Prevent focus on titlebar buttons. // This can break the :active state. In Firefox, a setTimeout before any focus() was enough, // but now in Chrome 95, focus() breaks the :active state too, and setTimeout only delays the brokenness, // so I have to use a CSS class now for the pressed state. refocus(); // Emulate :enabled:active:hover state with .pressing class const button = e.currentTarget; if (!$(button).is(":enabled")) { return; } button.classList.add("pressing"); const release = (event) => { // blur is just to handle the edge case of alt+tabbing/ctrl+tabbing away if (event && event.type === "blur") { // if (document.activeElement?.tagName === "IFRAME") { if (document.hasFocus()) { return; // the window isn't really blurred; an iframe got focus } } button.classList.remove("pressing"); $G.off("mouseup blur", release); $(button).off("mouseenter", on_mouse_enter); $(button).off("mouseleave", on_mouse_leave); }; const on_mouse_enter = () => { button.classList.add("pressing"); }; const on_mouse_leave = () => { button.classList.remove("pressing"); }; $G.on("mouseup blur", release); $(button).on("mouseenter", on_mouse_enter); $(button).on("mouseleave", on_mouse_leave); }); $w.$titlebar.on("pointerdown", (e) => { if ($(e.target).closest("button").length) { return; } if ($w.hasClass("maximized")) { return; } const customEvent = $.Event("window-drag-start"); $w.trigger(customEvent); if (customEvent.isDefaultPrevented()) { return; // allow custom drag behavior of component windows in jspaint (Tools / Colors) } drag_offset_x = e.clientX + scrollX - $w.position().left; drag_offset_y = e.clientY + scrollY - $w.position().top; drag_pointer_x = e.clientX; drag_pointer_y = e.clientY; drag_pointer_id = (e.pointerId ?? e.originalEvent?.pointerId); // originalEvent doesn't exist for triggerHandler() $G.on("pointermove", update_drag); $G.on("scroll", update_drag); $("body").addClass("dragging"); // for when mouse goes over an iframe }); $G.on("pointerup pointercancel", (e) => { const pointerId = e.pointerId ?? e.originalEvent?.pointerId; // originalEvent doesn't exist for triggerHandler() if (pointerId !== drag_pointer_id && pointerId !== undefined) { return; } // (allowing synthetic events to affect the drag without pointerId) $G.off("pointermove", update_drag); $G.off("scroll", update_drag); $("body").removeClass("dragging"); // $w.applyBounds(); // Windows doesn't really try to keep windows on screen // but you also can't really drag off of the desktop, whereas here you can drag to way outside the web page. $w.bringTitleBarInBounds(); drag_pointer_id = -1; // prevent bringTitleBarInBounds from making the window go to top left when unminimizing window from taskbar after previously dragging it }); $w.$titlebar.on("dblclick", (e) => { if ($component) { $component.dock(); } }); if (options.resizable) { const HANDLE_MIDDLE = 0; const HANDLE_START = -1; const HANDLE_END = 1; const HANDLE_LEFT = HANDLE_START; const HANDLE_RIGHT = HANDLE_END; const HANDLE_TOP = HANDLE_START; const HANDLE_BOTTOM = HANDLE_END; [ [HANDLE_TOP, HANDLE_RIGHT], // ↗ [HANDLE_TOP, HANDLE_MIDDLE], // ↑ [HANDLE_TOP, HANDLE_LEFT], // ↖ [HANDLE_MIDDLE, HANDLE_LEFT], // ← [HANDLE_BOTTOM, HANDLE_LEFT], // ↙ [HANDLE_BOTTOM, HANDLE_MIDDLE], // ↓ [HANDLE_BOTTOM, HANDLE_RIGHT], // ↘ [HANDLE_MIDDLE, HANDLE_RIGHT], // → ].forEach(([y_axis, x_axis]) => { // const resizes_height = y_axis !== HANDLE_MIDDLE; // const resizes_width = x_axis !== HANDLE_MIDDLE; const $handle = $("
").addClass("handle").appendTo($w); let cursor = ""; if (y_axis === HANDLE_TOP) { cursor += "n"; } if (y_axis === HANDLE_BOTTOM) { cursor += "s"; } if (x_axis === HANDLE_LEFT) { cursor += "w"; } if (x_axis === HANDLE_RIGHT) { cursor += "e"; } cursor += "-resize"; // Note: MISNOMER: innerWidth() is less "inner" than width(), because it includes padding! // Here's a little diagram of sorts: // outerWidth(true): margin, [ outerWidth(): border, [ innerWidth(): padding, [ width(): content ] ] ] const handle_thickness = ($w.outerWidth() - $w.width()) / 2; // padding + border const border_width = ($w.outerWidth() - $w.innerWidth()) / 2; // border; need to outset the handles by this amount so they overlap the border + padding, and not the content const window_frame_height = $w.outerHeight() - $w.$content.outerHeight(); // includes titlebar and borders, padding, but not content const window_frame_width = $w.outerWidth() - $w.$content.outerWidth(); // includes borders, padding, but not content $handle.css({ position: "absolute", top: y_axis === HANDLE_TOP ? -border_width : y_axis === HANDLE_MIDDLE ? `calc(${handle_thickness}px - ${border_width}px)` : "", bottom: y_axis === HANDLE_BOTTOM ? -border_width : "", left: x_axis === HANDLE_LEFT ? -border_width : x_axis === HANDLE_MIDDLE ? `calc(${handle_thickness}px - ${border_width}px)` : "", right: x_axis === HANDLE_RIGHT ? -border_width : "", width: x_axis === HANDLE_MIDDLE ? `calc(100% - ${handle_thickness}px * 2 + ${border_width * 2}px)` : `${handle_thickness}px`, height: y_axis === HANDLE_MIDDLE ? `calc(100% - ${handle_thickness}px * 2 + ${border_width * 2}px)` : `${handle_thickness}px`, // background: x_axis === HANDLE_MIDDLE || y_axis === HANDLE_MIDDLE ? "rgba(255,0,0,0.4)" : "rgba(0,255,0,0.8)", touchAction: "none", cursor, }); let rect; let resize_offset_x, resize_offset_y, resize_pointer_x, resize_pointer_y, resize_pointer_id; $handle.on("pointerdown", (e) => { e.preventDefault(); $G.on("pointermove", handle_pointermove); $G.on("scroll", update_resize); // scroll doesn't have clientX/Y, so we have to remember it $("body").addClass("dragging"); // for when mouse goes over an iframe $G.on("pointerup pointercancel", end_resize); rect = { x: $w.position().left, y: $w.position().top, width: $w.outerWidth(), height: $w.outerHeight(), }; resize_offset_x = e.clientX + scrollX - rect.x - (x_axis === HANDLE_RIGHT ? rect.width : 0); resize_offset_y = e.clientY + scrollY - rect.y - (y_axis === HANDLE_BOTTOM ? rect.height : 0); resize_pointer_x = e.clientX; resize_pointer_y = e.clientY; resize_pointer_id = (e.pointerId ?? e.originalEvent?.pointerId); // originalEvent doesn't exist for triggerHandler() $handle[0].setPointerCapture(resize_pointer_id); // keeps cursor consistent when mouse moves over other elements // handle_pointermove(e); // was useful for checking that the offset is correct (should not do anything, if it's correct!) }); function handle_pointermove(e) { const pointerId = e.pointerId ?? e.originalEvent?.pointerId; // originalEvent doesn't exist for triggerHandler() if (pointerId !== resize_pointer_id && pointerId !== undefined) { return; } // (allowing synthetic events to affect the drag without pointerId) resize_pointer_x = e.clientX; resize_pointer_y = e.clientY; update_resize(); } function end_resize(e) { const pointerId = e.pointerId ?? e.originalEvent?.pointerId; // originalEvent doesn't exist for triggerHandler() if (pointerId !== resize_pointer_id && pointerId !== undefined) { return; } // (allowing synthetic events to affect the drag without pointerId) $G.off("pointermove", handle_pointermove); $G.off("scroll", onscroll); $("body").removeClass("dragging"); $G.off("pointerup pointercancel", end_resize); $w.bringTitleBarInBounds(); } function update_resize() { const mouse_x = resize_pointer_x + scrollX - resize_offset_x; const mouse_y = resize_pointer_y + scrollY - resize_offset_y; let delta_x = 0; let delta_y = 0; let width, height; if (x_axis === HANDLE_RIGHT) { delta_x = 0; width = ~~(mouse_x - rect.x); } else if (x_axis === HANDLE_LEFT) { delta_x = ~~(mouse_x - rect.x); width = ~~(rect.x + rect.width - mouse_x); } else { width = ~~(rect.width); } if (y_axis === HANDLE_BOTTOM) { delta_y = 0; height = ~~(mouse_y - rect.y); } else if (y_axis === HANDLE_TOP) { delta_y = ~~(mouse_y - rect.y); height = ~~(rect.y + rect.height - mouse_y); } else { height = ~~(rect.height); } let new_rect = { x: rect.x + delta_x, y: rect.y + delta_y, width, height, }; new_rect.width = Math.max(1, new_rect.width); new_rect.height = Math.max(1, new_rect.height); // Constraints if (options.constrainRect) { new_rect = options.constrainRect(new_rect, x_axis, y_axis); } new_rect.width = Math.max(new_rect.width, options.minOuterWidth ?? 100); new_rect.height = Math.max(new_rect.height, options.minOuterHeight ?? 0); new_rect.width = Math.max(new_rect.width, (options.minInnerWidth ?? 0) + window_frame_width); new_rect.height = Math.max(new_rect.height, (options.minInnerHeight ?? 0) + window_frame_height); // prevent free movement via resize past minimum size if (x_axis === HANDLE_LEFT) { new_rect.x = Math.min(new_rect.x, rect.x + rect.width - new_rect.width); } if (y_axis === HANDLE_TOP) { new_rect.y = Math.min(new_rect.y, rect.y + rect.height - new_rect.height); } $w.css({ top: new_rect.y, left: new_rect.x, }); $w.outerWidth(new_rect.width); $w.outerHeight(new_rect.height); } }); } $w.$Button = (text, handler) => { var $b = $(E("button")) .appendTo($w.$content) .text(text) .on("click", () => { if (handler) { handler(); } $w.close(); }); return $b; }; $w.title = title => { if (title) { $w.$title.text(title); $w.trigger("title-change"); if ($w.task) { $w.task.updateTitle(); } return $w; } else { return $w.$title.text(); } }; $w.getTitle = () => { return $w.title(); }; let animating_titlebar = false; let when_done_animating_titlebar = []; // queue of functions to call when done animating, // so maximize() / minimize() / restore() eventually gives the same result as if there was no animation $w.animateTitlebar = (from, to, callback = () => { }) => { // flying titlebar animation animating_titlebar = true; const $eye_leader = $w.$titlebar.clone(true); $eye_leader.find("button").remove(); $eye_leader.appendTo("body"); const duration_ms = $Window.OVERRIDE_TRANSITION_DURATION ?? 200; // TODO: how long? const duration_str = `${duration_ms}ms`; $eye_leader.css({ transition: `left ${duration_str} linear, top ${duration_str} linear, width ${duration_str} linear, height ${duration_str} linear`, position: "fixed", zIndex: 10000000, pointerEvents: "none", left: from.left, top: from.top, width: from.width, height: from.height, }); setTimeout(() => { $eye_leader.css({ left: to.left, top: to.top, width: to.width, height: to.height, }); }, 5); let handled_transition_completion = false; const handle_transition_completion = () => { if (handled_transition_completion) { return; // ignore multiple calls (an idempotency pattern) } else { handled_transition_completion = true; } animating_titlebar = false; $eye_leader.remove(); callback(); when_done_animating_titlebar.shift()?.(); // relies on animating_titlebar = false; }; $eye_leader.on("transitionend transitioncancel", handle_transition_completion); setTimeout(handle_transition_completion, duration_ms * 1.2); }; $w.close = (force) => { if (force && force !== true) { throw new TypeError("force must be a boolean or undefined, not " + Object.prototype.toString.call(force)); } if (!force) { var e = $.Event("close"); $w.trigger(e); if (e.isDefaultPrevented()) { return; } } if ($component) { $component.detach(); } $w.closed = true; $event_target.triggerHandler("closed"); $w.trigger("closed"); // TODO: change usages of "close" to "closed" where appropriate // and probably rename the "close" event ("before[-]close"? "may-close"? "close-request"?) // MUST be after any events are triggered! $w.remove(); // TODO: support modals, which should focus what was focused before the modal was opened. // (Note: must consider the element being removed from the DOM, or hidden, or made un-focusable) // (Also: modals should steal focus / be brought to the front when focusing the parent window, and the parent window's content should be inert/uninteractive) // Focus next-topmost window var $next_topmost = $($(".window:visible").toArray().sort((a, b) => b.style.zIndex - a.style.zIndex)[0]); $next_topmost.triggerHandler("refocus-window"); // Cleanup clean_up_debug_focus_tracking(); }; $w.closed = false; let current_menu_bar; // @TODO: should this be like setMenus(menu_definitions)? // It seems like setMenuBar(menu_bar) might be prone to bugs // trying to set the same menu bar on multiple windows. $w.setMenuBar = (menu_bar) => { // $w.find(".menus").remove(); // ugly, if only because of the class name haha if (current_menu_bar) { current_menu_bar.element.remove(); } if (menu_bar) { $w.$titlebar.after(menu_bar.element); menu_bar.setKeyboardScope($w[0]); current_menu_bar = menu_bar; } }; if (options.title) { $w.title(options.title); } if (!$component) { $w.center(); } // mustHaveMethods($w, windowInterfaceMethods); return $w; } function $FormWindow(title) { var $w = new $Window(); $w.title(title); $w.$form = $(E("form")).appendTo($w.$content); $w.$main = $(E("div")).appendTo($w.$form); $w.$buttons = $(E("div")).appendTo($w.$form).addClass("button-group"); $w.$Button = (label, action) => { var $b = $(E("button")).appendTo($w.$buttons).text(label); $b.on("click", (e) => { // prevent the form from submitting // @TODO: instead, prevent the form's submit event e.preventDefault(); action(); }); $b.on("pointerdown", () => { $b.focus(); }); return $b; }; return $w; } exports.$Window = $Window; exports.$FormWindow = $FormWindow; })(window);