Click here to Skip to main content
15,346,520 members
Articles / Programming Languages / Typescript
Article
Posted 6 Jul 2021

Tagged as

Stats

12.6K views
269 downloads
12 bookmarked

DivWindow

Rate me:
Please Sign up or sign in to vote.
4.97/5 (21 votes)
12 Jul 2021CPOL10 min read
Size, drag, minimize, and maximize floating windows with layout persistence
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.

Image 1

Table of Contents

Introduction

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.

Key Features

Windows are Sizeable and Draggable

Image 2

Windows are Maximizable

The screenshot here is clipped to the right:

Image 3

Windows are Minimizable to the Bottom of the Screen

Image 4

Windows within Containers are Minimized to the Bottom of the Container

Assuming that the flag minimized in place is false.

Image 5

Windows are Minimizable in Place

Image 6

Complete Control of the Close, Minimize, and Maximize Buttons

Image 7

Automatic Topmost When Clicking on the Header

Compare with the screenshot at the top of the article.

Image 8

Windows are Contained Within their Wrapper Div

Here, the inner windows are constrained to live within the outer div:

Image 9

Windows Within Windows

Image 10

Window State Can be Persisted Between Sessions

You can save and reload the DivWindow states (position, size, state) for the entire document or a container.

DivWindow Class API

Public Functions

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.

TypeScript
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()

Static Functions

Two static functions handle saving and loading layouts:

TypeScript
static saveLayout(id?: string)

static loadLayout(id?: string)

Get/Set Properties

The following properties are also defined, mainly for the convenience of the DivWindow code itself.

TypeScript
get x()
set x(x: number)

get y()
set y(y:number)

get w()
set w(w: number)

get h()
set h(h: number)

Usage

At a minimum, one creates a div with some content, for example:

HTML
<div id="window1" caption="Another Window">
  <p>All good men<br />Must come to an end.</p>
</div>

and initializes the DivWindow with:

TypeScript
new DivWindow("window1");

rendering:

Image 11

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:

HTML
<div id="outerwindow1" caption="Window 1" divWindowOptions='{"left":100, "top":50}'>
  Window 1
</div>

Example 2:

HTML
<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>

Windows within Window

Declare the outer window and inner windows, for example:

HTML
<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:

TypeScript
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:

Image 12

The inner windows are confined to the outer window.

Windows Within Container Elements

Here's a simple example where the windows are contained and confined to a container element.

HTML
<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:

TypeScript
new DivWindow("window1").setPosition("0px", "0px");
new DivWindow("window2", { hasMaximize: false });
new DivWindow("window3");

which renders as:

Image 13

Implementation

Here, I'll cover the more interesting aspects of the implementation, as much of the code should be obvious. No jQuery is used!

A Note Regard require.js

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.

HTML
<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:

TypeScript
import { AppMain } from "./AppMain"

require(['AppMain'],
  (main: any) => {
    const appMain = new AppMain();
    appMain.run();
  }
);

AppMain.ts (for initializing the demo):

TypeScript
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....};

Events

The following events are captured for each window:

TypeScript
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 Window Template

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:

TypeScript
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">&nbsp;</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:

TypeScript
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 ids 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:

HTML
<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:

TypeScript
new DivWindow("exampleContent").setPosition("100px", "700px").w = 300;

renders as:

Image 14

and the final HTML structure is (clipped on the right):

Image 15

Some Things to Note

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:

Image 16

And as you move the cursor right, it changes to the "move" cursor:

Image 17

Also, note the attribute divWindow in the outer template DIV:

HTML
<div id="{w}_windowTemplate" class="divWindowPanel" divWindow>\

This is used in a couple places to get elements specific to the container or the document:

TypeScript
protected getDivWindows(useDocument = false): NodeListOf<Element> {
  const el = this.dw.parentElement.parentElement;
  const els = ((el.localName === "body" || useDocument) ? 
                document : el).querySelectorAll("[divWindow]");

  return els;
}

All Those IDs

Yeah, the template has 8 elements that have dynamic ids, so I found this makes the rest of the code a lot more readable:

TypeScript
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`;
}

Z-Order

TypeScript
protected updateZOrder(): void {
  // Get all divWindow instances in the document 
  // so the current divWindow becomes topmost of all.
  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:

Image 18

Clicking on A Window, which is contained in a DIV, always appears in front of the other windows, such as Window 1:

Image 19

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.

Containing Windows

TypeScript
protected contain(dwx: number, dwy: number): DivWindowPosition {
  let el = this.dw.parentElement.parentElement;
  let offsety = 0;

  // DivWindow within DivWindow?
  if (el.id.includes("_windowContent")) {
    // If so, get the parent container, not the content area.
    el = el.parentElement;

    // Account for the caption:
    offsety = this.CAPTION_HEIGHT;
  }

  dwx = dwx < 0 ? 0 : dwx;
  dwy = dwy < offsety ? offsety : dwy;

  // Constrained within a parent?
  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.

Minimizing Windows

TypeScript
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);

    // Force minimized window when moving to bottom to have a fixed width.
    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:

Image 20

Minimize to the bottom of another window:

Image 21

Minimize the bottom of a non-window container:

Image 22

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:

Image 23

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.

Save Layout

TypeScript
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) // ignore windows we can't find, though this should not happen.
    .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:

TypeScript
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.

Load Layout

TypeScript
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];

      // Is it in our list, and does it exist (maybe the user closed it?)
      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.

The Full Set of Initialization Options

These are all the options one can specify when the window is created:

TypeScript
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.

CSS

For some reason, people like to see the CSS, so here it is:

CSS
.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;
}

/* https://stackoverflow.com/a/4407335 */
/* We have this attribute in the caption because dragging the panel 
   with selected text causes problems. */
.noselect {
  -webkit-touch-callout: none; /* iOS Safari */
  -webkit-user-select: none; /* Safari */
  -moz-user-select: none; /* Old versions of Firefox */
  -ms-user-select: none; /* Internet Explorer/Edge */
  user-select: none; /* Non-prefixed version, currently supported by 
                        Chrome, Edge, Opera and Firefox */
}

.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.

Conclusion

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!

Some Loose Ends

  • 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.

Revision 2021-06-12

I added the following events:

C#
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:

C#
protected restoreState(): void {
    if (this.minimizedState || this.maximizedState) {
        // restore in place?
        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");
    }
}

History

  • 6th July, 2021: Initial version
  • 12th July, 2021: Added events and restore-in-place

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

Marc Clifton
Architect Interacx
United States United States
Blog: https://marcclifton.wordpress.com/
Home Page: http://www.marcclifton.com
Research: http://www.higherorderprogramming.com/
GitHub: https://github.com/cliftonm

All my life I have been passionate about architecture / software design, as this is the cornerstone to a maintainable and extensible application. As such, I have enjoyed exploring some crazy ideas and discovering that they are not so crazy after all. I also love writing about my ideas and seeing the community response. As a consultant, I've enjoyed working in a wide range of industries such as aerospace, boatyard management, remote sensing, emergency services / data management, and casino operations. I've done a variety of pro-bono work non-profit organizations related to nature conservancy, drug recovery and women's health.

Comments and Discussions

 
Questionmore information on context, please Pin
BillWoodruff31-Oct-21 20:20
mveBillWoodruff31-Oct-21 20:20 
GeneralMy vote of 5 Pin
Ștefan-Mihai MOGA15-Aug-21 14:54
professionalȘtefan-Mihai MOGA15-Aug-21 14:54 
QuestionNice, but... Pin
Dewey20-Jul-21 22:07
MemberDewey20-Jul-21 22:07 
AnswerRe: Nice, but... Pin
Marc Clifton22-Jul-21 5:39
mvaMarc Clifton22-Jul-21 5:39 
GeneralMy vote of 5 Pin
Pete Lomax Member 1066450519-Jul-21 2:40
professionalPete Lomax Member 1066450519-Jul-21 2:40 
GeneralMy vote of 5 Pin
hkswan13-Jul-21 6:44
Memberhkswan13-Jul-21 6:44 
GeneralMy vote of 5 Pin
Vincent Radio13-Jul-21 1:38
professionalVincent Radio13-Jul-21 1:38 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.