Create floating windows (no IFrames) that can be sized, minimized, maximized, and dragged. Layout can be persisted and minimize can be in place or to the bottom of the containing region.
Table of Contents
I've been wanting a sizeable, minimizable, maximizable floating window for a while now. As usual, I was not happy with what I've found on the interwebs. The following:
are three examples that came closest, but lacked either the full behavior I wanted or lacked a sufficiently complete API or were overly complicated, as in jsFrame. However, they all were good starting points for this implementation. Nor did I want to bring in a large package like jqWidgets or similar web UI simply for this one feature. So, time to invent the wheel again except this time make it more like a smooth round wheel rather than something roughly hewn from a rock.
The screenshot here is clipped to the right:
Assuming that the flag minimized in place is false.
Compare with the screenshot at the top of the article.
Here, the inner windows are constrained to live within the outer div
:
You can save and reload the DivWindow
states (position
, size
, state
) for the entire document or a container.
The public
methods provide for a reasonable amount of control over the DivWindow
and these are self-explanatory. Except for the "get
" functions, these return the DivWindow
instance so they can be chained for a fluent syntax style.
constructor(id: string, options?: DivWindowOptions)
create(id: string, options?: DivWindowOptions)
setCaption(caption: string)
setColor(color: string)
setContent(html: string)
getPosition(): DivWindowPosition
getSize(): DivWindowSize
setPosition(x: string, y: string)
setSize(w: string, h: string)
setWidth(w: string)
setHeight(h: string)
close()
minimize(atPosition = false)
maximize()
restore()
Two static
functions handle saving and loading layouts:
static saveLayout(id?: string)
static loadLayout(id?: string)
The following properties are also defined, mainly for the convenience of the DivWindow
code itself.
get x()
set x(x: number)
get y()
set y(y:number)
get w()
set w(w: number)
get h()
set h(h: number)
At a minimum, one creates a div
with some content, for example:
<div id="window1" caption="Another Window">
<p>All good men<br />Must come to an end.</p>
</div>
and initializes the DivWindow
with:
new DivWindow("window1");
rendering:
The window by default will size automatically to the extents of the content.
Options can be defined declaratively using the divWindowOptions
attribute with a JSON value declaring the options that are being set:
Example 1:
<div id="outerwindow1" caption="Window 1" divWindowOptions='{"left":100, "top":50}'>
Window 1
</div>
Example 2:
<div id="window3" caption=Example" divWindowOptions='{ "left":250, "top":50, "width":300,
"color": "darkred", "hasClose": false, "hasMaximize": false,
"moveMinimizedToBottom": false, "isMinimized": true }'>
Some Content
</div>
Declare the outer window and inner windows, for example:
<div id="www" caption="W-w-W">
<div id="innerwindow1" caption="Window 1">
Inner Window 1
</div>
<div id="innerwindow2" caption="Window 2">
Inner Window 2
</div>
</div>
Then initialize them similar to this:
new DivWindow("www")
.setPosition("50px", "300px")
.setSize("400px", "400px")
.create("innerwindow1").setPosition("10px", "50px").setColor("#90EE90")
.create("innerwindow2").setPosition("60px", "100px").setColor("#add8e6");
Note the fluent syntax. There's nothing special about create
here, it's just like calling new DivWindow()
.
This renders:
The inner windows are confined to the outer window.
Here's a simple example where the windows are contained and confined to a container element.
<div style="position:absolute; left:600px; top:100px; width:600px;
height:400px; border:1px solid black;">
<div id="window1" caption="A Window">
<p>All good men<br />Must come to an end.</p>
</div>
<div id="window2" caption="My Window">
Hello World!
</div>
<div id="window3" caption="Three by Three"
divWindowOptions='{ "left":250, "top":75, "width":300,
"color": "darkred", "hasClose": false, "hasMaximize": false,
"moveMinimizedToBottom": false, "isMinimized": true }'>
<p>Some content</p>
</div>
</div>
Example initialization:
new DivWindow("window1").setPosition("0px", "0px");
new DivWindow("window2", { hasMaximize: false });
new DivWindow("window3");
which renders as:
Here, I'll cover the more interesting aspects of the implementation, as much of the code should be obvious. No jQuery is used!
Because I'm intending to use this as a component in other projects where I'm using require.js, there is a small amount of boilerplate to support the export
keyword.
<head>
<meta charset="utf-8" />
<title>DivWin</title>
<link rel="stylesheet" href="divWindow.css" type="text/css" />
<script data-main="AppConfig" src="require.js"></script>
</head>
AppConfig.ts:
import { AppMain } from "./AppMain"
require(['AppMain'],
(main: any) => {
const appMain = new AppMain();
appMain.run();
}
);
AppMain.ts (for initializing the demo):
import { DivWindow } from "./divWindow"
export class AppMain {
public run() {
document.getElementById("saveLayout").onclick = () => DivWindow.saveLayout();
document.getElementById("loadLayout").onclick = () => DivWindow.loadLayout();
new DivWindow("outerwindow1");
new DivWindow("outerwindow2");
new DivWindow("window1").setPosition("0px", "0px");
new DivWindow("window2", { hasMaximize: false });
new DivWindow("window3");
new DivWindow("www")
.setPosition("50px", "300px")
.setSize("400px", "400px")
.create("innerwindow1").setPosition("10px", "50px").setColor("#90EE90")
.create("innerwindow2").setPosition("60px", "100px").setColor("#add8e6");
new DivWindow("exampleContent").setPosition("100px", "700px").w = 200;
}
}
I had the project working fine without require.js, but I really wanted to have the implementation in its final form for other projects, but it's easy to revert back -- just remove the export
keyword on all the classes and change how the page is initialized to window.onLoad = () => {...initializate stuff....};
The following events are captured for each window:
document.getElementById(this.idCaptionBar).onmousedown = () => this.updateZOrder();
document.getElementById(this.idWindowDraggableArea).onmousedown =
e => this.onDraggableAreaMouseDown(e);
document.getElementById(this.idClose).onclick = () => this.close();
document.getElementById(this.idMinimize).onclick = () => this.minimizeRestore();
document.getElementById(this.idMaximize).onclick = () => this.maximizeRestore();
The thing I struggled with the most, ironically, was the template that adds itself to the containing DIV
element. The struggle here was getting the elements in the right parent-child relationship so that the close/minimize/maximize click events would fire! This may seem silly to the reader, but I had issues as I basically first had child elements defined as a sibling after the div
containing the "buttons." Here's the final form:
protected template = '\
<div id="{w}_windowTemplate" class="divWindowPanel" divWindow>\
<div id="{w}_captionBar" class="divWindowCaption" style="height: 18px">\
<div class="noselect"
style="position:absolute; top:3px; left:0px; text-align:center; width: 100%">\
<div id="{w}_windowCaption"
style="display:inline-block">\</div>\
<div style="position:absolute; left:5px; display:inline-block">\
<div id="{w}_close" class="dot"
style="background-color:#FC615C; margin-right: 3px"></div>\
<div id="{w}_minimize" class="dot"
style="background-color: #FDBE40; margin-right: 3px"></div>\
<div id="{w}_maximize" class="dot"
style="background-color: #34CA49"></div>\
</div>\
</div>\
<div id="{w}_windowDraggableArea" class="noselect"
style="position:absolute; top:0px; left:55px;
width: 100%; height:22px; cursor: move; display:inline-block"> </div>\
</div>\
<div id="{w}_windowContent" class="divWindowContent"></div>\
</div>\
';
Note that any occurrence of {w}
is replaced with the container's element id. So, in the constructor, you'll see:
const divwin = document.getElementById(id);
const content = divwin.innerHTML;
divwin.innerHTML = this.template.replace(/{w}/g, id);
document.getElementById(this.idWindowContent).innerHTML = content;
What this code is doing is first grabbing the content declaratively defined, then replacing the content with the template (having set the id
s of the template elements), and finally replacing the content area of the template with the content of the original DIV
element. Thus, a window that is declaratively described as:
<div id="exampleContent" caption="Enter Name">
<div>
<span style="min-width:100px; display:inline-block">First Name:</span> <input />
</div>
<div style="margin-top:3px">
<span style="min-width:100px; display:inline-block">Last Name:</span> <input />
</div>
</div>
And initialized as:
new DivWindow("exampleContent").setPosition("100px", "700px").w = 300;
renders as:
and the final HTML structure is (clipped on the right):
There is a DIV
specifically to indicate the draggable area with a "move" cursor, that is offset from the buttons in the caption, so if your mouse is over the buttons, the cursor appears as a pointer:
And as you move the cursor right, it changes to the "move
" cursor:
Also, note the attribute divWindow
in the outer template DIV
:
<div id="{w}_windowTemplate" class="divWindowPanel" divWindow>\
This is used in a couple places to get elements specific to the container or the document:
protected getDivWindows(useDocument = false): NodeListOf<Element> {
const el = this.dw.parentElement.parentElement;
const els = ((el.localName === "body" || useDocument) ?
document : el).querySelectorAll("[divWindow]");
return els;
}
Yeah, the template has 8 elements that have dynamic ids, so I found this makes the rest of the code a lot more readable:
protected setupIDs(id: string): void {
this.idWindowTemplate = `${id}_windowTemplate`;
this.idCaptionBar = `${id}_captionBar`;
this.idWindowCaption = `${id}_windowCaption`;
this.idWindowDraggableArea = `${id}_windowDraggableArea`;
this.idWindowContent = `${id}_windowContent`;
this.idClose = `${id}_close`;
this.idMinimize = `${id}_minimize`;
this.idMaximize = `${id}_maximize`;
}
protected updateZOrder(): void {
const nodes = this.getDivWindows(true);
const maxz = Math.max(
...Array.from(nodes)
.map(n =>
parseInt(window.document.defaultView.getComputedStyle(n).getPropertyValue("z-index"))
));
this.dw.style.setProperty("z-index", (maxz + 1).toString());
}
As the code comment points out, any time a window is clicked, it is placed above any other window, including any windows outside of its container. This was done so that in this and similar scenarios:
Clicking on A Window, which is contained in a DIV
, always appears in front of the other windows, such as Window 1:
If we don't do this, the user ends up having to click multiple times to get the window to be topmost, depending on what other windows inside or outside a container were selected.
And yes, the code is lame, simply adding 1 one to current max z-order, but given that JavaScript's number maximum is 1.7976931348623157e+308, I really don't think I have to worry about the user clicking windows to the foreground and exceeding the count.
protected contain(dwx: number, dwy: number): DivWindowPosition {
let el = this.dw.parentElement.parentElement;
let offsety = 0;
if (el.id.includes("_windowContent")) {
el = el.parentElement;
offsety = this.CAPTION_HEIGHT;
}
dwx = dwx < 0 ? 0 : dwx;
dwy = dwy < offsety ? offsety : dwy;
if (el.localName !== "body") {
if (dwx + this.dw.offsetWidth >= el.offsetWidth) {
dwx = el.offsetWidth - this.dw.offsetWidth - 1;
}
if (dwy + this.dw.offsetHeight >= el.offsetHeight) {
dwy = el.offsetHeight - this.dw.offsetHeight - 1;
}
}
return { x: dwx, y: dwy };
}
This code, and some other places in the code, have some magic numbers, like CAPTION_HEIGHT
. I guess I could have queried the caption element for its height. The salient point is that the contained window cannot be moved beyond the boundaries of its container. This includes windows that are defined in the body
element -- the window cannot move outside of the screen boundaries.
public minimize(atPosition = false): DivWindow {
this.saveState();
this.dw.style.height = this.MINIMIZED_HEIGHT;
this.minimizedState = true;
this.maximizedState = false;
if (this.options.moveMinimizedToBottom && !atPosition) {
let minTop;
if (this.isContained()) {
let el = this.dw.parentElement.parentElement;
if (el.id.includes("_windowContent")) {
el = el.parentElement;
}
minTop = el.offsetHeight - (this.CAPTION_HEIGHT + 3);
} else {
minTop = (window.innerHeight || document.documentElement.clientHeight ||
document.body.clientHeight) - (this.CAPTION_HEIGHT + 1);
}
const left = this.findAvailableMinimizedSlot(minTop);
this.dw.style.width = this.MINIMIZED_WIDTH;
this.dw.style.top = minTop + "px";
this.dw.style.left = left + "px";
}
this.dw.style.setProperty("resize", "none");
if (this.options.moveMinimizedToBottom) {
document.getElementById
(this.idWindowDraggableArea).style.setProperty("cursor", "default");
}
return this;
}
If a window has moveMinimizedToBottom === false
, it is minimized in place. Otherwise, it is minimized to the bottom of the container element, which might be the bottom of the screen. What the above code does is handle the following scenarios:
Minimize to bottom of the screen for windows whose parent is body
:
Minimize to the bottom of another window:
Minimize the bottom of a non-window container:
Furthermore, the minimizer tries to be smart in a dumb way. It sets the width of a minimized window to 200px and places them in order horizontally across the bottom. If a window is restored, the other minimized windows do not shift position:
But the empty slot is filled again when a window is minimized again (see the previous screenshot.)
This behavior was entirely my choice, obviously if you don't like this behavior, you can change it to your liking, I would imagine rather easily.
public static saveLayout(id?: string): void {
const els = (id ? document.getElementById(id) : document).querySelectorAll("[divWindow]");
const key = `divWindowState${id ?? "document"}`;
const states: DivWindowState[] = Array
.from(els)
.map(el => DivWindow.divWindows.filter(dw => dw.idWindowTemplate === el.id)[0])
.filter(dw => dw)
.map(dw => ({
id: dw.idWindowTemplate,
minimizedState: dw.minimizedState,
maximizedState: dw.maximizedState,
left: dw.x,
top: dw.y,
width: dw.w,
height: dw.h,
restoreLeft: dw.left,
restoreTop: dw.top,
restoreWidth: dw.width,
restoreHeight: dw.height
}) as DivWindowState);
window.localStorage.setItem(key, JSON.stringify(states));
}
This code should be self-explanatory, the idea being that the application using DivWindow
can determine whether to save the layout for the entire document or just the windows inside a container.
Normally, one might have a wrapper class for managing all the DivWindow
instances, but this seem like overkill, so you'll note that this is a static
function (as well as loadLayout
), and the DivWindow
class implements:
export class DivWindow {
protected static divWindows: DivWindow[] = [];
I saw no reason to implement a separate class simply to manage the collection of DivWindow
instances. However, if you are implementing something like a Single Page Application (SPA) that actually has multiple pages with different window layouts, then I would recommend modifying the code so that each "page" maintains its own collection.
Also note that local storage is used so that the layout persists between sessions.
public static loadLayout(id?: string): void {
const key = `divWindowState${id ?? "document"}`;
const jsonStates = window.localStorage.getItem(key);
if (jsonStates) {
const states = JSON.parse(jsonStates) as DivWindowState[];
states.forEach(state => {
const dw = DivWindow.divWindows.filter(dw => dw.idWindowTemplate === state.id)[0];
if (dw && document.getElementById(dw.idWindowTemplate)) {
dw.minimizedState = state.minimizedState;
dw.maximizedState = state.maximizedState;
dw.left = state.restoreLeft;
dw.top = state.restoreTop;
dw.width = state.restoreWidth;
dw.height = state.restoreHeight;
dw.setPosition(state.left + "px", state.top + "px");
dw.setSize(state.width + "px", state.height + "px");
if (dw.maximizedState) {
document.getElementById(dw.idWindowTemplate).style.setProperty("resize", "none");
document.getElementById
(dw.idWindowDraggableArea).style.setProperty("cursor", "default");
} else if (dw.minimizedState) {
document.getElementById(dw.idWindowTemplate).style.setProperty("resize", "none");
if (dw.options.moveMinimizedToBottom) {
document.getElementById
(dw.idWindowDraggableArea).style.setProperty("cursor", "default");
}
} else {
document.getElementById(dw.idWindowTemplate).style.setProperty("resize", "both");
document.getElementById
(dw.idWindowDraggableArea).style.setProperty("cursor", "move");
}
}
});
}
}
The only thing to note here is the management of the resize and cursor state depending on the restored window's minimized / maximized state and the minimized "in place" option.
These are all the options one can specify when the window is created:
export class DivWindowOptions {
public left?: number;
public top?: number;
public width?: number;
public height?: number;
public hasClose? = true;
public hasMinimize?= true;
public hasMaximize?= true;
public moveMinimizedToBottom?= true;
public color?: string;
public isMinimized?: boolean;
public isMaximized?: boolean;
}
Any option not defined (no pun intended) reverts to its default behavior.
For some reason, people like to see the CSS, so here it is:
.divWindowPanel {
left: 300px;
width: 200px;
position: absolute;
z-index: 100;
overflow: hidden;
resize: both;
border: 1px solid #2196f3;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
background-color: white;
}
.divWindowCaption {
padding: 3px;
z-index: 10;
background-color: #2196f3;
color: #fff;
}
.divWindowContent {
text-align: left;
padding: 7px;
}
.noselect {
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.dot {
height: 10px;
width: 10px;
border-radius: 50%;
display: inline-block;
}
Note the noselect
CSS. I had an interesting problem where I could click on the caption and it would highlight the text and would cause strange behavior when subsequently dragging the window. This problem was solved by making the caption not selectable.
This was quite fun to implement and I finally have a simple but comprehensive windowing component that I can now use for other applications, such as my Adaptive Hierarchical Knowledge Management series, which I haven't forgotten about but I actually needed a decent window management module for Part III!
- For a constrained window, when dragging it past the extents of the parent container, the mouse keeps moving and loses the "move" cursor and its position relative to the window caption.
- If you shrink a
DivWindow
that itself contains DivWindows
, the inner DivWindows
will not adjust to remain constrained, which includes minimized windows within the container. - A window caption that is too long will collide with the close/minimize/maximize buttons.
- I have a kludge when maximizing the window to avoid scrollbars.
DivWindows
within DivWindows
within DivWindows
etc. might work but I haven't tested this scenario. - I don't handle the resize event as this cannot be wired up to an element and I didn't want to dig deeper into this, so it's possible to resize a contained window beyond the size of the container.
- If you resize a
DivWindow
that has minimized child DivWindows
, the child DivWindows
will not automatically move to the bottom of the parent DivWindow
. - The code prevents dragging a minimized or maximized window, but you can change that.
- I decided not to implement any event triggers that an application could hook in to, but this is easily added if you need the application to do something depending on window state change, or if you want to override the default behavior.
- Styling options (would you prefer Window's style _, box, and X for the minimize, maximize, and close buttons?) is not implemented and I really don't want to get into styling options.
I added the following events:
public onMinimize?: (dw: DivWindow) => void;
public onMaximize?: (dw: DivWindow) => void;
public onRestore?: (dw: DivWindow) => void;
public onSelect?: (dw: DivWindow) => void;
public onClose?: (dw: DivWindow) => void;
It should be obvious when these events get triggered.
For windows that minimize in place, if they are dragged to another location and then restored, they are now restored in place:
protected restoreState(): void {
if (this.minimizedState || this.maximizedState) {
if ((this.options.moveMinimizedToBottom && this.minimizedState) ||
this.maximizedState) {
this.dw.style.left = this.left;
this.dw.style.top = this.top;
}
this.dw.style.width = this.width + "px";
this.dw.style.height = this.height + "px";
this.dw.style.setProperty("resize", "both");
document.getElementById
(this.idWindowDraggableArea).style.setProperty("cursor", "move");
}
}
- 6th July, 2021: Initial version
- 12th July, 2021: Added events and restore-in-place