Files
m3e-canvas/web/vendor/components/Preview.tsx
T
pastilhasandClaude Fable 5 240d63940d vendor m3e-canvas 5c83ac6, with the import rewrite scripted
upstream imports through a @/ alias, and this platform already binds
@/* to src/apps/officer-web and @/components/* to its own shared
components — so an unrewritten import resolves to the WRONG module
rather than failing. the rewrite is mechanical and lives in
scripts/vendor.sh so a re-vendor produces a readable diff.

lib/ai.ts and AiPanel are vendored despite being unwanted: deleting them
meant editing vendored code, and Canvas.tsx references them throughout.
a faithful copy carrying an unused module beats a doctored one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-05 12:00:17 +01:00

914 lines
33 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Item } from "../lib/tokens";
import { AnimatePresence, animate, motion, useMotionValue, useTransform } from "motion/react";
import type { TargetAndTransition, Variants } from "motion/react";
import {
Action,
BACK_TARGET,
BEZEL,
Doc,
Frame,
GAP,
Group,
NAV_BAR_H,
PHONE_H,
PHONE_R,
PHONE_W,
Palette,
SLIDE_SPEC,
STATUS_BAR_H,
SWIPE_DIRS,
SwipeDir,
TAPPABLE,
Transition,
baseRadii,
connectSpecOf,
fontFamilyOf,
freeRadii,
frameRadius,
frameSizeOf,
groupsInFrame,
isPhoneFrame,
normalizeTheme,
toggleIcon,
uniformRadii,
RAIL_W,
RAIL_TOP,
RAIL_ITEM_H,
RAIL_GAP,
sizeOf,
} from "../lib/tokens";
import { Icon, M3Node } from "./M3Node";
import { IconBtn } from "./ui";
import { t, useLang } from "../lib/i18n";
const EASE = [0.2, 0, 0, 1] as const;
const SLIDE_MS = 0.42;
/** room reserved for the wide preview controls: panel, right margin and breathing space */
const WIDE_CONTROL_SPACE = 220;
type Anim = { t: Transition; back: boolean; /** the expressive motion scheme: springs instead of eased tweens */ spring?: boolean };
/** M3 Expressive spatial spring, with a visible overshoot */
const SPRING = { type: "spring" as const, stiffness: 360, damping: 26, mass: 1 };
/** how the current screen was reached, so "back" can play it in reverse */
type Entry = { id: string; t: Transition };
const pct = (v: number) => `${v * 100}%`;
/** offset along one axis, as a percentage of the screen */
const off = (axis: "x" | "y", v: number) => (axis === "x" ? { x: pct(v), y: 0 } : { x: 0, y: pct(v) });
type Pose = TargetAndTransition;
/** enter / leave poses for one screen change; the same spec drives forward and back */
function poses(c: Anim): { initial: Pose; animate: Pose; exit: Pose } {
const zi = { zIndex: { duration: 0 } };
const s = SLIDE_SPEC[c.t];
if (s) {
const tr = c.spring ? { ...SPRING, ...zi } : { duration: SLIDE_MS, ease: EASE, ...zi };
return c.back
? {
initial: { ...off(s.axis, s.exit), opacity: 0.6, scale: 1, zIndex: 1 },
animate: { x: 0, y: 0, opacity: 1, scale: 1, zIndex: 1, transition: tr },
exit: { ...off(s.axis, s.enter), opacity: 1, scale: 1, zIndex: 2, transition: tr },
}
: {
initial: { ...off(s.axis, s.enter), opacity: 1, scale: 1, zIndex: 2 },
animate: { x: 0, y: 0, opacity: 1, scale: 1, zIndex: 2, transition: tr },
exit: { ...off(s.axis, s.exit), opacity: 0.6, scale: 1, zIndex: 1, transition: tr },
};
}
if (c.t === "fade") {
const tr = { duration: 0.3, ease: EASE, ...zi };
return {
initial: { x: 0, y: 0, opacity: 0, scale: 1, zIndex: 2 },
animate: { x: 0, y: 0, opacity: 1, scale: 1, zIndex: 2, transition: tr },
exit: { x: 0, y: 0, opacity: 0, scale: 1, zIndex: 1, transition: tr },
};
}
if (c.t === "expand") {
const tr = c.spring ? { ...SPRING, ...zi } : { duration: 0.36, ease: EASE, ...zi };
return c.back
? {
initial: { x: 0, y: 0, scale: 0.92, opacity: 0, zIndex: 1 },
animate: { x: 0, y: 0, scale: 1, opacity: 1, zIndex: 1, transition: tr },
exit: { x: 0, y: 0, scale: 1.06, opacity: 0, zIndex: 2, transition: tr },
}
: {
initial: { x: 0, y: 0, scale: 0.92, opacity: 0, zIndex: 2 },
animate: { x: 0, y: 0, scale: 1, opacity: 1, zIndex: 2, transition: tr },
exit: { x: 0, y: 0, scale: 1.06, opacity: 0, zIndex: 1, transition: tr },
};
}
const tr = { duration: 0, ...zi };
return {
initial: { x: 0, y: 0, opacity: 1, scale: 1, zIndex: 2 },
animate: { x: 0, y: 0, opacity: 1, scale: 1, zIndex: 2, transition: tr },
exit: { x: 0, y: 0, opacity: 1, scale: 1, zIndex: 1, transition: tr },
};
}
const screenVariants: Variants = {
initial: (c: Anim) => poses(c).initial,
animate: (c: Anim) => poses(c).animate,
exit: (c: Anim) => poses(c).exit,
};
/** kinds whose on/off state flips when tapped in the preview */
const TOGGLES = ["switch", "checkbox", "chip"] as const;
const flips = (it: Item) => (TOGGLES as readonly string[]).includes(it.kind) || !!it.toggle;
/** the look of a part after the visitor tapped it */
function flippedLook(it: Item): Item {
if ((TOGGLES as readonly string[]).includes(it.kind)) return { ...it, checked: !it.checked };
if (it.toggle) {
return {
...it,
label: it.toggle.label ?? it.label,
icon: toggleIcon(it),
variant: it.toggle.variant ?? it.variant,
};
}
return it;
}
/** A part in the preview: presses down and shows a state layer while the
* pointer is on it, then fires its action on release, like a real widget. */
function Tappable({
item,
p,
radii,
widths,
onTap,
onSlot,
onValue,
}: {
item: Item;
p: Palette;
radii: ReturnType<typeof baseRadii>;
widths: Record<string, number>;
onTap?: () => void;
/** per-slot targets on bars */
onSlot?: (slot: string) => void;
/** live value for sliders */
onValue?: (v: number) => void;
}) {
const [pressed, setPressed] = useState(false);
const [hot, setHot] = useState<string | null>(null);
const live = !!onTap || (TAPPABLE.includes(item.kind) && item.kind !== "text");
const ref = useRef<HTMLDivElement>(null);
const dragValue = (e: React.PointerEvent) => {
const r = ref.current?.getBoundingClientRect();
if (!r || !onValue) return;
onValue(Math.round(Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) * 100));
};
/** hit areas for the icons on a top app bar and the destinations on a navigation bar */
const slots: { key: string; style: React.CSSProperties }[] = [];
if (onSlot && item.kind === "topAppBar") {
/* the icons sit below the status-bar inset only where the bar has one (see sizeOf) */
const inset = sizeOf(item, {}).h - 64;
if (item.icon) slots.push({ key: "icon", style: { left: 4, top: inset + 8, width: 48, height: 48, borderRadius: 24 } });
if (item.icon2) slots.push({ key: "icon2", style: { right: 4, top: inset + 8, width: 48, height: 48, borderRadius: 24 } });
}
if (onSlot && (item.kind === "bottomNav" || item.kind === "tabs")) {
const n = item.tabs?.length ?? 0;
for (let i = 0; i < n; i++)
slots.push({ key: `tab:${i}`, style: { left: `${(i / n) * 100}%`, width: `${100 / n}%`, top: 0, bottom: item.kind === "bottomNav" ? NAV_BAR_H : 0, borderRadius: 16 } });
}
if (onSlot && item.kind === "navRail") {
const n = item.tabs?.length ?? 0;
for (let i = 0; i < n; i++)
slots.push({ key: `tab:${i}`, style: { left: 6, width: RAIL_W - 12, top: RAIL_TOP + i * (RAIL_ITEM_H + RAIL_GAP), height: RAIL_ITEM_H, borderRadius: 16 } });
}
if (onSlot && item.kind === "toolbar") {
const n = item.tabs?.length ?? 0;
for (let i = 0; i < n; i++) slots.push({ key: `tab:${i}`, style: { left: 8 + i * 52, width: 48, top: 8, height: 48, borderRadius: 24 } });
}
if (onSlot && item.kind === "fabMenu") {
/* the pills hug their text on the right; the hit area covers the right part of the row */
const n = item.tabs?.length ?? 0;
for (let i = 0; i < n; i++) slots.push({ key: `tab:${i}`, style: { right: 0, width: "70%", top: i * 64, height: 56, borderRadius: 28 } });
}
return (
<div
ref={ref}
onPointerDown={(e) => {
if (onValue) {
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
dragValue(e);
setPressed(true);
return;
}
if (live) setPressed(true);
}}
onPointerMove={(e) => {
if (onValue && pressed) dragValue(e);
}}
onPointerUp={() => setPressed(false)}
onPointerCancel={() => setPressed(false)}
onPointerLeave={() => !onValue && setPressed(false)}
onClick={onTap}
style={{ cursor: live || onValue ? "pointer" : "default", display: "flex", position: "relative", touchAction: "none" }}
>
<M3Node item={item} palette={p} widths={widths} radii={radii} interactive={false} pressed={pressed && !onValue} />
{live && (
<motion.div
aria-hidden
initial={false}
animate={{ opacity: pressed ? 1 : 0, scale: pressed ? 0.97 : 1 }}
transition={{ duration: pressed ? 0.08 : 0.24, ease: EASE }}
style={{
position: "absolute",
inset: 0,
pointerEvents: "none",
background: `color-mix(in srgb, ${p.onSurface} 12%, transparent)`,
borderTopLeftRadius: radii.tl,
borderTopRightRadius: radii.tr,
borderBottomLeftRadius: radii.bl,
borderBottomRightRadius: radii.br,
}}
/>
)}
{slots.map((s) => (
<div
key={s.key}
onPointerDown={(e) => {
e.stopPropagation();
setHot(s.key);
}}
onPointerUp={() => setHot(null)}
onPointerCancel={() => setHot(null)}
onPointerLeave={() => setHot(null)}
onClick={(e) => {
e.stopPropagation();
onSlot!(s.key);
}}
style={{
position: "absolute",
cursor: "pointer",
background: hot === s.key ? `color-mix(in srgb, ${p.onSurface} 12%, transparent)` : "transparent",
transition: "background 120ms",
...s.style,
}}
/>
))}
</div>
);
}
function Screen({
frame,
groups,
widths,
p,
onAction,
flipped,
onFlip,
values,
onValue,
}: {
frame: Frame;
groups: Group[];
widths: Record<string, number>;
p: Palette;
onAction: (a: Action) => void;
/** ids of toggles the visitor has flipped since the preview opened */
flipped: Set<string>;
onFlip: (id: string) => void;
values: Record<string, number>;
onValue: (id: string, v: number) => void;
}) {
return (
<div style={{ position: "absolute", inset: 0, background: p[frame.bg ?? "surface"], overflow: "hidden" }}>
{groups.map((g) => (
<div
key={g.id}
style={
g.free
? { position: "absolute", left: g.x - frame.x, top: g.y - frame.y }
: {
position: "absolute",
left: g.x - frame.x,
top: g.y - frame.y,
display: "flex",
flexDirection: g.axis === "x" ? "row" : "column",
alignItems: g.axis === "x" ? "center" : "stretch",
gap: GAP,
}
}
>
{((corners) => g.items.map((it, i) => {
const conn = connectSpecOf(it);
const n = g.free ? 1 : g.items.length;
const radii = g.free
? (corners?.get(it.id) ?? baseRadii(it))
: conn && n > 1
? g.axis === "x"
? {
tl: i === 0 ? conn.outer : conn.inner,
bl: i === 0 ? conn.outer : conn.inner,
tr: i === n - 1 ? conn.outer : conn.inner,
br: i === n - 1 ? conn.outer : conn.inner,
}
: {
tl: i === 0 ? conn.outer : conn.inner,
tr: i === 0 ? conn.outer : conn.inner,
bl: i === n - 1 ? conn.outer : conn.inner,
br: i === n - 1 ? conn.outer : conn.inner,
}
: conn
? uniformRadii(conn.outer)
: baseRadii(it);
const act = it.action;
let shown = flipped.has(it.id) ? flippedLook(it) : it;
if (it.kind === "slider" && values[it.id] !== undefined) shown = { ...shown, value: values[it.id] };
const navKind = it.kind === "bottomNav" || it.kind === "navRail" || it.kind === "tabs";
/* bars with the same destinations are one bar to the visitor: the choice follows them across screens */
const navKey = navKind ? `nav:${it.kind}:${(it.tabs ?? []).map((t) => t.label).join("|")}` : "";
if (navKind && values[navKey] !== undefined && values[navKey] >= 0) shown = { ...shown, selected: values[navKey] };
const tap =
act || flips(it)
? () => {
if (flips(it)) onFlip(it.id);
if (act) onAction(act);
}
: undefined;
const slotActions = it.actions;
const node = (
<Tappable
key={it.id}
item={shown}
p={p}
radii={radii}
widths={widths}
onTap={tap}
onSlot={
slotActions || navKind
? (slot) => {
/* a tapped destination lights up where it opens nothing; where it opens a
screen, that screen's bar shows its own selected destination */
const a = slotActions?.[slot];
if (navKind && slot.startsWith("tab:")) onValue(navKey, a ? -1 : Number(slot.slice(4)));
if (a) onAction(a);
}
: undefined
}
onValue={it.kind === "slider" ? (v) => onValue(it.id, v) : undefined}
/>
);
if (!g.free) return node;
const o = g.pos?.[it.id] ?? { x: 0, y: 0 };
return (
<div key={it.id} style={{ position: "absolute", left: o.x, top: o.y }}>
{node}
</div>
);
}))(g.free ? freeRadii(g, widths) : null)}
</div>
))}
</div>
);
}
/** the screen being pulled in by a swipe, and the slide it arrives with */
type Peek = { frameId: string; t: Transition };
export function Preview({
doc,
widths,
palette: p,
startId,
onClose,
}: {
doc: Doc;
widths: Record<string, number>;
palette: Palette;
startId: string | null;
onClose: () => void;
}) {
const lang = useLang();
const frames = doc.frames;
const [stack, setStack] = useState<Entry[]>(() => [{ id: startId ?? frames[0]?.id ?? "", t: "none" }]);
const [anim, setAnim] = useState<Anim>({ t: "none", back: false });
const theme = normalizeTheme(doc.theme);
const spring = theme.motion === "expressive";
const [scale, setScale] = useState(1);
const [flipped, setFlipped] = useState<Set<string>>(() => new Set());
const [values, setValues] = useState<Record<string, number>>({});
const [peek, setPeek] = useState<Peek | null>(null);
const stackRef = useRef(stack);
stackRef.current = stack;
const peekRef = useRef(peek);
peekRef.current = peek;
const swiped = useRef(false);
const flip = (id: string) =>
setFlipped((s) => {
const n = new Set(s);
if (n.has(id)) n.delete(id);
else n.add(id);
return n;
});
const top = stack[stack.length - 1];
const current = frames.find((f) => f.id === top?.id) ?? frames[0];
const peekFrame = peek ? frames.find((f) => f.id === peek.frameId) : undefined;
const { w: frameW, h: frameH } = current ? frameSizeOf(current) : { w: PHONE_W, h: PHONE_H };
const phone = current ? isPhoneFrame(current) : true;
/* every screen sits in the same bezel; only the corners tell a phone from a window */
const radius = current ? frameRadius(current) : PHONE_R;
const outerW = frameW + BEZEL * 2;
const outerH = frameH + BEZEL * 2;
const targetFrame = peekFrame ?? current;
const { w: targetFrameW, h: targetFrameH } = targetFrame ? frameSizeOf(targetFrame) : { w: frameW, h: frameH };
const targetRadius = targetFrame ? frameRadius(targetFrame) : radius;
const targetOuterW = targetFrameW + BEZEL * 2;
const targetOuterH = targetFrameH + BEZEL * 2;
/* The shell is the size of the screen on show. When the screen changes it eases to
* the new size in step with the slide; a tracked swipe steers it toward the target
* with the finger, so a phone and a desktop screen hand over without a snap. */
const prog = useMotionValue(0);
const shellW = useMotionValue(outerW);
const shellH = useMotionValue(outerH);
const screenRadius = useMotionValue(radius);
useEffect(() => {
const opts = { duration: SLIDE_MS, ease: EASE };
const runs = [animate(shellW, outerW, opts), animate(shellH, outerH, opts), animate(screenRadius, radius, opts)];
return () => runs.forEach((r) => r.stop());
}, [outerW, outerH, radius, shellW, shellH, screenRadius]);
useEffect(
() =>
prog.on("change", (v) => {
if (!peekRef.current) return;
shellW.set(outerW + (targetOuterW - outerW) * v);
shellH.set(outerH + (targetOuterH - outerH) * v);
screenRadius.set(radius + (targetRadius - radius) * v);
}),
[prog, outerW, outerH, radius, targetOuterW, targetOuterH, targetRadius, shellW, shellH, screenRadius],
);
const screenW = useTransform(shellW, (v) => v - BEZEL * 2);
const screenH = useTransform(shellH, (v) => v - BEZEL * 2);
const shellRadius = useTransform(screenRadius, (v) => v + BEZEL);
/* the stage is sized for the largest screen in the document, so the scale never
* changes while a swipe crosses sizes or a screen is opened from the picker */
const maxOuterW = Math.max(outerW, ...frames.map((f) => frameSizeOf(f).w + BEZEL * 2));
const maxOuterH = Math.max(outerH, ...frames.map((f) => frameSizeOf(f).h + BEZEL * 2));
const shellLeft = useTransform(shellW, (v) => ((maxOuterW - v) * scale) / 2);
const shellTop = useTransform(shellH, (v) => ((maxOuterH - v) * scale) / 2);
/* on a wide window the controls stand in a column at the right edge, clear of the phone;
* on a phone they stay along the bottom, where the frame fills the width anyway */
const [wide, setWide] = useState(false);
useEffect(() => {
const fit = () => {
const isWide = window.innerWidth >= 720;
setWide(isWide);
setScale(
Math.min(1.4, (window.innerHeight - 32) / maxOuterH, (window.innerWidth - (isWide ? WIDE_CONTROL_SPACE + 16 : 16)) / maxOuterW),
);
};
fit();
window.addEventListener("resize", fit);
return () => window.removeEventListener("resize", fit);
}, [maxOuterH, maxOuterW]);
/* a slide between a phone and a desktop screen would drag one shape through the
* other; screens of different sizes cross-fade while the shell changes size */
const sameSize = useCallback(
(fromId: string, toId: string) => {
const a = frames.find((f) => f.id === fromId);
const b = frames.find((f) => f.id === toId);
if (!a || !b) return true;
const sa = frameSizeOf(a);
const sb = frameSizeOf(b);
return sa.w === sb.w && sa.h === sb.h;
},
[frames],
);
const back = useCallback(() => {
const s = stackRef.current;
if (s.length < 2 || peekRef.current) return;
const from = s[s.length - 1];
const to = s[s.length - 2];
setAnim({ t: sameSize(from.id, to.id) ? from.t : "fade", back: true, spring });
setStack(s.slice(0, -1));
}, [spring, sameSize]);
const go = useCallback(
(a: Action) => {
if (swiped.current) return;
if (a.to === BACK_TARGET) {
back();
return;
}
if (!frames.some((f) => f.id === a.to)) return;
const s = stackRef.current;
const t = sameSize(s[s.length - 1].id, a.to) ? a.transition : "fade";
setAnim({ t, back: false, spring });
setStack((cur) => [...cur, { id: a.to, t }]);
},
[frames, back, spring, sameSize],
);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key === "Backspace" || e.key === "ArrowLeft") back();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [back, onClose]);
const groupsFor = useCallback((f: Frame) => groupsInFrame(doc.groups, f, frames, widths), [doc.groups, frames, widths]);
const groups = useMemo(() => (current ? groupsFor(current) : []), [current, groupsFor]);
const peekGroups = useMemo(() => (peekFrame ? groupsFor(peekFrame) : []), [peekFrame, groupsFor]);
/* ---- finger-tracked swipes: only the swipes the author set on the frame ---- */
const axisMV = useMotionValue(0); // 0 = x, 1 = y
const enterMV = useMotionValue(0);
const exitMV = useMotionValue(0);
const curX = useTransform([prog, axisMV, exitMV], ([p, a, ex]: number[]) => (a === 0 ? pct(ex * p) : "0%"));
const curY = useTransform([prog, axisMV, exitMV], ([p, a, ex]: number[]) => (a === 1 ? pct(ex * p) : "0%"));
const curOp = useTransform(prog, (p: number) => 1 - 0.4 * p);
const peekX = useTransform([prog, axisMV, enterMV], ([p, a, en]: number[]) => (a === 0 ? pct(en * (1 - p)) : "0%"));
const peekY = useTransform([prog, axisMV, enterMV], ([p, a, en]: number[]) => (a === 1 ? pct(en * (1 - p)) : "0%"));
const gesture = useRef<{
id: number;
x0: number;
y0: number;
phase: "idle" | "drag" | "none";
dir?: SwipeDir;
size: number;
last: number;
lastT: number;
vel: number;
} | null>(null);
const onScreenPointerDown = (e: React.PointerEvent) => {
if (peekRef.current || e.button !== 0) return;
gesture.current = { id: e.pointerId, x0: e.clientX, y0: e.clientY, phase: "idle", size: frameW, last: 0, lastT: e.timeStamp, vel: 0 };
swiped.current = false;
};
useEffect(() => {
const move = (e: PointerEvent) => {
const g = gesture.current;
if (!g || g.id !== e.pointerId) return;
const dx = (e.clientX - g.x0) / scale;
const dy = (e.clientY - g.y0) / scale;
if (g.phase === "none") return;
if (g.phase === "idle") {
if (Math.hypot(dx, dy) < 8) return;
const dir: SwipeDir = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? "right" : "left") : dy > 0 ? "down" : "up";
const s = stackRef.current;
const cur = frames.find((f) => f.id === s[s.length - 1]?.id);
if (!cur) return;
const to = cur.swipe?.[dir];
const spec = SWIPE_DIRS.find((d) => d.key === dir)!;
/* only swipes the author set up move screens; nothing is inferred */
let pk: Peek | null = null;
if (to && frames.some((f) => f.id === to)) pk = { frameId: to, t: spec.transition };
if (!pk) {
g.phase = "none";
return;
}
const sl = SLIDE_SPEC[pk.t]!;
g.phase = "drag";
g.dir = dir;
const { w, h } = frameSizeOf(cur);
g.size = sl.axis === "x" ? w : h;
swiped.current = true;
axisMV.set(sl.axis === "x" ? 0 : 1);
enterMV.set(sl.enter);
exitMV.set(sl.exit);
prog.set(0);
peekRef.current = pk;
setPeek(pk);
}
const along = g.dir === "left" ? -dx : g.dir === "right" ? dx : g.dir === "up" ? -dy : dy;
const pr = Math.max(0, Math.min(1, along / g.size));
const dt = Math.max(1, e.timeStamp - g.lastT);
g.vel = (pr - g.last) / dt;
g.last = pr;
g.lastT = e.timeStamp;
prog.set(pr);
};
const up = (e: PointerEvent) => {
const g = gesture.current;
if (!g || g.id !== e.pointerId) return;
gesture.current = null;
if (g.phase !== "drag") return;
const pk = peekRef.current;
if (!pk) return;
const commit = prog.get() > 0.25 || g.vel > 0.0012;
animate(prog, commit ? 1 : 0, { duration: 0.26, ease: EASE }).then(() => {
if (commit) {
setAnim({ t: "none", back: false, spring });
setStack((s) => [...s, { id: pk.frameId, t: pk.t }]);
}
peekRef.current = null;
setPeek(null);
enterMV.set(0);
exitMV.set(0);
prog.set(0);
window.setTimeout(() => {
swiped.current = false;
}, 50);
});
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", up);
return () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", up);
};
}, [frames, scale, prog, axisMV, enterMV, exitMV]);
const [picker, setPicker] = useState(false);
const pickerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!picker) return;
const onDown = (e: PointerEvent) => {
if (!pickerRef.current?.contains(e.target as Node)) setPicker(false);
};
window.addEventListener("pointerdown", onDown);
return () => window.removeEventListener("pointerdown", onDown);
}, [picker]);
if (!current) {
return null;
}
const screenProps = {
widths,
p,
onAction: go,
flipped,
onFlip: flip,
values,
onValue: (id: string, v: number) => setValues((m) => ({ ...m, [id]: v })),
};
const barBtn: React.CSSProperties = {
height: 40,
padding: "0 14px 0 10px",
borderRadius: 20,
border: "none",
background: "transparent",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: 6,
whiteSpace: "nowrap",
/* the column has a fixed width, so labels are cut with an ellipsis instead of widening it */
width: wide ? "100%" : undefined,
minWidth: 0,
};
const label: React.CSSProperties = { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", minWidth: 0 };
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.22, ease: EASE }}
style={{
position: "fixed",
inset: 0,
zIndex: 500,
background: p.surfaceContainer,
display: "grid",
placeItems: "center",
boxSizing: "border-box",
paddingRight: wide ? WIDE_CONTROL_SPACE : 0,
}}
>
<div
style={{
width: maxOuterW * scale,
height: maxOuterH * scale,
position: "relative",
marginBottom: wide ? 0 : 56,
}}
>
<motion.div
onPointerDown={onScreenPointerDown}
style={{
position: "absolute",
left: shellLeft,
top: shellTop,
width: shellW,
height: shellH,
transform: `scale(${scale})`,
touchAction: "none",
transformOrigin: "0 0",
borderRadius: shellRadius,
background: p.inverseSurface,
boxShadow: "0 30px 80px rgba(0,0,0,0.22)",
}}
>
<motion.div
onClickCapture={(e) => {
if (swiped.current) {
e.stopPropagation();
e.preventDefault();
}
}}
style={{
position: "absolute",
left: BEZEL,
top: BEZEL,
width: screenW,
height: screenH,
borderRadius: screenRadius,
overflow: "hidden",
background: p[current.bg ?? "surface"],
fontFamily: fontFamilyOf(theme.font, lang),
touchAction: "none",
}}
>
<motion.div
style={{
position: "absolute",
inset: 0,
zIndex: 1,
x: curX,
y: curY,
opacity: curOp,
}}
>
<AnimatePresence initial={false} mode="popLayout" custom={anim}>
<motion.div
key={current.id}
custom={anim}
variants={screenVariants}
initial="initial"
animate="animate"
exit="exit"
style={{ position: "absolute", inset: 0 }}
>
<Screen frame={current} groups={groups} {...screenProps} />
</motion.div>
</AnimatePresence>
</motion.div>
{peek && peekFrame && (
<motion.div
style={{
position: "absolute",
inset: 0,
zIndex: 2,
x: peekX,
y: peekY,
pointerEvents: "none",
}}
>
<Screen frame={peekFrame} groups={peekGroups} {...screenProps} />
</motion.div>
)}
</motion.div>
</motion.div>
</div>
<div
style={
wide
? { position: "absolute", right: 20, bottom: 20, display: "flex", alignItems: "flex-end", pointerEvents: "none" }
: { position: "absolute", left: 0, right: 0, bottom: 14, display: "flex", justifyContent: "center", pointerEvents: "none" }
}
>
<div
style={{
display: "flex",
flexDirection: wide ? "column" : "row",
alignItems: wide ? "stretch" : "center",
gap: 4,
padding: 6,
borderRadius: 28,
background: p.surface,
boxShadow: "0 4px 18px rgba(0,0,0,0.14)",
pointerEvents: "auto",
width: wide ? 172 : undefined,
maxWidth: "calc(100vw - 24px)",
}}
>
<button
onClick={back}
disabled={stack.length < 2}
title={t("back", lang)}
className="m3-press"
style={{
...barBtn,
color: stack.length < 2 ? p.outlineVariant : p.onSurfaceVariant,
cursor: stack.length < 2 ? "default" : "pointer",
}}
>
<Icon name="arrow_back" size={20} />
<span style={label}>{t("back", lang)}</span>
</button>
<div ref={pickerRef} style={{ position: "relative", minWidth: 0 }}>
<button
onClick={() => setPicker((v) => !v)}
title={t("screens", lang)}
aria-expanded={picker}
className="m3-press"
style={{
...barBtn,
background: p.secondaryContainer,
color: p.onSecondaryContainer,
maxWidth: wide ? undefined : 200,
}}
>
<Icon name={phone ? "smartphone" : "desktop_windows"} size={20} />
<span style={{ ...label, flex: wide ? 1 : undefined, textAlign: "left" }}>{current.name || t("screen", lang)}</span>
<Icon name={wide ? (picker ? "chevron_right" : "chevron_left") : picker ? "expand_more" : "expand_less"} size={18} />
</button>
<AnimatePresence>
{picker && (
<motion.div
role="menu"
initial={wide ? { opacity: 0, x: 6, scale: 0.96 } : { opacity: 0, y: 6, scale: 0.96 }}
animate={{ opacity: 1, x: wide ? 0 : "-50%", y: 0, scale: 1 }}
exit={wide ? { opacity: 0, x: 6, scale: 0.96 } : { opacity: 0, y: 6, scale: 0.96 }}
transition={{ duration: 0.16, ease: EASE }}
style={{
position: "absolute",
...(wide ? { right: "calc(100% + 14px)", bottom: 0 } : { bottom: 48, left: "50%" }),
minWidth: 160,
maxHeight: "50vh",
overflowY: "auto",
padding: 6,
borderRadius: 18,
background: p.surfaceContainerLow,
boxShadow: "0 6px 20px rgba(0,0,0,0.16), 0 0 0 1px rgba(0,0,0,0.04)",
display: "flex",
flexDirection: "column",
gap: 2,
transformOrigin: wide ? "bottom right" : "bottom center",
}}
>
{frames.map((f) => {
const on = f.id === current.id;
return (
<button
key={f.id}
role="menuitemradio"
aria-checked={on}
onClick={() => {
setPicker(false);
if (on) return;
setAnim({ t: "fade", back: false, spring });
setStack([{ id: f.id, t: "fade" }]);
}}
className="m3-press"
style={{
height: 40,
padding: "0 14px 0 10px",
borderRadius: 12,
border: "none",
background: on ? p.secondaryContainer : "transparent",
color: on ? p.onSecondaryContainer : p.onSurface,
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 10,
whiteSpace: "nowrap",
textAlign: "left",
}}
>
<span style={{ width: 18, display: "inline-flex" }}>
{on ? <Icon name="check" size={18} /> : <Icon name={isPhoneFrame(f) ? "smartphone" : "desktop_windows"} size={18} />}
</span>
{f.name || t("screen", lang)}
</button>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
<button onClick={onClose} title={t("close", lang)} className="m3-press" style={{ ...barBtn, color: p.onSurfaceVariant }}>
<Icon name="close" size={20} />
<span style={label}>{t("closeBtn", lang)}</span>
</button>
</div>
</div>
</motion.div>
);
}