desktop: keep drag/resize working over embedded iframes + add embed loading spinner
This commit is contained in:
parent
e740a0061b
commit
bc738541cf
5 changed files with 65 additions and 3 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
10
agents.md
10
agents.md
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue