pvbemu/app/toolkit/Window.js

700 lines
22 KiB
JavaScript

import { Component } from /**/"./Component.js";
let Toolkit;
///////////////////////////////////////////////////////////////////////////////
// Window //
///////////////////////////////////////////////////////////////////////////////
// Standalone movable dialog
class Window extends Component {
static Component = Component;
///////////////////////// Initialization Methods //////////////////////////
constructor(gui, options) {
super(gui, options, {
className : "tk tk-window",
focusable : true,
role : "dialog",
tabStop : false,
tagName : "div",
visibility: true,
style : {
position: "absolute"
}
});
// Configure instance fields
this.firstShown = false;
this.lastFocus = null;
// DOM container
this.contents = document.createElement("div");
this.contents.style.display = "flex";
this.contents.style.flexDirection = "column";
this.element.append(this.contents);
// Sizing borders
this.borders = {}
this.border("n" ); this.border("w" );
this.border("e" ); this.border("s" );
this.border("nw"); this.border("ne");
this.border("sw"); this.border("se");
// Title bar
this.titleBar = document.createElement("div");
this.titleBar.className = "tk tk-title";
this.titleBar.style.display = "flex";
this.contents.append(this.titleBar);
this.titleBar.addEventListener(
"pointerdown", e=>this.onTitlePointerDown(e));
this.titleBar.addEventListener(
"pointermove", e=>this.onTitlePointerMove(e));
this.titleBar.addEventListener(
"pointerup" , e=>this.onTitlePointerUp (e));
// Title bar text
this.titleText = document.createElement("div");
this.titleText.className = "tk tk-text";
this.titleText.id = Toolkit.id();
this.titleText.style.flexGrow = "1";
this.titleText.style.position = "relative";
this.titleBar.append(this.titleText);
this.setAttribute("aria-labelledby", this.titleText.id);
// Close button
this.titleClose = document.createElement("div");
this.titleClose.className = "tk tk-close";
this.titleClose.setAttribute("aria-hidden", "true");
this.titleBar.append(this.titleClose);
this.titleClose.addEventListener(
"pointerdown", e=>this.onClosePointerDown(e));
this.titleClose.addEventListener(
"pointermove", e=>this.onClosePointerMove(e));
this.titleClose.addEventListener(
"pointerup" , e=>this.onClosePointerUp (e));
// Window client area
this.client = document.createElement("div");
this.client.className = "tk tk-client";
this.client.style.flexGrow = "1";
this.client.style.minHeight = "0";
this.client.style.minWidth = "0";
this.client.style.overflow = "hidden";
this.client.style.position = "relative";
this.contents.append(this.client);
// User agent behavior override
let observer = new ResizeObserver(
()=>this.titleBar.style.width =
this.client.getBoundingClientRect().width + "px"
);
observer.observe(this.client);
// Configure element
this.setAttribute("aria-modal", "false");
this.setBounds(
options.x , options.y,
options.width, options.height
);
// Configure component
this.gui.localize(this);
this.setTitle (options.title );
this.setCloseToolTip(options.closeToolTip);
this.addEventListener("focus" , e=>this.onFocus(e), true);
this.addEventListener("keydown" , e=>this.onWindowKeyDown (e));
this.addEventListener("pointerdown", e=>this.onWindowPointerDown(e));
}
///////////////////////////// Event Handlers //////////////////////////////
// Border pointer down
onBorderPointerDown(e, edge) {
if (e.target.hasPointerCapture(e.pointerId) || e.button != 0)
return;
e.target.setPointerCapture(e.pointerId);
e.preventDefault();
let bndClient = this.client.getBoundingClientRect();
let bndWindow = this .getBounds ();
let bndDesktop = this.parent ? this.parent.getBounds() : bndWindow;
let coords = Toolkit.screenCoords(e);
this.drag = {
clickX : coords.x,
clickY : coords.y,
mode : "resize",
pointerId : e.pointerId,
startHeight: bndClient.height,
startWidth : bndClient.width,
startX : bndWindow.x - bndDesktop.x,
startY : bndWindow.y - bndDesktop.y,
target : e.target
};
}
// Border pointer move
onBorderPointerMove(e, edge) {
if (!e.target.hasPointerCapture(e.pointerId))
return;
e.stopPropagation();
e.preventDefault();
let bndWindow = this.getBounds();
this["resize" + edge.toUpperCase()](
Toolkit.screenCoords(e),
this.client .getBoundingClientRect(),
this.parent ? this.parent.getBounds() : bndWindow,
bndWindow,
this.titleBar.getBoundingClientRect()
);
}
// Border pointer up
onBorderPointerUp(e, edge) {
if (!e.target.hasPointerCapture(e.pointerId) || e.button != 0)
return;
e.target.releasePointerCapture(e.pointerId);
e.stopPropagation();
e.preventDefault();
}
// Close pointer down
onClosePointerDown(e) {
if (this.titleClose.hasPointerCapture(e.pointerId) || e.button != 0)
return;
this.titleClose.setPointerCapture(e.pointerId);
e.stopPropagation();
e.preventDefault();
this.titleClose.classList.add("active");
this.drag = {
mode: "close",
x : e.offsetX,
y : e.offsetY
};
}
// Close pointer move
onClosePointerMove(e) {
if (!this.titleClose.hasPointerCapture(e.pointerId))
return;
e.stopPropagation();
e.preventDefault();
if (Toolkit.isInside(this.titleClose, e))
this.titleClose.classList.add("active");
else this.titleClose.classList.remove("active");
}
// Close pointer up
onClosePointerUp(e) {
if (!this.titleClose.hasPointerCapture(e.pointerId) || e.button != 0)
return;
this.titleClose.releasePointerCapture(e.pointerId);
e.stopPropagation();
e.preventDefault();
this.titleClose.classList.remove("active");
if (Toolkit.isInside(this.titleClose, e))
this.element.dispatchEvent(Toolkit.event("close", this));
this.drag = null;
}
// Focus capture
onFocus(e) {
// Bring this window to the foreground of its siblings
if (!this.contains(e.relatedTarget) && this.parent)
this.parent.bringToFront(this);
// The target is not the window itself
if (e.target != this.element) {
this.lastFocus = e.target;
return;
}
// Select the first focusable child
if (this.lastFocus == null)
this.lastFocus = Toolkit.listFocusables(this.element)[0] || null;
// Send focus to the most recently focused element
if (this.lastFocus)
this.lastFocus.focus();
}
// Title pointer down
onTitlePointerDown(e) {
if (this.titleBar.hasPointerCapture(e.pointerId) || e.button != 0)
return;
this.titleBar.setPointerCapture(e.pointerId);
e.preventDefault();
let bndWindow = this.getBounds();
let bndDesktop = this.parent ? this.parent.getBounds() : bndWindow;
let coords = Toolkit.screenCoords(e);
this.drag = {
clickX : coords.x,
clickY : coords.y,
mode : "move",
pointerId: e.pointerId,
startX : bndWindow.x - bndDesktop.x,
startY : bndWindow.y - bndDesktop.y
};
}
// Title pointer move
onTitlePointerMove(e) {
if (!this.titleBar.hasPointerCapture(e.pointerId))
return;
e.stopPropagation();
e.preventDefault();
let coords = Toolkit.screenCoords(e);
let valid = this.getValidLocations(
this.drag.startX + coords.x - this.drag.clickX,
this.drag.startY + coords.y - this.drag.clickY
);
this.setLocation(valid.x, valid.y);
}
// Title pointer up
onTitlePointerUp(e) {
if (!this.titleBar.hasPointerCapture(e.pointerId) || e.button != 0)
return;
this.titleBar.releasePointerCapture(e.pointerId);
e.stopPropagation();
e.preventDefault();
this.drag = null;
}
// Window key press
onWindowKeyDown(e) {
// Process by key
switch (e.key) {
// Undo un-committed bounds modifications
case "Escape":
// Not dragging
if (this.drag == null)
return;
// Moving
if (this.drag.mode == "move") {
this.titleBar.releasePointerCapture(this.drag.pointerId);
this.setLocation(this.drag.startX, this.drag.startY);
this.drag = null;
}
// Resizing
else if (this.drag.mode == "resize") {
this.drag.target
.releasePointerCapture(this.drag.pointerId);
this.setBounds(
this.drag.startX , this.drag.startY,
this.drag.startWidth, this.drag.startHeight
);
this.drag = null;
}
break;
// Transfer focus to another element
case "Tab":
default: return;
}
// The event was handled
e.stopPropagation();
e.preventDefault();
}
// Window pointer down
onWindowPointerDown(e) {
this.focus(e);
e.stopPropagation();
e.preventDefault();
}
///////////////////////////// Public Methods //////////////////////////////
// Add a DOM element to this component's element
append(child) {
let element = child instanceof Element ? child : child.element;
this.client.append(element);
}
// Position the window in the center of the parent Desktop
center() {
if (!this.parent)
return;
let bndParent = this.parent.getBounds();
let bndWindow = this .getBounds();
this.setLocation(
Math.max(Math.floor((bndParent.width - bndWindow.width ) / 2), 0),
Math.max(Math.floor((bndParent.height - bndWindow.height) / 2), 0)
);
}
// Programmatically close the window
close() {
this.event("close");
}
// Add a DOM element to the beginning of this component's children
prepend(child) {
let element = child instanceof Element ? child : child.element;
this.element.prepend(element);
}
// Specify a new position and size for the window
setBounds(x, y, width, height) {
this.setSize(width, height);
this.setLocation(x, y);
}
// Specify the over text for the close button
setCloseToolTip(key) {
this.closeToolTip = key;
this.translate();
}
// Specify a new position for the window
setLocation(x, y) {
Object.assign(this.element.style, {
left: Math.round(parseFloat(x) || 0) + "px",
top : Math.round(parseFloat(y) || 0) + "px"
});
}
// Specify a new size for the window
setSize(width, height) {
Object.assign(this.client.style, {
width : Math.max(Math.round(parseFloat(width ) || 0, 32)) + "px",
height: Math.max(Math.round(parseFloat(height) || 0, 32)) + "px"
});
}
// Specify the window title text
setTitle(key) {
this.title = key;
this.translate();
}
// Specify whether the component is visible
setVisible(visible) {
super.setVisible(visible);
if (!visible || this.firstShown)
return;
this.firstShown = true;
this.event("firstshow", this);
}
///////////////////////////// Package Methods /////////////////////////////
// Ensure the window is partially visible within its desktop
contain() {
let valid = this.getValidLocations();
this.setLocation(valid.x, valid.y);
}
// Determine the range of valid window coordinates
getValidLocations(x, y) {
// Measure the bounding boxes of the relevant elements
let bndClient = this.client .getBoundingClientRect();
let bndWindow = this .getBounds ();
let bndTitleBar = this.titleBar.getBoundingClientRect();
let bndDesktop = this.parent ? this.parent.getBounds() : bndWindow;
// Compute the minimum and maximum valid window coordinates
let ret = {
maxX: bndDesktop .width - bndTitleBar.height -
bndTitleBar.x + bndWindow .x,
maxY: bndDesktop .height - bndClient .y +
bndWindow .y,
minX: bndTitleBar.height - bndWindow .width +
bndWindow .right - bndTitleBar.right,
minY: 0
};
// Compute the effective "best" window coordinates
ret.x = Math.max(ret.minX, Math.min(ret.maxX,
x === undefined ? bndWindow.x - bndDesktop.x : x));
ret.y = Math.max(ret.minY, Math.min(ret.maxY,
y === undefined ? bndWindow.y - bndDesktop.y : y));
return ret;
}
// Update the global Toolkit object
static setToolkit(toolkit) {
Toolkit = toolkit;
}
// Regenerate localized display text
translate() {
if (!this.titleText)
return;
this.titleText.innerText = this.gui.translate(this.title, this);
if (this.closeToolTip)
this.titleClose.setAttribute("title",
this.gui.translate(this.closeToolTip, this));
else this.titleClose.removeAttribute("title");
}
///////////////////////////// Private Methods /////////////////////////////
// Produce a border element and add it to the window
border(edge) {
let border = this.borders[edge] = document.createElement("div");
border.className = "tk tk-" + edge;
border.style.cursor = edge + "-resize";
border.style.position = "absolute";
this.contents.append(border);
border.addEventListener(
"pointerdown", e=>this.onBorderPointerDown(e, edge));
border.addEventListener(
"pointermove", e=>this.onBorderPointerMove(e, edge));
border.addEventListener(
"pointerup" , e=>this.onBorderPointerUp (e, edge));
}
// Compute client bounds when resizing on the east border
constrainE(coords, bndClient, bndDesktop, bndWindow, bndTitleBar) {
let w = this.drag.startWidth + coords.x - this.drag.clickX;
w = Math.max(w, bndTitleBar.height * 4);
if (bndClient.x - bndDesktop.x < 0)
w = Math.max(w, bndDesktop.x - bndClient.x + bndTitleBar.height);
return w;
}
// Compute client bounds when resizing on the north border
constrainN(coords, bndClient, bndDesktop, bndWindow, bndTitleBar) {
let delta = coords.y - this.drag.clickY;
let y = this.drag.startY + delta;
let h = this.drag.startHeight - delta;
let min = Math.max(0, bndClient.bottom - bndDesktop.bottom);
if (h < min) {
delta = min - h;
h += delta;
y -= delta;
}
if (y < 0) {
h += y;
y = 0;
}
return {
height: h,
y : y
};
}
// Compute client bounds when resizing on the south border
constrainS(coords, bndClient, bndDesktop, bndWindow, bndTitleBar) {
return Math.max(0, this.drag.startHeight+coords.y-this.drag.clickY);
}
// Compute client bounds when resizing on the west border
constrainW(coords, bndClient, bndDesktop, bndWindow, bndTitleBar) {
let delta = coords.x - this.drag.clickX;
let x = this.drag.startX + delta;
let w = this.drag.startWidth - delta;
let min = bndTitleBar.height * 4;
if (bndClient.right - bndDesktop.right > 0) {
min = Math.max(min, bndClient.right -
bndDesktop.right + bndTitleBar.height);
}
if (w < min) {
delta = min - w;
w += delta;
x -= delta;
}
return {
x : x,
width: w
};
}
// Resize on the east border
resizeE(coords, bndClient, bndDesktop, bndWindow, bndTitleBar) {
this.setSize(
this.constrainE(coords,bndClient,bndDesktop,bndWindow,bndTitleBar),
this.drag.startHeight
);
}
// Resize on the north border
resizeN(coords, bndClient, bndDesktop, bndWindow, bndTitleBar) {
let con = this.constrainN(coords,
bndClient, bndDesktop, bndWindow, bndTitleBar);
this.setBounds(
this.drag.startX , con.y,
this.drag.startWidth, con.height
);
}
// Resize on the northeast border
resizeNE(coords, bndClient, bndDesktop, bndWindow, bndTitleBar) {
let con = this.constrainN(coords,
bndClient, bndDesktop, bndWindow, bndTitleBar);
this.setBounds(
this.drag.startX, con.y,
this.constrainE(coords,bndClient,bndDesktop,bndWindow,bndTitleBar),
con.height
);
}
// Resize on the northwest border
resizeNW(coords, bndClient, bndDesktop, bndWindow, bndTitleBar) {
let conN = this.constrainN(coords,
bndClient, bndDesktop, bndWindow, bndTitleBar);
let conW = this.constrainW(coords,
bndClient, bndDesktop, bndWindow, bndTitleBar);
this.setBounds(conW.x, conN.y, conW.width, conN.height);
}
// Resize on the south border
resizeS(coords, bndClient, bndDesktop, bndWindow, bndTitleBar) {
this.setSize(
this.drag.startWidth,
this.constrainS(coords,bndClient,bndDesktop,bndWindow,bndTitleBar),
);
}
// Resize on the southeast border
resizeSE(coords, bndClient, bndDesktop, bndWindow, bndTitleBar) {
this.setSize(
this.constrainE(coords,bndClient,bndDesktop,bndWindow,bndTitleBar),
this.constrainS(coords,bndClient,bndDesktop,bndWindow,bndTitleBar)
);
}
// Resize on the southwest border
resizeSW(coords, bndClient, bndDesktop, bndWindow, bndTitleBar) {
let con = this.constrainW(coords,
bndClient, bndDesktop, bndWindow, bndTitleBar);
this.setBounds(
con.x , this.drag.startY,
con.width,
this.constrainS(coords,bndClient,bndDesktop,bndWindow,bndTitleBar)
);
}
// Resize on the west border
resizeW(coords, bndClient, bndDesktop, bndWindow, bndTitleBar) {
let con = this.constrainW(coords,
bndClient, bndDesktop, bndWindow, bndTitleBar);
this.setBounds(
con.x , this.drag.startY,
con.width, this.drag.startHeight
);
}
};
///////////////////////////////////////////////////////////////////////////////
// Desktop //
///////////////////////////////////////////////////////////////////////////////
// Parent container for encapsulating groups of Windows
class Desktop extends Component {
///////////////////////// Initialization Methods //////////////////////////
constructor(gui, options) {
super(gui, options, {
className: "tk tk-desktop",
role : "group",
tagName : "div",
style : {
position: "relative"
}
});
// Configure event handlers
this.addEventListener("resize", e=>this.onResize(e));
}
///////////////////////////// Event Handlers //////////////////////////////
// Element resized
onResize(e) {
for (let wnd of this.children)
wnd.contain();
}
///////////////////////////// Public Methods //////////////////////////////
// Re-order windows to bring a particular one to the foreground
bringToFront(wnd) {
// The window is not a child of this Desktop
let index = this.children.indexOf(wnd);
if (index == -1)
return;
// The window is already in the foreground
let afters = this.children.slice(index + 1).map(c=>c.element);
if (afters.length == 0)
return;
// Record scroll pane positions
let scrolls = [];
for (let after of afters)
for (let scroll of
after.querySelectorAll(".tk-scrollpane > .tk-viewport")) {
scrolls.push({
element: scroll,
left : scroll.scrollLeft,
top : scroll.scrollTop
});
}
// Update window collection
wnd.element.before(... this.children.slice(index+1).map(c=>c.element));
this.children.splice(index, 1);
this.children.push(wnd);
// Restore scroll pane positions
for (let scroll of scrolls) {
Object.assign(scroll.element, {
scrollLeft: scroll.left,
scrollTop : scroll.top
});
}
}
// Position a window in the center of the viewable area
center(wnd) {
// The window is not a child of the desktop pane
if (this.children.indexOf(wnd) == -1)
return;
let bndDesktop = this.getBounds();
let bndWindow = wnd .getBounds();
wnd.setLocation(
Math.max(0, Math.round((bndDesktop.width - bndWindow.width) / 2)),
Math.max(0, Math.round((bndDesktop.height-bndWindow.height) / 2))
);
}
};
export { Desktop, Window };