desktop: keep drag/resize working over embedded iframes + add embed loading spinner

This commit is contained in:
Martin Dvořák 2026-09-25 14:00:56 +00:00
commit bc738541cf
5 changed files with 65 additions and 3 deletions

View file

@ -39,7 +39,8 @@ heatmap**, a gym SBD panel and links to friends' sites.
Jellyfin, Navidrome, GitDXD) from the desktop shows the real site inside the window (an Jellyfin, Navidrome, GitDXD) from the desktop shows the real site inside the window (an
iframe). A service must not send a restrictive `X-Frame-Options`: Navidrome needs iframe). A service must not send a restrictive `X-Frame-Options`: Navidrome needs
`ND_HTTPHEADERS_FRAMEOPTIONS=ALLOWALL` and Forgejo needs `[cors] X_FRAME_OPTIONS = unset`. `ND_HTTPHEADERS_FRAMEOPTIONS=ALLOWALL` and Forgejo needs `[cors] X_FRAME_OPTIONS = unset`.
Discord sends `DENY` and keeps a link card instead. A spinner covers the frame while the site boots. Discord sends `DENY` and keeps a link card
instead.
- **Minecraft-style SMP window** — the SMP shortcut opens a join menu modelled on - **Minecraft-style SMP window** — the SMP shortcut opens a join menu modelled on
Minecraft's multiplayer screen, with live status, player count, MOTD and version pulled Minecraft's multiplayer screen, with live status, player count, MOTD and version pulled
from mcstatus.io through the site's own ping API. from mcstatus.io through the site's own ping API.

View file

@ -283,6 +283,16 @@ list reordering, not free placement. Don't confuse the two.
`Content-Security-Policy: frame-ancestors`. Discord keeps a link card because `Content-Security-Policy: frame-ancestors`. Discord keeps a link card because
it sends `DENY` and can't be configured. Always verify headers before enabling it sends `DENY` and can't be configured. Always verify headers before enabling
`embed` on a new service. `embed` on a new service.
- **Embedded iframes swallow the pointer.** While dragging/resizing a window,
the moment the cursor passes over a service `<iframe>` the site's own document
receives the `pointermove`/`pointerup`, so a gesture that listens on `window`
(see `WinWindow.tsx`) freezes mid-drag. `WinWindow` therefore flags the window
`interacting` for the duration of the gesture and CSS sets
`pointer-events: none` on `.website-embed-frame` until it ends. Keep that rule
if you add another gestural interaction that can pass over an embed.
- `EmbeddedSite` (in `apps.tsx`) shows a spinner until the iframe fires `load`,
because Navidrome/Forgejo are SPAs that render blank for a moment while they
boot.
- Reduced motion is handled with a near-zero `animation-duration` (not - Reduced motion is handled with a near-zero `animation-duration` (not
`animation: none`) so `animationend` still fires and windows unmount. `animation: none`) so `animationend` still fires and windows unmount.

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { type CSSProperties, type PointerEvent as ReactPointerEvent } from "react"; import { useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from "react";
import type { AppDef } from "./apps"; import type { AppDef } from "./apps";
export interface WinState { export interface WinState {
@ -49,6 +49,10 @@ export default function WinWindow({
onMove, onMove,
onResize, onResize,
}: Props) { }: Props) {
// While a drag/resize is in flight we disarm the embedded iframe (see the
// `.interacting` rule in skeuo.css). Otherwise a service window swallows the
// pointer as soon as it passes over the site and the gesture freezes.
const [interacting, setInteracting] = useState(false);
const centered = win.centered && !win.maximized; const centered = win.centered && !win.maximized;
const style: CSSProperties = win.maximized const style: CSSProperties = win.maximized
? { left: 0, top: 0, width: "100%", height: "100%", zIndex: win.z } ? { left: 0, top: 0, width: "100%", height: "100%", zIndex: win.z }
@ -62,6 +66,7 @@ export default function WinWindow({
if ((e.target as HTMLElement).closest(".win-controls")) return; if ((e.target as HTMLElement).closest(".win-controls")) return;
e.preventDefault(); e.preventDefault();
setInteracting(true);
let ox = win.x; let ox = win.x;
let oy = win.y; let oy = win.y;
if (win.centered) { if (win.centered) {
@ -79,6 +84,7 @@ export default function WinWindow({
onMove(ox + (ev.clientX - sx), oy + (ev.clientY - sy)); onMove(ox + (ev.clientX - sx), oy + (ev.clientY - sy));
}; };
const handleUp = () => { const handleUp = () => {
setInteracting(false);
window.removeEventListener("pointermove", handleMove); window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", handleUp); window.removeEventListener("pointerup", handleUp);
window.removeEventListener("pointercancel", handleUp); window.removeEventListener("pointercancel", handleUp);
@ -95,6 +101,7 @@ export default function WinWindow({
if (win.maximized) return; if (win.maximized) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setInteracting(true);
const node = e.currentTarget.closest(".win-window") as HTMLElement | null; const node = e.currentTarget.closest(".win-window") as HTMLElement | null;
const rect = node?.getBoundingClientRect(); const rect = node?.getBoundingClientRect();
@ -137,6 +144,7 @@ export default function WinWindow({
onResize(Math.round(x), Math.round(y), Math.round(w), Math.round(h)); onResize(Math.round(x), Math.round(y), Math.round(w), Math.round(h));
}; };
const handleUp = () => { const handleUp = () => {
setInteracting(false);
window.removeEventListener("pointermove", handleMove); window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", handleUp); window.removeEventListener("pointerup", handleUp);
window.removeEventListener("pointercancel", handleUp); window.removeEventListener("pointercancel", handleUp);
@ -156,7 +164,8 @@ export default function WinWindow({
(win.minimized ? " minimized" : "") + (win.minimized ? " minimized" : "") +
(win.maximized ? " maximized" : "") + (win.maximized ? " maximized" : "") +
(closing ? " closing" : "") + (closing ? " closing" : "") +
(minimizing ? " minimizing" : "") (minimizing ? " minimizing" : "") +
(interacting ? " interacting" : "")
} }
style={style} style={style}
onPointerDown={onFocus} onPointerDown={onFocus}

View file

@ -818,12 +818,20 @@ function WebsiteApp({
/** Renders a site in an iframe. Only usable for sites that allow framing. */ /** Renders a site in an iframe. Only usable for sites that allow framing. */
function EmbeddedSite({ title, url }: { title: string; url: string }) { function EmbeddedSite({ title, url }: { title: string; url: string }) {
const [loaded, setLoaded] = useState(false);
return ( return (
<div className="website-embed"> <div className="website-embed">
{!loaded && (
<div className="website-embed-loading" role="status">
<span className="website-embed-spinner" aria-hidden="true" />
<span>Loading {title}…</span>
</div>
)}
<iframe <iframe
className="website-embed-frame" className="website-embed-frame"
src={url} src={url}
title={title} title={title}
onLoad={() => setLoaded(true)}
allow="autoplay; fullscreen; encrypted-media; clipboard-read; clipboard-write" allow="autoplay; fullscreen; encrypted-media; clipboard-read; clipboard-write"
allowFullScreen allowFullScreen
/> />

View file

@ -686,6 +686,7 @@ a.win-icon {
/* Live service sites rendered inside the window. The site fills the window /* Live service sites rendered inside the window. The site fills the window
body edge-to-edge, so the iframe owns all the scrolling. */ body edge-to-edge, so the iframe owns all the scrolling. */
.website-embed { .website-embed {
position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
@ -698,6 +699,39 @@ a.win-icon {
border: 0; border: 0;
background: #ffffff; background: #ffffff;
} }
/* While the window is dragged/resized, disown the iframe's pointer so the
gesture isn't swallowed when the cursor passes over the embedded site. */
.win-window.interacting .website-embed-frame {
pointer-events: none;
}
/* Navidrome/Forgejo are full SPAs, so the frame is blank for a moment while
they boot — cover it with a spinner until the iframe reports load. */
.website-embed-loading {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
background: #232629;
color: #9fd0f5;
font-size: 0.9rem;
}
.website-embed-spinner {
width: 26px;
height: 26px;
border-radius: 50%;
border: 3px solid rgba(159, 208, 245, 0.25);
border-top-color: #3daee9;
animation: website-embed-spin 0.8s linear infinite;
}
@keyframes website-embed-spin {
to {
transform: rotate(360deg);
}
}
/* ============================================================ /* ============================================================
MINECRAFT SMP JOIN MENU (opened from the SMP service shortcut) MINECRAFT SMP JOIN MENU (opened from the SMP service shortcut)