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>
This commit is contained in:
Vendored
+208
@@ -0,0 +1,208 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { Palette } from "../lib/tokens";
|
||||
import { t, useLang } from "../lib/i18n";
|
||||
import { AiSettings, PROVIDERS, Provider, providerSpec } from "../lib/ai";
|
||||
import { Icon } from "./M3Node";
|
||||
|
||||
/** the message shown for a failed request, mapped from the error codes lib/ai throws */
|
||||
export function aiErrorText(e: unknown, lang: ReturnType<typeof useLang>): string {
|
||||
const m = e instanceof Error ? e.message : String(e);
|
||||
if (m === "refusal") return t("aiErrorRefusal", lang);
|
||||
if (m === "json" || m === "empty") return t("aiErrorJson", lang);
|
||||
if (m === "model") return t("aiErrorModel", lang);
|
||||
if (m === "insecure") return t("aiErrorInsecure", lang);
|
||||
if (/failed to fetch|networkerror|load failed/i.test(m)) return t("aiErrorNetwork", lang);
|
||||
return `${t("aiError", lang)}: ${m}`;
|
||||
}
|
||||
|
||||
export type AiActionKey = "behavior" | "describe";
|
||||
|
||||
/** The button that asks the model to write the field above it; spins while it works. */
|
||||
export function AiWriteBtn({ p, busy, disabled, onClick, onCancel, label, title }: { p: Palette; busy: boolean; disabled?: boolean; onClick: () => void; onCancel: () => void; label: string; title: string }) {
|
||||
const lang = useLang();
|
||||
const shown = busy ? t("cancel", lang) : title;
|
||||
return (
|
||||
<button
|
||||
onClick={busy ? onCancel : onClick}
|
||||
disabled={disabled}
|
||||
title={shown}
|
||||
aria-label={shown}
|
||||
className="m3-press"
|
||||
style={{
|
||||
height: 40,
|
||||
padding: "0 16px 0 12px",
|
||||
borderRadius: 20,
|
||||
border: "none",
|
||||
background: disabled ? p.surfaceContainerHighest : p.primary,
|
||||
color: disabled ? p.onSurfaceVariant : p.onPrimary,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: disabled ? "default" : "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
flex: "0 0 auto",
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<span className={busy ? "m3-spin" : undefined} style={{ display: "inline-flex" }}>
|
||||
<Icon name={busy ? "progress_activity" : "auto_awesome"} size={20} />
|
||||
</span>
|
||||
{/* both labels are laid out so the button keeps one width while it flips */}
|
||||
<span style={{ display: "grid" }}>
|
||||
<span style={{ gridArea: "1 / 1", visibility: busy ? "hidden" : "visible" }}>{label}</span>
|
||||
<span style={{ gridArea: "1 / 1", visibility: busy ? "visible" : "hidden", textAlign: "center" }}>{t("cancel", lang)}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const BASE = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
|
||||
|
||||
const field = (p: Palette): React.CSSProperties => ({
|
||||
width: "100%",
|
||||
height: 44,
|
||||
padding: "0 14px",
|
||||
borderRadius: 22,
|
||||
border: `1px solid ${p.outlineVariant}`,
|
||||
background: p.surface,
|
||||
color: p.onSurface,
|
||||
fontSize: 13,
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
});
|
||||
|
||||
function Input({ value, onChange, placeholder, p, type = "text", label }: { value: string; onChange: (v: string) => void; placeholder?: string; p: Palette; type?: string; label: string }) {
|
||||
return <input type={type} value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} aria-label={label} spellCheck={false} autoComplete="off" style={field(p)} />;
|
||||
}
|
||||
|
||||
const Label = ({ children, p, right }: { children: React.ReactNode; p: Palette; right?: React.ReactNode }) => (
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 8, fontSize: 12, fontWeight: 700, letterSpacing: 0.4, color: p.onSurfaceVariant, marginBottom: 6, padding: "0 4px" }}>
|
||||
<span style={{ flex: 1 }}>{children}</span>
|
||||
{right}
|
||||
</div>
|
||||
);
|
||||
|
||||
/** The providers as one connected button group; each segment shows the provider's mark in the button's color. */
|
||||
function ProviderGroup({ value, onChange, p }: { value: Provider; onChange: (k: Provider) => void; p: Palette }) {
|
||||
const n = PROVIDERS.length;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const move = (from: number, delta: number) => {
|
||||
const to = (from + delta + n) % n;
|
||||
onChange(PROVIDERS[to].key);
|
||||
(ref.current?.children[to] as HTMLElement | undefined)?.focus();
|
||||
};
|
||||
return (
|
||||
<div ref={ref} role="radiogroup" style={{ display: "flex", gap: 3 }}>
|
||||
{PROVIDERS.map((pr, i) => {
|
||||
const on = pr.key === value;
|
||||
const outer = 22;
|
||||
const inner = 8;
|
||||
const l = i === 0 ? outer : inner;
|
||||
const r = i === n - 1 ? outer : inner;
|
||||
return (
|
||||
<button
|
||||
key={pr.key}
|
||||
role="radio"
|
||||
aria-checked={on}
|
||||
tabIndex={on ? 0 : -1}
|
||||
title={pr.label}
|
||||
aria-label={pr.label}
|
||||
onClick={() => onChange(pr.key)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
move(i, 1);
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
move(i, -1);
|
||||
}
|
||||
}}
|
||||
className="m3-press"
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 44,
|
||||
border: "none",
|
||||
borderRadius: `${l}px ${r}px ${r}px ${l}px`,
|
||||
background: on ? p.primary : p.surfaceContainerHigh,
|
||||
color: on ? p.onPrimary : p.onSurfaceVariant,
|
||||
cursor: "pointer",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
transition: "background 160ms, color 160ms",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
display: "block",
|
||||
background: "currentColor",
|
||||
WebkitMaskImage: `url(${BASE}/logos/${pr.key}.svg)`,
|
||||
maskImage: `url(${BASE}/logos/${pr.key}.svg)`,
|
||||
WebkitMaskSize: "contain",
|
||||
maskSize: "contain",
|
||||
WebkitMaskRepeat: "no-repeat",
|
||||
maskRepeat: "no-repeat",
|
||||
WebkitMaskPosition: "center",
|
||||
maskPosition: "center",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The AI tab of the left rail: the provider settings. The actions live with each screen on the right. */
|
||||
export function AiPanel({ p, settings, onSettings }: { p: Palette; settings: AiSettings; onSettings: (s: AiSettings) => void }) {
|
||||
const lang = useLang();
|
||||
const spec = providerSpec(settings.provider);
|
||||
const pick = (k: Provider) => {
|
||||
const s = providerSpec(k);
|
||||
onSettings({ ...settings, provider: k, baseUrl: s.baseUrl, model: s.model });
|
||||
};
|
||||
return (
|
||||
<div className="no-scrollbar" style={{ height: "100%", overflowY: "auto", padding: "12px 12px 20px" }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, letterSpacing: 0.4, color: p.onSurfaceVariant, padding: "8px 6px 12px" }}>{t("aiSettings", lang)}</div>
|
||||
<div style={{ padding: "0 4px" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<div>
|
||||
<Label p={p}>{t("aiProvider", lang)}</Label>
|
||||
<ProviderGroup value={settings.provider} onChange={pick} p={p} />
|
||||
</div>
|
||||
<div>
|
||||
<Label p={p}>{t("aiModel", lang)}</Label>
|
||||
<Input label={t("aiModel", lang)} value={settings.model} onChange={(model) => onSettings({ ...settings, model })} placeholder={spec.model || "model"} p={p} />
|
||||
</div>
|
||||
<div>
|
||||
<Label p={p}>{t("aiBaseUrl", lang)}</Label>
|
||||
<Input label={t("aiBaseUrl", lang)} value={settings.baseUrl} onChange={(baseUrl) => onSettings({ ...settings, baseUrl })} placeholder={spec.baseUrl} p={p} />
|
||||
</div>
|
||||
<div>
|
||||
<Label
|
||||
p={p}
|
||||
right={
|
||||
spec.keysUrl && (
|
||||
<a href={spec.keysUrl} target="_blank" rel="noreferrer" style={{ fontSize: 12, fontWeight: 600, color: p.primary }}>
|
||||
{t("aiGetKey", lang)}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("aiKey", lang)}
|
||||
</Label>
|
||||
<Input label={t("aiKey", lang)} value={settings.key} onChange={(key) => onSettings({ ...settings, key })} placeholder="sk-…" p={p} type="password" />
|
||||
<div style={{ fontSize: 12, lineHeight: 1.5, color: p.onSurfaceVariant, marginTop: 8, padding: "0 4px" }}>{t("aiKeyHint", lang)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Vendored
+343
@@ -0,0 +1,343 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { CONTRASTS, Contrast, PALETTES, Palette, Theme } from "../lib/tokens";
|
||||
import { isHex, onColorFor, schemeFromSeed } from "../lib/color";
|
||||
import { t, useLang } from "../lib/i18n";
|
||||
import { Section, Segmented } from "./ui";
|
||||
import { Icon } from "./M3Node";
|
||||
|
||||
/** roles the author can override by hand; their "on" color follows automatically */
|
||||
type Role = Exclude<keyof Palette, "seed">;
|
||||
const TUNABLE: { key: Role; on?: Role }[] = [
|
||||
{ key: "primary", on: "onPrimary" },
|
||||
{ key: "primaryContainer", on: "onPrimaryContainer" },
|
||||
{ key: "secondaryContainer", on: "onSecondaryContainer" },
|
||||
{ key: "tertiaryContainer", on: "onTertiaryContainer" },
|
||||
{ key: "surfaceContainer" },
|
||||
{ key: "surfaceContainerHigh" },
|
||||
{ key: "inverseSurface", on: "inverseOnSurface" },
|
||||
];
|
||||
|
||||
/** the palette's key colors as overlapping dots, right-aligned in the row */
|
||||
function Swatches({ pal, p }: { pal: Palette; p: Palette }) {
|
||||
const colors = [pal.primary, pal.primaryContainer, pal.secondaryContainer, pal.tertiaryContainer, pal.surfaceContainerHigh];
|
||||
return (
|
||||
<span style={{ display: "inline-flex", flex: "0 0 auto", marginLeft: "auto", paddingLeft: 8 }}>
|
||||
{colors.map((c, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
background: c,
|
||||
marginLeft: i === 0 ? 0 : -7,
|
||||
boxShadow: `0 0 0 2px ${p.surfaceContainerLow}, inset 0 0 0 1px rgba(0,0,0,0.08)`,
|
||||
zIndex: colors.length - i,
|
||||
position: "relative",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorField({ value, onChange, p, label }: { value: string; onChange: (hex: string) => void; p: Palette; label: string }) {
|
||||
const [text, setText] = useState(value);
|
||||
useEffect(() => setText(value), [value]);
|
||||
return (
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 8, height: 36 }}>
|
||||
<span style={{ position: "relative", width: 28, height: 28, borderRadius: 14, background: value, boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.12)", flex: "0 0 auto", overflow: "hidden" }}>
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value.toUpperCase())}
|
||||
aria-label={label}
|
||||
style={{ position: "absolute", inset: -8, width: 44, height: 44, opacity: 0, cursor: "pointer" }}
|
||||
/>
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 12, color: p.onSurface, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{label}</span>
|
||||
<input
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
if (isHex(e.target.value)) onChange(e.target.value.toUpperCase());
|
||||
}}
|
||||
onBlur={() => setText(value)}
|
||||
spellCheck={false}
|
||||
style={{
|
||||
width: 76,
|
||||
height: 28,
|
||||
padding: "0 8px",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background: p.surfaceContainerHigh,
|
||||
color: p.onSurface,
|
||||
fontSize: 12,
|
||||
fontFamily: "ui-monospace, monospace",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/** A settings row in the same shape as the palette rows: icon disc, label, and the
|
||||
* value or switch at the trailing edge. The whole row is the control. */
|
||||
function SettingRow({
|
||||
p,
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
pressed,
|
||||
children,
|
||||
}: {
|
||||
p: Palette;
|
||||
icon: string;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
pressed?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
aria-pressed={pressed}
|
||||
className="m3-press"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
height: 48,
|
||||
padding: "0 12px 0 10px",
|
||||
borderRadius: 16,
|
||||
border: "none",
|
||||
background: p.surfaceContainerLow,
|
||||
color: p.onSurface,
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 28, height: 28, borderRadius: 14, background: p.secondaryContainer, color: p.onSecondaryContainer, display: "grid", placeItems: "center", flex: "0 0 auto" }}>
|
||||
<Icon name={icon} size={18} />
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 13, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{label}</span>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** the switch of a settings row, drawn without its own button */
|
||||
function Knob({ on, p }: { on: boolean; p: Palette }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
position: "relative",
|
||||
width: 44,
|
||||
height: 26,
|
||||
borderRadius: 13,
|
||||
background: on ? p.primary : p.surfaceContainerHighest,
|
||||
border: on ? "2px solid transparent" : `2px solid ${p.outline}`,
|
||||
boxSizing: "border-box",
|
||||
transition: "background 160ms",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: on ? 20 : 3,
|
||||
width: on ? 18 : 12,
|
||||
height: on ? 18 : 12,
|
||||
marginTop: on ? -9 : -6,
|
||||
borderRadius: 9,
|
||||
background: on ? p.onPrimary : p.outline,
|
||||
transition: "left 160ms, width 160ms, height 160ms, margin 160ms",
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The color tab of the theme panel: light / dark and contrast, preset themes,
|
||||
* a seed-based custom scheme with per-role tweaks, and the dynamic-color switch. */
|
||||
export function ColorPanel({
|
||||
p,
|
||||
paletteKey,
|
||||
onPalette,
|
||||
custom,
|
||||
onCustom,
|
||||
dynamic,
|
||||
onDynamic,
|
||||
theme,
|
||||
onTheme,
|
||||
}: {
|
||||
p: Palette;
|
||||
paletteKey: string;
|
||||
onPalette: (key: string) => void;
|
||||
custom: Palette | null;
|
||||
onCustom: (pal: Palette) => void;
|
||||
dynamic: boolean;
|
||||
onDynamic: (on: boolean) => void;
|
||||
theme: Theme;
|
||||
onTheme: (patch: Partial<Theme>) => void;
|
||||
}) {
|
||||
const lang = useLang();
|
||||
const contrastLabel = (c: Contrast) => (c === "high" ? t("contrastHigh", lang) : c === "medium" ? t("contrastMedium", lang) : t("contrastStandard", lang));
|
||||
const [tab, setTab] = useState<"templates" | "custom">(paletteKey === "custom" ? "custom" : "templates");
|
||||
const [seed, setSeed] = useState(custom?.seed ?? custom?.primary ?? "#6750A4");
|
||||
|
||||
const applySeed = (hex: string) => {
|
||||
setSeed(hex);
|
||||
onCustom(schemeFromSeed(hex));
|
||||
onPalette("custom");
|
||||
};
|
||||
const cur = custom ?? schemeFromSeed(seed);
|
||||
|
||||
return (
|
||||
<div className="no-scrollbar" style={{ height: "100%", overflowY: "auto", padding: "12px 12px 20px" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginBottom: 14 }}>
|
||||
<SettingRow p={p} icon={theme.dark ? "dark_mode" : "light_mode"} label={t("brightness", lang)} onClick={() => onTheme({ dark: !theme.dark })} pressed={theme.dark}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 4, fontSize: 13, fontWeight: 600, color: p.primary }}>
|
||||
{theme.dark ? t("dark", lang) : t("light", lang)}
|
||||
<Icon name="swap_horiz" size={18} />
|
||||
</span>
|
||||
</SettingRow>
|
||||
<SettingRow p={p} icon="routine" label={t("bothModes", lang)} onClick={() => onTheme({ bothModes: !theme.bothModes })} pressed={theme.bothModes}>
|
||||
<Knob on={theme.bothModes} p={p} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
p={p}
|
||||
icon="contrast"
|
||||
label={t("contrast", lang)}
|
||||
onClick={() => {
|
||||
const i = CONTRASTS.findIndex((c) => c.key === theme.contrast);
|
||||
onTheme({ contrast: CONTRASTS[(i + 1) % CONTRASTS.length].key });
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 4, fontSize: 13, fontWeight: 600, color: p.primary }}>
|
||||
{contrastLabel(theme.contrast)}
|
||||
<Icon name="chevron_right" size={18} />
|
||||
</span>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<Segmented<"templates" | "custom">
|
||||
options={[
|
||||
{ key: "templates", icon: "palette", label: t("templates", lang) },
|
||||
{ key: "custom", icon: "colorize", label: t("customColor", lang) },
|
||||
]}
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
p={p}
|
||||
height={38}
|
||||
/>
|
||||
|
||||
{tab === "templates" ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 12 }}>
|
||||
{PALETTES.map((pal) => {
|
||||
const on = pal.key === paletteKey;
|
||||
return (
|
||||
<button
|
||||
key={pal.key}
|
||||
onClick={() => onPalette(pal.key)}
|
||||
aria-pressed={on}
|
||||
className="m3-press"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
height: 48,
|
||||
padding: "0 12px 0 10px",
|
||||
borderRadius: 16,
|
||||
border: "none",
|
||||
background: on ? p.secondaryContainer : p.surfaceContainerLow,
|
||||
color: on ? p.onSecondaryContainer : p.onSurface,
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 28, height: 28, borderRadius: 14, background: `linear-gradient(135deg, ${pal.primary} 50%, ${pal.primaryContainer} 50%)`, flex: "0 0 auto" }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", minWidth: 0 }}>{pal.label}</span>
|
||||
<Swatches pal={pal} p={p} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10, marginTop: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "flex-end",
|
||||
gap: 8,
|
||||
padding: 10,
|
||||
borderRadius: 16,
|
||||
background: paletteKey === "custom" ? p.secondaryContainer : p.surfaceContainerLow,
|
||||
color: paletteKey === "custom" ? p.onSecondaryContainer : p.onSurface,
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: "1 1 180px", minWidth: 0 }}>
|
||||
<ColorField value={seed} onChange={applySeed} p={p} label={t("seedColor", lang)} />
|
||||
</div>
|
||||
{paletteKey !== "custom" && (
|
||||
<button
|
||||
onClick={() => applySeed(seed)}
|
||||
className="m3-press"
|
||||
style={{ height: 32, padding: "0 12px", borderRadius: 16, border: "none", background: p.primary, color: p.onPrimary, fontSize: 12, fontWeight: 600, cursor: "pointer" }}
|
||||
>
|
||||
{t("useThis", lang)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, lineHeight: 1.5, color: p.onSurfaceVariant, padding: "0 4px" }}>{t("seedHint", lang)}</div>
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", padding: "0 2px" }}>
|
||||
{["#6750A4", "#0B57D0", "#2E6A45", "#984061", "#8B5000", "#00696E", "#B3261E", "#4A4459"].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => applySeed(c)}
|
||||
title={c}
|
||||
aria-label={c}
|
||||
className="m3-press"
|
||||
style={{ width: 26, height: 26, borderRadius: 13, border: "none", padding: 0, background: c, cursor: "pointer", outline: seed === c ? `2px solid ${p.primary}` : "2px solid transparent", outlineOffset: 2 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Section id="color-tune" icon="tune" title={t("fineTune", lang)} p={p} defaultOpen={false}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
{TUNABLE.map((r) => (
|
||||
<ColorField
|
||||
key={r.key}
|
||||
value={cur[r.key]}
|
||||
label={r.key}
|
||||
p={p}
|
||||
onChange={(hex) => {
|
||||
const next: Palette = { ...cur, [r.key]: hex };
|
||||
if (r.on) (next as Record<string, string>)[r.on] = onColorFor(hex);
|
||||
onCustom(next);
|
||||
onPalette("custom");
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 16, display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<SettingRow p={p} icon="wallpaper" label={t("dynamicColor", lang)} onClick={() => onDynamic(!dynamic)} pressed={dynamic}>
|
||||
<Knob on={dynamic} p={p} />
|
||||
</SettingRow>
|
||||
<div style={{ fontSize: 11, lineHeight: 1.5, color: p.onSurfaceVariant, padding: "2px 4px 0" }}>
|
||||
{dynamic ? t("dynamicOnHint", lang) : t("dynamicOffHint", lang)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Vendored
+208
@@ -0,0 +1,208 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { Palette } from "../lib/tokens";
|
||||
import { t, useLang } from "../lib/i18n";
|
||||
|
||||
type IconMeta = { n: string; p: number; t: string };
|
||||
|
||||
const FONT = '24px "Material Symbols Rounded"';
|
||||
/** module-level so validation survives re-mounts of the panel */
|
||||
let cache: IconMeta[] | null = null;
|
||||
const glyphOk = new Map<string, boolean>();
|
||||
|
||||
/** Candidates measured per pass. A real Material Symbols glyph is exactly 1em
|
||||
* wide; a name with no glyph falls back to text and measures wider. Icon names
|
||||
* can be as short as "tv"/"4k", so we compare against a known-good reference
|
||||
* glyph rather than a width threshold. */
|
||||
const BATCH = 400;
|
||||
const SHOWN = 240;
|
||||
|
||||
export function IconPicker({
|
||||
value,
|
||||
onChange,
|
||||
palette,
|
||||
}: {
|
||||
value: string | null;
|
||||
onChange: (icon: string | null) => void;
|
||||
palette: Palette;
|
||||
}) {
|
||||
const lang = useLang();
|
||||
const [icons, setIcons] = useState<IconMeta[] | null>(cache);
|
||||
const [q, setQ] = useState("");
|
||||
const [fontReady, setFontReady] = useState(false);
|
||||
/* bumped after each measuring pass; the visible list depends on it because
|
||||
* glyphOk is a plain map, not React state */
|
||||
const [tick, bump] = useState(0);
|
||||
|
||||
const refEl = useRef<HTMLSpanElement>(null);
|
||||
const probeEls = useRef<Map<string, HTMLElement>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (cache) return;
|
||||
fetch(`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/material-symbols.json`)
|
||||
.then((r) => r.json())
|
||||
.then((d: IconMeta[]) => {
|
||||
cache = d;
|
||||
setIcons(d);
|
||||
})
|
||||
.catch(() => setIcons([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const done = () => alive && setFontReady(true);
|
||||
if (document.fonts.check(FONT)) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
document.fonts.load(FONT, "search").then(done, done);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const candidates = useMemo(() => {
|
||||
if (!icons) return [];
|
||||
const s = q.trim().toLowerCase().replace(/\s+/g, "_");
|
||||
if (!s) return icons.slice(0, BATCH);
|
||||
const raw = q.trim().toLowerCase();
|
||||
const starts: IconMeta[] = [];
|
||||
const rest: IconMeta[] = [];
|
||||
for (const i of icons) {
|
||||
if (i.n.startsWith(s)) starts.push(i);
|
||||
else if (i.n.includes(s) || i.t.includes(raw)) rest.push(i);
|
||||
if (starts.length + rest.length >= BATCH * 2) break;
|
||||
}
|
||||
return [...starts, ...rest].slice(0, BATCH);
|
||||
}, [icons, q]);
|
||||
|
||||
const unknown = useMemo(() => candidates.filter((c) => !glyphOk.has(c.n)), [candidates]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!fontReady || unknown.length === 0) return;
|
||||
const ref = refEl.current?.getBoundingClientRect().width ?? 0;
|
||||
if (ref <= 0) return;
|
||||
let learned = false;
|
||||
for (const c of unknown) {
|
||||
const el = probeEls.current.get(c.n);
|
||||
if (!el) continue;
|
||||
const w = el.getBoundingClientRect().width;
|
||||
glyphOk.set(c.n, Math.abs(w - ref) < 0.75);
|
||||
learned = true;
|
||||
}
|
||||
if (learned) bump((v) => v + 1);
|
||||
}, [fontReady, unknown]);
|
||||
|
||||
const visible = useMemo(
|
||||
() => candidates.filter((c) => glyphOk.get(c.n) === true).slice(0, SHOWN),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[candidates, fontReady, tick],
|
||||
);
|
||||
|
||||
const loading = !icons || !fontReady;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* hidden probes — measured before paint, so nothing broken ever renders */}
|
||||
<div
|
||||
aria-hidden
|
||||
style={{ position: "fixed", left: -99999, top: 0, visibility: "hidden", pointerEvents: "none" }}
|
||||
>
|
||||
<span ref={refEl} className="msr" style={{ fontSize: 24 }}>
|
||||
search
|
||||
</span>
|
||||
{unknown.map((c) => (
|
||||
<span
|
||||
key={c.n}
|
||||
ref={(el) => {
|
||||
if (el) probeEls.current.set(c.n, el);
|
||||
else probeEls.current.delete(c.n);
|
||||
}}
|
||||
className="msr"
|
||||
style={{ fontSize: 24 }}
|
||||
>
|
||||
{c.n}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center", marginBottom: 8 }}>
|
||||
<div style={{ position: "relative", flex: 1, minWidth: 0 }}>
|
||||
<span
|
||||
className="msr"
|
||||
style={{ position: "absolute", left: 12, top: 10, fontSize: 20, color: palette.outline }}
|
||||
>
|
||||
search
|
||||
</span>
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder={icons ? t("searchIcons", lang) : "…"}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 40,
|
||||
paddingLeft: 40,
|
||||
paddingRight: 12,
|
||||
borderRadius: 20,
|
||||
border: "none",
|
||||
background: palette.surfaceContainerHigh,
|
||||
color: palette.onSurface,
|
||||
fontSize: 14,
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="no-scrollbar"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(42px, 1fr))",
|
||||
gap: 4,
|
||||
height: 244,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
padding: 6,
|
||||
borderRadius: 16,
|
||||
background: palette.surfaceContainerLow,
|
||||
alignContent: "start",
|
||||
}}
|
||||
>
|
||||
{visible.map((i) => (
|
||||
<button
|
||||
key={i.n}
|
||||
title={i.n}
|
||||
onClick={() => onChange(i.n)}
|
||||
style={{
|
||||
aspectRatio: "1",
|
||||
minWidth: 0,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
borderRadius: 12,
|
||||
border: "none",
|
||||
background: value === i.n ? palette.primary : "transparent",
|
||||
color: value === i.n ? palette.onPrimary : palette.onSurfaceVariant,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<span className="msr" style={{ fontSize: 22 }}>
|
||||
{i.n}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{!loading && visible.length === 0 && (
|
||||
<div style={{ gridColumn: "1 / -1", padding: 16, fontSize: 13, color: palette.outline }}>
|
||||
<span className="msr" style={{ fontSize: 24 }}>search_off</span>
|
||||
</div>
|
||||
)}
|
||||
{loading && (
|
||||
<div style={{ gridColumn: "1 / -1", padding: 16, fontSize: 13, color: palette.outline }}>
|
||||
<span className="msr" style={{ fontSize: 24 }}>hourglass_top</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Vendored
+1290
File diff suppressed because it is too large
Load Diff
Vendored
+341
@@ -0,0 +1,341 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useMemo, useState } from "react";
|
||||
import { Reorder, useDragControls } from "motion/react";
|
||||
import { Frame, Group, Item, KIND_SPEC, Palette, explodeGroup, isPhoneFrame } from "../lib/tokens";
|
||||
import { Icon } from "./M3Node";
|
||||
import { Lang, KIND_TEXT, t, useLang } from "../lib/i18n";
|
||||
|
||||
/* Rows never animate their size: opening a row only adds rows under it, so
|
||||
* nothing stretches. Only the drag itself moves.
|
||||
*
|
||||
* The panel is a tree with two kinds of level. A z level lists what is drawn on
|
||||
* top of what, front first: the runs of a screen, and the runs hidden inside a
|
||||
* free group. A run level lists the parts of one connected run in reading order
|
||||
* (left to right, top to bottom). Every level is reordered by dragging the
|
||||
* handle; the page turns the new order back into a group. */
|
||||
|
||||
function nameOf(it: Item, lang: Lang) {
|
||||
const spec = KIND_SPEC[it.kind];
|
||||
const noun = KIND_TEXT[lang][it.kind]?.noun ?? spec.label;
|
||||
return it.label.trim() || (it.kind === "iconButton" || it.kind === "fab" ? (it.icon ?? noun) : noun);
|
||||
}
|
||||
|
||||
function runLabel(g: Group, lang: Lang) {
|
||||
const first = g.items[0];
|
||||
const noun = KIND_TEXT[lang][first.kind]?.noun ?? KIND_SPEC[first.kind].label;
|
||||
return g.free ? `${t("group", lang)} × ${g.items.length}` : g.items.length > 1 ? `${noun} × ${g.items.length}` : nameOf(first, lang);
|
||||
}
|
||||
|
||||
function Row({
|
||||
id,
|
||||
p,
|
||||
depth,
|
||||
icon,
|
||||
label,
|
||||
on,
|
||||
onSelect,
|
||||
open,
|
||||
onToggle,
|
||||
onDragging,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
p: Palette;
|
||||
depth: number;
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
on: boolean;
|
||||
onSelect: (add: boolean) => void;
|
||||
/** set when the row can open to show what it holds */
|
||||
open?: boolean;
|
||||
onToggle?: () => void;
|
||||
onDragging: (dragging: boolean) => void;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const lang = useLang();
|
||||
const controls = useDragControls();
|
||||
const h = depth === 0 ? 40 : 36;
|
||||
return (
|
||||
<Reorder.Item value={id} layout="position" transition={{ layout: { duration: 0 } }} dragListener={false} dragControls={controls} onDragStart={() => onDragging(true)} onDragEnd={() => onDragging(false)} style={{ listStyle: "none", display: "flex", flexDirection: "column", gap: 4, position: "relative" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
height: h,
|
||||
padding: "0 6px 0 2px",
|
||||
marginLeft: depth * 14,
|
||||
borderRadius: depth === 0 ? 14 : 12,
|
||||
background: on ? p.secondaryContainer : depth === 0 ? p.surfaceContainerLow : p.surface,
|
||||
color: on ? p.onSecondaryContainer : p.onSurface,
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
controls.start(e);
|
||||
}}
|
||||
style={{ cursor: "grab", color: p.outline, display: "grid", placeItems: "center", width: depth === 0 ? 24 : 20, height: h, touchAction: "none" }}
|
||||
>
|
||||
<Icon name="drag_indicator" size={18} />
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => onSelect(e.shiftKey)}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: h,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: "inherit",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "inline-flex", gap: 2, color: on ? p.onSecondaryContainer : p.primary }}>{icon}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: depth === 0 ? 600 : 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{label}</span>
|
||||
</button>
|
||||
{onToggle && (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
title={t(open ? "hideParts" : "showParts", lang)}
|
||||
aria-expanded={open}
|
||||
className="m3-press"
|
||||
style={{ width: 28, height: 28, borderRadius: 14, border: "none", background: "transparent", color: on ? p.onSecondaryContainer : p.onSurfaceVariant, cursor: "pointer", padding: 0, display: "grid", placeItems: "center", flex: "0 0 auto" }}
|
||||
>
|
||||
<span style={{ display: "inline-flex", transform: open ? "rotate(90deg)" : "none", transition: "transform 160ms" }}>
|
||||
<Icon name="chevron_right" size={20} />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{open && children}
|
||||
</Reorder.Item>
|
||||
);
|
||||
}
|
||||
|
||||
/** One reorderable level: drag the handles; `values` is the shown order. */
|
||||
function Level({ values, onReorder, children }: { values: string[]; onReorder: (next: string[]) => void; children: ReactNode }) {
|
||||
return (
|
||||
<Reorder.Group axis="y" values={values} onReorder={onReorder} style={{ display: "flex", flexDirection: "column", gap: 4, padding: 0, margin: 0 }}>
|
||||
{children}
|
||||
</Reorder.Group>
|
||||
);
|
||||
}
|
||||
|
||||
/** the parts of one connected run, in reading order */
|
||||
function RunParts({
|
||||
run,
|
||||
p,
|
||||
depth,
|
||||
sel,
|
||||
onSelect,
|
||||
onReorder,
|
||||
onDragging,
|
||||
}: {
|
||||
run: Group;
|
||||
p: Palette;
|
||||
depth: number;
|
||||
sel: Set<string>;
|
||||
onSelect: (itemIds: string[], add: boolean) => void;
|
||||
/** the run's parts in a new reading order */
|
||||
onReorder: (ids: string[]) => void;
|
||||
onDragging: (dragging: boolean) => void;
|
||||
}) {
|
||||
const lang = useLang();
|
||||
const ids = run.items.map((it) => it.id);
|
||||
return (
|
||||
<Level values={ids} onReorder={onReorder}>
|
||||
{run.items.map((it) => (
|
||||
<Row
|
||||
key={it.id}
|
||||
id={it.id}
|
||||
p={p}
|
||||
depth={depth}
|
||||
icon={<Icon name={KIND_SPEC[it.kind].paletteIcon} size={16} />}
|
||||
label={nameOf(it, lang)}
|
||||
on={sel.has(it.id)}
|
||||
onSelect={(add) => onSelect([it.id], add)}
|
||||
onDragging={onDragging}
|
||||
/>
|
||||
))}
|
||||
</Level>
|
||||
);
|
||||
}
|
||||
|
||||
/** The z-order of one screen, top layer first, reordered by dragging the handles.
|
||||
* A connected run opens to its parts in reading order; a free group opens to the
|
||||
* runs it holds, front first, and each of those opens in turn. */
|
||||
export function LayersPanel({
|
||||
p,
|
||||
frames,
|
||||
frameId,
|
||||
onFrame,
|
||||
groups,
|
||||
widths,
|
||||
selectedIds,
|
||||
onSelect,
|
||||
onReorder,
|
||||
onReorderItems,
|
||||
onDragging,
|
||||
}: {
|
||||
p: Palette;
|
||||
frames: Frame[];
|
||||
frameId: string | null;
|
||||
onFrame: (id: string) => void;
|
||||
/** the runs on the chosen frame, in canvas order (bottom first) */
|
||||
groups: Group[];
|
||||
widths: Record<string, number>;
|
||||
selectedIds: string[];
|
||||
/** `add` is set when Shift was held, to extend the selection */
|
||||
onSelect: (itemIds: string[], add: boolean) => void;
|
||||
/** new order, top layer first */
|
||||
onReorder: (topFirst: string[]) => void;
|
||||
/** a group's parts in a new order: back to front for a free group, reading order for a run */
|
||||
onReorderItems: (groupId: string, ids: string[]) => void;
|
||||
/** a drag on any level starting or ending, so the page can record one undo step for the whole drag */
|
||||
onDragging: (dragging: boolean) => void;
|
||||
}) {
|
||||
const lang = useLang();
|
||||
const topFirst = useMemo(() => [...groups].reverse(), [groups]);
|
||||
const ids = topFirst.map((g) => g.id);
|
||||
const sel = new Set(selectedIds);
|
||||
const [openIds, setOpenIds] = useState<Set<string>>(() => new Set());
|
||||
const toggle = (id: string) =>
|
||||
setOpenIds((cur) => {
|
||||
const next = new Set(cur);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
/** the runs hidden in a free group, front first, and the flat back-to-front list they make */
|
||||
const freeRuns = (g: Group) => [...explodeGroup(g, widths)].reverse();
|
||||
const flatten = (runsTopFirst: Group[]) => [...runsTopFirst].reverse().flatMap((r) => r.items.map((it) => it.id));
|
||||
|
||||
const groupBody = (g: Group, depth: number) => {
|
||||
if (!g.free) {
|
||||
return <RunParts run={g} p={p} depth={depth} sel={sel} onSelect={onSelect} onReorder={(order) => onReorderItems(g.id, order)} onDragging={onDragging} />;
|
||||
}
|
||||
const runs = freeRuns(g);
|
||||
const runIds = runs.map((r) => r.id);
|
||||
const byId = new Map(runs.map((r) => [r.id, r]));
|
||||
const reorderRuns = (next: string[]) => onReorderItems(g.id, flatten(next.map((id) => byId.get(id)).filter((r): r is Group => !!r)));
|
||||
return (
|
||||
<Level values={runIds} onReorder={reorderRuns}>
|
||||
{runs.map((r) => {
|
||||
const many = r.items.length > 1;
|
||||
const open = many && openIds.has(r.id);
|
||||
return (
|
||||
<Row
|
||||
key={r.id}
|
||||
id={r.id}
|
||||
p={p}
|
||||
depth={depth}
|
||||
icon={r.items.slice(0, 3).map((it, k) => <Icon key={k} name={KIND_SPEC[it.kind].paletteIcon} size={16} />)}
|
||||
label={runLabel(r, lang)}
|
||||
on={r.items.some((it) => sel.has(it.id))}
|
||||
onSelect={(add) => onSelect(r.items.map((it) => it.id), add)}
|
||||
open={many ? open : undefined}
|
||||
onToggle={many ? () => toggle(r.id) : undefined}
|
||||
onDragging={onDragging}
|
||||
>
|
||||
<RunParts
|
||||
run={r}
|
||||
p={p}
|
||||
depth={depth + 1}
|
||||
sel={sel}
|
||||
onSelect={onSelect}
|
||||
onDragging={onDragging}
|
||||
onReorder={(order) => {
|
||||
/* the run's members take each other's places in the list; every other part keeps its own */
|
||||
const members = new Set(r.items.map((it) => it.id));
|
||||
let k = 0;
|
||||
onReorderItems(
|
||||
g.id,
|
||||
g.items.map((it) => (members.has(it.id) ? order[k++] : it.id)),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</Level>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{frames.length > 1 && (
|
||||
<div className="no-scrollbar" style={{ display: "flex", gap: 6, padding: "12px 12px 4px", overflowX: "auto", flex: "0 0 auto" }}>
|
||||
{frames.map((f) => {
|
||||
const on = f.id === frameId;
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => onFrame(f.id)}
|
||||
className="m3-press"
|
||||
style={{
|
||||
height: 32,
|
||||
padding: "0 12px 0 8px",
|
||||
borderRadius: 16,
|
||||
border: "none",
|
||||
background: on ? p.primary : p.surfaceContainerHigh,
|
||||
color: on ? p.onPrimary : p.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
whiteSpace: "nowrap",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
<Icon name={isPhoneFrame(f) ? "smartphone" : "desktop_windows"} size={16} />
|
||||
{f.name || t("screen", lang)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="no-scrollbar" style={{ flex: 1, overflowY: "auto", padding: "8px 10px 12px" }}>
|
||||
{topFirst.length === 0 ? (
|
||||
<div style={{ padding: 24, textAlign: "center", color: p.outline, fontSize: 12 }}>
|
||||
<Icon name="layers_clear" size={32} />
|
||||
<div style={{ marginTop: 8 }}>{t("noLayers", lang)}</div>
|
||||
</div>
|
||||
) : (
|
||||
<Level values={ids} onReorder={onReorder}>
|
||||
{topFirst.map((g) => {
|
||||
const canOpen = g.free || g.items.length > 1;
|
||||
const open = canOpen && openIds.has(g.id);
|
||||
return (
|
||||
<Row
|
||||
key={g.id}
|
||||
id={g.id}
|
||||
p={p}
|
||||
depth={0}
|
||||
icon={g.free ? <Icon name="group_work" size={18} /> : g.items.slice(0, 3).map((it, k) => <Icon key={k} name={KIND_SPEC[it.kind].paletteIcon} size={18} />)}
|
||||
label={runLabel(g, lang)}
|
||||
on={g.items.some((it) => sel.has(it.id))}
|
||||
onSelect={(add) => onSelect(g.items.map((it) => it.id), add)}
|
||||
open={canOpen ? open : undefined}
|
||||
onToggle={canOpen ? () => toggle(g.id) : undefined}
|
||||
onDragging={onDragging}
|
||||
>
|
||||
{groupBody(g, 1)}
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</Level>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Vendored
+300
@@ -0,0 +1,300 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { LoadingAnimator, morphedShape } from "../lib/shapes";
|
||||
|
||||
/* ---------- shared helpers ---------- */
|
||||
|
||||
/** Cubic bezier easing (same parametrisation as CSS / Compose CubicBezierEasing). */
|
||||
function bezier(x1: number, y1: number, x2: number, y2: number) {
|
||||
const ax = 3 * x1 - 3 * x2 + 1;
|
||||
const bx = 3 * x2 - 6 * x1;
|
||||
const cx = 3 * x1;
|
||||
const ay = 3 * y1 - 3 * y2 + 1;
|
||||
const by = 3 * y2 - 6 * y1;
|
||||
const cy = 3 * y1;
|
||||
const sx = (t: number) => ((ax * t + bx) * t + cx) * t;
|
||||
const sy = (t: number) => ((ay * t + by) * t + cy) * t;
|
||||
return (x: number) => {
|
||||
if (x <= 0) return 0;
|
||||
if (x >= 1) return 1;
|
||||
let lo = 0;
|
||||
let hi = 1;
|
||||
let t = x;
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const v = sx(t);
|
||||
if (Math.abs(v - x) < 1e-4) break;
|
||||
if (v < x) lo = t;
|
||||
else hi = t;
|
||||
t = (lo + hi) / 2;
|
||||
}
|
||||
return sy(t);
|
||||
};
|
||||
}
|
||||
|
||||
const seg = (t: number, from: number, to: number, ease: (x: number) => number) =>
|
||||
ease(Math.min(1, Math.max(0, (t - from) / (to - from))));
|
||||
|
||||
function useFrame(cb: (ts: number) => void) {
|
||||
const ref = useRef(cb);
|
||||
ref.current = cb;
|
||||
useEffect(() => {
|
||||
let raf = 0;
|
||||
const loop = (ts: number) => {
|
||||
ref.current(ts);
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
raf = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/* ---------- loading indicator (shape morph) ---------- */
|
||||
|
||||
export function LoadingIndicator({
|
||||
size,
|
||||
color,
|
||||
contained,
|
||||
containerColor,
|
||||
}: {
|
||||
size: number;
|
||||
color: string;
|
||||
contained?: boolean;
|
||||
containerColor?: string;
|
||||
}) {
|
||||
const pathRef = useRef<SVGPathElement>(null);
|
||||
const anim = useRef<LoadingAnimator | null>(null);
|
||||
if (!anim.current) anim.current = new LoadingAnimator();
|
||||
|
||||
const scale = (size * 0.79) / 2;
|
||||
const c = size / 2;
|
||||
|
||||
useFrame((ts) => {
|
||||
const a = anim.current!;
|
||||
a.update(ts);
|
||||
const pts = morphedShape(a.morph);
|
||||
let d = "";
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
d += (i === 0 ? "M" : "L") + (pts[i][0] * scale).toFixed(2) + " " + (pts[i][1] * scale).toFixed(2);
|
||||
}
|
||||
const el = pathRef.current;
|
||||
if (el) {
|
||||
el.setAttribute("d", d + "Z");
|
||||
el.setAttribute("transform", `translate(${c} ${c}) rotate(${a.rotation.toFixed(2)})`);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ display: "block" }}>
|
||||
{contained && <circle cx={c} cy={c} r={c} fill={containerColor ?? "rgba(0,0,0,0.08)"} />}
|
||||
<path ref={pathRef} fill={color} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- linear progress (flat / wavy) ---------- */
|
||||
|
||||
const LINEAR_CYCLE = 1800;
|
||||
const l1Head = bezier(0.2, 0, 0.8, 1);
|
||||
const l1Tail = bezier(0.4, 0, 1, 1);
|
||||
const l2Head = bezier(0, 0, 0.65, 1);
|
||||
const l2Tail = bezier(0.1, 0, 0.45, 1);
|
||||
|
||||
const STROKE = 4;
|
||||
const TRACK_GAP = 4;
|
||||
const STOP_SIZE = 4;
|
||||
const LINEAR_WAVELENGTH = 40;
|
||||
const LINEAR_AMPLITUDE = 3;
|
||||
const WAVE_SPEED = 40; // px per second
|
||||
|
||||
function wavePath(x0: number, x1: number, mid: number, amp: number, phase: number, wl: number) {
|
||||
if (x1 - x0 < 0.5) return "";
|
||||
const step = amp > 0 ? 2 : Math.max(2, x1 - x0);
|
||||
let d = "";
|
||||
for (let x = x0; ; x += step) {
|
||||
const xx = Math.min(x, x1);
|
||||
const y = mid + amp * Math.sin((2 * Math.PI * xx) / wl + phase);
|
||||
d += (d ? "L" : "M") + xx.toFixed(2) + " " + y.toFixed(2);
|
||||
if (xx >= x1) break;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
export function LinearProgress({
|
||||
width,
|
||||
color,
|
||||
trackColor,
|
||||
wavy,
|
||||
value,
|
||||
}: {
|
||||
width: number;
|
||||
color: string;
|
||||
trackColor: string;
|
||||
wavy?: boolean;
|
||||
/** 0..1, undefined = indeterminate */
|
||||
value?: number;
|
||||
}) {
|
||||
const height = wavy ? STROKE + LINEAR_AMPLITUDE * 2 + 2 : STROKE + 2;
|
||||
const mid = height / 2;
|
||||
const inset = STROKE / 2;
|
||||
const w = width - STROKE;
|
||||
const activeRef = useRef<SVGPathElement>(null);
|
||||
const active2Ref = useRef<SVGPathElement>(null);
|
||||
const trackRef = useRef<SVGPathElement>(null);
|
||||
const start = useRef<number>(0);
|
||||
|
||||
useFrame((ts) => {
|
||||
if (!start.current) start.current = ts;
|
||||
const ms = ts - start.current;
|
||||
const phase = wavy ? (-(ms / 1000) * WAVE_SPEED * 2 * Math.PI) / LINEAR_WAVELENGTH : 0;
|
||||
const ampBase = wavy ? LINEAR_AMPLITUDE : 0;
|
||||
const a = activeRef.current;
|
||||
const b = active2Ref.current;
|
||||
const t = trackRef.current;
|
||||
if (!a || !b || !t) return;
|
||||
|
||||
if (value !== undefined) {
|
||||
const v = Math.min(1, Math.max(0, value));
|
||||
const amp = v < 0.1 || v > 0.95 ? 0 : ampBase;
|
||||
const end = inset + w * v;
|
||||
a.setAttribute("d", wavePath(inset, end, mid, amp, phase, LINEAR_WAVELENGTH));
|
||||
b.setAttribute("d", "");
|
||||
const trackStart = Math.min(inset + w - STOP_SIZE, end + TRACK_GAP + STROKE / 2);
|
||||
t.setAttribute("d", wavePath(v <= 0 ? inset : trackStart, inset + w, mid, 0, 0, 1));
|
||||
return;
|
||||
}
|
||||
|
||||
const c = ms % LINEAR_CYCLE;
|
||||
const h1 = seg(c, 0, 750, l1Head);
|
||||
const t1 = seg(c, 333, 1183, l1Tail);
|
||||
const h2 = seg(c, 1000, 1567, l2Head);
|
||||
const t2 = seg(c, 1267, 1800, l2Tail);
|
||||
const s1 = [inset + w * t1, inset + w * h1];
|
||||
const s2 = [inset + w * t2, inset + w * h2];
|
||||
a.setAttribute("d", wavePath(s1[0], s1[1], mid, ampBase, phase, LINEAR_WAVELENGTH));
|
||||
b.setAttribute("d", wavePath(s2[0], s2[1], mid, ampBase, phase, LINEAR_WAVELENGTH));
|
||||
|
||||
const segs = [s1, s2].filter((s) => s[1] - s[0] > 0.5).sort((p, q) => p[0] - q[0]);
|
||||
let cursor = inset;
|
||||
let d = "";
|
||||
for (const s of segs) {
|
||||
const to = s[0] - TRACK_GAP - STROKE / 2;
|
||||
if (to - cursor > 0.5) d += wavePath(cursor, to, mid, 0, 0, 1);
|
||||
cursor = Math.max(cursor, s[1] + TRACK_GAP + STROKE / 2);
|
||||
}
|
||||
if (inset + w - cursor > 0.5) d += wavePath(cursor, inset + w, mid, 0, 0, 1);
|
||||
t.setAttribute("d", d);
|
||||
});
|
||||
|
||||
const stroke = { fill: "none", strokeWidth: STROKE, strokeLinecap: "round" as const };
|
||||
return (
|
||||
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} style={{ display: "block" }}>
|
||||
<path ref={trackRef} stroke={trackColor} {...stroke} />
|
||||
<path ref={activeRef} stroke={color} {...stroke} />
|
||||
<path ref={active2Ref} stroke={color} {...stroke} />
|
||||
{value !== undefined && (
|
||||
<circle cx={width - inset - STOP_SIZE / 2} cy={mid} r={STOP_SIZE / 2} fill={color} />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- circular progress (flat / wavy) ---------- */
|
||||
|
||||
const ROTATION_MS = 1332;
|
||||
const ROTATIONS_PER_CYCLE = 5;
|
||||
const BASE_ROTATION = 286;
|
||||
const JUMP_ROTATION = 290;
|
||||
const ROTATION_OFFSET = (BASE_ROTATION + JUMP_ROTATION) % 360;
|
||||
const circEase = bezier(0.4, 0, 0.2, 1);
|
||||
const CIRC_WAVELENGTH = 20;
|
||||
const CIRC_AMPLITUDE = 1.5;
|
||||
|
||||
function arcPath(
|
||||
cx: number,
|
||||
cy: number,
|
||||
r: number,
|
||||
a0: number,
|
||||
a1: number,
|
||||
amp: number,
|
||||
k: number,
|
||||
phase: number,
|
||||
) {
|
||||
if (a1 - a0 < 0.05) return "";
|
||||
const step = amp > 0 ? 2 : 6;
|
||||
let d = "";
|
||||
for (let a = a0; ; a += step) {
|
||||
const aa = Math.min(a, a1);
|
||||
const rad = (aa * Math.PI) / 180;
|
||||
const rr = r + amp * Math.sin(k * rad + phase);
|
||||
d += (d ? "L" : "M") + (cx + rr * Math.cos(rad)).toFixed(2) + " " + (cy + rr * Math.sin(rad)).toFixed(2);
|
||||
if (aa >= a1) break;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
export function CircularProgress({
|
||||
size,
|
||||
color,
|
||||
trackColor,
|
||||
wavy,
|
||||
value,
|
||||
}: {
|
||||
size: number;
|
||||
color: string;
|
||||
trackColor: string;
|
||||
wavy?: boolean;
|
||||
value?: number;
|
||||
}) {
|
||||
const amp = wavy ? CIRC_AMPLITUDE : 0;
|
||||
const r = size / 2 - STROKE / 2 - amp;
|
||||
const c = size / 2;
|
||||
const k = Math.max(3, Math.round((2 * Math.PI * r) / CIRC_WAVELENGTH));
|
||||
const gapDeg = ((TRACK_GAP + STROKE) / (2 * Math.PI * r)) * 360;
|
||||
const activeRef = useRef<SVGPathElement>(null);
|
||||
const trackRef = useRef<SVGPathElement>(null);
|
||||
const start = useRef(0);
|
||||
|
||||
useFrame((ts) => {
|
||||
if (!start.current) start.current = ts;
|
||||
const ms = ts - start.current;
|
||||
const phase = wavy ? -(ms / 1000) * 2 * Math.PI : 0;
|
||||
const a = activeRef.current;
|
||||
const t = trackRef.current;
|
||||
if (!a || !t) return;
|
||||
|
||||
if (value !== undefined) {
|
||||
const v = Math.min(1, Math.max(0, value));
|
||||
const sweep = 360 * v;
|
||||
const useAmp = v < 0.05 || v > 0.95 ? 0 : amp;
|
||||
a.setAttribute("d", arcPath(c, c, r, -90, -90 + sweep, useAmp, k, phase));
|
||||
const trackFrom = -90 + sweep + (v > 0 ? gapDeg : 0);
|
||||
const trackTo = 270 - (v < 1 ? gapDeg : 0);
|
||||
t.setAttribute("d", v >= 1 ? "" : arcPath(c, c, r, trackFrom, trackTo, 0, k, 0));
|
||||
return;
|
||||
}
|
||||
|
||||
const cycle = ROTATION_MS * ROTATIONS_PER_CYCLE;
|
||||
const inCycle = ms % cycle;
|
||||
const rotIndex = Math.floor(inCycle / ROTATION_MS);
|
||||
const inRot = inCycle % ROTATION_MS;
|
||||
const baseRotation = (BASE_ROTATION * inCycle) / cycle * ROTATIONS_PER_CYCLE;
|
||||
const head = seg(inRot, 0, 666, circEase) * JUMP_ROTATION;
|
||||
const tail = seg(inRot, 666, 1332, circEase) * JUMP_ROTATION;
|
||||
const offset = (rotIndex * ROTATION_OFFSET) % 360;
|
||||
const startAngle = tail + offset + baseRotation - 90;
|
||||
const sweep = Math.max(0.1, head - tail);
|
||||
a.setAttribute("d", arcPath(c, c, r, startAngle, startAngle + sweep, amp, k, phase));
|
||||
const trackFrom = startAngle + sweep + gapDeg;
|
||||
const trackTo = startAngle + 360 - gapDeg;
|
||||
t.setAttribute("d", trackTo - trackFrom > 1 ? arcPath(c, c, r, trackFrom, trackTo, 0, k, 0) : "");
|
||||
});
|
||||
|
||||
const stroke = { fill: "none", strokeWidth: STROKE, strokeLinecap: "round" as const };
|
||||
return (
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ display: "block" }}>
|
||||
<path ref={trackRef} stroke={trackColor} {...stroke} />
|
||||
<path ref={activeRef} stroke={color} {...stroke} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
/** The app mark: one frozen frame of the M3 Expressive loading indicator with the layers glyph inside. */
|
||||
const COOKIE =
|
||||
"M56.3679 6.02002C57.1442 5.3783 57.5324 5.05744 57.8867 4.78656C66.2354 -1.59552 77.7646 -1.59552 86.1133 4.78656C86.4676 5.05744 86.8558 5.3783 87.6321 6.02002C87.9786 6.30648 88.1519 6.44971 88.3233 6.58606C92.2522 9.71203 97.0693 11.4835 102.068 11.6405C102.286 11.6473 102.51 11.6501 102.957 11.6558C103.96 11.6684 104.462 11.6747 104.906 11.6973C115.361 12.2303 124.193 19.7179 126.528 30.0289C126.627 30.4666 126.721 30.9644 126.907 31.9602C126.99 32.4047 127.032 32.627 127.076 32.8427C128.097 37.789 130.661 42.2744 134.39 45.6409C134.552 45.7878 134.722 45.9353 135.062 46.2304C135.822 46.8914 136.202 47.2219 136.528 47.5275C144.198 54.7262 146.2 66.1979 141.429 75.6132C141.227 76.0128 140.981 76.4547 140.49 77.3386C140.271 77.7332 140.162 77.9304 140.059 78.1246C137.694 82.5768 136.804 87.6775 137.519 92.6782C137.55 92.8964 137.586 93.1196 137.658 93.5661C137.82 94.5662 137.901 95.0663 137.956 95.5117C139.252 106.008 133.488 116.096 123.843 120.21C123.434 120.385 122.965 120.564 122.026 120.922C121.608 121.082 121.398 121.162 121.196 121.244C116.552 123.119 112.625 126.448 109.991 130.743C109.876 130.93 109.762 131.125 109.533 131.514C109.021 132.385 108.765 132.821 108.523 133.198C102.839 142.08 92.0048 146.064 81.9992 142.952C81.5745 142.82 81.1011 142.652 80.1544 142.318C79.7318 142.168 79.5205 142.094 79.3133 142.025C74.5631 140.445 69.4369 140.445 64.6867 142.025C64.4795 142.094 64.2682 142.168 63.8456 142.318C62.8989 142.652 62.4255 142.82 62.0008 142.952C51.9952 146.064 41.1613 142.08 35.4766 133.198C35.2353 132.821 34.9791 132.385 34.4669 131.514C34.2382 131.125 34.1239 130.93 34.009 130.743C31.3752 126.448 27.4482 123.119 22.8044 121.244C22.6018 121.162 22.3924 121.082 21.9736 120.922C21.0354 120.564 20.5663 120.385 20.1569 120.21C10.5122 116.096 4.74763 106.008 6.04367 95.5117C6.09868 95.0663 6.17963 94.5662 6.34151 93.5661C6.41377 93.1196 6.4499 92.8964 6.48109 92.6783C7.19603 87.6775 6.30587 82.5768 3.94121 78.1246C3.83807 77.9304 3.72855 77.7332 3.50951 77.3386C3.01883 76.4547 2.77349 76.0128 2.57099 75.6132C-2.19995 66.1979 -0.197931 54.7262 7.47248 47.5275C7.79804 47.2219 8.17819 46.8914 8.93848 46.2304C9.27787 45.9353 9.44757 45.7878 9.61023 45.6409C13.3394 42.2744 15.9025 37.789 16.9235 32.8427C16.9681 32.627 17.0097 32.4047 17.0929 31.9602C17.2793 30.9644 17.3725 30.4666 17.4717 30.0289C19.8069 19.7179 28.6387 12.2303 39.0944 11.6973C39.5382 11.6747 40.0397 11.6684 41.0426 11.6558C41.4903 11.6501 41.7142 11.6473 41.9322 11.6405C46.9307 11.4835 51.7478 9.71203 55.6767 6.58606C55.8481 6.44971 56.0214 6.30648 56.3679 6.02002Z";
|
||||
const LAYERS =
|
||||
"M32 14 16 24l16 10 16-10-16-10zm-11.6 15.4L16 32l16 10 16-10-4.4-2.6L32 36.6l-11.6-7.2zm0 8L16 40l16 10 16-10-4.4-2.6L32 44.6l-11.6-7.2z";
|
||||
|
||||
export function Logo({ size = 32, color, glyph = "#FFFFFF" }: { size?: number; color: string; glyph?: string }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 144 144" aria-hidden="true" style={{ display: "block", flex: "0 0 auto" }}>
|
||||
<path fill={color} d={COOKIE} />
|
||||
<path fill={glyph} transform="translate(72 74) scale(1.35) translate(-32 -32)" d={LAYERS} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Vendored
+1251
File diff suppressed because it is too large
Load Diff
Vendored
+120
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { Palette } from "../lib/tokens";
|
||||
import { LANGS, Lang, t, useLang } from "../lib/i18n";
|
||||
import { IconBtn } from "./ui";
|
||||
import { Icon } from "./M3Node";
|
||||
|
||||
const EASE = [0.2, 0, 0, 1] as const;
|
||||
|
||||
/** An icon button with a small menu anchored to it. `side` is where the menu opens. */
|
||||
export function Popover({
|
||||
p,
|
||||
icon,
|
||||
title,
|
||||
side = "down",
|
||||
size = 40,
|
||||
children,
|
||||
}: {
|
||||
p: Palette;
|
||||
icon: string;
|
||||
title: string;
|
||||
side?: "down" | "right";
|
||||
size?: number;
|
||||
children: (close: () => void) => React.ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
||||
window.addEventListener("pointerdown", onDown);
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", onDown);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
const anchor: React.CSSProperties =
|
||||
side === "down"
|
||||
? { top: size + 8, right: 0, transformOrigin: "top right" }
|
||||
: { left: size + 8, bottom: 0, transformOrigin: "bottom left" };
|
||||
const hidden = side === "down" ? { opacity: 0, y: -6, scale: 0.96 } : { opacity: 0, x: -6, scale: 0.96 };
|
||||
return (
|
||||
<div ref={ref} style={{ position: "relative" }}>
|
||||
<IconBtn icon={icon} p={p} on={open} onClick={() => setOpen((o) => !o)} title={title} size={size} />
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
role="menu"
|
||||
initial={hidden}
|
||||
animate={{ opacity: 1, x: 0, y: 0, scale: 1 }}
|
||||
exit={hidden}
|
||||
transition={{ duration: 0.16, ease: EASE }}
|
||||
style={{
|
||||
position: "absolute",
|
||||
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)",
|
||||
zIndex: 60,
|
||||
...anchor,
|
||||
}}
|
||||
>
|
||||
{children(() => setOpen(false))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LangMenu({ p, onLang, side, size }: { p: Palette; onLang: (l: Lang) => void; side?: "down" | "right"; size?: number }) {
|
||||
const lang = useLang();
|
||||
return (
|
||||
<Popover p={p} icon="translate" title={t("language", lang)} side={side} size={size}>
|
||||
{(close) => (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 140 }}>
|
||||
{LANGS.map((l) => {
|
||||
const on = l.key === lang;
|
||||
return (
|
||||
<button
|
||||
key={l.key}
|
||||
role="menuitemradio"
|
||||
aria-checked={on}
|
||||
onClick={() => {
|
||||
onLang(l.key);
|
||||
close();
|
||||
}}
|
||||
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",
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 18, display: "inline-flex" }}>{on && <Icon name="check" size={18} />}</span>
|
||||
{l.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
Vendored
+534
@@ -0,0 +1,534 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion, useDragControls } from "motion/react";
|
||||
import { CONTRASTS, Contrast, FONTS, Item, KIND_SPEC, NavTab, PALETTES, Palette, SHAPES, ShapeScale, Theme, defaultTabsFor, iconSlotsOf, setIconSlot } from "../lib/tokens";
|
||||
import { ensureFontLoaded } from "../lib/theme";
|
||||
import { KIND_TEXT, LANGS, Lang, t, useLang } from "../lib/i18n";
|
||||
import { IconPicker } from "./IconPicker";
|
||||
import { Icon } from "./M3Node";
|
||||
import { VariantSwatch, variantsOf } from "./Inspector";
|
||||
import { Field, IconBtn, Segmented, Toggle } from "./ui";
|
||||
|
||||
/** Sheet that slides up from the bottom edge; the canvas above stays usable.
|
||||
* Dragging the handle moves the sheet with the finger; a flick or a long pull closes it. */
|
||||
export function BottomSheet({ p, onClose, children }: { p: Palette; onClose: () => void; children: React.ReactNode }) {
|
||||
const lang = useLang();
|
||||
const controls = useDragControls();
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ y: "100%" }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: "100%" }}
|
||||
transition={{ type: "spring", stiffness: 420, damping: 38, mass: 0.8 }}
|
||||
drag="y"
|
||||
dragListener={false}
|
||||
dragControls={controls}
|
||||
dragConstraints={{ top: 0 }}
|
||||
dragElastic={{ top: 0, bottom: 1 }}
|
||||
dragTransition={{ bounceStiffness: 500, bounceDamping: 40 }}
|
||||
onDragEnd={(_, info) => {
|
||||
if (info.offset.y > 90 || info.velocity.y > 600) onClose();
|
||||
}}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
maxHeight: "72%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderTopLeftRadius: 28,
|
||||
borderTopRightRadius: 28,
|
||||
background: p.surfaceContainerLow,
|
||||
boxShadow: "0 -6px 24px rgba(0,0,0,0.16)",
|
||||
zIndex: 60,
|
||||
paddingBottom: "calc(var(--bottom-ui, 0px) + env(safe-area-inset-bottom))",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
onPointerDown={(e) => controls.start(e)}
|
||||
aria-label={t("close", lang)}
|
||||
style={{
|
||||
height: 30,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
cursor: "grab",
|
||||
flex: "0 0 auto",
|
||||
touchAction: "none",
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 32, height: 4, borderRadius: 2, background: p.outlineVariant }} />
|
||||
</button>
|
||||
<div className="no-scrollbar" style={{ overflowY: "auto", padding: "0 14px 16px", minHeight: 0 }}>
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ icon, label, p, children }: { icon: string; label: string; p: Palette; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
marginBottom: 6,
|
||||
color: p.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.4,
|
||||
}}
|
||||
>
|
||||
<Icon name={icon} size={16} />
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The compact phone editor: text, icon, style, state and a one-line note. */
|
||||
export function MobileInspector({
|
||||
item,
|
||||
palette: p,
|
||||
onChange,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
onClose,
|
||||
}: {
|
||||
item: Item;
|
||||
palette: Palette;
|
||||
onChange: (patch: Partial<Item>) => void;
|
||||
onDelete: () => void;
|
||||
onDuplicate: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const lang = useLang();
|
||||
const spec = KIND_SPEC[item.kind];
|
||||
const slots = iconSlotsOf(item).filter((s) => !s.key.startsWith("tab:"));
|
||||
const [slotKey, setSlotKey] = useState(slots[0]?.key ?? "icon");
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
setSlotKey(iconSlotsOf(item)[0]?.key ?? "icon");
|
||||
setPickerOpen(false);
|
||||
}, [item.id]);
|
||||
const activeSlot = slots.find((s) => s.key === slotKey) ?? slots[0];
|
||||
const variants = spec.hasVariant ? variantsOf(item.kind) : [];
|
||||
const tabs: NavTab[] = item.tabs ?? [];
|
||||
const [tabSlot, setTabSlot] = useState<number | null>(null);
|
||||
const setTabCount = (n: number) => {
|
||||
const defaults = defaultTabsFor(item.kind);
|
||||
const next: NavTab[] = [];
|
||||
for (let i = 0; i < n; i++) next.push(tabs[i] ? { ...tabs[i] } : { ...defaults[i % defaults.length] });
|
||||
onChange({ tabs: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
background: p.secondaryContainer,
|
||||
color: p.onSecondaryContainer,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
}}
|
||||
>
|
||||
<Icon name={spec.paletteIcon} size={22} />
|
||||
</div>
|
||||
<span style={{ fontSize: 16, fontWeight: 700, color: p.onSurface, flex: 1 }}>{KIND_TEXT[lang][item.kind]?.noun ?? spec.label}</span>
|
||||
<IconBtn icon="content_copy" p={p} onClick={onDuplicate} title={t("duplicate", lang)} size={44} />
|
||||
<IconBtn icon="delete" p={p} danger onClick={onDelete} title={t("delete", lang)} size={44} />
|
||||
<IconBtn icon="check" p={p} on onClick={onClose} title={t("done", lang)} size={44} />
|
||||
</div>
|
||||
|
||||
{(spec.hasLabel || spec.hasSupporting) && (
|
||||
<Row icon="title" label={t("text", lang)} p={p}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{spec.hasLabel && (
|
||||
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||||
<Field value={item.label} onChange={(label) => onChange({ label })} placeholder={t("label", lang)} p={p} icon="short_text" height={48} />
|
||||
{item.kind === "text" && (
|
||||
<IconBtn icon="format_bold" p={p} size={48} on={!!item.bold} onClick={() => onChange({ bold: !item.bold })} title={t("bold", lang)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{spec.hasSupporting && (
|
||||
<Field
|
||||
value={item.supporting ?? ""}
|
||||
onChange={(supporting) => onChange({ supporting })}
|
||||
placeholder={item.kind === "snackbar" ? t("action", lang) : t("supporting", lang)}
|
||||
p={p}
|
||||
icon="notes"
|
||||
height={48}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{spec.hasTabs && (
|
||||
<Row icon="view_column" label={t("tabs", lang)} p={p}>
|
||||
<Segmented
|
||||
options={(item.kind === "toolbar" ? [2, 3, 4, 5, 6] : [2, 3, 4, 5]).map((n) => ({ key: String(n), label: String(n) }))}
|
||||
value={String(tabs.length)}
|
||||
onChange={(k) => setTabCount(Number(k))}
|
||||
p={p}
|
||||
height={44}
|
||||
/>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 8 }}>
|
||||
{tabs.map((tab, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||||
{item.kind !== "tabs" && (
|
||||
<IconBtn icon={tab.icon || "add"} p={p} size={48} on={tabSlot === i} onClick={() => setTabSlot(tabSlot === i ? null : i)} title={t("changeIcon", lang)} />
|
||||
)}
|
||||
{item.kind !== "toolbar" && (
|
||||
<Field value={tab.label} onChange={(label) => onChange({ tabs: tabs.map((x, j) => (j === i ? { ...x, label } : x)) })} placeholder={t("label", lang)} p={p} height={48} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{tabSlot !== null && tabs[tabSlot] && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<IconPicker value={tabs[tabSlot].icon || null} onChange={(icon) => onChange(setIconSlot(item, `tab:${tabSlot}`, icon))} palette={p} />
|
||||
</div>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{slots.length > 0 && activeSlot && (
|
||||
<Row icon="emoji_symbols" label={t("icon", lang)} p={p}>
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||
{slots.map((s) => {
|
||||
const on = s.key === activeSlot.key && pickerOpen;
|
||||
return (
|
||||
<button
|
||||
key={s.key}
|
||||
onClick={() => {
|
||||
setSlotKey(s.key);
|
||||
setPickerOpen(!(on && pickerOpen));
|
||||
}}
|
||||
title={s.label}
|
||||
className="m3-press"
|
||||
style={{
|
||||
height: 48,
|
||||
minWidth: 48,
|
||||
padding: slots.length > 1 ? "0 14px 0 10px" : 0,
|
||||
borderRadius: 24,
|
||||
border: "none",
|
||||
background: on ? p.primary : p.surfaceContainerHigh,
|
||||
color: on ? p.onPrimary : s.value ? p.onSurface : p.outline,
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<Icon name={s.value ?? "block"} size={24} />
|
||||
{slots.length > 1 && <span>{s.label}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{activeSlot.value && (
|
||||
<button
|
||||
onClick={() => onChange(setIconSlot(item, activeSlot.key, null))}
|
||||
className="m3-press"
|
||||
style={{
|
||||
height: 48,
|
||||
padding: "0 14px 0 10px",
|
||||
borderRadius: 24,
|
||||
border: `1px solid ${p.outline}`,
|
||||
background: "transparent",
|
||||
color: p.onSurfaceVariant,
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<Icon name="close" size={20} />
|
||||
{t("noIcon", lang)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{pickerOpen && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<IconPicker value={activeSlot.value} onChange={(icon) => onChange(setIconSlot(item, activeSlot.key, icon))} palette={p} />
|
||||
</div>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{variants.length > 0 && (
|
||||
<Row icon="palette" label={t("style", lang)} p={p}>
|
||||
<div className="no-scrollbar" style={{ display: "flex", gap: 6, overflowX: "auto", padding: "3px 3px 6px" }}>
|
||||
{variants.map((v) => (
|
||||
<VariantSwatch key={v.key} v={v.key} label={v.label} p={p} on={item.variant === v.key} onClick={() => onChange({ variant: v.key })} />
|
||||
))}
|
||||
</div>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{spec.hasChecked && (
|
||||
<Row icon="tune" label={t("state", lang)} p={p}>
|
||||
<Toggle
|
||||
on={!!item.checked}
|
||||
onChange={(checked) => onChange({ checked })}
|
||||
p={p}
|
||||
icon={item.kind === "chip" ? "check_circle" : item.kind === "box" ? "drag_handle" : "toggle_on"}
|
||||
label={item.kind === "chip" ? t("selected", lang) : item.kind === "box" ? t("handle", lang) : t("on", lang)}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Row icon="bolt" label={t("behavior", lang)} p={p}>
|
||||
<Field value={item.note ?? ""} onChange={(note) => onChange({ note })} placeholder={["button", "fab", "iconButton", "extendedFab"].includes(item.kind) ? t("whenPressed", lang) : t("whatItDoes", lang)} p={p} icon="bolt" height={48} />
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The language list, one row per language. */
|
||||
export function MobileLang({ palette: p, lang, onLang }: { palette: Palette; lang: Lang; onLang: (l: Lang) => void }) {
|
||||
return (
|
||||
<Row icon="translate" label={t("language", lang)} p={p}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
{LANGS.map((l) => {
|
||||
const on = l.key === lang;
|
||||
return (
|
||||
<button
|
||||
key={l.key}
|
||||
onClick={() => onLang(l.key)}
|
||||
aria-pressed={on}
|
||||
className="m3-press"
|
||||
style={{
|
||||
height: 52,
|
||||
padding: "0 16px 0 12px",
|
||||
borderRadius: 16,
|
||||
border: "none",
|
||||
background: on ? p.secondaryContainer : p.surfaceContainerHigh,
|
||||
color: on ? p.onSecondaryContainer : p.onSurface,
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 22, display: "inline-flex" }}>{on && <Icon name="check" size={22} />}</span>
|
||||
{l.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
/** The theme sheet: palette, light / dark, shape, type and motion, sized for thumbs. */
|
||||
export function MobileSettings({
|
||||
palette: p,
|
||||
paletteKey,
|
||||
onPalette,
|
||||
theme,
|
||||
onTheme,
|
||||
}: {
|
||||
palette: Palette;
|
||||
paletteKey: string;
|
||||
onPalette: (key: string) => void;
|
||||
theme: Theme;
|
||||
onTheme: (patch: Partial<Theme>) => void;
|
||||
}) {
|
||||
const lang = useLang();
|
||||
const shapeLabel = (k: ShapeScale) => (k === "square" ? t("shapeSquare", lang) : k === "full" ? t("shapeFull", lang) : t("shapeRounded", lang));
|
||||
return (
|
||||
<div>
|
||||
<Row icon="brightness_6" label={t("brightness", lang)} p={p}>
|
||||
<Segmented<"light" | "dark">
|
||||
options={[
|
||||
{ key: "light", icon: "light_mode", label: t("light", lang) },
|
||||
{ key: "dark", icon: "dark_mode", label: t("dark", lang) },
|
||||
]}
|
||||
value={theme.dark ? "dark" : "light"}
|
||||
onChange={(k) => onTheme({ dark: k === "dark" })}
|
||||
p={p}
|
||||
height={44}
|
||||
/>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Toggle on={theme.bothModes} onChange={(bothModes) => onTheme({ bothModes })} p={p} icon="routine" label={t("bothModes", lang)} grow />
|
||||
</div>
|
||||
</Row>
|
||||
<Row icon="contrast" label={t("contrast", lang)} p={p}>
|
||||
<Segmented<Contrast>
|
||||
options={CONTRASTS.map((c) => ({ key: c.key, label: c.key === "high" ? t("contrastHigh", lang) : c.key === "medium" ? t("contrastMedium", lang) : t("contrastStandard", lang) }))}
|
||||
value={theme.contrast}
|
||||
onChange={(contrast) => onTheme({ contrast })}
|
||||
p={p}
|
||||
height={44}
|
||||
/>
|
||||
</Row>
|
||||
<Row icon="palette" label={t("theme", lang)} p={p}>
|
||||
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", padding: "2px 0" }}>
|
||||
{PALETTES.map((pal) => {
|
||||
const on = pal.key === paletteKey;
|
||||
return (
|
||||
<button
|
||||
key={pal.key}
|
||||
onClick={() => onPalette(pal.key)}
|
||||
title={pal.label}
|
||||
aria-label={pal.label}
|
||||
aria-pressed={on}
|
||||
className="m3-press"
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
border: "none",
|
||||
background: pal.primary,
|
||||
color: pal.onPrimary,
|
||||
cursor: "pointer",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
outline: on ? `3px solid ${p.onSurface}` : "3px solid transparent",
|
||||
outlineOffset: 3,
|
||||
}}
|
||||
>
|
||||
{on && <Icon name="check" size={24} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Row>
|
||||
<Row icon="rounded_corner" label={t("shape", lang)} p={p}>
|
||||
<Segmented<ShapeScale>
|
||||
options={SHAPES.map((s) => ({ key: s.key, icon: s.icon, label: shapeLabel(s.key) }))}
|
||||
value={theme.shape}
|
||||
onChange={(shape) => onTheme({ shape })}
|
||||
p={p}
|
||||
height={44}
|
||||
/>
|
||||
</Row>
|
||||
<Row icon="text_fields" label={t("typography", lang)} p={p}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
{FONTS.map((f) => {
|
||||
const on = theme.font === f.key;
|
||||
ensureFontLoaded(f.key);
|
||||
return (
|
||||
<button
|
||||
key={f.key}
|
||||
onClick={() => onTheme({ font: f.key })}
|
||||
aria-pressed={on}
|
||||
className="m3-press"
|
||||
style={{
|
||||
height: 48,
|
||||
padding: "0 16px 0 12px",
|
||||
borderRadius: 16,
|
||||
border: "none",
|
||||
background: on ? p.secondaryContainer : p.surfaceContainerHigh,
|
||||
color: on ? p.onSecondaryContainer : p.onSurface,
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
textAlign: "left",
|
||||
fontFamily: f.family,
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 22, display: "inline-flex" }}>{on && <Icon name="check" size={22} />}</span>
|
||||
{f.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Toggle on={theme.emphasized} onChange={(emphasized) => onTheme({ emphasized })} p={p} icon="format_bold" label={t("emphasized", lang)} />
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
<Row icon="animation" label={t("motion", lang)} p={p}>
|
||||
<Segmented<"standard" | "expressive">
|
||||
options={[
|
||||
{ key: "standard", label: t("motionStandard", lang) },
|
||||
{ key: "expressive", label: t("motionExpressive", lang) },
|
||||
]}
|
||||
value={theme.motion}
|
||||
onChange={(motion) => onTheme({ motion })}
|
||||
p={p}
|
||||
height={44}
|
||||
/>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Edit / duplicate / delete for the selected part, sized for thumbs. */
|
||||
export function MobileActionBar({
|
||||
p,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
}: {
|
||||
p: Palette;
|
||||
onEdit: () => void;
|
||||
onDuplicate: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const lang = useLang();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 14,
|
||||
bottom: "calc(16px + var(--bottom-ui, 0px) + env(safe-area-inset-bottom))",
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
padding: 6,
|
||||
borderRadius: 32,
|
||||
background: p.surface,
|
||||
boxShadow: "0 6px 18px rgba(0,0,0,0.14)",
|
||||
zIndex: 46,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="m3-press"
|
||||
style={{
|
||||
height: 52,
|
||||
padding: "0 20px 0 16px",
|
||||
borderRadius: 26,
|
||||
border: "none",
|
||||
background: p.primary,
|
||||
color: p.onPrimary,
|
||||
fontSize: 15,
|
||||
fontWeight: 700,
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Icon name="tune" size={22} />
|
||||
{t("edit", lang)}
|
||||
</button>
|
||||
<IconBtn icon="content_copy" p={p} size={52} title={t("duplicate", lang)} onClick={onDuplicate} />
|
||||
<IconBtn icon="delete" p={p} size={52} danger title={t("delete", lang)} onClick={onDelete} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { CATEGORIES, KIND_ORDER, KIND_SPEC, Category, Kind, Palette } from "../lib/tokens";
|
||||
import { Icon } from "./M3Node";
|
||||
import { KIND_TEXT, t, useLang } from "../lib/i18n";
|
||||
import { Field, Section, Tile } from "./ui";
|
||||
|
||||
const CATEGORY_TEXT = {
|
||||
ja: { actions: "操作", navigation: "ナビゲーション", containment: "コンテナ", inputs: "入力", content: "コンテンツ", progress: "進捗" },
|
||||
zh: { actions: "操作", navigation: "导航", containment: "容器", inputs: "输入", content: "内容", progress: "进度" },
|
||||
ko: { actions: "동작", navigation: "내비게이션", containment: "컨테이너", inputs: "입력", content: "콘텐츠", progress: "진행 상태" },
|
||||
} satisfies Record<string, Record<Category, string>>;
|
||||
|
||||
export function PartsPalette({
|
||||
palette: p,
|
||||
favorites,
|
||||
onToggleFavorite,
|
||||
onPartPointerDown,
|
||||
overBin,
|
||||
}: {
|
||||
palette: Palette;
|
||||
favorites: Kind[];
|
||||
onToggleFavorite: (k: Kind) => void;
|
||||
onPartPointerDown: (e: React.PointerEvent, kind: Kind) => void;
|
||||
overBin: boolean;
|
||||
}) {
|
||||
const lang = useLang();
|
||||
const [q, setQ] = useState("");
|
||||
const labelOf = (k: Kind) => lang === "en" ? KIND_SPEC[k].label : KIND_TEXT[lang][k]?.noun ?? KIND_SPEC[k].label;
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const s = q.trim().toLowerCase();
|
||||
if (!s) return KIND_ORDER;
|
||||
return KIND_ORDER.filter((k) => {
|
||||
const sp = KIND_SPEC[k];
|
||||
return labelOf(k).toLowerCase().includes(s) || sp.label.toLowerCase().includes(s) || sp.noun.includes(s) || k.toLowerCase().includes(s);
|
||||
});
|
||||
}, [q, lang]);
|
||||
|
||||
const tile = (k: Kind) => {
|
||||
const s = KIND_SPEC[k];
|
||||
return (
|
||||
<Tile
|
||||
key={k}
|
||||
icon={s.paletteIcon}
|
||||
label={labelOf(k)}
|
||||
p={p}
|
||||
onPointerDown={(e) => onPartPointerDown(e, k)}
|
||||
starred={favorites.includes(k)}
|
||||
onStar={() => onToggleFavorite(k)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const grid: React.CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(78px, 1fr))",
|
||||
gap: 6,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", position: "relative" }}>
|
||||
<div style={{ padding: "12px 12px 8px" }}>
|
||||
<Field value={q} onChange={setQ} placeholder={t("search", lang)} p={p} icon="search" height={40} />
|
||||
</div>
|
||||
|
||||
<div className="no-scrollbar" style={{ flex: 1, overflowY: "auto", overflowX: "hidden", padding: "0 8px" }}>
|
||||
{!q && favorites.length > 0 && (
|
||||
<Section id="fav" icon="star" title={t("favorites", lang)} p={p}>
|
||||
<div style={grid}>{favorites.filter((k) => KIND_SPEC[k]).map(tile)}</div>
|
||||
</Section>
|
||||
)}
|
||||
{q ? (
|
||||
<div style={{ ...grid, padding: "4px 4px 12px" }}>
|
||||
{filtered.map(tile)}
|
||||
{filtered.length === 0 && (
|
||||
<div style={{ gridColumn: "1 / -1", color: p.outline, fontSize: 13, padding: 12, textAlign: "center" }}>
|
||||
<Icon name="search_off" size={28} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
CATEGORIES.map((c) => (
|
||||
<Section key={c.key} id={`cat:${c.key}`} icon={c.icon} title={lang === "en" ? c.label : CATEGORY_TEXT[lang][c.key]} p={p}>
|
||||
<div style={grid}>{KIND_ORDER.filter((k) => KIND_SPEC[k].category === c.key).map(tile)}</div>
|
||||
</Section>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{overBin && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: "rgba(179,38,30,0.10)",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
pointerEvents: "none",
|
||||
color: p.error,
|
||||
borderRadius: "inherit",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 36,
|
||||
background: p.errorContainer,
|
||||
color: p.onErrorContainer,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
boxShadow: "0 4px 14px rgba(0,0,0,0.14)",
|
||||
}}
|
||||
>
|
||||
<Icon name="delete" size={34} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Vendored
+913
@@ -0,0 +1,913 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { buildPrompt } from "../lib/prompt";
|
||||
import { Doc, Palette, Platform, defaultPlatformOf } from "../lib/tokens";
|
||||
import { Icon } from "./M3Node";
|
||||
import { Field, IconBtn, Segmented } from "./ui";
|
||||
import { t, useLang } from "../lib/i18n";
|
||||
|
||||
export function PromptPanel({
|
||||
doc,
|
||||
widths,
|
||||
palette: p,
|
||||
onDoc,
|
||||
}: {
|
||||
doc: Doc;
|
||||
widths: Record<string, number>;
|
||||
palette: Palette;
|
||||
onDoc: (patch: Partial<Doc>) => void;
|
||||
}) {
|
||||
const lang = useLang();
|
||||
const generated = useMemo(() => buildPrompt(doc, widths, undefined, lang), [doc, widths, lang]);
|
||||
const edited = doc.promptEdit !== undefined;
|
||||
const text = edited ? doc.promptEdit! : generated;
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!copied) return;
|
||||
const t = setTimeout(() => setCopied(false), 1400);
|
||||
return () => clearTimeout(t);
|
||||
}, [copied]);
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const projectButton = (icon: string, label: string, onClick: () => void) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="m3-press"
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 42,
|
||||
borderRadius: 21,
|
||||
border: "none",
|
||||
background: p.secondaryContainer,
|
||||
color: p.onSecondaryContainer,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 7,
|
||||
}}
|
||||
>
|
||||
<Icon name={icon} size={19} />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", padding: 12, gap: 10 }}>
|
||||
<Field
|
||||
value={doc.title}
|
||||
onChange={(title) => onDoc({ title })}
|
||||
placeholder={t("appName", lang)}
|
||||
p={p}
|
||||
icon="smartphone"
|
||||
/>
|
||||
<Field
|
||||
value={doc.brief}
|
||||
onChange={(brief) => onDoc({ brief })}
|
||||
placeholder={t("brief", lang)}
|
||||
p={p}
|
||||
icon="lightbulb"
|
||||
multiline
|
||||
rows={3}
|
||||
/>
|
||||
<Segmented<Platform>
|
||||
options={[
|
||||
{ key: "android", icon: "android", label: "Android", title: t("targetAndroid", lang) },
|
||||
{ key: "web", icon: "language", label: "Web", title: t("targetWeb", lang) },
|
||||
]}
|
||||
value={doc.platform ?? defaultPlatformOf(doc.frames, doc.frame)}
|
||||
onChange={(platform) => onDoc({ platform })}
|
||||
p={p}
|
||||
height={40}
|
||||
/>
|
||||
<div style={{ position: "relative", flex: 1, minHeight: 0, display: "flex" }}>
|
||||
<textarea
|
||||
className="no-scrollbar"
|
||||
value={text}
|
||||
onChange={(e) => onDoc({ promptEdit: e.target.value })}
|
||||
spellCheck={false}
|
||||
aria-label={t("prompt", lang)}
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
width: "100%",
|
||||
borderRadius: 18,
|
||||
border: "none",
|
||||
background: p.surfaceContainerLow,
|
||||
padding: edited ? "14px 14px 48px" : 14,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.75,
|
||||
color: p.onSurface,
|
||||
fontFamily: "inherit",
|
||||
resize: "none",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
{edited && (
|
||||
<div style={{ position: "absolute", right: 8, bottom: 8 }}>
|
||||
<IconBtn icon="undo" p={p} size={32} onClick={() => onDoc({ promptEdit: undefined })} title={t("promptReset", lang)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={copy}
|
||||
className="m3-press"
|
||||
style={{
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
border: "none",
|
||||
background: copied ? p.tertiaryContainer : p.primary,
|
||||
color: copied ? p.onTertiaryContainer : p.onPrimary,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
transition: "background 160ms, color 160ms",
|
||||
}}
|
||||
>
|
||||
<Icon name={copied ? "check" : "content_copy"} size={20} />
|
||||
{copied ? t("copied", lang) : t("copyPrompt", lang)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Vendored
+248
@@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { Doc, Palette } from "../lib/tokens";
|
||||
import { shareLink } from "../lib/share";
|
||||
import { Icon } from "./M3Node";
|
||||
import { t, useLang } from "../lib/i18n";
|
||||
|
||||
const BASE = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
|
||||
|
||||
/** the app's own URL without any hash, and the agent guide beside it */
|
||||
const appUrl = () => `${window.location.origin}${BASE}/`;
|
||||
const guideUrl = () => `${appUrl()}agent.md`;
|
||||
|
||||
/** the text button that opens the dialog: a label and a beta badge, no icon; busy while a model drafts */
|
||||
export function ShareButton({ p, onClick, busy }: { p: Palette; onClick: () => void; busy?: boolean }) {
|
||||
const lang = useLang();
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={busy}
|
||||
title={busy ? t("askAiGenerating", lang) : t("askAiTitle", lang)}
|
||||
className="m3-press"
|
||||
style={{
|
||||
height: 40,
|
||||
padding: "0 8px 0 16px",
|
||||
borderRadius: 20,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: busy ? p.onSurfaceVariant : p.onSurface,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: busy ? "default" : "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{busy && <Icon name="hourglass_top" size={18} />}
|
||||
{busy ? t("askAiGenerating", lang) : t("askAi", lang)}
|
||||
{!busy && <Beta p={p} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const Beta = ({ p }: { p: Palette }) => (
|
||||
<span style={{ fontSize: 10, fontWeight: 700, padding: "2px 7px", borderRadius: 8, background: p.tertiaryContainer, color: p.onTertiaryContainer, letterSpacing: 0.4 }}>BETA</span>
|
||||
);
|
||||
|
||||
/** Ask an AI (beta): write the idea and have the author's own model draft it, or copy the
|
||||
* instruction for a coding agent. The title row carries a link to the design as it is now. */
|
||||
export function ShareDialog({
|
||||
p,
|
||||
doc,
|
||||
aiReady,
|
||||
idea,
|
||||
onIdea,
|
||||
open,
|
||||
onClose,
|
||||
onDraft,
|
||||
onSetupAi,
|
||||
}: {
|
||||
p: Palette;
|
||||
doc: Doc;
|
||||
aiReady: boolean;
|
||||
/** what to build, kept by the page so a failed draft does not lose it */
|
||||
idea: string;
|
||||
onIdea: (v: string) => void;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** the idea, for the author's own model to draft */
|
||||
onDraft: (idea: string) => void;
|
||||
/** opens the AI settings so a key can be entered */
|
||||
onSetupAi: () => void;
|
||||
}) {
|
||||
const lang = useLang();
|
||||
const [copied, setCopied] = useState<"ask" | "link" | null>(null);
|
||||
useEffect(() => {
|
||||
if (!copied) return;
|
||||
const id = setTimeout(() => setCopied(null), copied === "ask" ? 4000 : 1400);
|
||||
return () => clearTimeout(id);
|
||||
}, [copied]);
|
||||
/* a fresh dialog starts without a "copied" mark; kept apart from the key handler, whose
|
||||
onClose changes identity on every render of the page */
|
||||
useEffect(() => {
|
||||
if (open) setCopied(null);
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => window.removeEventListener("keydown", onKey, true);
|
||||
}, [open, onClose]);
|
||||
|
||||
const copyAsk = async () => {
|
||||
const text = t("askAiText", lang).replace("{url}", guideUrl()).replace("{idea}", idea.trim() || t("askAiIdeaFallback", lang));
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied("ask");
|
||||
} catch {}
|
||||
};
|
||||
const copyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(await shareLink(doc, appUrl()));
|
||||
setCopied("link");
|
||||
} catch {}
|
||||
};
|
||||
|
||||
/* a connected pair, the way the canvas draws connected buttons: outer corners round, inner ones tight */
|
||||
const pill = (icon: string, label: string, onClick: () => void, opts?: { primary?: boolean; disabled?: boolean; corners?: "left" | "right"; title?: string }) => {
|
||||
const outer = 20;
|
||||
const inner = 8;
|
||||
const radius = opts?.corners === "left" ? `${outer}px ${inner}px ${inner}px ${outer}px` : opts?.corners === "right" ? `${inner}px ${outer}px ${outer}px ${inner}px` : outer;
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={opts?.disabled}
|
||||
title={opts?.title}
|
||||
className="m3-press"
|
||||
style={{
|
||||
height: 40,
|
||||
padding: "0 18px",
|
||||
borderRadius: radius,
|
||||
border: "none",
|
||||
background: opts?.primary ? p.primary : p.secondaryContainer,
|
||||
color: opts?.primary ? p.onPrimary : p.onSecondaryContainer,
|
||||
opacity: opts?.disabled ? 0.5 : 1,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: opts?.disabled ? "default" : "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
flex: "0 0 auto",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<Icon name={icon} size={18} />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
key="scrim"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.16 }}
|
||||
onClick={onClose}
|
||||
style={{ position: "fixed", inset: 0, zIndex: 600, background: "rgba(0,0,0,0.32)", display: "grid", placeItems: "center", padding: 24 }}
|
||||
>
|
||||
<motion.div
|
||||
role="dialog"
|
||||
aria-modal
|
||||
aria-label={t("askAi", lang)}
|
||||
initial={{ scale: 0.92, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.96, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 520, damping: 34, mass: 0.7 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "min(100%, 620px)",
|
||||
padding: 24,
|
||||
borderRadius: 28,
|
||||
background: p.surfaceContainerHigh,
|
||||
color: p.onSurface,
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.18), 0 2px 6px rgba(0,0,0,0.10)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<span style={{ fontSize: 22 }}>{t("askAi", lang)}</span>
|
||||
<Beta p={p} />
|
||||
<span style={{ flex: 1 }} />
|
||||
{pill(copied === "link" ? "check" : "link", copied === "link" ? t("copied", lang) : t("shareLinkCopy", lang), copyLink, { title: t("shareLinkHint", lang) })}
|
||||
</div>
|
||||
<p style={{ margin: 0, fontSize: 13, lineHeight: 1.55, color: p.onSurfaceVariant }}>{t("askAiHint", lang)}</p>
|
||||
<textarea
|
||||
value={idea}
|
||||
onChange={(e) => onIdea(e.target.value)}
|
||||
placeholder={t("askAiIdea", lang)}
|
||||
rows={4}
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 14px",
|
||||
borderRadius: 14,
|
||||
border: "none",
|
||||
background: p.surface,
|
||||
color: p.onSurface,
|
||||
font: "inherit",
|
||||
fontSize: 14,
|
||||
lineHeight: 1.45,
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
resize: "none",
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
{/* the left of the row: where to paste after a copy, or how to unlock drafting */}
|
||||
<span style={{ flex: 1, minWidth: 0, display: "inline-flex", alignItems: "center", gap: 6, fontSize: 13, fontWeight: copied === "ask" ? 600 : 400, color: copied === "ask" ? p.primary : p.onSurfaceVariant }}>
|
||||
{copied === "ask" ? (
|
||||
<>
|
||||
<Icon name="content_paste_go" size={18} />
|
||||
{t("askAiPasted", lang)}
|
||||
</>
|
||||
) : !aiReady ? (
|
||||
t("aiSetupHint", lang)
|
||||
) : null}
|
||||
</span>
|
||||
<div style={{ display: "inline-flex", gap: 3, flex: "0 0 auto" }}>
|
||||
{pill(copied === "ask" ? "check" : "content_copy", copied === "ask" ? t("copied", lang) : t("askAiCopy", lang), copyAsk, { corners: "left", title: t("askAiCopyTitle", lang) })}
|
||||
{aiReady
|
||||
? pill("auto_awesome", t("askAiGenerate", lang), () => onDraft(idea), { primary: true, disabled: !idea.trim(), corners: "right", title: t("askAiGenerateTitle", lang) })
|
||||
: pill("key", t("aiSetup", lang), onSetupAi, { primary: true, corners: "right", title: t("aiSetupTitle", lang) })}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
title={t("closeBtn", lang)}
|
||||
className="m3-press"
|
||||
style={{ height: 40, padding: "0 16px", borderRadius: 20, border: "none", background: "transparent", color: p.primary, fontSize: 14, fontWeight: 600, cursor: "pointer" }}
|
||||
>
|
||||
{t("closeBtn", lang)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
Vendored
+219
@@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { FONTS, Palette, SHAPES, ShapeScale, Theme } from "../lib/tokens";
|
||||
import { ensureFontLoaded } from "../lib/theme";
|
||||
import { Lang, t, useLang } from "../lib/i18n";
|
||||
import { Icon } from "./M3Node";
|
||||
import { Toggle } from "./ui";
|
||||
|
||||
const shapeLabel = (k: ShapeScale, lang: Lang) => (k === "square" ? t("shapeSquare", lang) : k === "full" ? t("shapeFull", lang) : t("shapeRounded", lang));
|
||||
|
||||
/** the corner radius a 28dp default becomes under each scale, for the option art */
|
||||
const previewR = (k: ShapeScale) => (k === "square" ? 6 : k === "full" ? 28 : 16);
|
||||
|
||||
function Hint({ p, children }: { p: Palette; children: React.ReactNode }) {
|
||||
return <div style={{ fontSize: 11, lineHeight: 1.5, color: p.onSurfaceVariant, padding: "0 4px" }}>{children}</div>;
|
||||
}
|
||||
|
||||
function OptionCard({
|
||||
on,
|
||||
onClick,
|
||||
p,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
on: boolean;
|
||||
onClick: () => void;
|
||||
p: Palette;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
aria-pressed={on}
|
||||
className="m3-press"
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "12px 8px 10px",
|
||||
borderRadius: 16,
|
||||
border: "none",
|
||||
background: on ? p.secondaryContainer : p.surfaceContainerLow,
|
||||
color: on ? p.onSecondaryContainer : p.onSurface,
|
||||
cursor: "pointer",
|
||||
outline: on ? `2px solid ${p.primary}` : "2px solid transparent",
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<span style={{ fontSize: 12, fontWeight: 600, maxWidth: "100%", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Three cards, each drawing a button, a FAB and a card at that corner scale. */
|
||||
export function ShapePanel({ p, theme, onChange }: { p: Palette; theme: Theme; onChange: (patch: Partial<Theme>) => void }) {
|
||||
const lang = useLang();
|
||||
return (
|
||||
<div className="no-scrollbar" style={{ height: "100%", overflowY: "auto", padding: "12px 12px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: p.onSurfaceVariant, padding: "0 4px" }}>{t("shapeScale", lang)}</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{SHAPES.map((sh) => {
|
||||
const r = previewR(sh.key);
|
||||
const on = theme.shape === sh.key;
|
||||
return (
|
||||
<button
|
||||
key={sh.key}
|
||||
onClick={() => onChange({ shape: sh.key })}
|
||||
aria-pressed={on}
|
||||
className="m3-press"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
gap: 10,
|
||||
padding: "12px 14px 14px",
|
||||
borderRadius: 16,
|
||||
border: "none",
|
||||
background: on ? p.secondaryContainer : p.surfaceContainerLow,
|
||||
color: on ? p.onSecondaryContainer : p.onSurface,
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 8, width: "100%" }}>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 14, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{shapeLabel(sh.key, lang)}</span>
|
||||
{on && <Icon name="check" size={20} />}
|
||||
</span>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ width: 64, height: 26, borderRadius: r, background: p.primary }} />
|
||||
<span style={{ width: 26, height: 26, borderRadius: Math.round(r * 0.6), background: p.primaryContainer }} />
|
||||
<span style={{ width: 40, height: 26, borderRadius: Math.round(r * 0.7), background: p.surfaceContainerHighest, border: `1px solid ${p.outlineVariant}`, boxSizing: "border-box" }} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Hint p={p}>{t("shapeHint", lang)}</Hint>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Typeface rows rendered in their own face, and the emphasized switch with a live sample. */
|
||||
export function TypePanel({ p, theme, onChange }: { p: Palette; theme: Theme; onChange: (patch: Partial<Theme>) => void }) {
|
||||
const lang = useLang();
|
||||
return (
|
||||
<div className="no-scrollbar" style={{ height: "100%", overflowY: "auto", padding: "12px 12px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: p.onSurfaceVariant, padding: "0 4px" }}>{t("fontFamily", lang)}</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{FONTS.map((f) => {
|
||||
const on = theme.font === f.key;
|
||||
ensureFontLoaded(f.key);
|
||||
return (
|
||||
<button
|
||||
key={f.key}
|
||||
onClick={() => onChange({ font: f.key })}
|
||||
aria-pressed={on}
|
||||
className="m3-press"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
height: 52,
|
||||
padding: "0 14px 0 12px",
|
||||
borderRadius: 16,
|
||||
border: "none",
|
||||
background: on ? p.secondaryContainer : p.surfaceContainerLow,
|
||||
color: on ? p.onSecondaryContainer : p.onSurface,
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
fontFamily: f.family,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 24, fontWeight: 600, width: 36, flex: "0 0 auto" }}>Aa</span>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 14, fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{f.label}</span>
|
||||
{on && <Icon name="check" size={20} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 12, borderRadius: 16, background: p.surfaceContainerLow, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<Toggle on={theme.emphasized} onChange={(emphasized) => onChange({ emphasized })} p={p} icon="format_bold" label={t("emphasized", lang)} grow />
|
||||
<Hint p={p}>{t("emphasizedHint", lang)}</Hint>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Two option cards that replay their own motion when chosen, plus a tap-to-try dot. */
|
||||
export function MotionPanel({ p, theme, onChange }: { p: Palette; theme: Theme; onChange: (patch: Partial<Theme>) => void }) {
|
||||
const lang = useLang();
|
||||
const [tick, setTick] = useState(0);
|
||||
const expressive = theme.motion === "expressive";
|
||||
const transition = expressive
|
||||
? { type: "spring" as const, stiffness: 380, damping: 18, mass: 1 }
|
||||
: { duration: 0.35, ease: [0.2, 0, 0, 1] as const };
|
||||
return (
|
||||
<div className="no-scrollbar" style={{ height: "100%", overflowY: "auto", padding: "12px 12px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: p.onSurfaceVariant, padding: "0 4px" }}>{t("motionScheme", lang)}</div>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
<OptionCard on={!expressive} onClick={() => { onChange({ motion: "standard" }); setTick((n) => n + 1); }} p={p} label={t("motionStandard", lang)}>
|
||||
<svg width="64" height="36" viewBox="0 0 64 36" aria-hidden>
|
||||
<path d="M4 32 C 24 32, 30 4, 60 4" fill="none" stroke={p.primary} strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
</OptionCard>
|
||||
<OptionCard on={expressive} onClick={() => { onChange({ motion: "expressive" }); setTick((n) => n + 1); }} p={p} label={t("motionExpressive", lang)}>
|
||||
<svg width="64" height="36" viewBox="0 0 64 36" aria-hidden>
|
||||
<path d="M4 32 C 16 32, 20 -6, 32 6 S 48 10, 60 4" fill="none" stroke={p.primary} strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
</OptionCard>
|
||||
</div>
|
||||
<Hint p={p}>{t("motionHint", lang)}</Hint>
|
||||
|
||||
<button
|
||||
onClick={() => setTick((n) => n + 1)}
|
||||
className="m3-press"
|
||||
title={t("tryIt", lang)}
|
||||
aria-label={t("tryIt", lang)}
|
||||
style={{
|
||||
position: "relative",
|
||||
height: 96,
|
||||
borderRadius: 16,
|
||||
border: "none",
|
||||
background: p.surfaceContainerLow,
|
||||
cursor: "pointer",
|
||||
overflow: "hidden",
|
||||
padding: 0,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
}}
|
||||
>
|
||||
<motion.span
|
||||
key={`${theme.motion}:${tick}`}
|
||||
initial={{ x: -110, scale: 0.8, rotate: -20 }}
|
||||
animate={{ x: 0, scale: 1, rotate: 0 }}
|
||||
transition={transition}
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: expressive ? 14 : 24,
|
||||
background: p.primary,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: p.onPrimary,
|
||||
}}
|
||||
>
|
||||
<Icon name="arrow_forward" size={24} />
|
||||
</motion.span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Vendored
+419
@@ -0,0 +1,419 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { FrameMode, Palette } from "../lib/tokens";
|
||||
import { IconBtn, Segmented, TidyButton, TidyState } from "./ui";
|
||||
import { ShareButton } from "./ShareMenu";
|
||||
import { Icon } from "./M3Node";
|
||||
import { Popover } from "./Menus";
|
||||
import { t, useLang } from "../lib/i18n";
|
||||
|
||||
export type Mode = "select" | "hand";
|
||||
|
||||
const REPO_URL = "https://github.com/lnkiai/m3e-canvas";
|
||||
|
||||
export function GitHubLink({ p, size = 40 }: { p: Palette; size?: number }) {
|
||||
return (
|
||||
<a
|
||||
href={REPO_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="GitHub"
|
||||
aria-label="GitHub"
|
||||
className="m3-press"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: p.onSurfaceVariant,
|
||||
textDecoration: "none",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
<svg width={Math.round(size * 0.55)} height={Math.round(size * 0.55)} viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function Pill({ p, children }: { p: Palette; children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
padding: 6,
|
||||
borderRadius: 28,
|
||||
background: p.surfaceContainerLow,
|
||||
boxShadow: "0 2px 10px rgba(0,0,0,0.10), 0 0 0 1px rgba(0,0,0,0.04)",
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Toolbar({
|
||||
p,
|
||||
mode,
|
||||
onMode,
|
||||
frame,
|
||||
zoom,
|
||||
onZoom,
|
||||
onFit,
|
||||
canUndo,
|
||||
canRedo,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onClear,
|
||||
onAddFrame,
|
||||
onPreview,
|
||||
rightInset,
|
||||
mobile,
|
||||
onPrompt,
|
||||
onSettings,
|
||||
onLangSheet,
|
||||
tidy,
|
||||
onTidy,
|
||||
note,
|
||||
onSaveProject,
|
||||
onOpenProject,
|
||||
onShare,
|
||||
shareState = "idle",
|
||||
onDraftKeep,
|
||||
onDraftUndo,
|
||||
onDraftSave,
|
||||
quickUndo,
|
||||
}: {
|
||||
p: Palette;
|
||||
mode: Mode;
|
||||
onMode: (m: Mode) => void;
|
||||
frame: FrameMode;
|
||||
onFrame: (f: FrameMode) => void;
|
||||
zoom: number;
|
||||
onZoom: (z: number) => void;
|
||||
onFit: () => void;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onClear: () => void;
|
||||
onAddFrame: () => void;
|
||||
onPreview: () => void;
|
||||
/** width of the open right panel, so the zoom pill slides out of its way */
|
||||
rightInset: number;
|
||||
mobile?: boolean;
|
||||
onPrompt?: () => void;
|
||||
onSettings?: () => void;
|
||||
/** phone: open the language sheet instead of the menu */
|
||||
onLangSheet?: () => void;
|
||||
/** the tidy button for the screen being worked on; absent when no screen is in play */
|
||||
tidy?: TidyState;
|
||||
onTidy?: () => void;
|
||||
/** a short message shown beside the tidy button for a moment */
|
||||
note?: { text: string; icon: string } | null;
|
||||
onSaveProject?: () => void;
|
||||
onOpenProject?: () => void;
|
||||
/** opens the "ask an AI" dialog from the left end of the zoom row */
|
||||
onShare?: () => void;
|
||||
/** busy while a model drafts; review while the draft waits to be kept or undone */
|
||||
shareState?: "idle" | "busy" | "review";
|
||||
onDraftKeep?: () => void;
|
||||
onDraftUndo?: () => void;
|
||||
/** saves the arrived design as a project file, the same as the header's save */
|
||||
onDraftSave?: () => void;
|
||||
/** after a kept draft: the header's undo, shown beside the opener so the previous design stays a tap away */
|
||||
quickUndo?: boolean;
|
||||
}) {
|
||||
const lang = useLang();
|
||||
if (mobile) {
|
||||
const S = 42;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 10,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
pointerEvents: "none",
|
||||
zIndex: 40,
|
||||
padding: "0 8px",
|
||||
}}
|
||||
>
|
||||
<Pill p={p}>
|
||||
<IconBtn icon="undo" p={p} onClick={onUndo} disabled={!canUndo} title={t("undo", lang)} size={S} />
|
||||
<IconBtn icon="redo" p={p} onClick={onRedo} disabled={!canRedo} title={t("redo", lang)} size={S} />
|
||||
<IconBtn icon="translate" p={p} onClick={onLangSheet} title={t("language", lang)} size={S} />
|
||||
<IconBtn icon="palette" p={p} onClick={onSettings} title={t("settings", lang)} size={S} />
|
||||
<GitHubLink p={p} size={S} />
|
||||
<button
|
||||
onClick={onPrompt}
|
||||
title={t("copyPrompt", lang)}
|
||||
className="m3-press"
|
||||
style={{
|
||||
height: S,
|
||||
padding: "0 14px 0 10px",
|
||||
borderRadius: S / 2,
|
||||
border: "none",
|
||||
background: p.primary,
|
||||
color: p.onPrimary,
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<Icon name="auto_awesome" size={22} />
|
||||
{t("prompt", lang)}
|
||||
</button>
|
||||
</Pill>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
right: rightInset + 22,
|
||||
bottom: 22,
|
||||
zIndex: 40,
|
||||
transition: "right 260ms cubic-bezier(0.2, 0, 0, 1)",
|
||||
display: "flex",
|
||||
gap: 10,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div role="status" aria-live="polite" style={{ display: "contents" }}>
|
||||
<AnimatePresence>
|
||||
{note && (
|
||||
<motion.div
|
||||
key="note"
|
||||
initial={{ opacity: 0, x: 8, scale: 0.96 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: 8, scale: 0.96 }}
|
||||
transition={{ duration: 0.18, ease: [0.2, 0, 0, 1] }}
|
||||
style={{
|
||||
height: 40,
|
||||
padding: "0 16px",
|
||||
borderRadius: 20,
|
||||
background: p.inverseSurface,
|
||||
color: p.inverseOnSurface,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
whiteSpace: "nowrap",
|
||||
boxShadow: "0 2px 10px rgba(0,0,0,0.10)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<Icon name={note.icon} size={18} />
|
||||
{note.text}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{tidy && onTidy && (
|
||||
<Pill p={p}>
|
||||
<TidyButton state={tidy} onClick={onTidy} p={p} pill />
|
||||
</Pill>
|
||||
)}
|
||||
<Pill p={p}>
|
||||
<IconBtn
|
||||
icon="remove"
|
||||
p={p}
|
||||
onClick={() => onZoom(zoom / 1.2)}
|
||||
title={t("zoomOut", lang)}
|
||||
size={40}
|
||||
/>
|
||||
<button
|
||||
onClick={onFit}
|
||||
title={t("fit", lang)}
|
||||
className="m3-press"
|
||||
style={{
|
||||
height: 40,
|
||||
minWidth: 56,
|
||||
borderRadius: 20,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: p.onSurface,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{Math.round(zoom * 100)}%
|
||||
</button>
|
||||
<IconBtn
|
||||
icon="add"
|
||||
p={p}
|
||||
onClick={() => onZoom(zoom * 1.2)}
|
||||
title={t("zoomIn", lang)}
|
||||
size={40}
|
||||
/>
|
||||
<IconBtn icon="fit_screen" p={p} onClick={onFit} title={t("fit", lang)} size={40} />
|
||||
</Pill>
|
||||
</div>
|
||||
{onShare && (
|
||||
<div style={{ position: "absolute", left: 22, bottom: 22, zIndex: 40, pointerEvents: "none" }}>
|
||||
<Pill p={p}>
|
||||
{shareState === "review" && onDraftUndo && onDraftKeep ? (
|
||||
/* the draft waits for a verdict: undo sits quietly, keep is the primary action */
|
||||
[
|
||||
{ icon: "undo", title: t("draftUndo", lang), onClick: onDraftUndo, primary: false },
|
||||
{ icon: "check", title: t("draftKeep", lang), onClick: onDraftKeep, primary: true },
|
||||
...(onDraftSave ? [{ icon: "download", title: t("saveProject", lang), onClick: onDraftSave, primary: false }] : []),
|
||||
].map((b) => (
|
||||
<button
|
||||
key={b.icon}
|
||||
onClick={b.onClick}
|
||||
title={b.title}
|
||||
aria-label={b.title}
|
||||
className="m3-press"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
border: "none",
|
||||
background: b.primary ? p.primary : p.surfaceContainerHigh,
|
||||
color: b.primary ? p.onPrimary : p.onSurfaceVariant,
|
||||
cursor: "pointer",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
}}
|
||||
>
|
||||
<Icon name={b.icon} size={22} />
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<ShareButton p={p} onClick={onShare} busy={shareState === "busy"} />
|
||||
{quickUndo && canUndo && <IconBtn icon="undo" p={p} onClick={onUndo} title={t("undo", lang)} size={40} />}
|
||||
</>
|
||||
)}
|
||||
</Pill>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 14,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
gap: 10,
|
||||
pointerEvents: "none",
|
||||
zIndex: 40,
|
||||
flexWrap: "nowrap",
|
||||
padding: "0 72px",
|
||||
}}
|
||||
>
|
||||
<Pill p={p}>
|
||||
<Segmented<Mode>
|
||||
options={[
|
||||
{
|
||||
key: "select",
|
||||
icon: "arrow_selector_tool",
|
||||
title: t("select", lang),
|
||||
},
|
||||
{ key: "hand", icon: "pan_tool", title: t("hand", lang) },
|
||||
]}
|
||||
value={mode}
|
||||
onChange={onMode}
|
||||
p={p}
|
||||
height={40}
|
||||
grow={false}
|
||||
/>
|
||||
</Pill>
|
||||
|
||||
<Pill p={p}>
|
||||
{frame === "phone" && (
|
||||
<IconBtn
|
||||
icon="add_to_photos"
|
||||
p={p}
|
||||
onClick={onAddFrame}
|
||||
title={t("addFrame", lang)}
|
||||
size={40}
|
||||
/>
|
||||
)}
|
||||
<IconBtn
|
||||
icon="play_arrow"
|
||||
p={p}
|
||||
onClick={onPreview}
|
||||
title={t("preview", lang)}
|
||||
size={40}
|
||||
fill
|
||||
/>
|
||||
</Pill>
|
||||
|
||||
<Pill p={p}>
|
||||
<IconBtn
|
||||
icon="undo"
|
||||
p={p}
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
title={t("undo", lang)}
|
||||
size={40}
|
||||
/>
|
||||
<IconBtn
|
||||
icon="redo"
|
||||
p={p}
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
title={t("redo", lang)}
|
||||
size={40}
|
||||
/>
|
||||
<IconBtn
|
||||
icon="delete_sweep"
|
||||
p={p}
|
||||
onClick={onClear}
|
||||
title={t("clearAll", lang)}
|
||||
size={40}
|
||||
/>
|
||||
{onSaveProject && onOpenProject && (
|
||||
<Popover p={p} icon="folder_open" title={t("project", lang)} size={40}>
|
||||
{(close) => (
|
||||
<div style={{ display: "flex", gap: 2 }}>
|
||||
<IconBtn
|
||||
icon="download"
|
||||
p={p}
|
||||
size={44}
|
||||
title={t("saveProject", lang)}
|
||||
onClick={() => {
|
||||
close();
|
||||
onSaveProject();
|
||||
}}
|
||||
/>
|
||||
<IconBtn
|
||||
icon="upload"
|
||||
p={p}
|
||||
size={44}
|
||||
title={t("openProject", lang)}
|
||||
onClick={() => {
|
||||
close();
|
||||
onOpenProject();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Popover>
|
||||
)}
|
||||
</Pill>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Vendored
+887
@@ -0,0 +1,887 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { COLOR_TOKENS, ColorToken, Palette, R_INNER, clamp } from "../lib/tokens";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { COLOR_TOKEN_TEXT, t, useLang } from "../lib/i18n";
|
||||
import { Icon } from "./M3Node";
|
||||
|
||||
export function IconBtn({
|
||||
icon,
|
||||
on,
|
||||
onClick,
|
||||
title,
|
||||
size = 36,
|
||||
p,
|
||||
danger,
|
||||
disabled,
|
||||
fill,
|
||||
}: {
|
||||
icon: string;
|
||||
on?: boolean;
|
||||
onClick?: () => void;
|
||||
title?: string;
|
||||
size?: number;
|
||||
p: Palette;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
fill?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
disabled={disabled}
|
||||
className="m3-press"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
border: "none",
|
||||
background: on ? (danger ? p.errorContainer : p.secondaryContainer) : "transparent",
|
||||
color: disabled
|
||||
? p.outlineVariant
|
||||
: danger
|
||||
? p.error
|
||||
: on
|
||||
? p.onSecondaryContainer
|
||||
: p.onSurfaceVariant,
|
||||
cursor: disabled ? "default" : "pointer",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
<Icon name={icon} size={Math.round(size * 0.58)} fill={fill ?? on} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export type SegOption<K extends string> = { key: K; icon?: string; label?: string; title?: string; /** small marker: this option carries something */ dot?: boolean; /** this option alone takes the spare width */ grow?: boolean; /** an icon-only option that should not shrink to a square */ wide?: boolean };
|
||||
|
||||
/** Connected-button group with the same fused corners as the canvas. */
|
||||
export function Segmented<K extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
p,
|
||||
height = 40,
|
||||
grow = true,
|
||||
}: {
|
||||
options: SegOption<K>[];
|
||||
value: K;
|
||||
onChange: (k: K) => void;
|
||||
p: Palette;
|
||||
height?: number;
|
||||
grow?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 3 }}>
|
||||
{options.map((o, i) => {
|
||||
const on = o.key === value;
|
||||
const first = i === 0;
|
||||
const last = i === options.length - 1;
|
||||
const outer = height / 2;
|
||||
return (
|
||||
<button
|
||||
key={o.key}
|
||||
onClick={() => onChange(o.key)}
|
||||
title={o.title ?? o.label}
|
||||
aria-label={o.title ?? o.label}
|
||||
className="m3-press"
|
||||
style={{
|
||||
flex: (o.grow ?? grow) ? 1 : "0 0 auto",
|
||||
minWidth: o.wide ? height * 1.4 : height,
|
||||
height,
|
||||
padding: o.label ? "0 14px" : 0,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
borderTopLeftRadius: first ? outer : R_INNER,
|
||||
borderBottomLeftRadius: first ? outer : R_INNER,
|
||||
borderTopRightRadius: last ? outer : R_INNER,
|
||||
borderBottomRightRadius: last ? outer : R_INNER,
|
||||
background: on ? p.primary : p.surfaceContainerHigh,
|
||||
color: on ? p.onPrimary : p.onSurfaceVariant,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: on ? 600 : 500,
|
||||
transition: "background 120ms, color 120ms, border-radius 160ms",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{o.icon && <Icon name={o.icon} size={Math.round(height * 0.5)} fill={on} />}
|
||||
{o.label && <span>{o.label}</span>}
|
||||
{o.dot && (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 5,
|
||||
right: 7,
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
background: on ? p.onPrimary : p.primary,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
p,
|
||||
icon,
|
||||
multiline,
|
||||
rows = 3,
|
||||
height = 44,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
p: Palette;
|
||||
icon?: string;
|
||||
multiline?: boolean;
|
||||
rows?: number;
|
||||
height?: number;
|
||||
}) {
|
||||
const lang = useLang();
|
||||
const filled = value.length > 0;
|
||||
const base: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: multiline ? `12px ${filled ? 40 : 14}px 12px ${icon ? 42 : 14}px` : `0 ${filled ? 40 : 14}px 0 ${icon ? 42 : 14}px`,
|
||||
borderRadius: multiline ? 18 : height / 2,
|
||||
border: "none",
|
||||
background: p.surfaceContainerHigh,
|
||||
color: p.onSurface,
|
||||
fontSize: 14,
|
||||
lineHeight: multiline ? 1.55 : undefined,
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
fontFamily: "inherit",
|
||||
resize: "none",
|
||||
};
|
||||
return (
|
||||
<div style={{ position: "relative", width: "100%" }}>
|
||||
{icon && (
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 12,
|
||||
top: multiline ? 12 : (height - 20) / 2,
|
||||
color: p.onSurfaceVariant,
|
||||
pointerEvents: "none",
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
<Icon name={icon} size={20} />
|
||||
</span>
|
||||
)}
|
||||
{multiline ? (
|
||||
<textarea
|
||||
value={value}
|
||||
rows={rows}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
style={base}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
style={{ ...base, height }}
|
||||
/>
|
||||
)}
|
||||
{filled && (
|
||||
<button
|
||||
onClick={() => onChange("")}
|
||||
title={t("clear", lang)}
|
||||
aria-label={t("clear", lang)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 6,
|
||||
top: multiline ? 8 : (height - 30) / 2,
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: p.onSurfaceVariant,
|
||||
cursor: "pointer",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
}}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Slider with a typed-in number beside it. The slider moves in `step`s; the
|
||||
* field accepts any whole number and is clamped to min..max when it commits. */
|
||||
/** A rectangle whose top or bottom corners are rounded: the corner-radius slider icons. */
|
||||
/** a pair of corners on one side, or a single corner */
|
||||
export function CornerIcon({ side, size = 20 }: { side: "top" | "bottom" | "left" | "right" | "tl" | "tr" | "bl" | "br"; size?: number }) {
|
||||
const PATHS: Record<typeof side, string> = {
|
||||
top: "M4 17 V9 a5 5 0 0 1 5 -5 h6 a5 5 0 0 1 5 5 v8",
|
||||
bottom: "M4 3 v8 a5 5 0 0 0 5 5 h6 a5 5 0 0 0 5 -5 v-8",
|
||||
left: "M17 4 H9 a5 5 0 0 0 -5 5 v6 a5 5 0 0 0 5 5 h8",
|
||||
right: "M7 4 h8 a5 5 0 0 1 5 5 v6 a5 5 0 0 1 -5 5 h-8",
|
||||
tl: "M5 20 V11 a6 6 0 0 1 6 -6 H20",
|
||||
tr: "M4 5 H13 a6 6 0 0 1 6 6 V20",
|
||||
bl: "M5 4 V13 a6 6 0 0 0 6 6 H20",
|
||||
br: "M20 4 V13 a6 6 0 0 1 -6 6 H4",
|
||||
};
|
||||
const d = PATHS[side];
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" aria-hidden>
|
||||
<path d={d} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function Slider({
|
||||
icon = "tune",
|
||||
iconNode,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
onChange,
|
||||
p,
|
||||
unit = "",
|
||||
title,
|
||||
}: {
|
||||
icon?: string;
|
||||
/** custom glyph shown instead of the Material Symbol */
|
||||
iconNode?: React.ReactNode;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
onChange: (v: number) => void;
|
||||
p: Palette;
|
||||
unit?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
const [text, setText] = useState(String(value));
|
||||
const [editing, setEditing] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!editing) setText(String(value));
|
||||
}, [value, editing]);
|
||||
const commit = () => {
|
||||
const n = Math.round(Number(text));
|
||||
if (Number.isFinite(n) && text.trim() !== "") onChange(clamp(n, min, max));
|
||||
setEditing(false);
|
||||
setText(String(value));
|
||||
};
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<span title={title} style={{ color: p.onSurfaceVariant, lineHeight: 1, flex: "0 0 auto", display: "inline-flex" }}>
|
||||
{iconNode ?? <Icon name={icon} size={20} />}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
className="m3-range"
|
||||
aria-label={title}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
style={{ "--track": p.secondaryContainer, "--thumb": p.primary } as React.CSSProperties}
|
||||
/>
|
||||
<span style={{ position: "relative", flex: "0 0 auto", display: "inline-flex", alignItems: "center" }}>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
aria-label={title}
|
||||
min={min}
|
||||
max={max}
|
||||
value={editing ? text : String(value)}
|
||||
onFocus={(e) => {
|
||||
setEditing(true);
|
||||
setText(String(value));
|
||||
e.currentTarget.select();
|
||||
}}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
// apply as you type once the number is already in range, so the canvas follows
|
||||
const n = Math.round(Number(e.target.value));
|
||||
if (e.target.value.trim() !== "" && Number.isFinite(n) && n >= min && n <= max) onChange(n);
|
||||
}}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") e.currentTarget.blur();
|
||||
if (e.key === "Escape") {
|
||||
setText(String(value));
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
className="m3-number"
|
||||
style={{
|
||||
width: unit ? 56 : 52,
|
||||
height: 30,
|
||||
padding: unit ? "0 18px 0 6px" : "0 6px",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background: p.surfaceContainerHigh,
|
||||
color: p.onSurface,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
textAlign: "right",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
fontFamily: "inherit",
|
||||
outline: editing ? `2px solid ${p.primary}` : "none",
|
||||
outlineOffset: -1,
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
{unit && (
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 6,
|
||||
fontSize: 11,
|
||||
color: p.onSurfaceVariant,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{unit}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Quick picks for a size: the values the frame is built from (screen width,
|
||||
* content width, half a row) or a component's standard sizes. */
|
||||
export function SizePresets({
|
||||
values,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
onChange,
|
||||
p,
|
||||
labelOf,
|
||||
}: {
|
||||
values: number[];
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
onChange: (v: number) => void;
|
||||
p: Palette;
|
||||
/** optional hover text for a value, e.g. "Screen width" for 412 */
|
||||
labelOf?: (v: number) => string | undefined;
|
||||
}) {
|
||||
const shown = values.filter((v) => v >= min && v <= max);
|
||||
if (shown.length === 0) return null;
|
||||
return (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, paddingLeft: 30 }}>
|
||||
{shown.map((v) => {
|
||||
const on = v === value;
|
||||
const label = labelOf?.(v);
|
||||
return (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => onChange(v)}
|
||||
title={label}
|
||||
aria-label={label ? `${label}: ${v}` : String(v)}
|
||||
aria-pressed={on}
|
||||
className="m3-press"
|
||||
style={{
|
||||
height: 28,
|
||||
padding: "0 10px",
|
||||
borderRadius: 14,
|
||||
border: on ? "1px solid transparent" : `1px solid ${p.outlineVariant}`,
|
||||
background: on ? p.secondaryContainer : "transparent",
|
||||
color: on ? p.onSecondaryContainer : p.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Toggle({
|
||||
on,
|
||||
onChange,
|
||||
p,
|
||||
icon,
|
||||
label,
|
||||
grow,
|
||||
}: {
|
||||
on: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
p: Palette;
|
||||
icon?: string;
|
||||
label?: string;
|
||||
/** fill the row and push the switch to the trailing edge */
|
||||
grow?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onChange(!on)}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
aria-pressed={on}
|
||||
style={{
|
||||
display: grow ? "flex" : "inline-flex",
|
||||
width: grow ? "100%" : undefined,
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
color: p.onSurfaceVariant,
|
||||
}}
|
||||
>
|
||||
{icon && <Icon name={icon} size={20} />}
|
||||
{label && <span style={{ fontSize: 13, color: p.onSurface, flex: grow ? 1 : undefined, textAlign: "left" }}>{label}</span>}
|
||||
<span
|
||||
style={{
|
||||
position: "relative",
|
||||
width: 44,
|
||||
height: 26,
|
||||
borderRadius: 13,
|
||||
background: on ? p.primary : p.surfaceContainerHighest,
|
||||
border: on ? "2px solid transparent" : `2px solid ${p.outline}`,
|
||||
boxSizing: "border-box",
|
||||
transition: "background 160ms",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: on ? 20 : 3,
|
||||
width: on ? 18 : 12,
|
||||
height: on ? 18 : 12,
|
||||
marginTop: on ? -9 : -6,
|
||||
borderRadius: 9,
|
||||
background: on ? p.onPrimary : p.outline,
|
||||
transition: "left 160ms, width 160ms, height 160ms, margin 160ms",
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Collapsible section; collapsed state is remembered per key. */
|
||||
export function Section({
|
||||
id,
|
||||
icon,
|
||||
title,
|
||||
p,
|
||||
children,
|
||||
right,
|
||||
defaultOpen = true,
|
||||
}: {
|
||||
id: string;
|
||||
icon: string;
|
||||
title: string;
|
||||
p: Palette;
|
||||
children: React.ReactNode;
|
||||
right?: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
useEffect(() => {
|
||||
try {
|
||||
const v = localStorage.getItem(`m3e:sec:${id}`);
|
||||
if (v !== null) setOpen(v === "1");
|
||||
} catch {}
|
||||
}, [id]);
|
||||
const toggle = () => {
|
||||
setOpen((o) => {
|
||||
try {
|
||||
localStorage.setItem(`m3e:sec:${id}`, o ? "0" : "1");
|
||||
} catch {}
|
||||
return !o;
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, height: 36 }}>
|
||||
<button
|
||||
onClick={toggle}
|
||||
aria-expanded={open}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
height: 36,
|
||||
padding: "0 6px",
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: p.onSurfaceVariant,
|
||||
cursor: "pointer",
|
||||
borderRadius: 12,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.4,
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<Icon name={icon} size={18} />
|
||||
<span style={{ flex: 1 }}>{title}</span>
|
||||
<Icon name={open ? "expand_less" : "expand_more"} size={18} />
|
||||
</button>
|
||||
{right}
|
||||
</div>
|
||||
{open && <div style={{ padding: "6px 4px 14px" }}>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Tile({
|
||||
icon,
|
||||
label,
|
||||
p,
|
||||
onPointerDown,
|
||||
onClick,
|
||||
starred,
|
||||
onStar,
|
||||
active,
|
||||
compact,
|
||||
}: {
|
||||
icon: string;
|
||||
label: string;
|
||||
p: Palette;
|
||||
onPointerDown?: (e: React.PointerEvent) => void;
|
||||
onClick?: () => void;
|
||||
starred?: boolean;
|
||||
onStar?: () => void;
|
||||
active?: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const lang = useLang();
|
||||
return (
|
||||
<div
|
||||
className="m3-tile"
|
||||
onPointerDown={onPointerDown}
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
flexDirection: compact ? "row" : "column",
|
||||
alignItems: "center",
|
||||
justifyContent: compact ? "flex-start" : "center",
|
||||
gap: compact ? 10 : 6,
|
||||
padding: compact ? "8px 12px" : "12px 6px 10px",
|
||||
borderRadius: 16,
|
||||
background: active ? p.secondaryContainer : p.surfaceContainerLow,
|
||||
color: active ? p.onSecondaryContainer : p.onSurface,
|
||||
cursor: onPointerDown ? "grab" : "pointer",
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
minHeight: compact ? 40 : 72,
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<Icon name={icon} size={compact ? 20 : 26} color={active ? p.onSecondaryContainer : p.primary} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.2,
|
||||
textAlign: "center",
|
||||
color: active ? p.onSecondaryContainer : p.onSurfaceVariant,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{onStar && (
|
||||
<button
|
||||
className="m3-star"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStar();
|
||||
}}
|
||||
title={starred ? t("removeFavorite", lang) : t("addFavorite", lang)}
|
||||
aria-label={starred ? t("removeFavorite", lang) : t("addFavorite", lang)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 4,
|
||||
right: 4,
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: starred ? p.primary : p.outline,
|
||||
cursor: "pointer",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
opacity: starred ? 1 : undefined,
|
||||
}}
|
||||
>
|
||||
<Icon name="star" size={16} fill={starred} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** palette-role swatches; the dot shows the real color of the current theme */
|
||||
export function TokenChips({
|
||||
value,
|
||||
onChange,
|
||||
p,
|
||||
none,
|
||||
noneOn,
|
||||
onNone,
|
||||
}: {
|
||||
value: ColorToken;
|
||||
onChange: (t: ColorToken) => void;
|
||||
p: Palette;
|
||||
/** offer a "no background" chip */
|
||||
none?: boolean;
|
||||
noneOn?: boolean;
|
||||
onNone?: () => void;
|
||||
}) {
|
||||
const lang = useLang();
|
||||
return (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
|
||||
{none && (
|
||||
<button
|
||||
onClick={onNone}
|
||||
title={t("noBackground", lang)}
|
||||
aria-label={t("noBackground", lang)}
|
||||
aria-pressed={noneOn}
|
||||
className="m3-press"
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
border: `1px solid ${p.outlineVariant}`,
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
background: "transparent",
|
||||
color: p.onSurfaceVariant,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
outline: noneOn ? `2px solid ${p.primary}` : "2px solid transparent",
|
||||
outlineOffset: 2,
|
||||
}}
|
||||
>
|
||||
<Icon name="block" size={16} />
|
||||
</button>
|
||||
)}
|
||||
{COLOR_TOKENS.map((t) => {
|
||||
const on = !noneOn && t.key === value;
|
||||
const label = lang === "en" ? t.label : COLOR_TOKEN_TEXT[lang][t.key];
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => onChange(t.key)}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
aria-pressed={on}
|
||||
className="m3-press"
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
border: `1px solid ${p.outlineVariant}`,
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
background: p[t.key],
|
||||
outline: on ? `2px solid ${p.primary}` : "2px solid transparent",
|
||||
outlineOffset: 2,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** M3 basic dialog for a destructive confirmation. */
|
||||
/** A run of buttons fused like the canvas's connected buttons: round outside, small corners where they meet. */
|
||||
export function ButtonRun({ children }: { children: React.ReactNode }) {
|
||||
return <div className="m3-run" style={{ display: "flex", gap: 3 }}>{children}</div>;
|
||||
}
|
||||
|
||||
export type TidyState = "tidy" | "undo" | "done";
|
||||
|
||||
/** One button that reads as "Tidy", turns into "Undo tidy" right after, and is
|
||||
* disabled while the screen is already tidy. */
|
||||
export function TidyButton({ state, onClick, p, pill }: { state: TidyState; onClick: () => void; p: Palette; /** the toolbar version next to the zoom pill */ pill?: boolean }) {
|
||||
const lang = useLang();
|
||||
const done = state === "done";
|
||||
const label = state === "undo" ? t("tidyUndo", lang) : state === "done" ? t("tidyDone", lang) : t("tidy", lang);
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={done}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className="m3-press"
|
||||
style={{
|
||||
width: pill ? (done ? 40 : undefined) : "100%",
|
||||
height: pill ? 40 : 44,
|
||||
padding: done ? 0 : pill ? "0 16px 0 12px" : "0 16px",
|
||||
borderRadius: pill ? 20 : 22,
|
||||
border: "none",
|
||||
background: done ? "transparent" : state === "undo" ? p.tertiaryContainer : p.secondaryContainer,
|
||||
color: done ? p.onSurfaceVariant : state === "undo" ? p.onTertiaryContainer : p.onSecondaryContainer,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: done ? "default" : "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
opacity: done ? 0.7 : 1,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<Icon name={state === "undo" ? "undo" : state === "done" ? "check" : "align_space_even"} size={done ? 22 : 20} />
|
||||
{!done && label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
body,
|
||||
icon = "delete_sweep",
|
||||
p,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
body: string;
|
||||
icon?: string;
|
||||
p: Palette;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const lang = useLang();
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onCancel();
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
e.stopPropagation();
|
||||
onConfirm();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => window.removeEventListener("keydown", onKey, true);
|
||||
}, [open, onCancel, onConfirm]);
|
||||
const btn = (label: string, primary: boolean, onClick: () => void) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="m3-press"
|
||||
style={{
|
||||
height: 40,
|
||||
padding: "0 16px",
|
||||
borderRadius: 20,
|
||||
border: "none",
|
||||
background: primary ? p.primary : "transparent",
|
||||
color: primary ? p.onPrimary : p.primary,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
key="scrim"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.16 }}
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 600,
|
||||
background: "rgba(0,0,0,0.32)",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
role="alertdialog"
|
||||
aria-modal
|
||||
aria-label={title}
|
||||
initial={{ scale: 0.92, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.96, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 520, damping: 34, mass: 0.7 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "min(100%, 340px)",
|
||||
padding: 24,
|
||||
borderRadius: 28,
|
||||
background: p.surfaceContainerHigh,
|
||||
color: p.onSurface,
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.18), 0 2px 6px rgba(0,0,0,0.10)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: "center", color: p.error }}>
|
||||
<Icon name={icon} size={28} />
|
||||
</div>
|
||||
<div style={{ fontSize: 22, textAlign: "center" }}>{title}</div>
|
||||
<div style={{ fontSize: 14, lineHeight: 1.5, color: p.onSurfaceVariant }}>{body}</div>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
||||
{btn(t("cancel", lang), false, onCancel)}
|
||||
{btn(t("ok", lang), true, onConfirm)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user