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>
888 lines
25 KiB
TypeScript
888 lines
25 KiB
TypeScript
"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>
|
|
);
|
|
}
|