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
|
||||
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`.
|
||||
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's multiplayer screen, with live status, player count, MOTD and version pulled
|
||||
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
|
||||
it sends `DENY` and can't be configured. Always verify headers before enabling
|
||||
`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
|
||||
`animation: none`) so `animationend` still fires and windows unmount.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"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";
|
||||
|
||||
export interface WinState {
|
||||
|
|
@ -49,6 +49,10 @@ export default function WinWindow({
|
|||
onMove,
|
||||
onResize,
|
||||
}: 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 style: CSSProperties = win.maximized
|
||||
? { 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;
|
||||
|
||||
e.preventDefault();
|
||||
setInteracting(true);
|
||||
let ox = win.x;
|
||||
let oy = win.y;
|
||||
if (win.centered) {
|
||||
|
|
@ -79,6 +84,7 @@ export default function WinWindow({
|
|||
onMove(ox + (ev.clientX - sx), oy + (ev.clientY - sy));
|
||||
};
|
||||
const handleUp = () => {
|
||||
setInteracting(false);
|
||||
window.removeEventListener("pointermove", handleMove);
|
||||
window.removeEventListener("pointerup", handleUp);
|
||||
window.removeEventListener("pointercancel", handleUp);
|
||||
|
|
@ -95,6 +101,7 @@ export default function WinWindow({
|
|||
if (win.maximized) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setInteracting(true);
|
||||
|
||||
const node = e.currentTarget.closest(".win-window") as HTMLElement | null;
|
||||
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));
|
||||
};
|
||||
const handleUp = () => {
|
||||
setInteracting(false);
|
||||
window.removeEventListener("pointermove", handleMove);
|
||||
window.removeEventListener("pointerup", handleUp);
|
||||
window.removeEventListener("pointercancel", handleUp);
|
||||
|
|
@ -156,7 +164,8 @@ export default function WinWindow({
|
|||
(win.minimized ? " minimized" : "") +
|
||||
(win.maximized ? " maximized" : "") +
|
||||
(closing ? " closing" : "") +
|
||||
(minimizing ? " minimizing" : "")
|
||||
(minimizing ? " minimizing" : "") +
|
||||
(interacting ? " interacting" : "")
|
||||
}
|
||||
style={style}
|
||||
onPointerDown={onFocus}
|
||||
|
|
|
|||
|
|
@ -818,12 +818,20 @@ function WebsiteApp({
|
|||
|
||||
/** Renders a site in an iframe. Only usable for sites that allow framing. */
|
||||
function EmbeddedSite({ title, url }: { title: string; url: string }) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
return (
|
||||
<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
|
||||
className="website-embed-frame"
|
||||
src={url}
|
||||
title={title}
|
||||
onLoad={() => setLoaded(true)}
|
||||
allow="autoplay; fullscreen; encrypted-media; clipboard-read; clipboard-write"
|
||||
allowFullScreen
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -686,6 +686,7 @@ a.win-icon {
|
|||
/* Live service sites rendered inside the window. The site fills the window
|
||||
body edge-to-edge, so the iframe owns all the scrolling. */
|
||||
.website-embed {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
|
|
@ -698,6 +699,39 @@ a.win-icon {
|
|||
border: 0;
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue