pvbemu/web/toolkit/SplitPane.js

364 lines
12 KiB
JavaScript

let register = Toolkit => Toolkit.SplitPane =
// Presentational text
class SplitPane extends Toolkit.Component {
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}) {
super(app, options = Object.assign({
class: "tk split-pane"
}, options, { style: Object.assign({
display : "grid",
gridTemplateColumns: "max-content max-content auto",
overflow : "hidden"
}, options.style || {}) }));
// Configure instance fields
this.drag = null;
this._orientation = "left";
this._primary = null;
this.resizer = new ResizeObserver(()=>this.priResize());
this.restore = null;
this._secondary = null;
// Primary placeholder
this.noPrimary = document.createElement("div");
this.noPrimary.id = Toolkit.id();
// Separator widget
this.splitter = document.createElement("div");
this.splitter.setAttribute("aria-controls", this.noPrimary.id);
this.splitter.setAttribute("aria-valuemin", "0");
this.splitter.setAttribute("role", "separator");
this.splitter.setAttribute("tabindex", "0");
this.splitter.addEventListener("keydown",
e=>this.splitKeyDown (e));
this.splitter.addEventListener("pointerdown",
e=>this.splitPointerDown(e));
this.splitter.addEventListener("pointermove",
e=>this.splitPointerMove(e));
this.splitter.addEventListener("pointerup" ,
e=>this.splitPointerUp (e));
// Secondary placeholder
this.noSecondary = document.createElement("div");
// Configure options
if ("orientation" in options)
this.orientation = options.orientation;
if ("primary" in options)
this.primary = options.primary;
if ("secondary" in options)
this.secondary = options.secondary;
this.revalidate();
}
///////////////////////////// Event Handlers //////////////////////////////
// Primary pane resized
priResize() {
let metrics = this.measure();
this.splitter.setAttribute("aria-valuemax", metrics.max);
this.splitter.setAttribute("aria-valuenow", metrics.value);
}
// Splitter key press
splitKeyDown(e) {
// Error checking
if (e.altKey || e.ctrlKey || e.shiftKey || this.disabled)
return;
// Drag is in progress
if (this.drag) {
if (e.key != "Escape")
return;
this.splitter.releasePointerCapture(this.drag.pointerId);
this.value = this.drag.size;
this.drag = null;
Toolkit.handle(e);
return;
}
// Processing by key
let edge = this.orientation;
let horz = edge == "left" || edge == "right";
switch (e.key) {
case "ArrowDown":
if (horz)
return;
this.restore = null;
this.value += edge == "top" ? +10 : -10;
break;
case "ArrowLeft":
if (!horz)
return;
this.restore = null;
this.value += edge == "left" ? -10 : +10;
break;
case "ArrowRight":
if (!horz)
return;
this.restore = null;
this.value += edge == "left" ? +10 : -10;
break;
case "ArrowUp":
if (horz)
return;
this.restore = null;
this.value += edge == "top" ? -10 : +10;
break;
case "End":
this.restore = null;
this.value = this.element.getBoundingClientRect()
[horz ? "width" : "height"];
break;
case "Enter":
if (this.restore !== null) {
this.value = this.restore;
this.restore = null;
} else {
this.restore = this.value;
this.value = 0;
}
break;
case "Home":
this.restore = null;
this.value = 0;
break;
default: return;
}
Toolkit.handle(e);
}
// Splitter pointer down
splitPointerDown(e) {
// Do not obtain focus automatically
e.preventDefault();
// Error checking
if (
e.altKey || e.ctrlKey || e.shiftKey || e.button != 0 ||
this.disabled || this.splitter.hasPointerCapture(e.pointerId)
) return;
// Begin dragging
this.splitter.setPointerCapture(e.poinerId);
let horz = this.orientation == "left" || this.orientation == "right";
let prop = horz ? "width" : "height";
this.drag = {
pointerId: e.pointerId,
size : (this._primary || this.noPrimary)
.getBoundingClientRect()[prop],
start : e[horz ? "clientX" : "clientY" ]
};
Toolkit.handle(e);
}
// Splitter pointer move
splitPointerMove(e) {
// Error checking
if (!this.splitter.hasPointerCapture(e.pointerId))
return;
// Working variables
let horz = this.orientation == "left" || this.orientation == "right";
let delta = e[horz ? "clientX" : "clientY"] - this.drag.start;
let scale = this.orientation == "bottom" ||
this.orientation == "right" ? -1 : 1;
// Resize the primary component
this.restore = null;
this.value = Math.round(this.drag.size + scale * delta);
Toolkit.handle(e);
}
// Splitter pointer up
splitPointerUp(e) {
// Error checking
if (e.button != 0 || !this.splitter.hasPointerCapture(e.pointerId))
return;
// End dragging
this.splitter.releasePointerCapture(e.pointerId);
Toolkit.handle(e);
}
///////////////////////////// Public Methods //////////////////////////////
// Edge containing the primary pane
get orientation() { return this._orientation; }
set orientation(orientation) {
switch (orientation) {
case "bottom":
case "left":
case "right":
case "top":
break;
default: return;
}
if (orientation == this.orientation)
return;
this._orientation = orientation;
this.revalidate();
}
// Primary content pane
get primary() {
return !this._primary ? null :
this._primary.component || this._primary;
}
set primary(element) {
if (element instanceof Toolkit.Component)
element = element.element;
if (!(element instanceof HTMLElement) || element == this.element)
return;
this.resizer.unobserve(this._primary || this.noPrimary);
this._primary = element || null;
this.resizer.observe(element || this.noPrimary);
this.splitter.setAttribute("aria-controls",
(element || this.noPrimary).id);
this.revalidate();
}
// Secondary content pane
get secondary() {
return !this._secondary ? null :
this._secondary.component || this._secondary;
}
set secondary(element) {
if (element instanceof Toolkit.Component)
element = element.element;
if (!(element instanceof HTMLElement) || element == this.element)
return;
this._secondary = element || null;
this.revalidate();
}
// Current splitter position
get value() {
return Math.ceil(
(this._primary || this.primary).getBoundingClientRect()
[this.orientation == "left" || this.orientation == "right" ?
"width" : "height"]
);
}
set value(value) {
value = Math.round(value);
// Error checking
if (value == this.value)
return;
// Working variables
let pri = this._primary || this.noPrimary;
let sec = this._secondary || this.noSecondary;
let prop = this.orientation == "left" || this.orientation == "right" ?
"width" : "height";
// Resize the primary component
pri.style[prop] = Math.max(0, value) + "px";
// Ensure the pane didn't become too large due to margin styles
let propPri = pri .getBoundingClientRect()[prop];
let propSec = sec .getBoundingClientRect()[prop];
let propSplit = this.splitter.getBoundingClientRect()[prop];
let propThis = this.element .getBoundingClientRect()[prop];
if (propPri + propSec + propSplit > propThis) {
pri.style[prop] = Math.max(0, Math.floor(
propThis - propSec - propSplit)) + "px";
}
}
///////////////////////////// Private Methods /////////////////////////////
// Measure the current bounds of the child elements
measure() {
let prop = this.orientation == "top" || this.orientation == "bottom" ?
"height" : "width";
let bndThis = this.element .getBoundingClientRect();
let bndSplit = this.splitter.getBoundingClientRect();
let bndPri = (this._primary || this.noPrimary)
.getBoundingClientRect();
return {
max : bndThis[prop],
property: prop,
value : bndPri[prop]
}
}
// Arrange child components
revalidate() {
let horz = true;
let children = [
this._primary || this.noPrimary,
this.splitter,
this._secondary || this.noSecondary
];
// Select styles by orientation
switch (this.orientation) {
case "bottom":
Object.assign(this.element.style, {
gridAutoColumns : "100%",
gridTemplateRows: "auto max-content max-content"
});
horz = false;
children.reverse();
break;
case "left":
Object.assign(this.element.style, {
gridAutoRows : "100%",
gridTemplateColumns: "max-content max-content auto"
});
break;
case "right":
Object.assign(this.element.style, {
gridAutoRows : "100%",
gridTemplateColumns: "auto max-content max-content"
});
children.reverse();
break;
case "top":
Object.assign(this.element.style, {
gridAutoColumns : "100%",
gridTemplateRows: "max-content max-content auto"
});
horz = false;
break;
}
// Update element
if (horz) {
this.element.style.removeProperty("grid-auto-columns");
this.element.style.removeProperty("grid-template-rows");
this.splitter.className = "tk horizontal";
this.splitter.style.cursor = "ew-resize";
} else {
this.element.style.removeProperty("grid-auto-rows");
this.element.style.removeProperty("grid-template-columns");
this.splitter.className = "tk vertical";
this.splitter.style.cursor = "ns-resize";
}
this.element.replaceChildren(... children);
this.priResize();
}
}
export { register };