Skip to content

Floating Windows Developer Guide

Ed Mozley edited this page Oct 28, 2026 · 1 revision

Floating windows and the file viewer β€” Developer Guide

How one window and one file viewer are shared between the Files module and Tickets, and how to use them on another page. Added in 4.4.0 for #226, where a picture on a ticket note replaced the whole Tickets page.

In one sentence: the window (drag, resize, snap, maximise) is float-window.js; the viewer (pictures, PDFs, Word, spreadsheets...) is files-viewer.js; Files adds a taskbar on top, and any other page gets the same window floating over itself.

For the Files desktop itself (taskbar, previews, menus) see Files developer guide Β§10. For the rest of the Tickets page see the Inbox.js map.


1. Who owns what

File What it holds Used by
πŸͺŸ assets/js/float-window.js The window. Title bar, drag, resize from 8 handles, snap (top = maximise, sides = half), double-click to maximise, focus and z-order, keeping windows on screen when the browser shrinks. FloatWin.manager() and FloatWin.page(). Files, Tickets
πŸ–₯️ assets/js/files-wm.js The desktop half. A taskbar button per window, minimise to it, hover previews, menus, dialogs, notifications, clock, navbar. Builds on FloatWin.manager() through hooks. Files only
πŸ“„ assets/js/files-viewer.js The viewer. renderInto() draws a file into any element. FilesViewer.open(id) for a Files file; FilesViewer.openUrl({...}) for anything else. Libraries (pdf.js, mammoth, SheetJS) load on first use. Files, Tickets
🎨 assets/css/float-window.css The window (.fd-win, .fd-titlebar, .fd-rs-*), the viewer (.fdv-*) and .fw-layer Files, Tickets
🎨 assets/css/mobile.css 45d The phone viewer (.fdm-viewer) - full screen with Back. Not scoped to any page. Files, Tickets
πŸ”£ assets/js/files-icons.js File-type icons for the window's title bar Files, Tickets
πŸ”€ lang/en/files.php The strings: wm.* (window buttons), viewer.*, loading, ex.download Files, Tickets

The class names are fd-* and fdv-* on every page, because Files had them first. Renaming them would have meant touching every Files rule for no behaviour change.


2. Two managers, one window

A manager owns the windows in one area. There are two kinds:

Files desktop Any other page
Made by FloatWin.manager({...}) in files-wm.js FloatWin.page() - made on first use
Area #fdDesktop, between the header and the taskbar .fw-layer, a fixed layer over the viewport (z-index: 5000) that lets the pointer through everywhere except on a window
Minimise yes, to the taskbar no button - nothing to minimise to
Escape Files' own keys closes the window in front

files-wm.js passes hooks; the window calls them at the right moments:

var mgr = window.FloatWin.manager({
    area: desktop,
    ghost: ghost,
    minimise: true,
    L: L,
    /** A minimised window shrinks into, and grows back out of, its taskbar button. */
    taskRect: function (w) { return w.btn.getBoundingClientRect(); },
    /** Gives a new window its taskbar button. */
    onCreate: function (self) { /* builds self.btn, its click / hover / right-click */ },
    onFocus: function (w) { w.btn.classList.add('fd-active'); },
    onBlur: function (w) { w.btn.classList.remove('fd-active'); },
    onMinimise: function (w) { w.btn.classList.add('fd-min'); },
    onRestore: function (w) { w.btn.classList.remove('fd-min'); },
    onClose: function (w) { hidePreview(); w.btn.remove(); },
    onGeometry: function (kind, geo) { if (geometryHook) geometryHook(kind, geo); }
});

FilesWM.open(), windows() and active() are now one-line pass-throughs to mgr, so nothing in files-desktop.js changed.

Rule of thumb: if a feature needs a taskbar, it belongs in files-wm.js. If a window on any page would want it, it belongs in float-window.js.


3. Opening a file from another page: openUrl

FilesViewer.openUrl({ name, src, downloadUrl, size, key })
Option Meaning
name The file name. The extension decides the kind - .pdf is drawn by pdf.js, .png as a picture, and so on.
src An address the caller already has, and has already permission-checked. The viewer fetches it as it is.
downloadUrl Optional. Gives the window a Download button, with the download attribute set (see Traps).
size Optional. Shown when there is no preview.
key Optional, defaults to src. A second openUrl with the same key brings the existing window to the front instead of opening another.

It returns the window, or null for a kind the viewer cannot show - so the caller falls back to its old behaviour. On a phone (≀ 768px) it opens the full-screen viewer instead and returns that.

The point of openUrl is that it never asks Files for anything. FilesViewer.open(id) goes through api/files/view.php and Files' folder permissions; openUrl uses whatever address the caller passes, so the caller's own permission check is the one that applies.


4. How Tickets uses it

assets/js/inbox.js. One list decides what Tickets sends to the viewer:

// The files Tickets opens in the Files viewer: pictures and PDFs. The viewer can show
// more (Word, spreadsheets, zips...) - Ed chose these two. SVG is deliberately absent:
// it can carry script.
const VIEWER_FILE_RE = /\.(png|jpe?g|gif|webp|bmp|pdf)$/i;

Files on a note - renderNotes() marks a matching file's link with data-view-file, and one delegated click handler on #notesContainer opens it:

function openNoteFile(ev) {
    const a = ev.target.closest('a.note-file[data-view-file]');
    if (!a || ev.button !== 0 || ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return;
    if (!window.FilesViewer || !window.FilesViewer.openUrl) return;
    const href = a.getAttribute('href');
    const w = window.FilesViewer.openUrl({
        name: a.dataset.viewFile, src: href, downloadUrl: href, size: Number(a.dataset.size) || 0
    });
    if (w) ev.preventDefault();
}

The link stays a real link to api/documents/download.php: Ctrl-click and middle-click still open a new tab, and only a plain click is taken over.

Email attachments - every way of opening one (the Attachments list's rows, its thumbnails, the phone's cards) goes through openAttachment(id):

function openAttachment(attachmentId) {
    const url = `${API_BASE}get_attachment.php?id=${attachmentId}`;
    const att = (ticketAttachments || []).find(a => Number(a.id) === Number(attachmentId));
    if (att && VIEWER_FILE_RE.test(att.filename || '') && window.FilesViewer && window.FilesViewer.openUrl) {
        // openUrl decides by the file NAME (and never shows SVG); null = not a kind it shows.
        if (window.FilesViewer.openUrl({ name: att.filename, src: url, downloadUrl: url, size: att.file_size || 0 })) return;
    }
    window.open(url, '_blank');
}

To show more kinds in Tickets (Word, spreadsheets, text), add the extensions to VIEWER_FILE_RE - nothing else. Zips need a guard first: opening something inside a zip calls auditEntry(), which writes to Files' audit log by Files id - a ticket attachment has none.


5. Adding it to another page - the recipe

  1. CSS, before the page's own sheet:
    <link rel="stylesheet" href="../assets/css/float-window.css?v=1">
  2. Scripts, in this order, before the page's own script:
    <script src="../assets/js/float-window.js?v=1"></script>
    <script src="../assets/js/files-icons.js?v=2"></script>
    <script src="../assets/js/files-viewer.js?v=8"></script>
    (Check the current ?v= numbers in tickets/index.php - grep, never recall.)
  3. Strings - export only the viewer's, not the whole 28 KB Files namespace. From tickets/index.php:
    $jsTranslations = I18n::exportForJs($translationNamespaces);
    $filesStrings = I18n::exportForJs(['files'])['files'] ?? [];
    $jsTranslations['files'] = array_intersect_key($filesStrings, array_flip(['wm', 'viewer', 'loading']))
        + ['ex' => ['download' => $filesStrings['ex']['download'] ?? 'Download']];
  4. Call FilesViewer.openUrl() with an address your page already permission-checks, and fall back when it returns null.
  5. Just a window, no viewer? FloatWin.page().open({ title, width, height, onClose }) returns a window; put your content in win.body.

The phone viewer needs nothing extra - mobile.css 45d is not scoped to a page.


6. Traps

Where Trap
files-viewer.js (top) The base must be absolute. pdf.js loads with import(), which resolves a relative path against the script's address, not the page's. A base of '../' from assets/js/ pointed at assets/assets/js/vendor/. Pictures never noticed, because <img> resolves against the page. Outside Files the base comes from the viewer's own script URL.
files-viewer.js renderImage() Refit just after the ResizeObserver, never inside it. Resizing the picture adds or removes the stage's scrollbars, which resizes the stage again inside the same callback - "ResizeObserver loop completed with undelivered notifications" - and the notifications the browser drops are real window resizes that never refit. A 16ms timer, as the PDF view uses 120ms.
files-viewer.js Download Set download on the link. openUrl's address may serve a picture to show (both ticket endpoints do), so without it "Download" opened the picture in the tab - the bug #226 was about.
float-window.js Escape Escape closes the window in front, and only when focus is not in a text field elsewhere on the page - that Escape belongs to whatever is being typed. openUrl focuses the window's stage on opening, so Escape works straight away.
float-window.js / files-wm.js Load float-window.js first. files-wm.js and files-viewer.js read window.FloatWin as they load.
.fw-layer pointer-events: none on the layer, auto on its children - otherwise the invisible layer swallows every click on the page.

7. Testing it

Headless Chrome with a virtual clock (--virtual-time-budget) cannot judge any of this reliably. It does not lay the page out between script steps, so a ResizeObserver fired or not depending on whether the test happened to read a size, close animations never finished, and requestAnimationFrame never ran. The same build passed, failed and half-passed on three runs.

Run it in real time and read the console as it happens:

timeout 40 chrome --headless=old --disable-gpu --no-sandbox \
  --enable-logging=stderr --log-level=0 --window-size=1450,950 \
  --remote-debugging-port=9333 --user-data-dir=<scratch>/prof \
  http://localhost/freeitsm/<harness>.html 2> log.txt
grep -o 'HARNESS [^"]*' log.txt

The debugging port keeps Chrome alive (--dump-dom and --screenshot both end the run at page load); timeout stops it. The harness loads the real page in a same-origin iframe, clicks the real links, and console.error('HARNESS ...')s what it finds. Count open windows with FloatWin.page().windows(), not .fd-win elements - a closed window's element lingers while it fades.

What was proved for 4.4.0, on real tickets: the #226 write-up.


Related

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally