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:
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
Executable
+113
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Re-vendor the M3E Canvas editor from upstream.
|
||||||
|
#
|
||||||
|
# ./scripts/vendor.sh [<git-ref>] default: main
|
||||||
|
#
|
||||||
|
# ── Why a script rather than a copy ──
|
||||||
|
#
|
||||||
|
# The vendored copy is NOT byte-identical to upstream, and it cannot be. Upstream imports through a
|
||||||
|
# `@/` path alias, and this platform already binds `@/*` to `src/apps/officer-web` and
|
||||||
|
# `@/components/*` to its own shared components. Left alone, `import { M3Node } from
|
||||||
|
# "@/components/M3Node"` inside the vendored editor would silently resolve to Officer's components
|
||||||
|
# directory — a wrong module rather than a build error, which is the worst way to be broken.
|
||||||
|
#
|
||||||
|
# So every import is rewritten to a relative path. That transformation is MECHANICAL and lives here,
|
||||||
|
# which is what keeps the copy reviewable: `git diff` after a re-vendor shows upstream's real changes,
|
||||||
|
# because the rewrite produces the same output for the same input every time.
|
||||||
|
#
|
||||||
|
# Nothing else is edited. Officer's own changes live outside `web/vendor/`, reached through
|
||||||
|
# `web/adapter.ts`.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REF="${1:-main}"
|
||||||
|
UPSTREAM="https://github.com/lnkiai/m3e-canvas.git"
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
DEST="$HERE/web/vendor"
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$WORK"' EXIT
|
||||||
|
|
||||||
|
echo "cloning $UPSTREAM @ $REF"
|
||||||
|
git clone -q --depth 1 --branch "$REF" "$UPSTREAM" "$WORK/src"
|
||||||
|
SHA="$(git -C "$WORK/src" rev-parse HEAD)"
|
||||||
|
WHEN="$(git -C "$WORK/src" log -1 --format=%ad --date=short)"
|
||||||
|
|
||||||
|
rm -rf "$DEST"
|
||||||
|
mkdir -p "$DEST"
|
||||||
|
|
||||||
|
# lib/ and components/ only. `app/` is upstream's Next.js route and is deliberately NOT taken:
|
||||||
|
# app/page.tsx becomes web/vendor/Canvas.tsx below, and app/layout.tsx has no meaning here.
|
||||||
|
cp -r "$WORK/src/lib" "$DEST/lib"
|
||||||
|
cp -r "$WORK/src/components" "$DEST/components"
|
||||||
|
cp "$WORK/src/app/page.tsx" "$DEST/Canvas.tsx"
|
||||||
|
cp "$WORK/src/app/globals.css" "$DEST/globals.css"
|
||||||
|
cp "$WORK/src/LICENSE" "$DEST/LICENSE"
|
||||||
|
|
||||||
|
# lib/ai.ts and components/AiPanel.tsx ARE vendored, despite being the parts Officer least needs.
|
||||||
|
#
|
||||||
|
# The first attempt deleted them — they keep model-provider API keys in localStorage and call the
|
||||||
|
# provider from the browser, which Officer has no use for and would not do that way. But Canvas.tsx
|
||||||
|
# references them in about a dozen places, so deleting meant editing vendored code, and the rule one
|
||||||
|
# paragraph up says not to. A faithful copy that carries an unused module beats a doctored one that
|
||||||
|
# cannot be re-vendored.
|
||||||
|
#
|
||||||
|
# They stay inert: the panel does nothing without a key, and Officer's shell does not surface the
|
||||||
|
# entry point. See ../README.md, "What we do not use".
|
||||||
|
|
||||||
|
# ── the mechanical rewrite ──
|
||||||
|
# `@/lib/x` -> relative path to vendor/lib/x
|
||||||
|
# `@/components/x` -> relative path to vendor/components/x
|
||||||
|
python3 - "$DEST" <<'PY'
|
||||||
|
import os, re, sys
|
||||||
|
dest = sys.argv[1]
|
||||||
|
for root, _, files in os.walk(dest):
|
||||||
|
for name in files:
|
||||||
|
if not name.endswith((".ts", ".tsx")):
|
||||||
|
continue
|
||||||
|
path = os.path.join(root, name)
|
||||||
|
depth = os.path.relpath(root, dest)
|
||||||
|
prefix = "./" if depth == "." else "../" * len(depth.split(os.sep))
|
||||||
|
src = open(path, encoding="utf-8").read()
|
||||||
|
out = re.sub(r'(["\'])@/(lib|components)/', lambda m: f"{m.group(1)}{prefix}{m.group(2)}/", src)
|
||||||
|
if out != src:
|
||||||
|
open(path, "w", encoding="utf-8").write(out)
|
||||||
|
PY
|
||||||
|
|
||||||
|
cat > "$DEST/PROVENANCE.md" <<PROV
|
||||||
|
# Vendored from lnkiai/m3e-canvas
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| upstream | $UPSTREAM |
|
||||||
|
| ref | \`$REF\` |
|
||||||
|
| commit | \`$SHA\` |
|
||||||
|
| dated | $WHEN |
|
||||||
|
| licence | MIT — see \`LICENSE\` beside this file |
|
||||||
|
| vendored | $(date -u +%Y-%m-%d) by \`scripts/vendor.sh\` |
|
||||||
|
|
||||||
|
**Do not edit anything in this directory.** Officer's changes live outside it, reached through
|
||||||
|
\`web/adapter.ts\`. Editing here makes the next \`vendor.sh\` run either clobber the change or
|
||||||
|
produce a diff nobody can read.
|
||||||
|
|
||||||
|
## What was taken, and what was not
|
||||||
|
|
||||||
|
Taken: \`lib/\`, \`components/\`, upstream's \`app/page.tsx\` as \`Canvas.tsx\`, and \`globals.css\`.
|
||||||
|
|
||||||
|
Not taken: \`app/layout.tsx\` — a Next.js route shell with no meaning here.
|
||||||
|
|
||||||
|
\`lib/ai.ts\` and \`components/AiPanel.tsx\` ARE taken even though Officer does not use them. They
|
||||||
|
keep model-provider API keys in \`localStorage\`, which is not how anything here should work — but
|
||||||
|
\`Canvas.tsx\` references them throughout, so removing them would mean editing vendored code. A
|
||||||
|
faithful copy carrying an unused module beats a doctored one that cannot be re-vendored. They are
|
||||||
|
inert without a key, and the shell does not surface them.
|
||||||
|
|
||||||
|
## The one edit \`vendor.sh\` makes
|
||||||
|
|
||||||
|
Every \`@/lib/…\` and \`@/components/…\` import is rewritten to a relative path. 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 — silent breakage.
|
||||||
|
|
||||||
|
The rewrite is deterministic, so a re-vendor produces a readable diff of upstream's real changes.
|
||||||
|
PROV
|
||||||
|
|
||||||
|
echo "vendored $SHA ($WHEN) -> web/vendor/"
|
||||||
|
echo " $(find "$DEST" -name '*.ts' -o -name '*.tsx' | wc -l) source files"
|
||||||
Vendored
+3828
File diff suppressed because it is too large
Load Diff
Vendored
+21
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 lnkiai
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
Vendored
+34
@@ -0,0 +1,34 @@
|
|||||||
|
# Vendored from lnkiai/m3e-canvas
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| upstream | https://github.com/lnkiai/m3e-canvas.git |
|
||||||
|
| ref | `main` |
|
||||||
|
| commit | `5c83ac6f437153b4df4d2348c4f94c4ff71b864a` |
|
||||||
|
| dated | 2026-09-05 |
|
||||||
|
| licence | MIT — see `LICENSE` beside this file |
|
||||||
|
| vendored | 2026-09-05 by `scripts/vendor.sh` |
|
||||||
|
|
||||||
|
**Do not edit anything in this directory.** Officer's changes live outside it, reached through
|
||||||
|
`web/adapter.ts`. Editing here makes the next `vendor.sh` run either clobber the change or
|
||||||
|
produce a diff nobody can read.
|
||||||
|
|
||||||
|
## What was taken, and what was not
|
||||||
|
|
||||||
|
Taken: `lib/`, `components/`, upstream's `app/page.tsx` as `Canvas.tsx`, and `globals.css`.
|
||||||
|
|
||||||
|
Not taken: `app/layout.tsx` — a Next.js route shell with no meaning here.
|
||||||
|
|
||||||
|
`lib/ai.ts` and `components/AiPanel.tsx` ARE taken even though Officer does not use them. They
|
||||||
|
keep model-provider API keys in `localStorage`, which is not how anything here should work — but
|
||||||
|
`Canvas.tsx` references them throughout, so removing them would mean editing vendored code. A
|
||||||
|
faithful copy carrying an unused module beats a doctored one that cannot be re-vendored. They are
|
||||||
|
inert without a key, and the shell does not surface them.
|
||||||
|
|
||||||
|
## The one edit `vendor.sh` makes
|
||||||
|
|
||||||
|
Every `@/lib/…` and `@/components/…` import is rewritten to a relative path. 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 — silent breakage.
|
||||||
|
|
||||||
|
The rewrite is deterministic, so a re-vendor produces a readable diff of upstream's real changes.
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+197
@@ -0,0 +1,197 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
/* in-app browsers (X, Instagram, LINE) may not know dvh; fall back to vh
|
||||||
|
and let JS override with the measured visual viewport when available */
|
||||||
|
.app-root {
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
height: var(--app-h, 100dvh);
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1d1b20;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Material Symbols Rounded (variable font) */
|
||||||
|
.msr {
|
||||||
|
font-family: "Material Symbols Rounded";
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: normal;
|
||||||
|
text-transform: none;
|
||||||
|
display: inline-block;
|
||||||
|
white-space: nowrap;
|
||||||
|
word-wrap: normal;
|
||||||
|
direction: ltr;
|
||||||
|
-webkit-font-feature-settings: "liga";
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.msr[data-fill="1"] {
|
||||||
|
font-variation-settings: "FILL" 1, "wght" 400, "GRAD" 0, "opsz" 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-scrollbar {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--sb, #d6cfe0) transparent;
|
||||||
|
}
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
.no-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--sb, #d6cfe0);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.no-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* press feedback shared by toolbar and inspector buttons */
|
||||||
|
.m3-press {
|
||||||
|
transition: transform 120ms cubic-bezier(0.2, 0, 0, 1), background 120ms, color 120ms;
|
||||||
|
}
|
||||||
|
.m3-press:active:not(:disabled) {
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* parts palette tiles: the star only shows on hover */
|
||||||
|
.m3-tile {
|
||||||
|
transition: transform 140ms cubic-bezier(0.2, 0, 0, 1), background 120ms;
|
||||||
|
}
|
||||||
|
.m3-tile:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.m3-tile:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
.m3-tile .m3-star {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 120ms;
|
||||||
|
}
|
||||||
|
.m3-tile:hover .m3-star,
|
||||||
|
.m3-tile .m3-star[style*="opacity: 1"] {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* range input, themed per palette via CSS vars */
|
||||||
|
input[type="range"].m3-range {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 28px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
input[type="range"].m3-range::-webkit-slider-runnable-track {
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--track, #e8def8);
|
||||||
|
}
|
||||||
|
input[type="range"].m3-range::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 6px;
|
||||||
|
height: 26px;
|
||||||
|
margin-top: -8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--thumb, #6750a4);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
input[type="range"].m3-range::-moz-range-track {
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--track, #e8def8);
|
||||||
|
}
|
||||||
|
input[type="range"].m3-range::-moz-range-thumb {
|
||||||
|
width: 6px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--thumb, #6750a4);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.m3-number::-webkit-outer-spin-button,
|
||||||
|
input.m3-number::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
input.m3-number {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.m3-press,
|
||||||
|
.m3-tile {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
.m3-spin,
|
||||||
|
.m3e-reveal [data-screen] > * {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.m3e-reveal,
|
||||||
|
.m3e-reveal * {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
[style*="m3e-drift"] {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* a design arriving from a model or a link: colours ease over instead of snapping,
|
||||||
|
and the screens fade in. The class stays on the root only for the moment it takes. */
|
||||||
|
.m3e-reveal,
|
||||||
|
.m3e-reveal * {
|
||||||
|
transition:
|
||||||
|
background-color 700ms ease,
|
||||||
|
background 700ms ease,
|
||||||
|
color 700ms ease,
|
||||||
|
border-color 700ms ease,
|
||||||
|
box-shadow 700ms ease,
|
||||||
|
fill 700ms ease !important;
|
||||||
|
}
|
||||||
|
@keyframes m3e-fadein {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
.m3e-reveal [data-screen] > * {
|
||||||
|
animation: m3e-fadein 600ms cubic-bezier(0.2, 0, 0, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* the screens while a model drafts: a gradient that drifts across them */
|
||||||
|
@keyframes m3e-drift {
|
||||||
|
0% { background-position: 0% 50%; }
|
||||||
|
50% { background-position: 100% 50%; }
|
||||||
|
100% { background-position: 0% 50%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* the AI button while a request is in flight */
|
||||||
|
@keyframes m3-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.m3-spin {
|
||||||
|
animation: m3-spin 1.1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* buttons inside a run share fused corners, like a connected button group */
|
||||||
|
.m3-run > * {
|
||||||
|
border-radius: 8px !important;
|
||||||
|
}
|
||||||
|
.m3-run > :first-child {
|
||||||
|
border-top-left-radius: 22px !important;
|
||||||
|
border-bottom-left-radius: 22px !important;
|
||||||
|
}
|
||||||
|
.m3-run > :last-child {
|
||||||
|
border-top-right-radius: 22px !important;
|
||||||
|
border-bottom-right-radius: 22px !important;
|
||||||
|
}
|
||||||
Vendored
+254
@@ -0,0 +1,254 @@
|
|||||||
|
import { Doc, Frame, Group, Item, frameOfGroup } from "./tokens";
|
||||||
|
import { buildPrompt } from "./prompt";
|
||||||
|
import { isProject } from "./project";
|
||||||
|
import { Lang } from "./i18n";
|
||||||
|
|
||||||
|
/* Optional AI helpers. The browser talks to the model provider directly with the
|
||||||
|
* author's own key; there is no server in between. Every action has a fixed
|
||||||
|
* prompt and a fixed JSON answer shape, and the result is only applied after the
|
||||||
|
* author has looked at it. Coordinates are never touched by the model. */
|
||||||
|
|
||||||
|
export type Provider = "claude" | "openai" | "gemini" | "deepseek";
|
||||||
|
|
||||||
|
export type AiSettings = {
|
||||||
|
provider: Provider;
|
||||||
|
baseUrl: string;
|
||||||
|
model: string;
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PROVIDERS: { key: Provider; label: string; baseUrl: string; model: string; keysUrl?: string }[] = [
|
||||||
|
{ key: "openai", label: "OpenAI", baseUrl: "https://api.openai.com/v1", model: "gpt-5.6-luna", keysUrl: "https://platform.openai.com/api-keys" },
|
||||||
|
{ key: "claude", label: "Claude", baseUrl: "https://api.anthropic.com", model: "claude-sonnet-5", keysUrl: "https://console.anthropic.com/settings/keys" },
|
||||||
|
{ key: "gemini", label: "Gemini", baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", model: "gemini-3.8-flash", keysUrl: "https://aistudio.google.com/apikey" },
|
||||||
|
{ key: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/v1", model: "deepseek-v4-flash", keysUrl: "https://platform.deepseek.com/api_keys" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const providerSpec = (k: Provider) => PROVIDERS.find((p) => p.key === k) ?? PROVIDERS[0];
|
||||||
|
|
||||||
|
export const DEFAULT_AI: AiSettings = { provider: PROVIDERS[0].key, baseUrl: PROVIDERS[0].baseUrl, model: PROVIDERS[0].model, key: "" };
|
||||||
|
|
||||||
|
const STORE_KEY = "m3e:ai";
|
||||||
|
|
||||||
|
export function loadAiSettings(): AiSettings {
|
||||||
|
const s = { ...DEFAULT_AI };
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORE_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const v = JSON.parse(raw) as Partial<AiSettings>;
|
||||||
|
if (PROVIDERS.some((p) => p.key === v.provider)) s.provider = v.provider as Provider;
|
||||||
|
if (typeof v.baseUrl === "string") s.baseUrl = v.baseUrl;
|
||||||
|
if (typeof v.model === "string") s.model = v.model;
|
||||||
|
if (typeof v.key === "string") s.key = v.key;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settings live in this browser only, like the document itself. */
|
||||||
|
export function saveAiSettings(s: AiSettings) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORE_KEY, JSON.stringify(s));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLocal = (u: string) => /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/i.test(u.trim());
|
||||||
|
|
||||||
|
/** a hosted endpoint needs a key; a server on this machine may run without one */
|
||||||
|
export const hasKey = (s: AiSettings) => s.key.trim().length > 0 || isLocal(s.baseUrl);
|
||||||
|
|
||||||
|
/** the key must not travel over plain http, except to this machine */
|
||||||
|
export const isSecureUrl = (u: string) => /^https:\/\//i.test(u.trim()) || isLocal(u);
|
||||||
|
|
||||||
|
const trimSlash = (u: string) => u.trim().replace(/\/+$/, "");
|
||||||
|
|
||||||
|
async function readError(res: Response): Promise<string> {
|
||||||
|
let detail = "";
|
||||||
|
try {
|
||||||
|
const j = await res.json();
|
||||||
|
detail = j?.error?.message ?? j?.message ?? JSON.stringify(j);
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
detail = await res.text();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return `${res.status} ${res.statusText}${detail ? `: ${detail.slice(0, 300)}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** one round trip: a system prompt and a user message in, the model's text out */
|
||||||
|
export async function complete(s: AiSettings, system: string, user: string, signal?: AbortSignal, maxTokens = 4096): Promise<string> {
|
||||||
|
const base = trimSlash(s.baseUrl);
|
||||||
|
const model = s.model.trim();
|
||||||
|
if (!model) throw new Error("model");
|
||||||
|
if (!isSecureUrl(base)) throw new Error("insecure");
|
||||||
|
if (s.provider === "claude") {
|
||||||
|
const res = await fetch(`${base}/v1/messages`, {
|
||||||
|
method: "POST",
|
||||||
|
signal,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-api-key": s.key.trim(),
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"anthropic-dangerous-direct-browser-access": "true",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ model, max_tokens: Math.min(maxTokens, 8192), system, messages: [{ role: "user", content: user }] }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await readError(res));
|
||||||
|
const j = await res.json();
|
||||||
|
if (j.stop_reason === "refusal") throw new Error("refusal");
|
||||||
|
if (j.stop_reason === "max_tokens") throw new Error("long");
|
||||||
|
return (j.content ?? [])
|
||||||
|
.filter((b: { type: string }) => b.type === "text")
|
||||||
|
.map((b: { text: string }) => b.text)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||||
|
if (s.key.trim()) headers.authorization = `Bearer ${s.key.trim()}`;
|
||||||
|
const res = await fetch(`${base}/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
signal,
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
/* OpenAI's newer models refuse `max_tokens` and default generously, so they get no budget;
|
||||||
|
the other compatible endpoints cap around 8k */
|
||||||
|
...(s.provider === "openai" ? {} : { max_tokens: Math.min(maxTokens, 8192) }),
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: system },
|
||||||
|
{ role: "user", content: user },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await readError(res));
|
||||||
|
const j = await res.json();
|
||||||
|
if (j.choices?.[0]?.finish_reason === "length") throw new Error("long");
|
||||||
|
const c = j.choices?.[0]?.message?.content;
|
||||||
|
if (typeof c === "string") return c;
|
||||||
|
if (Array.isArray(c)) return c.map((x: { text?: string }) => x.text ?? "").join("");
|
||||||
|
throw new Error("empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** the first JSON object in a reply, with any code fence stripped */
|
||||||
|
function parseJsonObject(text: string): Record<string, unknown> {
|
||||||
|
const cleaned = text.replace(/```(?:json)?/gi, "");
|
||||||
|
const a = cleaned.indexOf("{");
|
||||||
|
const b = cleaned.lastIndexOf("}");
|
||||||
|
if (a < 0 || b <= a) throw new Error("json");
|
||||||
|
const v = JSON.parse(cleaned.slice(a, b + 1));
|
||||||
|
if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("json");
|
||||||
|
return v as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- actions ---------- */
|
||||||
|
|
||||||
|
const LANG_NAME: Record<Lang, string> = { ja: "Japanese", en: "English", zh: "Simplified Chinese", ko: "Korean" };
|
||||||
|
|
||||||
|
const hasText = (v?: string | null) => !!v && v.trim().length > 0;
|
||||||
|
|
||||||
|
const itemsOnFrame = (doc: Doc, frame: Frame, widths: Record<string, number>): Item[] =>
|
||||||
|
doc.groups.filter((g: Group) => frameOfGroup(g, doc.frames, widths)?.id === frame.id).flatMap((g) => g.items);
|
||||||
|
|
||||||
|
const describeItem = (it: Item) => {
|
||||||
|
const bits = [`id=${it.id}`, `kind=${it.kind}`];
|
||||||
|
if (hasText(it.label)) bits.push(`label=${JSON.stringify(it.label)}`);
|
||||||
|
if (hasText(it.supporting)) bits.push(`supporting=${JSON.stringify(it.supporting)}`);
|
||||||
|
if (it.icon) bits.push(`icon=${it.icon}`);
|
||||||
|
if (it.tabs?.length) bits.push(`items=${JSON.stringify(it.tabs.map((t) => t.label || t.icon))}`);
|
||||||
|
if (it.action) bits.push(`tap=${it.action.to}`);
|
||||||
|
if (it.toggle) bits.push("toggle");
|
||||||
|
if (hasText(it.note)) bits.push(`current_note=${JSON.stringify(it.note)}`);
|
||||||
|
return bits.join(" ");
|
||||||
|
};
|
||||||
|
|
||||||
|
const context = (doc: Doc, widths: Record<string, number>, frame: Frame, lang: Lang) =>
|
||||||
|
[
|
||||||
|
"The author is sketching a Material 3 Expressive app. Below is the generated description of the whole design, then the screen to work on.",
|
||||||
|
"",
|
||||||
|
"=== Whole design ===",
|
||||||
|
buildPrompt(doc, widths, undefined, lang),
|
||||||
|
"",
|
||||||
|
`=== Screen to work on: ${JSON.stringify(frame.name || "(unnamed)")} ===`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const SYSTEM = "You help an app designer finish a sketch. Answer with a single JSON object and nothing else: no prose, no markdown fence.";
|
||||||
|
|
||||||
|
/** picks the strings the model returned for exactly the parts asked about */
|
||||||
|
function pickStrings(v: unknown, parts: Item[], max: number): Record<string, string> {
|
||||||
|
const map = (v ?? {}) as Record<string, unknown>;
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const it of parts) {
|
||||||
|
const s = map[it.id];
|
||||||
|
if (typeof s === "string" && s.trim()) out[it.id] = s.trim().slice(0, max);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** a short behavior note for one part; an existing note is refined rather than replaced */
|
||||||
|
export async function proposeBehavior(s: AiSettings, doc: Doc, widths: Record<string, number>, frame: Frame, lang: Lang, itemId: string, signal?: AbortSignal): Promise<string | undefined> {
|
||||||
|
const parts = itemsOnFrame(doc, frame, widths).filter((it) => it.id === itemId);
|
||||||
|
if (!parts.length) return undefined;
|
||||||
|
const user = [
|
||||||
|
context(doc, widths, frame, lang),
|
||||||
|
"",
|
||||||
|
"For the part listed below, write what happens when the user interacts with it: what it does, where it leads, what it shows. One sentence, concrete, in the voice of a product spec (no 'should', no hedging). Infer from the labels, icons and the other screens; do not invent screens that do not exist.",
|
||||||
|
"A part with a current_note already has the author's own wording: keep its intent and facts, and improve it (clearer, more specific, consistent with the rest of the screen). Do not contradict it.",
|
||||||
|
`Write in ${LANG_NAME[lang]}.`,
|
||||||
|
"",
|
||||||
|
"Part:",
|
||||||
|
describeItem(parts[0]),
|
||||||
|
"",
|
||||||
|
'Answer as {"notes": {"<id>": "<sentence>"}} using exactly the id above.',
|
||||||
|
].join("\n");
|
||||||
|
const j = parseJsonObject(await complete(s, SYSTEM, user, signal));
|
||||||
|
return pickStrings(j.notes, parts, 300)[itemId];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** a name (only when the screen has none) and a one-line purpose for the screen; an existing description is refined */
|
||||||
|
export async function proposeDescription(s: AiSettings, doc: Doc, widths: Record<string, number>, frame: Frame, lang: Lang, signal?: AbortSignal): Promise<{ name?: string; note: string }> {
|
||||||
|
const user = [
|
||||||
|
context(doc, widths, frame, lang),
|
||||||
|
"",
|
||||||
|
"Describe this screen's purpose in one or two sentences: who opens it, what they see and what they can do here. Also propose a short screen name (one to three words).",
|
||||||
|
hasText(frame.note) ? `The author's current description is ${JSON.stringify(frame.note)}: keep its intent and facts, and improve it.` : "",
|
||||||
|
`Write in ${LANG_NAME[lang]}.`,
|
||||||
|
"",
|
||||||
|
'Answer as {"name": "<name>", "description": "<sentences>"}.',
|
||||||
|
]
|
||||||
|
.filter((l) => l !== "")
|
||||||
|
.join("\n");
|
||||||
|
const j = parseJsonObject(await complete(s, SYSTEM, user, signal));
|
||||||
|
const note = typeof j.description === "string" ? j.description.trim().slice(0, 400) : "";
|
||||||
|
if (!note) throw new Error("json");
|
||||||
|
const name = typeof j.name === "string" ? j.name.trim().slice(0, 40) : "";
|
||||||
|
return { name: !hasText(frame.name) && name ? name : undefined, note };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** the value a rewritten field had before, so the rewrite can be undone; an empty field leaves nothing to go back to */
|
||||||
|
export const pushHistory = (history: string[] | undefined, replaced: string | undefined): string[] | undefined => {
|
||||||
|
const cur = (replaced ?? "").trim();
|
||||||
|
return cur ? [cur] : history?.length ? history : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** the patch that swaps a field with what it said before the AI wrote it; pressing again swaps back */
|
||||||
|
export function popHistory<V extends string, H extends string>(current: string | undefined, history: string[] | undefined, valueKey: V, historyKey: H): Record<V, string> & Record<H, string[] | undefined> {
|
||||||
|
const [prev] = history ?? [];
|
||||||
|
const cur = (current ?? "").trim();
|
||||||
|
return { [valueKey]: prev ?? "", [historyKey]: cur ? [cur] : undefined } as Record<V, string> & Record<H, string[] | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A whole design from an idea, drafted by the author's own model. `guide` is the same
|
||||||
|
* agent guide a coding agent reads (public/agent.md), so both paths follow one spec.
|
||||||
|
* The answer is the document itself; a link would be pointless here. */
|
||||||
|
export async function draftDesign(s: AiSettings, guide: string, idea: string, lang: Lang, signal?: AbortSignal): Promise<Doc> {
|
||||||
|
const system = [
|
||||||
|
"You draft M3E Canvas designs. Follow the guide below exactly.",
|
||||||
|
"Reply with the JSON document only: no share link, no prose, no markdown fence, no explanation.",
|
||||||
|
"",
|
||||||
|
guide,
|
||||||
|
].join("\n");
|
||||||
|
const user = [`Sketch this app: ${idea.trim()}`, `Write every label, title and note in ${LANG_NAME[lang]}.`, "Three to five screens. Keep it simple."].join("\n");
|
||||||
|
const j = parseJsonObject(await complete(s, system, user, signal, 12000));
|
||||||
|
if (!isProject(j)) throw new Error("json");
|
||||||
|
return j;
|
||||||
|
}
|
||||||
|
|
||||||
Vendored
+79
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
import { hexToRgb, isHex, onColorFor, rgbToHex, rgbToLab, schemeFromSeed } from "./color";
|
||||||
|
|
||||||
|
/** perceptual lightness of a hex color, for readable-contrast assertions */
|
||||||
|
const toneOf = (hex: string) => rgbToLab(...hexToRgb(hex)!).L;
|
||||||
|
|
||||||
|
describe("isHex", () => {
|
||||||
|
it("accepts a six-digit hex color with a leading #", () => {
|
||||||
|
expect(isHex("#6750A4")).toBe(true);
|
||||||
|
expect(isHex("#ff0088")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects shorthand, missing #, and non-hex input", () => {
|
||||||
|
expect(isHex("#fff")).toBe(false);
|
||||||
|
expect(isHex("6750A4")).toBe(false);
|
||||||
|
expect(isHex("#GGGGGG")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hexToRgb / rgbToHex", () => {
|
||||||
|
it("parses a hex color into channels", () => {
|
||||||
|
expect(hexToRgb("#6750A4")).toEqual([103, 80, 164]);
|
||||||
|
expect(hexToRgb("6750a4")).toEqual([103, 80, 164]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips any channel values through both directions", () => {
|
||||||
|
for (const [r, g, b] of [[0, 0, 0], [255, 255, 255], [103, 80, 164], [18, 250, 199]] as const) {
|
||||||
|
expect(hexToRgb(rgbToHex(r, g, b))).toEqual([r, g, b]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps channels outside 0..255 when formatting", () => {
|
||||||
|
expect(rgbToHex(300, -4, 7.6)).toBe("#FF0008");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for input that is not a hex color", () => {
|
||||||
|
expect(hexToRgb("#XYZ123")).toBeNull();
|
||||||
|
expect(hexToRgb("#ABC")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("schemeFromSeed", () => {
|
||||||
|
const seed = "#6750A4";
|
||||||
|
|
||||||
|
it("honours the dark option: surfaces go dark, text on them light", () => {
|
||||||
|
const light = schemeFromSeed(seed);
|
||||||
|
const dark = schemeFromSeed(seed, "Custom", { dark: true });
|
||||||
|
expect(toneOf(dark.surface)).toBeLessThan(toneOf(light.surface));
|
||||||
|
expect(toneOf(dark.onSurface)).toBeGreaterThan(toneOf(light.onSurface));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours the contrast option: high contrast pushes the accent further from the surface", () => {
|
||||||
|
const standard = schemeFromSeed(seed);
|
||||||
|
const high = schemeFromSeed(seed, "Custom", { contrast: "high" });
|
||||||
|
expect(high.primary).not.toBe(standard.primary);
|
||||||
|
expect(Math.abs(toneOf(high.primary) - toneOf(high.surface))).toBeGreaterThan(
|
||||||
|
Math.abs(toneOf(standard.primary) - toneOf(standard.surface)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("onColorFor", () => {
|
||||||
|
it("picks a dark on-color on a light background", () => {
|
||||||
|
const on = onColorFor("#FFFFFF");
|
||||||
|
expect(isHex(on)).toBe(true);
|
||||||
|
expect(toneOf(on)).toBeLessThan(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks a light on-color on a dark background", () => {
|
||||||
|
const on = onColorFor("#000000");
|
||||||
|
expect(isHex(on)).toBe(true);
|
||||||
|
expect(toneOf(on)).toBeGreaterThan(90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to black when the background cannot be parsed", () => {
|
||||||
|
expect(onColorFor("nope")).toBe("#000000");
|
||||||
|
});
|
||||||
|
});
|
||||||
Vendored
+182
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* A small Material-style scheme generator: one seed color becomes a light
|
||||||
|
* color scheme by placing each role at a fixed tone (CIE L*) with the seed's
|
||||||
|
* hue and a role-specific chroma, then clipping chroma into the sRGB gamut.
|
||||||
|
* It follows the shape of Material's HCT tonal palettes without the full
|
||||||
|
* CAM16 model, which is more than enough for a mockup palette.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Palette } from "./tokens";
|
||||||
|
|
||||||
|
export type Lab = { L: number; a: number; b: number };
|
||||||
|
export type Lch = { L: number; C: number; h: number };
|
||||||
|
|
||||||
|
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
|
||||||
|
|
||||||
|
export function hexToRgb(hex: string): [number, number, number] | null {
|
||||||
|
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
||||||
|
if (!m) return null;
|
||||||
|
const n = parseInt(m[1], 16);
|
||||||
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rgbToHex = (r: number, g: number, b: number) =>
|
||||||
|
"#" + [r, g, b].map((v) => Math.round(clamp01(v / 255) * 255).toString(16).padStart(2, "0")).join("").toUpperCase();
|
||||||
|
|
||||||
|
const lin = (c: number) => {
|
||||||
|
const v = c / 255;
|
||||||
|
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
||||||
|
};
|
||||||
|
const gam = (v: number) => (v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055);
|
||||||
|
|
||||||
|
const WHITE = [0.95047, 1, 1.08883];
|
||||||
|
const f = (t: number) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
|
||||||
|
const fInv = (t: number) => (t > 0.2069 ? t * t * t : (t - 16 / 116) / 7.787);
|
||||||
|
|
||||||
|
export function rgbToLab(r: number, g: number, b: number): Lab {
|
||||||
|
const R = lin(r);
|
||||||
|
const G = lin(g);
|
||||||
|
const B = lin(b);
|
||||||
|
const x = (R * 0.4124 + G * 0.3576 + B * 0.1805) / WHITE[0];
|
||||||
|
const y = (R * 0.2126 + G * 0.7152 + B * 0.0722) / WHITE[1];
|
||||||
|
const z = (R * 0.0193 + G * 0.1192 + B * 0.9505) / WHITE[2];
|
||||||
|
const fx = f(x);
|
||||||
|
const fy = f(y);
|
||||||
|
const fz = f(z);
|
||||||
|
return { L: 116 * fy - 16, a: 500 * (fx - fy), b: 200 * (fy - fz) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** sRGB in 0..255, or null when the color is outside the gamut */
|
||||||
|
function labToRgb(lab: Lab): [number, number, number] | null {
|
||||||
|
const fy = (lab.L + 16) / 116;
|
||||||
|
const fx = lab.a / 500 + fy;
|
||||||
|
const fz = fy - lab.b / 200;
|
||||||
|
const x = fInv(fx) * WHITE[0];
|
||||||
|
const y = fInv(fy) * WHITE[1];
|
||||||
|
const z = fInv(fz) * WHITE[2];
|
||||||
|
const R = x * 3.2406 + y * -1.5372 + z * -0.4986;
|
||||||
|
const G = x * -0.9689 + y * 1.8758 + z * 0.0415;
|
||||||
|
const B = x * 0.0557 + y * -0.204 + z * 1.057;
|
||||||
|
const out = [R, G, B].map(gam);
|
||||||
|
if (out.some((v) => v < -0.002 || v > 1.002)) return null;
|
||||||
|
return out.map((v) => clamp01(v) * 255) as [number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function labToLch(lab: Lab): Lch {
|
||||||
|
const C = Math.hypot(lab.a, lab.b);
|
||||||
|
let h = (Math.atan2(lab.b, lab.a) * 180) / Math.PI;
|
||||||
|
if (h < 0) h += 360;
|
||||||
|
return { L: lab.L, C, h };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** the color at a tone / chroma / hue, with chroma reduced until it fits sRGB */
|
||||||
|
export function tone(L: number, C: number, h: number): string {
|
||||||
|
const rad = (h * Math.PI) / 180;
|
||||||
|
let c = C;
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
const rgb = labToRgb({ L, a: c * Math.cos(rad), b: c * Math.sin(rad) });
|
||||||
|
if (rgb) return rgbToHex(rgb[0], rgb[1], rgb[2]);
|
||||||
|
c *= 0.88;
|
||||||
|
}
|
||||||
|
const rgb = labToRgb({ L, a: 0, b: 0 }) ?? [128, 128, 128];
|
||||||
|
return rgbToHex(rgb[0], rgb[1], rgb[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Contrast = "standard" | "medium" | "high";
|
||||||
|
export type SchemeOptions = { dark?: boolean; contrast?: Contrast; /** keep a muted seed muted instead of lifting it to a vivid accent */ keepChroma?: boolean };
|
||||||
|
|
||||||
|
/** tone (CIE L*) of every role, light and dark, at each contrast level.
|
||||||
|
* The standard tones are Material's; medium and high push the accents and
|
||||||
|
* outlines further from their backgrounds the way Material Theme Builder does. */
|
||||||
|
type Tones = Record<
|
||||||
|
| "primary" | "onPrimary" | "primaryContainer" | "onPrimaryContainer" | "inversePrimary"
|
||||||
|
| "secondaryContainer" | "onSecondaryContainer" | "tertiaryContainer" | "onTertiaryContainer"
|
||||||
|
| "surface" | "surfaceContainerLow" | "surfaceContainer" | "surfaceContainerHigh" | "surfaceContainerHighest"
|
||||||
|
| "onSurface" | "onSurfaceVariant" | "outline" | "outlineVariant" | "inverseSurface" | "inverseOnSurface",
|
||||||
|
number
|
||||||
|
>;
|
||||||
|
|
||||||
|
const LIGHT: Tones = {
|
||||||
|
primary: 40, onPrimary: 100, primaryContainer: 90, onPrimaryContainer: 10, inversePrimary: 80,
|
||||||
|
secondaryContainer: 90, onSecondaryContainer: 10, tertiaryContainer: 90, onTertiaryContainer: 10,
|
||||||
|
surface: 98, surfaceContainerLow: 96, surfaceContainer: 94, surfaceContainerHigh: 92, surfaceContainerHighest: 90,
|
||||||
|
onSurface: 10, onSurfaceVariant: 30, outline: 50, outlineVariant: 80, inverseSurface: 20, inverseOnSurface: 95,
|
||||||
|
};
|
||||||
|
const DARK: Tones = {
|
||||||
|
primary: 80, onPrimary: 20, primaryContainer: 30, onPrimaryContainer: 90, inversePrimary: 40,
|
||||||
|
secondaryContainer: 30, onSecondaryContainer: 90, tertiaryContainer: 30, onTertiaryContainer: 90,
|
||||||
|
surface: 6, surfaceContainerLow: 10, surfaceContainer: 12, surfaceContainerHigh: 17, surfaceContainerHighest: 22,
|
||||||
|
onSurface: 90, onSurfaceVariant: 80, outline: 60, outlineVariant: 30, inverseSurface: 90, inverseOnSurface: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
function tonesFor(dark: boolean, contrast: Contrast): Tones {
|
||||||
|
const t = { ...(dark ? DARK : LIGHT) };
|
||||||
|
if (contrast === "medium") {
|
||||||
|
if (dark) Object.assign(t, { primary: 85, onPrimaryContainer: 95, onSecondaryContainer: 95, onTertiaryContainer: 95, onSurfaceVariant: 85, outline: 70, outlineVariant: 50 });
|
||||||
|
else Object.assign(t, { primary: 30, onPrimaryContainer: 20, onSecondaryContainer: 20, onTertiaryContainer: 20, onSurfaceVariant: 25, outline: 40, outlineVariant: 65 });
|
||||||
|
} else if (contrast === "high") {
|
||||||
|
if (dark) Object.assign(t, { primary: 95, onPrimary: 0, primaryContainer: 80, onPrimaryContainer: 0, secondaryContainer: 80, onSecondaryContainer: 0, tertiaryContainer: 80, onTertiaryContainer: 0, onSurface: 100, onSurfaceVariant: 95, outline: 90, outlineVariant: 90 });
|
||||||
|
else Object.assign(t, { primary: 20, primaryContainer: 30, onPrimaryContainer: 100, secondaryContainer: 30, onSecondaryContainer: 100, tertiaryContainer: 30, onTertiaryContainer: 100, onSurface: 0, onSurfaceVariant: 10, outline: 20, outlineVariant: 20 });
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ERROR_LIGHT = { error: "#B3261E", onError: "#FFFFFF", errorContainer: "#F9DEDC", onErrorContainer: "#410E0B" };
|
||||||
|
const ERROR_DARK = { error: "#F2B8B5", onError: "#601410", errorContainer: "#8C1D18", onErrorContainer: "#F9DEDC" };
|
||||||
|
|
||||||
|
/** Material 3 scheme from a seed color: light by default, dark and higher
|
||||||
|
* contrast on request. */
|
||||||
|
export function schemeFromSeed(seedHex: string, label = "Custom", opts: SchemeOptions = {}): Palette {
|
||||||
|
const rgb = hexToRgb(seedHex) ?? [103, 80, 164];
|
||||||
|
const lch = labToLch(rgbToLab(rgb[0], rgb[1], rgb[2]));
|
||||||
|
const h = lch.h;
|
||||||
|
const primaryC = opts.keepChroma ? Math.min(lch.C, 60) : Math.max(36, Math.min(lch.C, 60));
|
||||||
|
const secondaryC = primaryC / 3;
|
||||||
|
const tertiaryH = (h + 60) % 360;
|
||||||
|
const tertiaryC = primaryC / 2;
|
||||||
|
const neutralC = 3;
|
||||||
|
const neutralVarC = 7;
|
||||||
|
const P = (L: number) => tone(L, primaryC, h);
|
||||||
|
const S = (L: number) => tone(L, secondaryC, h);
|
||||||
|
const T = (L: number) => tone(L, tertiaryC, tertiaryH);
|
||||||
|
const N = (L: number) => tone(L, neutralC, h);
|
||||||
|
const NV = (L: number) => tone(L, neutralVarC, h);
|
||||||
|
const dark = !!opts.dark;
|
||||||
|
const k = tonesFor(dark, opts.contrast ?? "standard");
|
||||||
|
return {
|
||||||
|
key: "custom",
|
||||||
|
label,
|
||||||
|
seed: seedHex.toUpperCase(),
|
||||||
|
primary: P(k.primary),
|
||||||
|
onPrimary: P(k.onPrimary),
|
||||||
|
primaryContainer: P(k.primaryContainer),
|
||||||
|
onPrimaryContainer: P(k.onPrimaryContainer),
|
||||||
|
inversePrimary: P(k.inversePrimary),
|
||||||
|
secondaryContainer: S(k.secondaryContainer),
|
||||||
|
onSecondaryContainer: S(k.onSecondaryContainer),
|
||||||
|
tertiaryContainer: T(k.tertiaryContainer),
|
||||||
|
onTertiaryContainer: T(k.onTertiaryContainer),
|
||||||
|
surface: N(k.surface),
|
||||||
|
surfaceContainerLow: N(k.surfaceContainerLow),
|
||||||
|
surfaceContainer: N(k.surfaceContainer),
|
||||||
|
surfaceContainerHigh: N(k.surfaceContainerHigh),
|
||||||
|
surfaceContainerHighest: N(k.surfaceContainerHighest),
|
||||||
|
onSurface: N(k.onSurface),
|
||||||
|
onSurfaceVariant: NV(k.onSurfaceVariant),
|
||||||
|
outline: NV(k.outline),
|
||||||
|
outlineVariant: NV(k.outlineVariant),
|
||||||
|
inverseSurface: N(k.inverseSurface),
|
||||||
|
inverseOnSurface: N(k.inverseOnSurface),
|
||||||
|
...(dark ? ERROR_DARK : ERROR_LIGHT),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** readable text color for an arbitrary background: the same hue at tone 10 or 100 */
|
||||||
|
export function onColorFor(hex: string): string {
|
||||||
|
const rgb = hexToRgb(hex);
|
||||||
|
if (!rgb) return "#000000";
|
||||||
|
const lch = labToLch(rgbToLab(rgb[0], rgb[1], rgb[2]));
|
||||||
|
return lch.L > 60 ? tone(10, Math.min(lch.C, 30), lch.h) : tone(100, 0, lch.h);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isHex = (v: string) => /^#[0-9a-f]{6}$/i.test(v.trim());
|
||||||
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
import { isLang, LANGS } from "./i18n";
|
||||||
|
|
||||||
|
describe("isLang", () => {
|
||||||
|
it("accepts every language the UI offers", () => {
|
||||||
|
for (const { key } of LANGS) expect(isLang(key)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects anything else", () => {
|
||||||
|
expect(isLang("fr")).toBe(false);
|
||||||
|
expect(isLang(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Vendored
+684
@@ -0,0 +1,684 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
export type Lang = "ja" | "en" | "zh" | "ko";
|
||||||
|
export const LANGS: { key: Lang; label: string }[] = [
|
||||||
|
{ key: "ja", label: "日本語" },
|
||||||
|
{ key: "en", label: "English" },
|
||||||
|
{ key: "zh", label: "中文" },
|
||||||
|
{ key: "ko", label: "한국어" },
|
||||||
|
];
|
||||||
|
export const isLang = (v: unknown): v is Lang => v === "ja" || v === "en" || v === "zh" || v === "ko";
|
||||||
|
|
||||||
|
/* A module-level copy lets non-React helpers (item defaults, prompt text)
|
||||||
|
* follow the language without threading it through every call. */
|
||||||
|
let current: Lang = "ja";
|
||||||
|
export const getLang = () => current;
|
||||||
|
export const setGlobalLang = (l: Lang) => {
|
||||||
|
current = l;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LangContext = createContext<Lang>("ja");
|
||||||
|
export const useLang = () => useContext(LangContext);
|
||||||
|
|
||||||
|
export const SEED_TEXT: Record<Lang, { favorite: string; share: string; inbox: string; starred: string; archive: string; supporting: string; start: string }> = {
|
||||||
|
ja: { favorite: "お気に入り", share: "共有", inbox: "受信トレイ", starred: "スター付き", archive: "アーカイブ", supporting: "サブテキスト", start: "はじめる" },
|
||||||
|
en: { favorite: "Favorite", share: "Share", inbox: "Inbox", starred: "Starred", archive: "Archive", supporting: "Supporting text", start: "Get started" },
|
||||||
|
zh: { favorite: "收藏", share: "分享", inbox: "收件箱", starred: "已加星标", archive: "归档", supporting: "辅助文本", start: "开始" },
|
||||||
|
ko: { favorite: "즐겨찾기", share: "공유", inbox: "받은편지함", starred: "별표 표시", archive: "보관함", supporting: "보조 텍스트", start: "시작하기" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** ponytail: matches defaults by text; add provenance if authored copies must be distinguished. */
|
||||||
|
export function translateDefaultText(value: string, kind: string, field: "label" | "supporting" | "tab", lang: Lang): string {
|
||||||
|
for (const { key: from } of LANGS) {
|
||||||
|
if (field === "tab") {
|
||||||
|
const labels = (l: Lang) => kind === "tabs" ? TAB_LABELS[l] : (kind === "fabMenu" ? FAB_MENU_TABS[l] : NAV_TABS[l]).map((tab) => tab.label);
|
||||||
|
const index = labels(from).indexOf(value);
|
||||||
|
if (index >= 0) return labels(lang)[index] ?? value;
|
||||||
|
} else {
|
||||||
|
if (value && value === KIND_TEXT[from][kind]?.[field]) return KIND_TEXT[lang][kind]?.[field] ?? value;
|
||||||
|
const keys: (keyof typeof SEED_TEXT.en)[] = field === "supporting" ? ["supporting"] : kind === "button" ? ["favorite", "share", "start"] : kind === "listItem" ? ["inbox", "starred", "archive"] : [];
|
||||||
|
for (const key of keys) {
|
||||||
|
if (value === SEED_TEXT[from][key]) return SEED_TEXT[lang][key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Str = { ja: string; en: string; zh: string };
|
||||||
|
|
||||||
|
export function translateDefaultFrameName(name: string, lang: Lang): string {
|
||||||
|
for (const { key } of LANGS) {
|
||||||
|
if (name === t("home", key)) return t("home", lang);
|
||||||
|
const prefix = `${t("screenN", key)} `;
|
||||||
|
if (name.startsWith(prefix) && /^\d+$/.test(name.slice(prefix.length))) {
|
||||||
|
return `${t("screenN", lang)} ${name.slice(prefix.length)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COLOR_TOKEN_TEXT = {
|
||||||
|
ja: { surface: "サーフェス", surfaceContainerLow: "コンテナ(低)", surfaceContainer: "コンテナ", surfaceContainerHigh: "コンテナ(高)", surfaceContainerHighest: "コンテナ(最高)", primaryContainer: "プライマリコンテナ", secondaryContainer: "セカンダリコンテナ", tertiaryContainer: "ターシャリコンテナ", primary: "プライマリ", inverseSurface: "反転サーフェス" },
|
||||||
|
zh: { surface: "表面", surfaceContainerLow: "低层容器", surfaceContainer: "容器", surfaceContainerHigh: "高层容器", surfaceContainerHighest: "最高层容器", primaryContainer: "主色容器", secondaryContainer: "次色容器", tertiaryContainer: "第三色容器", primary: "主色", inverseSurface: "反色表面" },
|
||||||
|
ko: { surface: "표면", surfaceContainerLow: "낮은 컨테이너", surfaceContainer: "컨테이너", surfaceContainerHigh: "높은 컨테이너", surfaceContainerHighest: "가장 높은 컨테이너", primaryContainer: "주 색상 컨테이너", secondaryContainer: "보조 색상 컨테이너", tertiaryContainer: "세 번째 색상 컨테이너", primary: "주 색상", inverseSurface: "반전 표면" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const UI = {
|
||||||
|
// panels
|
||||||
|
parts: { ja: "部品", en: "Parts", zh: "组件" },
|
||||||
|
layers: { ja: "レイヤー", en: "Layers", zh: "图层" },
|
||||||
|
edit: { ja: "編集", en: "Edit", zh: "编辑" },
|
||||||
|
prompt: { ja: "プロンプト", en: "Prompt", zh: "提示词" },
|
||||||
|
closePanel: { ja: "パネルを閉じる", en: "Close panel", zh: "关闭面板" },
|
||||||
|
search: { ja: "検索", en: "Search", zh: "搜索" },
|
||||||
|
favorites: { ja: "お気に入り", en: "Favorites", zh: "收藏" },
|
||||||
|
addFavorite: { ja: "お気に入りに追加", en: "Add to favorites", zh: "添加到收藏" },
|
||||||
|
removeFavorite: { ja: "お気に入りから外す", en: "Remove from favorites", zh: "取消收藏" },
|
||||||
|
clear: { ja: "クリア", en: "Clear", zh: "清除" },
|
||||||
|
language: { ja: "言語", en: "Language", zh: "语言" },
|
||||||
|
// toolbar
|
||||||
|
select: { ja: "選択 (V)", en: "Select (V)", zh: "选择 (V)" },
|
||||||
|
hand: { ja: "手のひら (H / Space)", en: "Hand (H / Space)", zh: "抓手 (H / Space)" },
|
||||||
|
blank: { ja: "白紙", en: "Blank canvas", zh: "空白画布" },
|
||||||
|
phone: { ja: "スマホ画面", en: "Phone screens", zh: "手机屏幕" },
|
||||||
|
addFrame: { ja: "画面を追加", en: "Add screen", zh: "添加屏幕" },
|
||||||
|
preview: { ja: "プレビュー (P)", en: "Preview (P)", zh: "预览 (P)" },
|
||||||
|
zoomIn: { ja: "拡大 (+)", en: "Zoom in (+)", zh: "放大 (+)" },
|
||||||
|
zoomOut: { ja: "縮小 (-)", en: "Zoom out (-)", zh: "缩小 (-)" },
|
||||||
|
fit: { ja: "全体を表示 (0)", en: "Fit (0)", zh: "适应窗口 (0)" },
|
||||||
|
undo: { ja: "元に戻す (Ctrl+Z)", en: "Undo (Ctrl+Z)", zh: "撤销 (Ctrl+Z)" },
|
||||||
|
redo: { ja: "やり直す (Ctrl+Shift+Z)", en: "Redo (Ctrl+Shift+Z)", zh: "重做 (Ctrl+Shift+Z)" },
|
||||||
|
clearAll: { ja: "すべて消す", en: "Clear canvas", zh: "全部清除" },
|
||||||
|
clearAllTitle: { ja: "すべて消しますか?", en: "Clear the canvas?", zh: "要全部清除吗?" },
|
||||||
|
clearAllBody: {
|
||||||
|
ja: "すべての画面と部品を削除します。元に戻す (Ctrl+Z) で復元できます。",
|
||||||
|
en: "Every screen and part will be removed. Undo (Ctrl+Z) can bring them back.",
|
||||||
|
zh: "将删除所有屏幕和组件。可以用撤销 (Ctrl+Z) 恢复。",
|
||||||
|
},
|
||||||
|
// inspector
|
||||||
|
screen: { ja: "画面", en: "Screen", zh: "屏幕" },
|
||||||
|
screenName: { ja: "画面の名前", en: "Screen name", zh: "屏幕名称" },
|
||||||
|
name: { ja: "名前", en: "Name", zh: "名称" },
|
||||||
|
background: { ja: "背景", en: "Background", zh: "背景" },
|
||||||
|
export: { ja: "書き出し", en: "Export", zh: "导出" },
|
||||||
|
project: { ja: "プロジェクト", en: "Project", zh: "项目" },
|
||||||
|
saveProject: { ja: "プロジェクトを保存", en: "Save project", zh: "保存项目" },
|
||||||
|
openProject: { ja: "プロジェクトを開く", en: "Open project", zh: "打开项目" },
|
||||||
|
replaceProjectTitle: { ja: "プロジェクトを開きますか?", en: "Open this project?", zh: "要打开这个项目吗?" },
|
||||||
|
askAi: { ja: "AI に頼む", en: "Ask an AI", zh: "让 AI 来画" },
|
||||||
|
askAiHint: {
|
||||||
|
ja: "作りたいものを書いて「AI で作る」を押すと、AI タブのキーで設計がキャンバスに描かれます。代わりに指示をコピーして Claude Code などの AI エージェントに渡すこともできます。返ってきたリンクを開くか、JSON をファイルに保存して「プロジェクトを開く」から読み込みます。",
|
||||||
|
en: "Describe what you want and press Draft with AI: the model from the AI tab draws the design on the canvas. Or copy the instruction for an AI agent such as Claude Code; open the link it replies with, or save its JSON and load it with Open project.",
|
||||||
|
zh: "写下想做的东西并按“让 AI 生成”,AI 标签页里的模型会把设计画到画布上。也可以复制指令交给 Claude Code 等 AI 代理:打开它回复的链接,或把 JSON 保存为文件后用“打开项目”读取。",
|
||||||
|
},
|
||||||
|
askAiIdea: { ja: "作りたいもの(例: レシピを保存して検索できるアプリ)", en: "What to build (e.g. an app to save and search recipes)", zh: "想做的东西(例如:能保存和搜索菜谱的应用)" },
|
||||||
|
askAiIdeaFallback: { ja: "(ここに作りたいものを書く)", en: "(describe what to build here)", zh: "(在这里写下想做的东西)" },
|
||||||
|
askAiCopy: { ja: "指示をコピー", en: "Copy the instruction", zh: "复制指令" },
|
||||||
|
askAiText: {
|
||||||
|
ja: "M3E Canvas のスケッチを作ってください。まず {url} を読み、その手順どおりに設計を JSON で組み立てて、共有リンクの形で返してください。コードを実行できない場合は、設計の JSON をコードブロックで返してください(ファイルに保存して読み込みます)。検証は不要です。\n\n作りたいもの: {idea}",
|
||||||
|
en: "Make an M3E Canvas sketch. First read {url} and follow it: build the design as JSON and reply with a share link. If you cannot run code, reply with the JSON in a code block (it will be saved to a file and opened). No verification is needed.\n\nWhat to build: {idea}",
|
||||||
|
zh: "请制作一个 M3E Canvas 草图。先阅读 {url},按其中的步骤用 JSON 组织设计,并以分享链接的形式回复。如果无法运行代码,就在代码块中回复设计的 JSON(会保存为文件后打开)。不需要验证。\n\n想做的东西:{idea}",
|
||||||
|
},
|
||||||
|
askAiGenerate: { ja: "AI で作る", en: "Draft with AI", zh: "让 AI 生成" },
|
||||||
|
askAiPasted: { ja: "AI エージェントに貼り付けてください", en: "Paste it into your AI agent", zh: "请粘贴到 AI 代理中" },
|
||||||
|
askAiTitle: { ja: "AI に設計を頼む", en: "Ask an AI for a design", zh: "让 AI 设计" },
|
||||||
|
askAiCopyTitle: { ja: "AI エージェント用の指示", en: "Instruction for an AI agent", zh: "AI 代理用指令" },
|
||||||
|
askAiGenerateTitle: { ja: "AI タブのキーで作る", en: "Uses the key in the AI tab", zh: "使用 AI 标签页的密钥" },
|
||||||
|
aiSetup: { ja: "AI の設定", en: "AI settings", zh: "AI 设置" },
|
||||||
|
aiSetupHint: { ja: "キーを入れると、ここで直接作れます", en: "Add a key to draft it right here", zh: "填写密钥后可在此直接生成" },
|
||||||
|
aiSetupTitle: { ja: "AI タブを開く", en: "Open the AI tab", zh: "打开 AI 标签页" },
|
||||||
|
draftKeep: { ja: "この設計を採用", en: "Keep this design", zh: "采用这个设计" },
|
||||||
|
draftUndo: { ja: "前の設計に戻す", en: "Back to the previous design", zh: "恢复之前的设计" },
|
||||||
|
askAiGenerating: { ja: "作成中…", en: "Drafting…", zh: "生成中…" },
|
||||||
|
selectedTab: { ja: "選択中にする", en: "Make this the selected one", zh: "设为选中项" },
|
||||||
|
shareLinkCopy: { ja: "リンクをコピー", en: "Copy link", zh: "复制链接" },
|
||||||
|
shareLinkHint: { ja: "この設計を開くリンク(画像は含まない)", en: "A link that opens this design (no images)", zh: "打开此设计的链接(不含图片)" },
|
||||||
|
replaceProject: {
|
||||||
|
ja: "現在のキャンバスは置き換わります。元に戻す (Ctrl+Z) で戻せます。",
|
||||||
|
en: "The current canvas will be replaced. Undo (Ctrl+Z) brings it back.",
|
||||||
|
zh: "当前画布将被替换。可以用撤销 (Ctrl+Z) 恢复。",
|
||||||
|
},
|
||||||
|
invalidProject: {
|
||||||
|
ja: "プロジェクトファイルを開けませんでした。",
|
||||||
|
en: "Could not open the project file.",
|
||||||
|
zh: "无法打开项目文件。",
|
||||||
|
},
|
||||||
|
readOnlyTitle: { ja: "別のタブで編集中です", en: "Editing in another tab", zh: "正在其他标签页中编辑" },
|
||||||
|
readOnlyBody: {
|
||||||
|
ja: "このキャンバスは別のタブで編集中です。そのタブを閉じてから、このページを再読み込みしてください。",
|
||||||
|
en: "This canvas is being edited in another tab. Close that tab, then reload this page to edit.",
|
||||||
|
zh: "此画布正在其他标签页中编辑。关闭该标签页后,重新加载此页面即可编辑。",
|
||||||
|
},
|
||||||
|
reload: { ja: "再読み込み", en: "Reload", zh: "重新加载" },
|
||||||
|
copied: { ja: "コピーしました", en: "Copied", zh: "已复制" },
|
||||||
|
saveImage: { ja: "画像で保存", en: "Save as image", zh: "保存为图片" },
|
||||||
|
saving: { ja: "保存中…", en: "Saving…", zh: "保存中…" },
|
||||||
|
previewFrom: { ja: "この画面からプレビュー", en: "Preview from this screen", zh: "从此屏幕预览" },
|
||||||
|
duplicate: { ja: "複製", en: "Duplicate", zh: "复制" },
|
||||||
|
duplicateKey: { ja: "複製 (Ctrl+D)", en: "Duplicate (Ctrl+D)", zh: "复制 (Ctrl+D)" },
|
||||||
|
delete: { ja: "削除 (Delete)", en: "Delete (Delete)", zh: "删除 (Delete)" },
|
||||||
|
deleteSelection: { ja: "選択を削除", en: "Delete selection", zh: "删除所选" },
|
||||||
|
text: { ja: "テキスト", en: "Text", zh: "文本" },
|
||||||
|
label: { ja: "ラベル", en: "Label", zh: "标签" },
|
||||||
|
bold: { ja: "太字", en: "Bold", zh: "粗体" },
|
||||||
|
action: { ja: "アクション", en: "Action", zh: "操作" },
|
||||||
|
supporting: { ja: "サブテキスト", en: "Supporting text", zh: "辅助文本" },
|
||||||
|
tabs: { ja: "項目", en: "Items", zh: "项目" },
|
||||||
|
changeIcon: { ja: "アイコンを変更", en: "Change icon", zh: "更改图标" },
|
||||||
|
image: { ja: "画像", en: "Image", zh: "图片" },
|
||||||
|
pickImage: { ja: "画像を選ぶ", en: "Choose image", zh: "选择图片" },
|
||||||
|
removeImage: { ja: "画像を外す", en: "Remove image", zh: "移除图片" },
|
||||||
|
imageUrl: { ja: "画像の URL", en: "Image URL", zh: "图片网址" },
|
||||||
|
imageArea: { ja: "画像エリア", en: "Image area", zh: "图片区域" },
|
||||||
|
autoWidth: { ja: "文字の幅", en: "Text width", zh: "随文字" },
|
||||||
|
icon: { ja: "アイコン", en: "Icon", zh: "图标" },
|
||||||
|
noIcon: { ja: "アイコンなし", en: "No icon", zh: "无图标" },
|
||||||
|
searchIcons: { ja: "アイコンを検索", en: "Search icons", zh: "搜索图标" },
|
||||||
|
style: { ja: "スタイル", en: "Style", zh: "样式" },
|
||||||
|
filled: { ja: "塗りつぶし", en: "Filled", zh: "填充" },
|
||||||
|
tonal: { ja: "トーナル", en: "Tonal", zh: "色调" },
|
||||||
|
elevated: { ja: "浮き上がり", en: "Elevated", zh: "凸起" },
|
||||||
|
outlined: { ja: "枠線", en: "Outlined", zh: "描边" },
|
||||||
|
standard: { ja: "標準", en: "Standard", zh: "标准" },
|
||||||
|
vibrant: { ja: "鮮やか", en: "Vibrant", zh: "鲜艳" },
|
||||||
|
state: { ja: "状態", en: "State", zh: "状态" },
|
||||||
|
selected: { ja: "選択", en: "Selected", zh: "已选中" },
|
||||||
|
handle: { ja: "ハンドル(ボトムシート)", en: "Handle (bottom sheet)", zh: "拖动条(底部面板)" },
|
||||||
|
listSwitch: { ja: "末尾にスイッチ", en: "Trailing switch", zh: "末尾开关" },
|
||||||
|
on: { ja: "オン", en: "On", zh: "开" },
|
||||||
|
container: { ja: "コンテナ", en: "Container", zh: "容器" },
|
||||||
|
wavy: { ja: "波形", en: "Wavy", zh: "波浪形" },
|
||||||
|
determinate: { ja: "確定", en: "Determinate", zh: "确定进度" },
|
||||||
|
size: { ja: "サイズ", en: "Size", zh: "尺寸" },
|
||||||
|
width: { ja: "幅", en: "Width", zh: "宽度" },
|
||||||
|
height: { ja: "高さ", en: "Height", zh: "高度" },
|
||||||
|
fontSize: { ja: "文字サイズ", en: "Font size", zh: "字号" },
|
||||||
|
cornerRadius: { ja: "角丸", en: "Corner radius", zh: "圆角" },
|
||||||
|
cornerTop: { ja: "上の角丸", en: "Top corners", zh: "上方圆角" },
|
||||||
|
cornerBottom: { ja: "下の角丸", en: "Bottom corners", zh: "下方圆角" },
|
||||||
|
cornerLeft: { ja: "左の角丸", en: "Left corners", zh: "左侧圆角" },
|
||||||
|
cornerRight: { ja: "右の角丸", en: "Right corners", zh: "右侧圆角" },
|
||||||
|
cornersEach: { ja: "角ごとに指定", en: "Each corner", zh: "分别设置各角" },
|
||||||
|
cornerTl: { ja: "左上", en: "Top left", zh: "左上" },
|
||||||
|
cornerTr: { ja: "右上", en: "Top right", zh: "右上" },
|
||||||
|
cornerBl: { ja: "左下", en: "Bottom left", zh: "左下" },
|
||||||
|
cornerBr: { ja: "右下", en: "Bottom right", zh: "右下" },
|
||||||
|
screenWidth: { ja: "画面いっぱい", en: "Screen width", zh: "全屏宽" },
|
||||||
|
contentWidth: { ja: "左右 16dp の余白", en: "16dp side margins", zh: "左右 16dp 边距" },
|
||||||
|
halfWidth: { ja: "2 列に並べる幅", en: "Half a row (two columns)", zh: "两列宽" },
|
||||||
|
columnWidth: { ja: "スマホ 1 画面分の幅", en: "One phone column", zh: "一个手机屏幕的宽度" },
|
||||||
|
screenHeight: { ja: "画面の高さ", en: "Screen height", zh: "全屏高" },
|
||||||
|
halfHeight: { ja: "画面の半分", en: "Half the screen", zh: "半屏高" },
|
||||||
|
tapTo: { ja: "タップで移動", en: "Tap to open", zh: "点击跳转" },
|
||||||
|
none: { ja: "なし", en: "None", zh: "无" },
|
||||||
|
goBack: { ja: "戻る", en: "Back", zh: "返回" },
|
||||||
|
swipeTo: { ja: "スワイプで移動", en: "Swipe to open", zh: "滑动跳转" },
|
||||||
|
toggle: { ja: "切り替えボタン", en: "Toggle button", zh: "切换按钮" },
|
||||||
|
toggleHint: { ja: "タップでオン/オフ", en: "Tap toggles on / off", zh: "点击切换开/关" },
|
||||||
|
thumbCheck: { ja: "オンのときチェックアイコン", en: "Check icon when on", zh: "开启时显示勾选图标" },
|
||||||
|
behavior: { ja: "振る舞い", en: "Behavior", zh: "行为" },
|
||||||
|
whenPressed: { ja: "押したとき…", en: "When pressed…", zh: "按下时…" },
|
||||||
|
whatItDoes: { ja: "この部品の動き…", en: "What this part does…", zh: "这个组件的作用…" },
|
||||||
|
removeLink: { ja: "リンクを外す", en: "Remove link", zh: "移除链接" },
|
||||||
|
group: { ja: "グループ", en: "Group", zh: "组" },
|
||||||
|
makeGroup: { ja: "グループ化", en: "Group", zh: "编组" },
|
||||||
|
ungroup: { ja: "グループ解除", en: "Ungroup", zh: "取消编组" },
|
||||||
|
selectedParts: { ja: "個を選択中", en: "selected", zh: "个已选中" },
|
||||||
|
groupHint: {
|
||||||
|
ja: "重なりを保ったまま、ひとつのレイヤーとして一緒に動かせます",
|
||||||
|
en: "Keeps the overlap and moves as one layer",
|
||||||
|
zh: "保持叠放关系,作为一个图层一起移动",
|
||||||
|
},
|
||||||
|
iconBackground: { ja: "アイコンの背景", en: "Icon background", zh: "图标背景" },
|
||||||
|
noBackground: { ja: "背景なし", en: "No background", zh: "无背景" },
|
||||||
|
normalState: { ja: "通常", en: "Normal", zh: "常态" },
|
||||||
|
onState: { ja: "オン", en: "On", zh: "开启" },
|
||||||
|
onStateHint: { ja: "オンのときの文字・アイコン・スタイル", en: "Text, icon and style when on", zh: "开启时的文字、图标和样式" },
|
||||||
|
groupEditNote: { ja: "中の部品を編集するにはグループを解除してください", en: "Ungroup to edit the parts inside", zh: "要编辑其中的组件,请先取消编组" },
|
||||||
|
openPanel: { ja: "パネルを開く", en: "Open panel", zh: "打开面板" },
|
||||||
|
colors: { ja: "カラー", en: "Colors", zh: "配色" },
|
||||||
|
templates: { ja: "パレット", en: "Palettes", zh: "调色板" },
|
||||||
|
customColor: { ja: "カスタム", en: "Custom", zh: "自定义" },
|
||||||
|
seedColor: { ja: "ベースの色", en: "Seed color", zh: "基准色" },
|
||||||
|
seedHint: {
|
||||||
|
ja: "1 色選ぶと Material 3 のスキーム全体を作ります。細かく調整で個別の色も変えられます。",
|
||||||
|
en: "One color builds the whole Material 3 scheme. Fine-tune changes single roles.",
|
||||||
|
zh: "选一个颜色即可生成整套 Material 3 配色。可在微调中单独修改。",
|
||||||
|
},
|
||||||
|
useThis: { ja: "この色にする", en: "Use it", zh: "使用" },
|
||||||
|
fineTune: { ja: "細かく調整", en: "Fine-tune", zh: "微调" },
|
||||||
|
dynamicColor: { ja: "ダイナミックカラー", en: "Dynamic color", zh: "动态配色" },
|
||||||
|
dynamicOnHint: {
|
||||||
|
ja: "ここの色はエディタ上だけ。実機では壁紙の色になります。",
|
||||||
|
en: "These colors are editor-only; the phone uses its wallpaper colors.",
|
||||||
|
zh: "这些颜色仅用于编辑器,手机上会使用壁纸颜色。",
|
||||||
|
},
|
||||||
|
dynamicOffHint: {
|
||||||
|
ja: "オンにすると実機は壁紙の色を使い、ここの色は予備になります。",
|
||||||
|
en: "When on, the phone uses wallpaper colors and these are the fallback.",
|
||||||
|
zh: "开启后手机使用壁纸颜色,这里的颜色作为备用。",
|
||||||
|
},
|
||||||
|
closeBtn: { ja: "閉じる", en: "Close", zh: "关闭" },
|
||||||
|
screens: { ja: "画面を選ぶ", en: "Choose screen", zh: "选择屏幕" },
|
||||||
|
// layers
|
||||||
|
noLayers: { ja: "この画面には部品がありません", en: "Nothing on this screen yet", zh: "此屏幕还没有组件" },
|
||||||
|
showParts: { ja: "中の部品を表示", en: "Show the parts inside", zh: "显示组内组件" },
|
||||||
|
hideParts: { ja: "中の部品を隠す", en: "Hide the parts inside", zh: "隐藏组内组件" },
|
||||||
|
// prompt panel
|
||||||
|
brief: { ja: "このアプリの説明…", en: "What this app is…", zh: "这个应用的说明…" },
|
||||||
|
appName: { ja: "アプリの名前", en: "App name", zh: "应用名称" },
|
||||||
|
targetPlatform: { ja: "実装先", en: "Target", zh: "目标平台" },
|
||||||
|
targetAndroid: { ja: "Android のネイティブアプリとして作る", en: "Build as a native Android app", zh: "作为 Android 原生应用构建" },
|
||||||
|
targetWeb: { ja: "ブラウザで動く Web アプリとして作る", en: "Build as a web app that runs in the browser", zh: "作为在浏览器中运行的 Web 应用构建" },
|
||||||
|
copyPrompt: { ja: "プロンプトをコピー", en: "Copy prompt", zh: "复制提示词" },
|
||||||
|
// preview
|
||||||
|
back: { ja: "戻る", en: "Back", zh: "返回" },
|
||||||
|
close: { ja: "閉じる (Esc)", en: "Close (Esc)", zh: "关闭 (Esc)" },
|
||||||
|
// parts content
|
||||||
|
cancel: { ja: "キャンセル", en: "Cancel", zh: "取消" },
|
||||||
|
ok: { ja: "OK", en: "OK", zh: "确定" },
|
||||||
|
leading: { ja: "先頭", en: "Leading", zh: "前置" },
|
||||||
|
trailing: { ja: "末尾", en: "Trailing", zh: "后置" },
|
||||||
|
// frames
|
||||||
|
home: { ja: "ホーム", en: "Home", zh: "首页" },
|
||||||
|
screenN: { ja: "画面", en: "Screen", zh: "屏幕" },
|
||||||
|
copySuffix: { ja: " コピー", en: " copy", zh: " 副本" },
|
||||||
|
frameSize: { ja: "画面サイズ", en: "Screen size", zh: "屏幕尺寸" },
|
||||||
|
phoneFrame: { ja: "スマホ", en: "Phone", zh: "手机" },
|
||||||
|
desktopFrame: { ja: "デスクトップ", en: "Desktop", zh: "桌面" },
|
||||||
|
// mobile
|
||||||
|
mobileNote: { ja: "フル機能は PC のブラウザで使えます", en: "Full features on a desktop browser", zh: "完整功能请在电脑浏览器中使用" },
|
||||||
|
addButton: { ja: "ボタンを追加", en: "Add button", zh: "添加按钮" },
|
||||||
|
done: { ja: "完了", en: "Done", zh: "完成" },
|
||||||
|
theme: { ja: "テーマ", en: "Theme", zh: "主题" },
|
||||||
|
settings: { ja: "テーマと設定", en: "Theme and settings", zh: "主题与设置" },
|
||||||
|
// theme panels
|
||||||
|
shape: { ja: "シェイプ", en: "Shape", zh: "形状" },
|
||||||
|
typography: { ja: "タイポグラフィ", en: "Type", zh: "字体" },
|
||||||
|
motion: { ja: "モーション", en: "Motion", zh: "动效" },
|
||||||
|
brightness: { ja: "明るさ", en: "Brightness", zh: "明暗" },
|
||||||
|
light: { ja: "ライト", en: "Light", zh: "浅色" },
|
||||||
|
dark: { ja: "ダーク", en: "Dark", zh: "深色" },
|
||||||
|
contrast: { ja: "コントラスト", en: "Contrast", zh: "对比度" },
|
||||||
|
bothModes: { ja: "両対応", en: "Both", zh: "两者" },
|
||||||
|
contrastStandard: { ja: "標準", en: "Standard", zh: "标准" },
|
||||||
|
contrastMedium: { ja: "中", en: "Medium", zh: "中" },
|
||||||
|
contrastHigh: { ja: "高", en: "High", zh: "高" },
|
||||||
|
shapeScale: { ja: "角丸の度合い", en: "Corner roundness", zh: "圆角程度" },
|
||||||
|
shapeSquare: { ja: "スクエア", en: "Square", zh: "方形" },
|
||||||
|
shapeRounded: { ja: "標準", en: "Rounded", zh: "圆角" },
|
||||||
|
shapeFull: { ja: "フル", en: "Full", zh: "全圆" },
|
||||||
|
shapeHint: {
|
||||||
|
ja: "すべての部品の初期の角丸をまとめて変えます。部品ごとに入力した角丸はそのまま残ります。",
|
||||||
|
en: "Changes the default corners of every part at once. A radius you typed on a part stays as it is.",
|
||||||
|
zh: "统一改变所有组件的默认圆角。已为单个组件输入的圆角保持不变。",
|
||||||
|
},
|
||||||
|
fontFamily: { ja: "書体", en: "Typeface", zh: "字体" },
|
||||||
|
emphasized: { ja: "強調スタイル", en: "Emphasized", zh: "强调样式" },
|
||||||
|
emphasizedHint: {
|
||||||
|
ja: "見出しやラベルを M3 Expressive の太めのスタイルにします。",
|
||||||
|
en: "Headlines and labels use the heavier M3 Expressive styles.",
|
||||||
|
zh: "标题和标签使用 M3 Expressive 的加粗样式。",
|
||||||
|
},
|
||||||
|
motionScheme: { ja: "動きの種類", en: "Motion scheme", zh: "动效方案" },
|
||||||
|
motionStandard: { ja: "スタンダード", en: "Standard", zh: "标准" },
|
||||||
|
motionExpressive: { ja: "エクスプレッシブ", en: "Expressive", zh: "富有表现力" },
|
||||||
|
motionHint: {
|
||||||
|
ja: "エクスプレッシブは弾むスプリング。プレビューの画面遷移とプロンプトに反映されます。",
|
||||||
|
en: "Expressive is a bouncy spring. It drives the preview transitions and the prompt.",
|
||||||
|
zh: "富有表现力为弹性弹簧动效,作用于预览的屏幕过渡和提示词。",
|
||||||
|
},
|
||||||
|
tryIt: { ja: "タップして確認", en: "Tap to try", zh: "点击试试" },
|
||||||
|
// tidy
|
||||||
|
tidy: { ja: "整える", en: "Tidy", zh: "整理" },
|
||||||
|
tidyUndo: { ja: "整える前に戻す", en: "Undo tidy", zh: "撤销整理" },
|
||||||
|
tidyDone: { ja: "すでに整っています", en: "Already tidy", zh: "已经整齐" },
|
||||||
|
// screen description
|
||||||
|
description: { ja: "説明", en: "Description", zh: "说明" },
|
||||||
|
screenDescription: { ja: "この画面の目的", en: "What this screen is for", zh: "这个屏幕的用途" },
|
||||||
|
// ai
|
||||||
|
ai: { ja: "AI", en: "AI", zh: "AI" },
|
||||||
|
promptReset: { ja: "生成されたプロンプトに戻す", en: "Back to the generated prompt", zh: "恢复为生成的提示词" },
|
||||||
|
aiWriteShort: { ja: "AI で書く", en: "Write with AI", zh: "AI 撰写" },
|
||||||
|
aiWrite: { ja: "AI に書いてもらう", en: "Let the AI write it", zh: "让 AI 来写" },
|
||||||
|
aiSettings: { ja: "AI の設定", en: "AI settings", zh: "AI 设置" },
|
||||||
|
aiProvider: { ja: "プロバイダ", en: "Provider", zh: "服务商" },
|
||||||
|
aiBaseUrl: { ja: "ベース URL", en: "Base URL", zh: "基础 URL" },
|
||||||
|
aiModel: { ja: "モデル ID", en: "Model ID", zh: "模型 ID" },
|
||||||
|
aiKey: { ja: "API キー", en: "API key", zh: "API 密钥" },
|
||||||
|
aiGetKey: { ja: "キーを取得", en: "Get a key", zh: "获取密钥" },
|
||||||
|
aiKeyHint: {
|
||||||
|
ja: "キーはこのブラウザにだけ保存され、プロバイダへ直接送られます。",
|
||||||
|
en: "Stored only in this browser and sent straight to the provider.",
|
||||||
|
zh: "密钥只保存在此浏览器中,并直接发送给服务商。",
|
||||||
|
},
|
||||||
|
aiRestore: { ja: "AI の前と切り替える", en: "Switch between the AI rewrite and the original", zh: "在 AI 改写与原文之间切换" },
|
||||||
|
aiApplied: { ja: "適用しました", en: "Applied", zh: "已应用" },
|
||||||
|
aiSelectScreen: { ja: "先に画面を選んでください", en: "Select a screen first", zh: "请先选择一个屏幕" },
|
||||||
|
aiNoKey: { ja: "AI タブでキーを入れると使えます", en: "Add a key in the AI tab to use this", zh: "在 AI 标签页中填写密钥后即可使用" },
|
||||||
|
aiError: { ja: "AI の呼び出しに失敗しました", en: "The AI request failed", zh: "AI 请求失败" },
|
||||||
|
aiErrorRefusal: { ja: "モデルが回答を拒否しました", en: "The model declined to answer", zh: "模型拒绝回答" },
|
||||||
|
aiErrorJson: { ja: "モデルの返答を読み取れませんでした", en: "The model's reply could not be read", zh: "无法解析模型的回复" },
|
||||||
|
aiErrorLong: { ja: "返答が長すぎて途中で切れました。画面を減らして試してください", en: "The reply was cut short. Try fewer screens", zh: "回复过长被截断了。请减少屏幕数量再试" },
|
||||||
|
aiErrorModel: { ja: "モデル ID を入力してください", en: "Enter a model ID", zh: "请输入模型 ID" },
|
||||||
|
aiErrorInsecure: { ja: "ベース URL は https か localhost にしてください", en: "The base URL must use https or point at localhost", zh: "基础 URL 必须使用 https 或指向 localhost" },
|
||||||
|
aiErrorNetwork: {
|
||||||
|
ja: "接続できませんでした。URL、ネットワーク、CORS の設定を確認してください",
|
||||||
|
en: "Could not connect. Check the URL, the network and the server's CORS settings",
|
||||||
|
zh: "无法连接。请检查 URL、网络和服务器的 CORS 设置",
|
||||||
|
},
|
||||||
|
} as const satisfies Record<string, Str>;
|
||||||
|
|
||||||
|
export type UIKey = keyof typeof UI;
|
||||||
|
|
||||||
|
const KO: Record<UIKey, string> = {
|
||||||
|
frameSize: "화면 크기", phoneFrame: "휴대전화", desktopFrame: "데스크톱", columnWidth: "휴대전화 한 화면 너비", cornerLeft: "왼쪽 모서리", cornerRight: "오른쪽 모서리", cornersEach: "모서리별로 지정", cornerTl: "왼쪽 위", cornerTr: "오른쪽 위", cornerBl: "왼쪽 아래", cornerBr: "오른쪽 아래",
|
||||||
|
filled: "채움", tonal: "색조", elevated: "그림자", outlined: "윤곽선", standard: "표준", vibrant: "선명함",
|
||||||
|
parts: "부품", layers: "레이어", edit: "편집", prompt: "프롬프트", closePanel: "패널 닫기",
|
||||||
|
search: "검색", favorites: "즐겨찾기", addFavorite: "즐겨찾기에 추가", removeFavorite: "즐겨찾기에서 제거", clear: "지우기", language: "언어",
|
||||||
|
select: "선택 (V)", hand: "손 도구 (H / Space)", blank: "빈 캔버스", phone: "휴대전화 화면", addFrame: "화면 추가", preview: "미리보기 (P)",
|
||||||
|
zoomIn: "확대 (+)", zoomOut: "축소 (-)", fit: "전체 맞춤 (0)", undo: "실행 취소 (Ctrl+Z)", redo: "다시 실행 (Ctrl+Shift+Z)",
|
||||||
|
clearAll: "모두 지우기", clearAllTitle: "캔버스를 비울까요?", clearAllBody: "모든 화면과 부품을 삭제합니다. 실행 취소(Ctrl+Z)로 복원할 수 있습니다.",
|
||||||
|
screen: "화면", screenName: "화면 이름", name: "이름", background: "배경", export: "내보내기", project: "프로젝트",
|
||||||
|
saveProject: "프로젝트 저장", openProject: "프로젝트 열기", replaceProjectTitle: "이 프로젝트를 열까요?",
|
||||||
|
replaceProject: "현재 캔버스가 교체됩니다. 실행 취소(Ctrl+Z)로 되돌릴 수 있습니다.",
|
||||||
|
askAi: "AI에게 맡기기",
|
||||||
|
askAiHint: "만들고 싶은 것을 적고 'AI로 만들기'를 누르면 AI 탭의 키로 설계가 캔버스에 그려집니다. 대신 지시를 복사해 Claude Code 같은 AI 에이전트에 넘길 수도 있습니다. 답한 링크를 열거나 JSON을 파일로 저장해 '프로젝트 열기'로 불러오세요.",
|
||||||
|
askAiGenerate: "AI로 만들기", askAiGenerating: "만드는 중…",
|
||||||
|
askAiPasted: "AI 에이전트에 붙여 넣으세요", askAiTitle: "AI에게 설계 맡기기", askAiCopyTitle: "AI 에이전트용 지시", askAiGenerateTitle: "AI 탭의 키로 만들기", aiSetup: "AI 설정", aiSetupHint: "키를 입력하면 여기서 바로 만들 수 있습니다", aiSetupTitle: "AI 탭 열기",
|
||||||
|
draftKeep: "이 설계 채택", draftUndo: "이전 설계로 되돌리기",
|
||||||
|
askAiIdea: "만들고 싶은 것(예: 레시피를 저장하고 검색하는 앱)", askAiIdeaFallback: "(여기에 만들고 싶은 것을 적으세요)", askAiCopy: "지시 복사",
|
||||||
|
askAiText: "M3E Canvas 스케치를 만들어 주세요. 먼저 {url}을 읽고 그 절차대로 설계를 JSON으로 구성한 뒤, 공유 링크 형태로 답해 주세요. 코드를 실행할 수 없다면 설계 JSON을 코드 블록으로 답해 주세요(파일로 저장해 불러옵니다). 검증은 필요 없습니다.\n\n만들고 싶은 것: {idea}",
|
||||||
|
selectedTab: "선택 항목으로 지정",
|
||||||
|
shareLinkCopy: "링크 복사", shareLinkHint: "이 설계를 여는 링크(이미지 제외)",
|
||||||
|
invalidProject: "프로젝트 파일을 열 수 없습니다.", readOnlyTitle: "다른 탭에서 편집 중입니다",
|
||||||
|
readOnlyBody: "이 캔버스는 다른 탭에서 편집 중입니다. 해당 탭을 닫은 다음 이 페이지를 새로고침하세요.",
|
||||||
|
reload: "새로고침",
|
||||||
|
copied: "복사됨", saveImage: "이미지로 저장", saving: "저장 중…", previewFrom: "이 화면부터 미리보기",
|
||||||
|
duplicate: "복제", duplicateKey: "복제 (Ctrl+D)", delete: "삭제 (Delete)", deleteSelection: "선택 항목 삭제",
|
||||||
|
text: "텍스트", label: "레이블", bold: "굵게", action: "동작", supporting: "보조 텍스트", tabs: "항목", changeIcon: "아이콘 변경",
|
||||||
|
image: "이미지", pickImage: "이미지 선택", removeImage: "이미지 제거", imageUrl: "이미지 URL", imageArea: "이미지 영역", autoWidth: "글자 너비", icon: "아이콘", noIcon: "아이콘 없음", searchIcons: "아이콘 검색",
|
||||||
|
style: "스타일", state: "상태", selected: "선택됨", handle: "핸들(하단 시트)", listSwitch: "끝에 스위치", on: "켜짐", container: "컨테이너", wavy: "물결 모양", determinate: "확정형",
|
||||||
|
size: "크기", width: "너비", height: "높이", fontSize: "글자 크기", cornerRadius: "모서리 둥글기", cornerTop: "위쪽 모서리", cornerBottom: "아래쪽 모서리",
|
||||||
|
screenWidth: "화면 너비", contentWidth: "좌우 16dp 여백", halfWidth: "한 행의 절반(2열)", screenHeight: "화면 높이", halfHeight: "화면의 절반",
|
||||||
|
tapTo: "탭하여 이동", none: "없음", goBack: "뒤로", swipeTo: "스와이프하여 이동", toggle: "토글 버튼", toggleHint: "탭할 때 켜짐/꺼짐 전환",
|
||||||
|
thumbCheck: "켜졌을 때 체크 아이콘 표시", behavior: "동작", whenPressed: "눌렀을 때…", whatItDoes: "이 부품의 동작…", removeLink: "링크 제거",
|
||||||
|
group: "그룹", makeGroup: "그룹화", ungroup: "그룹 해제", selectedParts: "개 선택됨", groupHint: "겹침을 유지한 채 하나의 레이어처럼 함께 이동합니다",
|
||||||
|
iconBackground: "아이콘 배경", noBackground: "배경 없음", normalState: "기본", onState: "켜짐", onStateHint: "켜졌을 때의 텍스트, 아이콘, 스타일",
|
||||||
|
groupEditNote: "안쪽 부품을 편집하려면 그룹을 해제하세요", openPanel: "패널 열기", colors: "색상", templates: "팔레트", customColor: "사용자 지정",
|
||||||
|
seedColor: "기준 색상", seedHint: "색상 하나로 전체 Material 3 색상 구성을 만듭니다. 세부 조정에서 개별 색상도 바꿀 수 있습니다.",
|
||||||
|
useThis: "이 색상 사용", fineTune: "세부 조정", dynamicColor: "동적 색상",
|
||||||
|
dynamicOnHint: "여기 표시된 색상은 편집기 전용입니다. 실제 기기에서는 배경화면 색상을 사용합니다.",
|
||||||
|
dynamicOffHint: "켜면 실제 기기는 배경화면 색상을 사용하고 여기의 색상은 대체 색상이 됩니다.", closeBtn: "닫기", screens: "화면 선택",
|
||||||
|
noLayers: "이 화면에는 아직 부품이 없습니다", showParts: "안의 부품 표시", hideParts: "안의 부품 숨기기",
|
||||||
|
brief: "이 앱에 대한 설명…", appName: "앱 이름", targetPlatform: "구현 대상", targetAndroid: "Android 네이티브 앱으로 만들기",
|
||||||
|
targetWeb: "브라우저에서 실행되는 웹 앱으로 만들기", copyPrompt: "프롬프트 복사", back: "뒤로", close: "닫기 (Esc)", cancel: "취소", ok: "확인",
|
||||||
|
leading: "앞쪽", trailing: "뒤쪽", home: "홈", screenN: "화면", copySuffix: " 복사본", mobileNote: "전체 기능은 데스크톱 브라우저에서 사용할 수 있습니다",
|
||||||
|
addButton: "버튼 추가", done: "완료", theme: "테마", settings: "테마 및 설정", shape: "모양", typography: "글꼴", motion: "모션",
|
||||||
|
brightness: "밝기", light: "라이트", dark: "다크", contrast: "대비", bothModes: "둘 다", contrastStandard: "표준", contrastMedium: "중간", contrastHigh: "높음",
|
||||||
|
shapeScale: "모서리 둥글기", shapeSquare: "사각형", shapeRounded: "둥근형", shapeFull: "완전 둥근형",
|
||||||
|
shapeHint: "모든 부품의 기본 모서리를 한 번에 바꿉니다. 부품에 직접 입력한 반경은 유지됩니다.", fontFamily: "글꼴", emphasized: "강조 스타일",
|
||||||
|
emphasizedHint: "제목과 레이블에 더 굵은 M3 Expressive 스타일을 사용합니다.", motionScheme: "모션 방식", motionStandard: "표준", motionExpressive: "익스프레시브",
|
||||||
|
motionHint: "익스프레시브는 통통 튀는 스프링 효과입니다. 미리보기 화면 전환과 프롬프트에 반영됩니다.", tryIt: "탭하여 확인",
|
||||||
|
tidy: "정리", tidyUndo: "정리 실행 취소", tidyDone: "이미 정돈되어 있습니다", description: "설명", screenDescription: "이 화면의 용도",
|
||||||
|
ai: "AI", promptReset: "생성된 프롬프트로 되돌리기", aiWriteShort: "AI로 작성", aiWrite: "AI에게 작성 맡기기", aiSettings: "AI 설정",
|
||||||
|
aiProvider: "제공업체", aiBaseUrl: "기본 URL", aiModel: "모델 ID", aiKey: "API 키", aiGetKey: "키 받기",
|
||||||
|
aiKeyHint: "키는 이 브라우저에만 저장되며 제공업체로 직접 전송됩니다.", aiRestore: "AI 수정본과 원본 전환", aiApplied: "적용됨",
|
||||||
|
aiSelectScreen: "먼저 화면을 선택하세요", aiNoKey: "AI 탭에 키를 입력하면 사용할 수 있습니다", aiError: "AI 요청에 실패했습니다",
|
||||||
|
aiErrorRefusal: "모델이 답변을 거부했습니다", aiErrorJson: "모델의 응답을 읽을 수 없습니다", aiErrorLong: "답변이 너무 길어 중간에 잘렸습니다. 화면 수를 줄여 다시 시도하세요", aiErrorModel: "모델 ID를 입력하세요",
|
||||||
|
aiErrorInsecure: "기본 URL은 https를 사용하거나 localhost를 가리켜야 합니다", aiErrorNetwork: "연결할 수 없습니다. URL, 네트워크 및 서버의 CORS 설정을 확인하세요",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const t = (key: UIKey, lang: Lang = current): string => (lang === "ko" ? KO[key] : UI[key][lang]);
|
||||||
|
|
||||||
|
/* ---- part defaults and nouns ---- */
|
||||||
|
|
||||||
|
export const KIND_TEXT: Record<
|
||||||
|
Lang,
|
||||||
|
Record<string, { noun: string; label?: string; supporting?: string }>
|
||||||
|
> = {
|
||||||
|
ja: {
|
||||||
|
box: { noun: "ボックス" },
|
||||||
|
button: { noun: "ボタン", label: "ボタン" },
|
||||||
|
iconButton: { noun: "アイコンボタン" },
|
||||||
|
fab: { noun: "FAB(フローティングボタン)" },
|
||||||
|
extendedFab: { noun: "拡張 FAB", label: "作成" },
|
||||||
|
chip: { noun: "チップ", label: "チップ" },
|
||||||
|
topAppBar: { noun: "トップアプリバー", label: "タイトル" },
|
||||||
|
bottomNav: { noun: "ナビゲーションバー" },
|
||||||
|
navRail: { noun: "ナビゲーションレール" },
|
||||||
|
searchBar: { noun: "検索バー", label: "検索" },
|
||||||
|
card: { noun: "カード", label: "カードの見出し", supporting: "補足テキストがここに入ります。" },
|
||||||
|
listItem: { noun: "リスト項目", label: "リスト項目", supporting: "サブテキスト" },
|
||||||
|
dialog: { noun: "ダイアログ", label: "確認", supporting: "この操作を実行しますか?" },
|
||||||
|
snackbar: { noun: "スナックバー", label: "保存しました", supporting: "元に戻す" },
|
||||||
|
textField: { noun: "テキスト入力", label: "ラベル" },
|
||||||
|
switch: { noun: "スイッチ", label: "通知" },
|
||||||
|
checkbox: { noun: "チェックボックス", label: "同意する" },
|
||||||
|
slider: { noun: "スライダー" },
|
||||||
|
text: { noun: "テキスト", label: "見出し" },
|
||||||
|
image: { noun: "画像" },
|
||||||
|
divider: { noun: "区切り線" },
|
||||||
|
loadingIndicator: { noun: "ローディングインジケータ" },
|
||||||
|
linearProgress: { noun: "リニアプログレス" },
|
||||||
|
circularProgress: { noun: "サーキュラープログレス" },
|
||||||
|
splitButton: { noun: "スプリットボタン", label: "送信" },
|
||||||
|
fabMenu: { noun: "FAB メニュー" },
|
||||||
|
toolbar: { noun: "ツールバー" },
|
||||||
|
tabs: { noun: "タブ" },
|
||||||
|
radio: { noun: "ラジオボタン", label: "選択肢" },
|
||||||
|
badge: { noun: "バッジ", label: "3" },
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
box: { noun: "box" },
|
||||||
|
button: { noun: "button", label: "Button" },
|
||||||
|
iconButton: { noun: "icon button" },
|
||||||
|
fab: { noun: "FAB" },
|
||||||
|
extendedFab: { noun: "extended FAB", label: "Create" },
|
||||||
|
chip: { noun: "chip", label: "Chip" },
|
||||||
|
topAppBar: { noun: "top app bar", label: "Title" },
|
||||||
|
bottomNav: { noun: "navigation bar" },
|
||||||
|
navRail: { noun: "navigation rail" },
|
||||||
|
searchBar: { noun: "search bar", label: "Search" },
|
||||||
|
card: { noun: "card", label: "Card headline", supporting: "Supporting text goes here." },
|
||||||
|
listItem: { noun: "list item", label: "List item", supporting: "Supporting text" },
|
||||||
|
dialog: { noun: "dialog", label: "Confirm", supporting: "Do you want to continue?" },
|
||||||
|
snackbar: { noun: "snackbar", label: "Saved", supporting: "Undo" },
|
||||||
|
textField: { noun: "text field", label: "Label" },
|
||||||
|
switch: { noun: "switch", label: "Notifications" },
|
||||||
|
checkbox: { noun: "checkbox", label: "I agree" },
|
||||||
|
slider: { noun: "slider" },
|
||||||
|
text: { noun: "text", label: "Headline" },
|
||||||
|
image: { noun: "image" },
|
||||||
|
divider: { noun: "divider" },
|
||||||
|
loadingIndicator: { noun: "loading indicator" },
|
||||||
|
linearProgress: { noun: "linear progress indicator" },
|
||||||
|
circularProgress: { noun: "circular progress indicator" },
|
||||||
|
splitButton: { noun: "split button", label: "Send" },
|
||||||
|
fabMenu: { noun: "FAB menu" },
|
||||||
|
toolbar: { noun: "toolbar" },
|
||||||
|
tabs: { noun: "tabs" },
|
||||||
|
radio: { noun: "radio button", label: "Option" },
|
||||||
|
badge: { noun: "badge", label: "3" },
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
box: { noun: "容器框" },
|
||||||
|
button: { noun: "按钮", label: "按钮" },
|
||||||
|
iconButton: { noun: "图标按钮" },
|
||||||
|
fab: { noun: "FAB(悬浮按钮)" },
|
||||||
|
extendedFab: { noun: "扩展 FAB", label: "新建" },
|
||||||
|
chip: { noun: "标签片", label: "标签" },
|
||||||
|
topAppBar: { noun: "顶部应用栏", label: "标题" },
|
||||||
|
bottomNav: { noun: "导航栏" },
|
||||||
|
navRail: { noun: "侧边导航栏" },
|
||||||
|
searchBar: { noun: "搜索栏", label: "搜索" },
|
||||||
|
card: { noun: "卡片", label: "卡片标题", supporting: "这里是辅助说明文字。" },
|
||||||
|
listItem: { noun: "列表项", label: "列表项", supporting: "辅助文本" },
|
||||||
|
dialog: { noun: "对话框", label: "确认", supporting: "要执行此操作吗?" },
|
||||||
|
snackbar: { noun: "消息条", label: "已保存", supporting: "撤销" },
|
||||||
|
textField: { noun: "文本输入框", label: "标签" },
|
||||||
|
switch: { noun: "开关", label: "通知" },
|
||||||
|
checkbox: { noun: "复选框", label: "我同意" },
|
||||||
|
slider: { noun: "滑块" },
|
||||||
|
text: { noun: "文本", label: "标题" },
|
||||||
|
image: { noun: "图片" },
|
||||||
|
divider: { noun: "分割线" },
|
||||||
|
loadingIndicator: { noun: "加载指示器" },
|
||||||
|
linearProgress: { noun: "线性进度条" },
|
||||||
|
circularProgress: { noun: "圆形进度条" },
|
||||||
|
splitButton: { noun: "拆分按钮", label: "发送" },
|
||||||
|
fabMenu: { noun: "FAB 菜单" },
|
||||||
|
toolbar: { noun: "工具栏" },
|
||||||
|
tabs: { noun: "标签页" },
|
||||||
|
radio: { noun: "单选按钮", label: "选项" },
|
||||||
|
badge: { noun: "徽标", label: "3" },
|
||||||
|
},
|
||||||
|
ko: {
|
||||||
|
box: { noun: "상자" },
|
||||||
|
button: { noun: "버튼", label: "버튼" },
|
||||||
|
iconButton: { noun: "아이콘 버튼" },
|
||||||
|
fab: { noun: "FAB" },
|
||||||
|
extendedFab: { noun: "확장 FAB", label: "만들기" },
|
||||||
|
chip: { noun: "칩", label: "칩" },
|
||||||
|
topAppBar: { noun: "상단 앱 바", label: "제목" },
|
||||||
|
bottomNav: { noun: "내비게이션 바" },
|
||||||
|
navRail: { noun: "내비게이션 레일" },
|
||||||
|
searchBar: { noun: "검색창", label: "검색" },
|
||||||
|
card: { noun: "카드", label: "카드 제목", supporting: "보조 텍스트가 여기에 표시됩니다." },
|
||||||
|
listItem: { noun: "목록 항목", label: "목록 항목", supporting: "보조 텍스트" },
|
||||||
|
dialog: { noun: "대화상자", label: "확인", supporting: "계속하시겠습니까?" },
|
||||||
|
snackbar: { noun: "스낵바", label: "저장됨", supporting: "실행 취소" },
|
||||||
|
textField: { noun: "텍스트 입력란", label: "레이블" },
|
||||||
|
switch: { noun: "스위치", label: "알림" },
|
||||||
|
checkbox: { noun: "체크박스", label: "동의합니다" },
|
||||||
|
slider: { noun: "슬라이더" },
|
||||||
|
text: { noun: "텍스트", label: "제목" },
|
||||||
|
image: { noun: "이미지" },
|
||||||
|
divider: { noun: "구분선" },
|
||||||
|
loadingIndicator: { noun: "로딩 표시기" },
|
||||||
|
linearProgress: { noun: "선형 진행 표시기" },
|
||||||
|
circularProgress: { noun: "원형 진행 표시기" },
|
||||||
|
splitButton: { noun: "분할 버튼", label: "보내기" },
|
||||||
|
fabMenu: { noun: "FAB 메뉴" },
|
||||||
|
toolbar: { noun: "도구 모음" },
|
||||||
|
tabs: { noun: "탭" },
|
||||||
|
radio: { noun: "라디오 버튼", label: "옵션" },
|
||||||
|
badge: { noun: "배지", label: "3" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** default labels of a tab row */
|
||||||
|
export const TAB_LABELS: Record<Lang, string[]> = {
|
||||||
|
ja: ["おすすめ", "フォロー中", "人気", "新着", "保存済み"],
|
||||||
|
en: ["For you", "Following", "Trending", "New", "Saved"],
|
||||||
|
zh: ["推荐", "关注", "热门", "最新", "已保存"],
|
||||||
|
ko: ["추천", "팔로잉", "인기", "새 항목", "저장됨"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** default entries of a FAB menu */
|
||||||
|
export const FAB_MENU_TABS: Record<Lang, { icon: string; label: string }[]> = {
|
||||||
|
ja: [
|
||||||
|
{ icon: "edit", label: "メモ" },
|
||||||
|
{ icon: "photo_camera", label: "写真" },
|
||||||
|
{ icon: "mic", label: "音声" },
|
||||||
|
{ icon: "attach_file", label: "ファイル" },
|
||||||
|
{ icon: "event", label: "予定" },
|
||||||
|
],
|
||||||
|
en: [
|
||||||
|
{ icon: "edit", label: "Note" },
|
||||||
|
{ icon: "photo_camera", label: "Photo" },
|
||||||
|
{ icon: "mic", label: "Audio" },
|
||||||
|
{ icon: "attach_file", label: "File" },
|
||||||
|
{ icon: "event", label: "Event" },
|
||||||
|
],
|
||||||
|
zh: [
|
||||||
|
{ icon: "edit", label: "笔记" },
|
||||||
|
{ icon: "photo_camera", label: "照片" },
|
||||||
|
{ icon: "mic", label: "语音" },
|
||||||
|
{ icon: "attach_file", label: "文件" },
|
||||||
|
{ icon: "event", label: "日程" },
|
||||||
|
],
|
||||||
|
ko: [
|
||||||
|
{ icon: "edit", label: "메모" },
|
||||||
|
{ icon: "photo_camera", label: "사진" },
|
||||||
|
{ icon: "mic", label: "오디오" },
|
||||||
|
{ icon: "attach_file", label: "파일" },
|
||||||
|
{ icon: "event", label: "일정" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NAV_TABS: Record<Lang, { icon: string; label: string }[]> = {
|
||||||
|
ja: [
|
||||||
|
{ icon: "home", label: "ホーム" },
|
||||||
|
{ icon: "search", label: "検索" },
|
||||||
|
{ icon: "favorite", label: "保存" },
|
||||||
|
{ icon: "settings", label: "設定" },
|
||||||
|
],
|
||||||
|
en: [
|
||||||
|
{ icon: "home", label: "Home" },
|
||||||
|
{ icon: "search", label: "Search" },
|
||||||
|
{ icon: "favorite", label: "Saved" },
|
||||||
|
{ icon: "settings", label: "Settings" },
|
||||||
|
],
|
||||||
|
zh: [
|
||||||
|
{ icon: "home", label: "首页" },
|
||||||
|
{ icon: "search", label: "搜索" },
|
||||||
|
{ icon: "favorite", label: "收藏" },
|
||||||
|
{ icon: "settings", label: "设置" },
|
||||||
|
],
|
||||||
|
ko: [
|
||||||
|
{ icon: "home", label: "홈" },
|
||||||
|
{ icon: "search", label: "검색" },
|
||||||
|
{ icon: "favorite", label: "저장됨" },
|
||||||
|
{ icon: "settings", label: "설정" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TRANSITION_TEXT: Record<Lang, Record<string, string>> = {
|
||||||
|
ja: {
|
||||||
|
slide: "右からのスライド",
|
||||||
|
slideLeft: "左からのスライド",
|
||||||
|
slideUp: "下からのスライド",
|
||||||
|
slideDown: "上からのスライド",
|
||||||
|
fade: "フェード",
|
||||||
|
expand: "拡大",
|
||||||
|
none: "アニメーションなし",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
slide: "a slide in from the right",
|
||||||
|
slideLeft: "a slide in from the left",
|
||||||
|
slideUp: "a slide up from the bottom",
|
||||||
|
slideDown: "a slide down from the top",
|
||||||
|
fade: "a fade",
|
||||||
|
expand: "an expand",
|
||||||
|
none: "no animation",
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
slide: "从右侧滑入",
|
||||||
|
slideLeft: "从左侧滑入",
|
||||||
|
slideUp: "从底部滑入",
|
||||||
|
slideDown: "从顶部滑入",
|
||||||
|
fade: "淡入",
|
||||||
|
expand: "放大",
|
||||||
|
none: "无动画",
|
||||||
|
},
|
||||||
|
ko: {
|
||||||
|
slide: "오른쪽에서 슬라이드",
|
||||||
|
slideLeft: "왼쪽에서 슬라이드",
|
||||||
|
slideUp: "아래에서 슬라이드",
|
||||||
|
slideDown: "위에서 슬라이드",
|
||||||
|
fade: "페이드",
|
||||||
|
expand: "확대",
|
||||||
|
none: "애니메이션 없음",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SWIPE_TEXT: Record<Lang, Record<string, string>> = {
|
||||||
|
ja: { left: "左へスワイプ", right: "右へスワイプ", up: "上へスワイプ", down: "下へスワイプ" },
|
||||||
|
en: { left: "swiping left", right: "swiping right", up: "swiping up", down: "swiping down" },
|
||||||
|
zh: { left: "向左滑动", right: "向右滑动", up: "向上滑动", down: "向下滑动" },
|
||||||
|
ko: { left: "왼쪽으로 스와이프", right: "오른쪽으로 스와이프", up: "위로 스와이프", down: "아래로 스와이프" },
|
||||||
|
};
|
||||||
Vendored
+82
@@ -0,0 +1,82 @@
|
|||||||
|
import { Doc, KIND_ORDER, Kind, VARIANTS, isPlatform } from "./tokens";
|
||||||
|
|
||||||
|
/* A project file is the Doc as JSON, nothing more. Reading one back only checks
|
||||||
|
* the shape the editor relies on; the same migrations that run on a saved
|
||||||
|
* document then bring an older file up to date. */
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null;
|
||||||
|
|
||||||
|
const KINDS = new Set<string>(KIND_ORDER);
|
||||||
|
|
||||||
|
const validTabs = (tabs: unknown) =>
|
||||||
|
tabs === undefined || (Array.isArray(tabs) && tabs.every((tab) => isRecord(tab) && typeof tab.label === "string" && (typeof tab.icon === "string" || tab.icon === null || tab.icon === undefined)));
|
||||||
|
|
||||||
|
const validCorners = (c: unknown) => c === undefined || (isRecord(c) && ["tl", "tr", "bl", "br"].every((k) => Number.isFinite(c[k])));
|
||||||
|
|
||||||
|
const validItem = (item: unknown) =>
|
||||||
|
isRecord(item) &&
|
||||||
|
validCorners(item.corners) &&
|
||||||
|
typeof item.id === "string" &&
|
||||||
|
typeof item.kind === "string" &&
|
||||||
|
KINDS.has(item.kind as Kind) &&
|
||||||
|
typeof item.label === "string" &&
|
||||||
|
(typeof item.icon === "string" || item.icon === null) &&
|
||||||
|
VARIANTS.some((variant) => variant.key === item.variant) &&
|
||||||
|
(item.supporting === undefined || typeof item.supporting === "string") &&
|
||||||
|
(item.selected === undefined || Number.isFinite(item.selected)) &&
|
||||||
|
(item.note === undefined || typeof item.note === "string") &&
|
||||||
|
validTabs(item.tabs);
|
||||||
|
|
||||||
|
const validGroup = (group: unknown) =>
|
||||||
|
isRecord(group) &&
|
||||||
|
typeof group.id === "string" &&
|
||||||
|
Number.isFinite(group.x) &&
|
||||||
|
Number.isFinite(group.y) &&
|
||||||
|
(group.axis === "x" || group.axis === "y") &&
|
||||||
|
Array.isArray(group.items) &&
|
||||||
|
group.items.length > 0 &&
|
||||||
|
group.items.every(validItem);
|
||||||
|
|
||||||
|
const validFrame = (frame: unknown) =>
|
||||||
|
isRecord(frame) &&
|
||||||
|
typeof frame.id === "string" &&
|
||||||
|
typeof frame.name === "string" &&
|
||||||
|
Number.isFinite(frame.x) &&
|
||||||
|
Number.isFinite(frame.y) &&
|
||||||
|
(frame.w === undefined || (Number.isFinite(frame.w) && (frame.w as number) > 0)) &&
|
||||||
|
(frame.h === undefined || (Number.isFinite(frame.h) && (frame.h as number) > 0)) &&
|
||||||
|
(frame.note === undefined || typeof frame.note === "string");
|
||||||
|
|
||||||
|
/** whether a parsed file has the shape of a document the editor can open */
|
||||||
|
export const isProject = (value: unknown): value is Doc =>
|
||||||
|
isRecord(value) && Array.isArray(value.groups) && Array.isArray(value.frames) && value.groups.every(validGroup) && value.frames.every(validFrame) && (value.platform === undefined || isPlatform(value.platform));
|
||||||
|
|
||||||
|
/** the file name a project is saved under: m3e-canvas, followed by the app's name when it has one */
|
||||||
|
export const projectFileName = (doc: Doc) => {
|
||||||
|
const name = doc.title
|
||||||
|
.trim()
|
||||||
|
.replace(/[\\/:*?"<>|]+/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
return name ? `m3e-canvas ${name}.json` : "m3e-canvas.json";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** hands the document to the browser as a JSON download */
|
||||||
|
export function saveProject(doc: Doc) {
|
||||||
|
const url = URL.createObjectURL(new Blob([JSON.stringify(doc, null, 2)], { type: "application/json" }));
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = projectFileName(doc);
|
||||||
|
a.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** reads a chosen file back into a document, or null when it is not one */
|
||||||
|
export async function readProject(file: File): Promise<Doc | null> {
|
||||||
|
try {
|
||||||
|
const next: unknown = JSON.parse(await file.text());
|
||||||
|
return isProject(next) ? next : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+111
@@ -0,0 +1,111 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { Lang, setGlobalLang } from "./i18n";
|
||||||
|
import { buildPrompt } from "./prompt";
|
||||||
|
import { BACK_TARGET, Doc, Item, Platform, defaultTabs, makeItem } from "./tokens";
|
||||||
|
|
||||||
|
const LANGS: Lang[] = ["ja", "en", "zh", "ko"];
|
||||||
|
|
||||||
|
/* Section headings in the order buildPrompt must emit them. */
|
||||||
|
const SECTIONS: Record<Lang, string[]> = {
|
||||||
|
ja: ["## カラー", "## 形・文字・動き", "## 画面構成", "## 振る舞いと画面遷移", "## 各部品のスタイル", "## 全体の指針"],
|
||||||
|
en: ["## Colors", "## Shape, type and motion", "## Layout", "## Behavior and navigation", "## Component styles", "## General guidance"],
|
||||||
|
zh: ["## 配色", "## 形状、字体与动效", "## 屏幕结构", "## 行为与屏幕跳转", "## 各组件的样式", "## 整体原则"],
|
||||||
|
ko: ["## 색상", "## 모양, 글꼴 및 모션", "## 화면 구성", "## 동작 및 화면 전환", "## 부품별 스타일", "## 전체 지침"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLATFORM_LINE: Record<Lang, Record<Platform, string>> = {
|
||||||
|
ja: { android: "実装先は Android(ネイティブアプリ)です。", web: "実装先は Web(ブラウザで動くアプリ)です。" },
|
||||||
|
en: { android: "Build it for Android, as a native app.", web: "Build it for the web, as an app that runs in the browser." },
|
||||||
|
zh: { android: "实现目标是 Android(原生应用)。", web: "实现目标是 Web(在浏览器中运行的应用)。" },
|
||||||
|
ko: { android: "Android 네이티브 앱으로 구현한다.", web: "브라우저에서 실행되는 웹 앱으로 구현한다." },
|
||||||
|
};
|
||||||
|
|
||||||
|
/* One phone screen with a top app bar, a connected pair of buttons (one with a
|
||||||
|
* tap action) and a navigation bar. makeItem / defaultTabs fill in the defaults;
|
||||||
|
* module-level language is set first so those defaults follow the test. */
|
||||||
|
function fixture(platform: Platform = "android", extraItems: Item[] = []): Doc {
|
||||||
|
const bar: Item = { ...makeItem("topAppBar"), id: "bar", label: "Home" };
|
||||||
|
const save: Item = { ...makeItem("button"), id: "save", label: "Save", action: { to: BACK_TARGET, transition: "fade" } };
|
||||||
|
const cancel: Item = { ...makeItem("button"), id: "cancel", label: "Cancel", variant: "text" };
|
||||||
|
const nav: Item = { ...makeItem("bottomNav"), id: "nav", tabs: defaultTabs() };
|
||||||
|
return {
|
||||||
|
groups: [
|
||||||
|
{ id: "g-bar", x: 16, y: 24, axis: "x", items: [bar] },
|
||||||
|
{ id: "g-row", x: 16, y: 400, axis: "x", items: [save, cancel, ...extraItems] },
|
||||||
|
{ id: "g-nav", x: 16, y: 812, axis: "x", items: [nav] },
|
||||||
|
],
|
||||||
|
frames: [{ id: "f-home", name: "Home", x: 0, y: 0 }],
|
||||||
|
paletteKey: "purple",
|
||||||
|
frame: "phone",
|
||||||
|
platform,
|
||||||
|
title: "Notes",
|
||||||
|
brief: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Set the module-level language for the fixture helpers, then build explicitly
|
||||||
|
* in that language — nothing is left to ambient state. */
|
||||||
|
function build(lang: Lang, platform: Platform = "android", extraItems: Item[] = []) {
|
||||||
|
setGlobalLang(lang);
|
||||||
|
return buildPrompt(fixture(platform, extraItems), {}, undefined, lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = (prompt: string) => prompt.split("\n");
|
||||||
|
const headings = (prompt: string) => lines(prompt).filter((l) => l.startsWith("## "));
|
||||||
|
/* bullet lines between the style heading and the closing guidance heading */
|
||||||
|
function styleBullets(prompt: string, lang: Lang) {
|
||||||
|
const ls = lines(prompt);
|
||||||
|
const style = ls.indexOf(SECTIONS[lang][4]);
|
||||||
|
const general = ls.indexOf(SECTIONS[lang][5]);
|
||||||
|
return ls.slice(style + 1, general).filter((l) => l.startsWith("- "));
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUOTED: Record<Lang, { label: string; others: string[] }> = {
|
||||||
|
ja: { label: "「Save」", others: ['"Save"', "“Save”"] },
|
||||||
|
en: { label: '"Save"', others: ["「Save」", "“Save”"] },
|
||||||
|
zh: { label: "“Save”", others: ["「Save」", '"Save"'] },
|
||||||
|
ko: { label: '"Save"', others: ["「Save」", "“Save”"] },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("buildPrompt structure", () => {
|
||||||
|
afterEach(() => setGlobalLang("ja")); // restore the module default
|
||||||
|
|
||||||
|
it.each(LANGS)("orders its sections the same way in %s", (lang) => {
|
||||||
|
expect(headings(build(lang))).toEqual(SECTIONS[lang]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(LANGS)("names the requested platform on the intro lines in %s", (lang) => {
|
||||||
|
const android = lines(build(lang, "android"));
|
||||||
|
const web = lines(build(lang, "web"));
|
||||||
|
expect(android[2]).toBe(PLATFORM_LINE[lang].android);
|
||||||
|
expect(web[2]).toBe(PLATFORM_LINE[lang].web);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names Android when the doc picks no platform", () => {
|
||||||
|
setGlobalLang("en");
|
||||||
|
const { platform, ...doc } = fixture();
|
||||||
|
expect(lines(buildPrompt(doc, {}, undefined, "en"))[2]).toBe(PLATFORM_LINE.en.android);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(LANGS)("writes one style note per part kind in use in %s", (lang) => {
|
||||||
|
expect(styleBullets(build(lang), lang)).toHaveLength(3); // topAppBar, button, bottomNav
|
||||||
|
const chip: Item = { ...makeItem("chip"), id: "chip" };
|
||||||
|
expect(styleBullets(build(lang, "android", [chip]), lang)).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(LANGS)("quotes labels with %s punctuation", (lang) => {
|
||||||
|
const prompt = build(lang);
|
||||||
|
expect(prompt).toContain(QUOTED[lang].label);
|
||||||
|
for (const other of QUOTED[lang].others) expect(prompt).not.toContain(other);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows its lang argument regardless of ambient module state", () => {
|
||||||
|
setGlobalLang("en");
|
||||||
|
const doc = fixture();
|
||||||
|
setGlobalLang("zh");
|
||||||
|
const prompt = buildPrompt(doc, {}, undefined, "ja");
|
||||||
|
expect(headings(prompt)).toEqual(SECTIONS.ja);
|
||||||
|
expect(prompt).toContain("「Save」");
|
||||||
|
});
|
||||||
|
});
|
||||||
Vendored
+1372
File diff suppressed because it is too large
Load Diff
Vendored
+99
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { DURATION_PER_SHAPE_MS, LoadingAnimator, SHAPE_COUNT, Spring, getShapes, morphedShape } from "./shapes";
|
||||||
|
|
||||||
|
describe("getShapes", () => {
|
||||||
|
it("samples every shape in the sequence with the same point count", () => {
|
||||||
|
const shapes = getShapes();
|
||||||
|
expect(shapes).toHaveLength(SHAPE_COUNT);
|
||||||
|
const n = shapes[0].length;
|
||||||
|
expect(n).toBeGreaterThan(0);
|
||||||
|
for (const s of shapes) expect(s).toHaveLength(n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes every shape into the unit box, up to resampling drift", () => {
|
||||||
|
for (const shape of getShapes()) {
|
||||||
|
let extreme = 0;
|
||||||
|
for (const [x, y] of shape) {
|
||||||
|
expect(Math.abs(x)).toBeLessThanOrEqual(1 + 1e-9);
|
||||||
|
expect(Math.abs(y)).toBeLessThanOrEqual(1 + 1e-9);
|
||||||
|
extreme = Math.max(extreme, Math.abs(x), Math.abs(y));
|
||||||
|
}
|
||||||
|
/* resampling along the arc can step just past the exact extreme vertex */
|
||||||
|
expect(extreme).toBeGreaterThan(0.99);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caches the sampled shapes", () => {
|
||||||
|
expect(getShapes()).toBe(getShapes());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("morphedShape", () => {
|
||||||
|
it("returns a shape exactly at every whole fraction", () => {
|
||||||
|
const shapes = getShapes();
|
||||||
|
for (let i = 0; i < SHAPE_COUNT; i++) expect(morphedShape(i)).toEqual(shapes[i]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("interpolates halfway between neighbours at a half fraction", () => {
|
||||||
|
const [a, b] = getShapes();
|
||||||
|
const mid = morphedShape(0.5);
|
||||||
|
expect(mid[0]).toEqual([(a[0][0] + b[0][0]) / 2, (a[0][1] + b[0][1]) / 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps around the sequence, past both ends", () => {
|
||||||
|
expect(morphedShape(SHAPE_COUNT)).toEqual(getShapes()[0]);
|
||||||
|
const shapes = getShapes();
|
||||||
|
const [last, first] = [shapes[SHAPE_COUNT - 1], shapes[0]];
|
||||||
|
const mid = morphedShape(-0.5);
|
||||||
|
expect(mid[0]).toEqual([(last[0][0] + first[0][0]) / 2, (last[0][1] + first[0][1]) / 2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Spring", () => {
|
||||||
|
it("settles at its target", () => {
|
||||||
|
const s = new Spring(200, 0.6);
|
||||||
|
s.target = 1;
|
||||||
|
for (let i = 0; i < 600; i++) s.step(1 / 60);
|
||||||
|
expect(s.pos).toBeCloseTo(1, 2);
|
||||||
|
expect(Math.abs(s.vel)).toBeLessThan(0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("overshoots when underdamped, not when critically damped", () => {
|
||||||
|
const peakAfter = (damping: number) => {
|
||||||
|
const s = new Spring(200, damping);
|
||||||
|
s.target = 1;
|
||||||
|
let peak = 0;
|
||||||
|
for (let i = 0; i < 120; i++) {
|
||||||
|
s.step(1 / 60);
|
||||||
|
peak = Math.max(peak, s.pos);
|
||||||
|
}
|
||||||
|
return peak;
|
||||||
|
};
|
||||||
|
expect(peakAfter(0.6)).toBeGreaterThan(1);
|
||||||
|
expect(peakAfter(1)).toBeLessThanOrEqual(1.001);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("LoadingAnimator", () => {
|
||||||
|
it("advances the morph with time while keeping rotation inside one turn", () => {
|
||||||
|
const a = new LoadingAnimator();
|
||||||
|
for (let ts = 0; ts <= 3 * DURATION_PER_SHAPE_MS; ts += 50) {
|
||||||
|
a.update(ts);
|
||||||
|
expect(a.rotation).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(a.rotation).toBeLessThan(360);
|
||||||
|
}
|
||||||
|
expect(a.morph).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is deterministic for identical frame sequences", () => {
|
||||||
|
const a = new LoadingAnimator();
|
||||||
|
const b = new LoadingAnimator();
|
||||||
|
for (let ts = 0; ts <= 2000; ts += 33) {
|
||||||
|
a.update(ts);
|
||||||
|
b.update(ts);
|
||||||
|
}
|
||||||
|
expect(a.morph).toBe(b.morph);
|
||||||
|
expect(a.rotation).toBe(b.rotation);
|
||||||
|
});
|
||||||
|
});
|
||||||
Vendored
+241
@@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* Material 3 Expressive loading-indicator shapes.
|
||||||
|
*
|
||||||
|
* SVG path data comes from the official Material Design shape assets and the
|
||||||
|
* morph/rotation model mirrors material-components-android's
|
||||||
|
* LoadingIndicatorAnimatorDelegate (Apache-2.0, see NOTICE).
|
||||||
|
* Sequence: SOFT_BURST → COOKIE_9 → PENTAGON → PILL → SUNNY → COOKIE_4 → OVAL
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Point = [x: number, y: number];
|
||||||
|
|
||||||
|
export const SHAPE_COUNT = 7;
|
||||||
|
const SAMPLES_PER_CURVE = 14;
|
||||||
|
const POINTS_PER_SHAPE = 180;
|
||||||
|
|
||||||
|
type ShapeDef = { viewBox: number; d: string };
|
||||||
|
|
||||||
|
const SVG_DEFS: (ShapeDef | null)[] = [
|
||||||
|
{
|
||||||
|
viewBox: 144,
|
||||||
|
d: "M65.3162 3.5567C68.3922 -1.18556 75.6078 -1.18557 78.6837 3.55669L86.0569 14.9241C88.0791 18.0418 92.1588 19.3094 95.7112 17.9237L108.664 12.8715C114.067 10.7638 119.905 14.8195 119.478 20.3849L118.456 33.7255C118.175 37.3845 120.697 40.7029 124.422 41.5786L138.007 44.7714C143.674 46.1033 145.903 52.6655 142.137 56.9283L133.11 67.1465C130.634 69.9491 130.634 74.0509 133.11 76.8535L142.137 87.0716C145.903 91.3345 143.674 97.8967 138.007 99.2286L124.422 102.421C120.697 103.297 118.175 106.616 118.456 110.274L119.478 123.615C119.905 129.181 114.067 133.236 108.664 131.129L95.7112 126.076C92.1588 124.691 88.0791 125.958 86.0569 129.076L78.6838 140.443C75.6078 145.186 68.3922 145.186 65.3162 140.443L57.9431 129.076C55.9208 125.958 51.8412 124.691 48.2888 126.076L35.3365 131.129C29.933 133.236 24.0954 129.181 24.5219 123.615L25.5442 110.274C25.8246 106.616 23.3033 103.297 19.5775 102.421L5.99339 99.2286C0.326335 97.8967 -1.90342 91.3345 1.86259 87.0717L10.8899 76.8535C13.3658 74.0509 13.3658 69.9491 10.8899 67.1465L1.8626 56.9283C-1.90341 52.6655 0.326326 46.1033 5.99338 44.7714L19.5775 41.5786C23.3033 40.7029 25.8246 37.3845 25.5442 33.7255L24.5219 20.3849C24.0954 14.8195 29.933 10.7638 35.3364 12.8715L48.2888 17.9237C51.8412 19.3094 55.9208 18.0418 57.9431 14.9241L65.3162 3.5567Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
viewBox: 144,
|
||||||
|
d: "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",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
viewBox: 144,
|
||||||
|
d: "M49.3332 10.9681C56.3577 5.53061 59.8699 2.81189 63.6224 1.46315C69.0501 -0.487717 74.9499 -0.487717 80.3776 1.46315C84.1301 2.81189 87.6423 5.53062 94.6668 10.9681L110.03 22.8606L125.386 34.0038C132.67 39.2902 136.313 41.9334 138.747 45.2576C142.27 50.0661 144.119 55.9761 143.994 62.0207C143.907 66.1996 142.46 70.5678 139.564 79.3044L133.535 97.4958L127.969 116.136C125.358 124.884 124.052 129.258 121.769 132.66C118.466 137.581 113.667 141.201 108.144 142.936C104.327 144.135 99.9259 144.065 91.1241 143.926L72 143.623L52.8759 143.926C44.0741 144.065 39.6732 144.135 35.8555 142.936C30.3334 141.201 25.5338 137.581 22.2314 132.66C19.9483 129.258 18.6425 124.884 16.0307 116.136L10.4655 97.4958L4.43609 79.3044C1.54044 70.5678 0.0926215 66.1996 0.00597479 62.0207C-0.119358 55.9761 1.73035 50.0661 5.2525 45.2576C7.68747 41.9334 11.3298 39.2902 18.6143 34.0038L33.9696 22.8606L49.3332 10.9681Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
viewBox: 144,
|
||||||
|
d: "M40.4355 21.4968C63.0979 -1.16559 99.8408 -1.16559 122.503 21.4968C145.166 44.1592 145.166 80.9021 122.503 103.565L103.565 122.503C80.9021 145.166 44.1592 145.166 21.4968 122.503C-1.16559 99.8408 -1.1656 63.0979 21.4968 40.4355L40.4355 21.4968Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
viewBox: 153,
|
||||||
|
d: "M117.835 18.5569C122.594 18.8803 124.973 19.0419 126.896 19.883C129.679 21.0999 131.9 23.3213 133.117 26.1039C133.958 28.027 134.12 30.4062 134.443 35.1647L135.181 46.0237C135.312 47.9482 135.377 48.9105 135.586 49.8297C135.889 51.1579 136.414 52.4254 137.139 53.5784C137.641 54.3763 138.275 55.1029 139.544 56.5563L146.7 64.7565C149.837 68.3499 151.405 70.1466 152.17 72.1011C153.277 74.9292 153.277 78.0708 152.17 80.8989C151.405 82.8534 149.837 84.6501 146.7 88.2435L139.544 96.4437C138.275 97.8971 137.641 98.6237 137.139 99.4217C136.414 100.575 135.889 101.842 135.586 103.17C135.377 104.09 135.312 105.052 135.181 106.976L134.443 117.835C134.12 122.594 133.958 124.973 133.117 126.896C131.9 129.679 129.679 131.9 126.896 133.117C124.973 133.958 122.594 134.12 117.835 134.443L106.976 135.181C105.052 135.312 104.09 135.377 103.17 135.586C101.842 135.889 100.575 136.414 99.4217 137.139C98.6237 137.641 97.8971 138.275 96.4437 139.544L88.2435 146.7C84.6501 149.837 82.8534 151.405 80.8989 152.17C78.0708 153.277 74.9292 153.277 72.1011 152.17C70.1466 151.405 68.3499 149.837 64.7565 146.7L56.5563 139.544C55.1029 138.275 54.3763 137.641 53.5784 137.139C52.4254 136.414 51.1579 135.889 49.8297 135.586C48.9105 135.377 47.9482 135.312 46.0237 135.181L35.1647 134.443C30.4062 134.12 28.027 133.958 26.1039 133.117C23.3213 131.9 21.0999 129.679 19.883 126.896C19.0419 124.973 18.8803 122.594 18.5569 117.835L17.819 106.976C17.6882 105.052 17.6228 104.09 17.4136 103.17C17.1113 101.842 16.5863 100.575 15.8608 99.4217C15.3588 98.6237 14.7246 97.8971 13.4562 96.4437L6.29956 88.2435C3.16348 84.6501 1.59544 82.8534 0.830322 80.8989C-0.276774 78.0708 -0.276774 74.9292 0.830323 72.1011C1.59544 70.1466 3.16348 68.3499 6.29956 64.7565L13.4562 56.5563C14.7246 55.1029 15.3588 54.3763 15.8608 53.5784C16.5863 52.4254 17.1113 51.1579 17.4136 49.8297C17.6228 48.9105 17.6882 47.9482 17.819 46.0237L18.5569 35.1647C18.8803 30.4062 19.0419 28.027 19.883 26.1039C21.0999 23.3213 23.3213 21.0999 26.1039 19.883C28.027 19.0419 30.4062 18.8803 35.1647 18.5569L46.0237 17.819C47.9482 17.6882 48.9105 17.6228 49.8297 17.4136C51.1579 17.1113 52.4254 16.5863 53.5784 15.8608C54.3763 15.3588 55.1029 14.7246 56.5563 13.4562L64.7565 6.29957C68.3499 3.16348 70.1466 1.59544 72.1011 0.830323C74.9292 -0.276774 78.0708 -0.276774 80.8989 0.830323C82.8534 1.59544 84.6501 3.16348 88.2435 6.29957L96.4437 13.4562C97.8971 14.7246 98.6237 15.3588 99.4216 15.8608C100.575 16.5863 101.842 17.1113 103.17 17.4136C104.09 17.6228 105.052 17.6882 106.976 17.819L117.835 18.5569Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
viewBox: 144,
|
||||||
|
d: "M89.4282 11.7931C116.492 0.0387955 143.961 27.5077 132.207 54.5718L130.263 59.0465C126.675 67.3094 126.675 76.6907 130.263 84.9535L132.207 89.4282C143.961 116.492 116.492 143.961 89.4282 132.207L84.9535 130.263C76.6907 126.675 67.3093 126.675 59.0465 130.263L54.5718 132.207C27.5077 143.961 0.0387983 116.492 11.7931 89.4282L13.7366 84.9535C17.3253 76.6907 17.3252 67.3093 13.7366 59.0465L11.7931 54.5718C0.0387955 27.5077 27.5077 0.0387993 54.5718 11.7931L59.0465 13.7366C67.3094 17.3252 76.6907 17.3252 84.9535 13.7366L89.4282 11.7931Z",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
];
|
||||||
|
|
||||||
|
type PathCmd = { cmd: string; args: number[] };
|
||||||
|
|
||||||
|
function parseSVGPath(d: string): PathCmd[] {
|
||||||
|
const cmds: PathCmd[] = [];
|
||||||
|
const re = /([MCLZmclz])([^MCLZmclz]*)/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(d)) !== null) {
|
||||||
|
const nums = m[2].trim().match(/-?\d+\.?\d*(?:e[+-]?\d+)?/g);
|
||||||
|
cmds.push({ cmd: m[1], args: nums ? nums.map(Number) : [] });
|
||||||
|
}
|
||||||
|
return cmds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleCubic(p0: Point, p1: Point, p2: Point, p3: Point, steps: number): Point[] {
|
||||||
|
const out: Point[] = [];
|
||||||
|
for (let i = 1; i <= steps; i++) {
|
||||||
|
const t = i / steps;
|
||||||
|
const u = 1 - t;
|
||||||
|
out.push([
|
||||||
|
u * u * u * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t * t * t * p3[0],
|
||||||
|
u * u * u * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t * t * t * p3[1],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function svgPathToPoints(d: string): Point[] {
|
||||||
|
const pts: Point[] = [];
|
||||||
|
let cx = 0;
|
||||||
|
let cy = 0;
|
||||||
|
for (const { cmd, args } of parseSVGPath(d)) {
|
||||||
|
if (cmd === "M") {
|
||||||
|
cx = args[0];
|
||||||
|
cy = args[1];
|
||||||
|
pts.push([cx, cy]);
|
||||||
|
} else if (cmd === "L") {
|
||||||
|
for (let i = 0; i < args.length; i += 2) {
|
||||||
|
cx = args[i];
|
||||||
|
cy = args[i + 1];
|
||||||
|
pts.push([cx, cy]);
|
||||||
|
}
|
||||||
|
} else if (cmd === "C") {
|
||||||
|
for (let i = 0; i < args.length; i += 6) {
|
||||||
|
const p3: Point = [args[i + 4], args[i + 5]];
|
||||||
|
pts.push(
|
||||||
|
...sampleCubic([cx, cy], [args[i], args[i + 1]], [args[i + 2], args[i + 3]], p3, SAMPLES_PER_CURVE),
|
||||||
|
);
|
||||||
|
cx = p3[0];
|
||||||
|
cy = p3[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(pts: Point[], viewBox: number): Point[] {
|
||||||
|
const h = viewBox / 2;
|
||||||
|
const c: Point[] = pts.map(([x, y]) => [(x - h) / h, (y - h) / h]);
|
||||||
|
let x0 = Infinity;
|
||||||
|
let x1 = -Infinity;
|
||||||
|
let y0 = Infinity;
|
||||||
|
let y1 = -Infinity;
|
||||||
|
for (const [x, y] of c) {
|
||||||
|
if (x < x0) x0 = x;
|
||||||
|
if (x > x1) x1 = x;
|
||||||
|
if (y < y0) y0 = y;
|
||||||
|
if (y > y1) y1 = y;
|
||||||
|
}
|
||||||
|
const s = Math.min(2 / (x1 - x0), 2 / (y1 - y0));
|
||||||
|
const ox = (x0 + x1) / 2;
|
||||||
|
const oy = (y0 + y1) / 2;
|
||||||
|
return c.map(([x, y]) => [(x - ox) * s, (y - oy) * s]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resample(pts: Point[], n: number): Point[] {
|
||||||
|
const all: Point[] = [...pts, pts[0]];
|
||||||
|
const arc = [0];
|
||||||
|
for (let i = 1; i < all.length; i++) {
|
||||||
|
const dx = all[i][0] - all[i - 1][0];
|
||||||
|
const dy = all[i][1] - all[i - 1][1];
|
||||||
|
arc.push(arc[i - 1] + Math.hypot(dx, dy));
|
||||||
|
}
|
||||||
|
const total = arc[arc.length - 1];
|
||||||
|
const out: Point[] = [];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const target = (i / n) * total;
|
||||||
|
let j = 1;
|
||||||
|
while (j < arc.length - 1 && arc[j] < target) j++;
|
||||||
|
const t = arc[j] > arc[j - 1] ? (target - arc[j - 1]) / (arc[j] - arc[j - 1]) : 0;
|
||||||
|
out.push([
|
||||||
|
all[j - 1][0] + t * (all[j][0] - all[j - 1][0]),
|
||||||
|
all[j - 1][1] + t * (all[j][1] - all[j - 1][1]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateOval(n: number): Point[] {
|
||||||
|
const pts: Point[] = [];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const t = (i / n) * 2 * Math.PI;
|
||||||
|
pts.push([Math.cos(t), 0.74 * Math.sin(t)]);
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cache: Point[][] | null = null;
|
||||||
|
|
||||||
|
export function getShapes(): Point[][] {
|
||||||
|
if (cache) return cache;
|
||||||
|
cache = SVG_DEFS.map((def) =>
|
||||||
|
def === null
|
||||||
|
? generateOval(POINTS_PER_SHAPE)
|
||||||
|
: resample(normalize(svgPathToPoints(def.d), def.viewBox), POINTS_PER_SHAPE),
|
||||||
|
);
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function morphedShape(fraction: number): Point[] {
|
||||||
|
const shapes = getShapes();
|
||||||
|
const idx = Math.floor(fraction);
|
||||||
|
const from = ((idx % SHAPE_COUNT) + SHAPE_COUNT) % SHAPE_COUNT;
|
||||||
|
const to = (from + 1) % SHAPE_COUNT;
|
||||||
|
const t = Math.max(0, Math.min(1, fraction - idx));
|
||||||
|
const a = shapes[from];
|
||||||
|
const b = shapes[to];
|
||||||
|
const out: Point[] = new Array(a.length);
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
out[i] = [a[i][0] + (b[i][0] - a[i][0]) * t, a[i][1] + (b[i][1] - a[i][1]) * t];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spring matching Android's SpringForce (mass = 1). */
|
||||||
|
export class Spring {
|
||||||
|
private k: number;
|
||||||
|
private c: number;
|
||||||
|
pos = 0;
|
||||||
|
vel = 0;
|
||||||
|
target = 0;
|
||||||
|
constructor(stiffness: number, dampingRatio: number) {
|
||||||
|
this.k = stiffness;
|
||||||
|
this.c = dampingRatio * 2 * Math.sqrt(stiffness);
|
||||||
|
}
|
||||||
|
step(dt: number) {
|
||||||
|
const sub = dt / 12;
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const accel = -this.k * (this.pos - this.target) - this.c * this.vel;
|
||||||
|
this.vel += accel * sub;
|
||||||
|
this.pos += this.vel * sub;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DURATION_PER_SHAPE_MS = 650;
|
||||||
|
const CONSTANT_ROTATION_DEG = 50;
|
||||||
|
const EXTRA_ROTATION_DEG = 90;
|
||||||
|
|
||||||
|
/** Mirrors LoadingIndicatorAnimatorDelegate: 650 ms per shape, spring-driven morph, dual rotation. */
|
||||||
|
export class LoadingAnimator {
|
||||||
|
private spring = new Spring(200, 0.6);
|
||||||
|
private morphTarget = 1;
|
||||||
|
private elapsed = 0;
|
||||||
|
private lastTs = 0;
|
||||||
|
private prevCycle = 0;
|
||||||
|
rotation = 0;
|
||||||
|
morph = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.spring.target = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(ts: number) {
|
||||||
|
if (this.lastTs === 0) this.lastTs = ts;
|
||||||
|
const dt = Math.min((ts - this.lastTs) / 1000, 0.1);
|
||||||
|
this.lastTs = ts;
|
||||||
|
if (dt <= 0) return;
|
||||||
|
this.elapsed += dt * 1000;
|
||||||
|
const cycle = Math.floor(this.elapsed / DURATION_PER_SHAPE_MS);
|
||||||
|
if (cycle > this.prevCycle) {
|
||||||
|
this.morphTarget += cycle - this.prevCycle;
|
||||||
|
this.spring.target = this.morphTarget;
|
||||||
|
this.prevCycle = cycle;
|
||||||
|
}
|
||||||
|
const fraction = (this.elapsed % DURATION_PER_SHAPE_MS) / DURATION_PER_SHAPE_MS;
|
||||||
|
this.spring.step(dt);
|
||||||
|
const base = this.morphTarget - 1;
|
||||||
|
const perShape = this.spring.pos - base;
|
||||||
|
this.rotation =
|
||||||
|
((CONSTANT_ROTATION_DEG + EXTRA_ROTATION_DEG) * base +
|
||||||
|
CONSTANT_ROTATION_DEG * fraction +
|
||||||
|
EXTRA_ROTATION_DEG * perShape) %
|
||||||
|
360;
|
||||||
|
this.morph = this.spring.pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+79
@@ -0,0 +1,79 @@
|
|||||||
|
import { Doc } from "./tokens";
|
||||||
|
import { isProject } from "./project";
|
||||||
|
|
||||||
|
/* A design travels in a link: the document as JSON, deflated and base64url-encoded
|
||||||
|
* after `#docz=`, or plain JSON after `#doc=` for tools that cannot compress. The
|
||||||
|
* fragment never reaches the server, so the static site stays static. */
|
||||||
|
|
||||||
|
export const DOC_PARAM = "doc";
|
||||||
|
export const DOCZ_PARAM = "docz";
|
||||||
|
|
||||||
|
/** the document with what a link should not carry: picked images and AI rewrite history */
|
||||||
|
export function shareable(doc: Doc): Doc {
|
||||||
|
return {
|
||||||
|
...doc,
|
||||||
|
frames: doc.frames.map(({ noteHistory: _h, ...f }) => f),
|
||||||
|
groups: doc.groups.map((g) => ({
|
||||||
|
...g,
|
||||||
|
items: g.items.map(({ src, noteHistory: _h, ...it }) => (src && /^https?:\/\//.test(src) ? { ...it, src } : it)),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const toBase64Url = (bytes: Uint8Array) => {
|
||||||
|
let bin = "";
|
||||||
|
for (const b of bytes) bin += String.fromCharCode(b);
|
||||||
|
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const fromBase64Url = (s: string) => {
|
||||||
|
const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (s.length % 4)) % 4);
|
||||||
|
const bin = atob(b64);
|
||||||
|
const out = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function pipe(bytes: Uint8Array, stream: { readable: ReadableStream; writable: WritableStream }): Promise<Uint8Array> {
|
||||||
|
const writer = stream.writable.getWriter();
|
||||||
|
/* the writer's own promises reject too on bad input; the read below reports the failure */
|
||||||
|
writer.write(bytes).catch(() => {});
|
||||||
|
writer.close().catch(() => {});
|
||||||
|
return new Uint8Array(await new Response(stream.readable).arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** the link that opens `doc` at `base` (the app URL without a hash) */
|
||||||
|
export async function shareLink(doc: Doc, base: string): Promise<string> {
|
||||||
|
const json = JSON.stringify(shareable(doc));
|
||||||
|
if (typeof CompressionStream !== "undefined") {
|
||||||
|
const packed = await pipe(new TextEncoder().encode(json), new CompressionStream("deflate-raw"));
|
||||||
|
return `${base}#${DOCZ_PARAM}=${toBase64Url(packed)}`;
|
||||||
|
}
|
||||||
|
return `${base}#${DOC_PARAM}=${encodeURIComponent(json)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** the document a hash carries, or null when it carries none or a broken one */
|
||||||
|
export async function readShareHash(hash: string): Promise<Doc | null> {
|
||||||
|
const params = new URLSearchParams(hash.replace(/^#/, ""));
|
||||||
|
try {
|
||||||
|
let json: string | null = null;
|
||||||
|
const packed = params.get(DOCZ_PARAM);
|
||||||
|
if (packed) {
|
||||||
|
const bytes = await pipe(fromBase64Url(packed), new DecompressionStream("deflate-raw"));
|
||||||
|
json = new TextDecoder().decode(bytes);
|
||||||
|
} else {
|
||||||
|
json = params.get(DOC_PARAM);
|
||||||
|
}
|
||||||
|
if (!json) return null;
|
||||||
|
const value: unknown = JSON.parse(json);
|
||||||
|
return isProject(value) ? value : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** whether a hash claims to carry a document at all */
|
||||||
|
export const hasShareHash = (hash: string) => {
|
||||||
|
const params = new URLSearchParams(hash.replace(/^#/, ""));
|
||||||
|
return params.has(DOCZ_PARAM) || params.has(DOC_PARAM);
|
||||||
|
};
|
||||||
Vendored
+57
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
import { DEFAULT_THEME, FONTS, FontKey, LANG_FONT, Theme } from "./tokens";
|
||||||
|
import type { Lang } from "./i18n";
|
||||||
|
|
||||||
|
/** The document theme, read by parts that render differently under it
|
||||||
|
* (emphasized type, motion scheme). Shape goes through the tokens helpers. */
|
||||||
|
export const ThemeContext = createContext<Theme>(DEFAULT_THEME);
|
||||||
|
export const useTheme = () => useContext(ThemeContext);
|
||||||
|
|
||||||
|
const loaded = new Set<FontKey>();
|
||||||
|
|
||||||
|
/** Fetch a Google font the first time it is chosen; the built-in faces need nothing.
|
||||||
|
* `onReady` fires once the face is usable, so measured widths can be refreshed. */
|
||||||
|
export function ensureFontLoaded(key: FontKey, onReady?: () => void) {
|
||||||
|
const f = FONTS.find((x) => x.key === key);
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const ready = () => document.fonts?.ready.then(() => onReady?.());
|
||||||
|
if (!f?.google || loaded.has(key)) {
|
||||||
|
ready();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loaded.add(key);
|
||||||
|
const link = document.createElement("link");
|
||||||
|
link.rel = "stylesheet";
|
||||||
|
link.href = `https://fonts.googleapis.com/css2?family=${f.google}&display=swap`;
|
||||||
|
link.onload = () => {
|
||||||
|
/* the stylesheet is in, but the face itself downloads on first use: load it explicitly */
|
||||||
|
const face = f.family.split(",")[0].trim().replace(/^'|'$/g, "");
|
||||||
|
Promise.all([400, 500, 600, 700].map((w) => document.fonts.load(`${w} 16px "${face}"`))).then(ready, ready);
|
||||||
|
};
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadedLangs = new Set<Lang>();
|
||||||
|
|
||||||
|
/** Fetch the Noto Sans face for a language the first time it is used. */
|
||||||
|
export function ensureLangFontLoaded(lang: Lang, onReady?: () => void) {
|
||||||
|
const f = LANG_FONT[lang];
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const ready = () => document.fonts?.ready.then(() => onReady?.());
|
||||||
|
if (!f || loadedLangs.has(lang)) {
|
||||||
|
ready();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadedLangs.add(lang);
|
||||||
|
const link = document.createElement("link");
|
||||||
|
link.rel = "stylesheet";
|
||||||
|
link.href = `https://fonts.googleapis.com/css2?family=${f.google}&display=swap`;
|
||||||
|
link.onload = () => {
|
||||||
|
const face = f.family.replace(/^'|'$/g, "");
|
||||||
|
Promise.all([400, 500, 600, 700].map((w) => document.fonts.load(`${w} 16px "${face}"`))).then(ready, ready);
|
||||||
|
};
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
Vendored
+52
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { tidyFrame } from "./tidy";
|
||||||
|
import { Frame, Group, Item, Kind, NAV_BAR_H, PHONE_H, PHONE_MARGIN, PHONE_W, makeItem } from "./tokens";
|
||||||
|
|
||||||
|
const frame: Frame = { id: "f1", name: "Home", x: 0, y: 0 };
|
||||||
|
const frames = [frame];
|
||||||
|
|
||||||
|
const grp = (id: string, x: number, y: number, items: Item[]): Group => ({ id, x, y, axis: "x", items });
|
||||||
|
const part = (kind: Kind, id: string): Item => ({ ...makeItem(kind), id });
|
||||||
|
|
||||||
|
describe("tidyFrame", () => {
|
||||||
|
it("snaps the app bar to the top edge and the navigation bar to the bottom", () => {
|
||||||
|
const groups = [grp("g-bar", 40, 300, [part("topAppBar", "bar")]), grp("g-nav", 40, 100, [part("bottomNav", "nav")])];
|
||||||
|
const out = tidyFrame(groups, frame, frames, {});
|
||||||
|
const bar = out!.find((g) => g.id === "g-bar")!;
|
||||||
|
const nav = out!.find((g) => g.id === "g-nav")!;
|
||||||
|
expect([bar.x, bar.y]).toEqual([0, 0]);
|
||||||
|
expect([nav.x, nav.y]).toEqual([0, PHONE_H - (80 + NAV_BAR_H)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a FAB to the bottom-right corner, one margin in", () => {
|
||||||
|
const out = tidyFrame([grp("g-fab", 40, 100, [part("fab", "fab")])], frame, frames, {});
|
||||||
|
expect([out![0].x, out![0].y]).toEqual([PHONE_W - PHONE_MARGIN - 56, PHONE_H - PHONE_MARGIN - 56]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins neighbouring buttons into one connected run in reading order", () => {
|
||||||
|
const out = tidyFrame([grp("g1", 16, 300, [part("button", "b1")]), grp("g2", 150, 300, [part("button", "b2")])], frame, frames, {});
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out![0].items.map((it) => it.id)).toEqual(["b1", "b2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins buttons dropped onto each other, in reading order", () => {
|
||||||
|
const out = tidyFrame([grp("g1", 150, 300, [part("button", "b1")]), grp("g2", 100, 304, [part("button", "b2")])], frame, frames, {});
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out![0].items.map((it) => it.id)).toEqual(["b2", "b1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins neighbouring list items into one connected column", () => {
|
||||||
|
const out = tidyFrame([grp("g1", 16, 100, [part("listItem", "l1")]), grp("g2", 16, 180, [part("listItem", "l2")])], frame, frames, {});
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out![0].axis).toBe("y");
|
||||||
|
expect(out![0].items.map((it) => it.id)).toEqual(["l1", "l2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a frame that is already tidy", () => {
|
||||||
|
const messy = [grp("g-bar", 40, 300, [part("topAppBar", "bar")]), grp("g-fab", 10, 10, [part("fab", "fab")])];
|
||||||
|
const once = tidyFrame(messy, frame, frames, {});
|
||||||
|
expect(once).not.toBeNull();
|
||||||
|
expect(tidyFrame(once!, frame, frames, {})).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Vendored
+502
@@ -0,0 +1,502 @@
|
|||||||
|
import { CONTENT_W, FULL_WIDTH, Frame, Group, Item, KIND_SPEC, Kind, PHONE_MARGIN, RAIL_W, canJoin, isPhoneFrame, scaleR, carryItemSize, connectSpecOf, frameOfGroup, frameRect, frameSizeOf, groupBounds, isExpanded } from "./tokens";
|
||||||
|
|
||||||
|
/* Rule-based layout for one screen. Nothing here is guessed by a model.
|
||||||
|
*
|
||||||
|
* 1. Parts of one connectable family that sit next to each other, or on top of
|
||||||
|
* each other, fuse into a connected run, the same way the magnetic drop does:
|
||||||
|
* list items stacked in a column, buttons or icon buttons side by side in a row.
|
||||||
|
* 2. Bars stick to the edges they belong to (app bar and tabs at the top, the
|
||||||
|
* navigation bar at the bottom, toolbars and snackbars hovering above it,
|
||||||
|
* a bottom sheet on the bottom edge), a FAB takes the bottom-right corner,
|
||||||
|
* a dialog is centered.
|
||||||
|
* 3. Everything else is stacked from the top on the 16dp layout margins. Rows,
|
||||||
|
* hand-made groups and intentional overlaps (a badge on an icon, parts on a
|
||||||
|
* box) are kept as one unit, and a part keeps the side it was on. A run
|
||||||
|
* nearly as wide as the content sits on the margin; a text with a switch,
|
||||||
|
* checkbox or radio beside it spans the row, the control on the right.
|
||||||
|
*
|
||||||
|
* 4. Inside one unit (parts on a box, a hand-made group) edges that are almost
|
||||||
|
* aligned snap to the common line; across the screen, boxes whose corners are
|
||||||
|
* almost the same radius become the same, and likewise cards. Both only act on
|
||||||
|
* differences a hand would call a slip, never on a gap someone clearly meant.
|
||||||
|
*
|
||||||
|
* Only positions change and runs are joined; sizes, order and contents stay,
|
||||||
|
* apart from a corner radius evened out by rule 4. */
|
||||||
|
|
||||||
|
/** the farthest two edges may be apart and still count as meant to line up */
|
||||||
|
const SNAP = 6;
|
||||||
|
/** the farthest two corner radii may be apart and still count as meant to match */
|
||||||
|
const RADIUS_SNAP = 4;
|
||||||
|
|
||||||
|
type Rect = { l: number; t: number; r: number; b: number };
|
||||||
|
|
||||||
|
/** vertical distance between stacked rows */
|
||||||
|
const ROW_GAP = 16;
|
||||||
|
/** a tighter gap after a heading and between list-like rows of one kind */
|
||||||
|
const TIGHT_GAP = 8;
|
||||||
|
/** horizontal distance between parts packed into one row */
|
||||||
|
const ROW_ITEM_GAP = 8;
|
||||||
|
/** the farthest two parts of one family may be apart and still be joined: side by side, and stacked */
|
||||||
|
const JOIN_GAP_X = 24;
|
||||||
|
const JOIN_GAP_Y = 48;
|
||||||
|
/** parts of one family the author kept apart stay clearly apart, beyond the joining distance */
|
||||||
|
const APART_GAP_X = JOIN_GAP_X + 8;
|
||||||
|
const APART_GAP_Y = JOIN_GAP_Y + 8;
|
||||||
|
|
||||||
|
const LIST_KINDS = new Set(["listItem", "textField", "checkbox", "radio", "switch", "chip", "divider", "card"]);
|
||||||
|
|
||||||
|
/** one movable unit: a group plus everything nested inside or overlapping it */
|
||||||
|
type Unit = { ids: string[]; bb: Rect; kind: string; checked?: boolean; /** the first part, for family checks */ probe: Item };
|
||||||
|
|
||||||
|
const overlap = (a: Rect, b: Rect) => Math.min(a.r, b.r) > Math.max(a.l, b.l) && Math.min(a.b, b.b) > Math.max(a.t, b.t);
|
||||||
|
const union = (a: Rect, b: Rect): Rect => ({ l: Math.min(a.l, b.l), t: Math.min(a.t, b.t), r: Math.max(a.r, b.r), b: Math.max(a.b, b.b) });
|
||||||
|
/** how much of the smaller extent two spans share, 0..1 */
|
||||||
|
const share = (a0: number, a1: number, b0: number, b1: number) => Math.max(0, Math.min(a1, b1) - Math.max(a0, b0)) / Math.max(1, Math.min(a1 - a0, b1 - b0));
|
||||||
|
|
||||||
|
/* ---------- 1. join runs ---------- */
|
||||||
|
|
||||||
|
/** whether the whole run is one connectable family, so a neighbour can join it */
|
||||||
|
const runFamily = (g: Group): { axis: "x" | "y"; probe: Item } | null => {
|
||||||
|
if (g.free) return null;
|
||||||
|
const spec = connectSpecOf(g.items[0]);
|
||||||
|
if (!spec) return null;
|
||||||
|
if (!g.items.every((it) => canJoin(g.items[0], it))) return null;
|
||||||
|
return { axis: spec.axis, probe: g.items[0] };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Neighbouring runs of one family fuse into a single run, in reading order. */
|
||||||
|
function joinRuns(groups: Group[], widths: Record<string, number>): Group[] {
|
||||||
|
const out = [...groups];
|
||||||
|
for (;;) {
|
||||||
|
let joined = false;
|
||||||
|
const bounds = new Map(out.map((g) => [g.id, groupBounds(g, widths)]));
|
||||||
|
outer: for (let i = 0; i < out.length; i++) {
|
||||||
|
const a = out[i];
|
||||||
|
const fa = runFamily(a);
|
||||||
|
if (!fa) continue;
|
||||||
|
for (let j = 0; j < out.length; j++) {
|
||||||
|
if (i === j) continue;
|
||||||
|
const b = out[j];
|
||||||
|
const fb = runFamily(b);
|
||||||
|
if (!fb || fa.axis !== fb.axis || !canJoin(fa.probe, fb.probe)) continue;
|
||||||
|
const ra = bounds.get(a.id)!;
|
||||||
|
const rb = bounds.get(b.id)!;
|
||||||
|
/* b must come right after a along the axis, and line up across it; parts dropped onto
|
||||||
|
* each other count as neighbours too, read from the one that starts first */
|
||||||
|
const gap = fa.axis === "x" ? rb.l - ra.r : rb.t - ra.b;
|
||||||
|
if (gap > (fa.axis === "x" ? JOIN_GAP_X : JOIN_GAP_Y)) continue;
|
||||||
|
const ahead = fa.axis === "x" ? ra.l < rb.l || (ra.l === rb.l && i < j) : ra.t < rb.t || (ra.t === rb.t && i < j);
|
||||||
|
if (gap < -2 && !ahead) continue;
|
||||||
|
const lined = fa.axis === "x" ? share(ra.t, ra.b, rb.t, rb.b) : share(ra.l, ra.r, rb.l, rb.r);
|
||||||
|
if (lined < 0.5) continue;
|
||||||
|
/* nothing else may sit between them */
|
||||||
|
const between: Rect = fa.axis === "x" ? { l: ra.r, t: Math.min(ra.t, rb.t), r: rb.l, b: Math.max(ra.b, rb.b) } : { l: Math.min(ra.l, rb.l), t: ra.b, r: Math.max(ra.r, rb.r), b: rb.t };
|
||||||
|
if (out.some((o) => o !== a && o !== b && overlap(bounds.get(o.id)!, between))) continue;
|
||||||
|
const merged: Group = { ...a, axis: fa.axis, items: [...a.items, ...b.items] };
|
||||||
|
/* the run keeps the later layer so the joined parts still draw above whatever they were over */
|
||||||
|
const at = Math.max(i, j);
|
||||||
|
out.splice(at, 1, merged);
|
||||||
|
out.splice(Math.min(i, j), 1);
|
||||||
|
joined = true;
|
||||||
|
break outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!joined) return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 2 and 3. place ---------- */
|
||||||
|
|
||||||
|
const area = (r: Rect) => Math.max(0, r.r - r.l) * Math.max(0, r.b - r.t);
|
||||||
|
|
||||||
|
/** Groups that touch each other stay together, so a badge on an icon or parts on a box move as one.
|
||||||
|
* Bars, FABs and dialogs never join a cluster: they have their own place and are often drawn over content. */
|
||||||
|
function clusters(groups: Group[], widths: Record<string, number>): Unit[] {
|
||||||
|
const units: Unit[] = groups.map((g) => ({ ids: [g.id], bb: groupBounds(g, widths), kind: g.items[0].kind, checked: g.items[0].checked, probe: g.items[0] }));
|
||||||
|
for (;;) {
|
||||||
|
let merged = false;
|
||||||
|
outer: for (let i = 0; i < units.length; i++) {
|
||||||
|
if (isAnchored(units[i])) continue;
|
||||||
|
for (let j = i + 1; j < units.length; j++) {
|
||||||
|
if (isAnchored(units[j]) || !overlap(units[i].bb, units[j].bb)) continue;
|
||||||
|
/* the larger member names the cluster: it is the container, the rest sits on it */
|
||||||
|
const big = area(units[i].bb) >= area(units[j].bb) ? units[i] : units[j];
|
||||||
|
units[i] = { ...big, ids: [...units[i].ids, ...units[j].ids], bb: union(units[i].bb, units[j].bb) };
|
||||||
|
units.splice(j, 1);
|
||||||
|
merged = true;
|
||||||
|
break outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!merged) return units;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Units whose vertical extents overlap and that sit side by side form one row. */
|
||||||
|
function rowsOf(units: Unit[]): Unit[][] {
|
||||||
|
const sorted = [...units].sort((a, b) => a.bb.t - b.bb.t || a.bb.l - b.bb.l);
|
||||||
|
const out: Unit[][] = [];
|
||||||
|
for (const u of sorted) {
|
||||||
|
const row = out[out.length - 1];
|
||||||
|
if (row) {
|
||||||
|
const rt = Math.min(...row.map((r) => r.bb.t));
|
||||||
|
const rb = Math.max(...row.map((r) => r.bb.b));
|
||||||
|
const cy = (u.bb.t + u.bb.b) / 2;
|
||||||
|
const rcy = (rt + rb) / 2;
|
||||||
|
const beside = row.every((r) => r.bb.r <= u.bb.l + 2 || r.bb.l >= u.bb.r - 2);
|
||||||
|
if (beside && ((cy >= rt && cy <= rb) || (rcy >= u.bb.t && rcy <= u.bb.b))) {
|
||||||
|
row.push(u);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push([u]);
|
||||||
|
}
|
||||||
|
for (const r of out) r.sort((a, b) => a.bb.l - b.bb.l);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRail = (u: Unit) => u.kind === "navRail";
|
||||||
|
const isTop = (u: Unit) => u.kind === "topAppBar" || u.kind === "tabs";
|
||||||
|
const isBottomBar = (u: Unit) => u.kind === "bottomNav" || (u.kind === "box" && !!u.checked);
|
||||||
|
const isFloatingBottom = (u: Unit) => u.kind === "toolbar" || u.kind === "snackbar";
|
||||||
|
const isFab = (u: Unit) => u.kind === "fab" || u.kind === "extendedFab" || u.kind === "fabMenu";
|
||||||
|
const isOverlay = (u: Unit) => u.kind === "dialog";
|
||||||
|
/** a line of text, and the small controls that pair with one across a row */
|
||||||
|
const isLabel = (u: Unit) => u.kind === "text";
|
||||||
|
const isControl = (u: Unit) => u.kind === "switch" || u.kind === "checkbox" || u.kind === "radio" || u.kind === "iconButton";
|
||||||
|
const isAnchored = (u: Unit) => isRail(u) || isTop(u) || isBottomBar(u) || isFloatingBottom(u) || isFab(u) || isOverlay(u);
|
||||||
|
|
||||||
|
/** where a unit sits horizontally, so tidying keeps a right-aligned part on the right.
|
||||||
|
* Judged from the edges, so a part already on the margin reads the same way after tidying. */
|
||||||
|
function align(bb: Rect, fr: Rect): "left" | "center" | "right" | "fill" {
|
||||||
|
const w = bb.r - bb.l;
|
||||||
|
if (w >= fr.r - fr.l - PHONE_MARGIN * 2 - 8) return "fill";
|
||||||
|
const near = PHONE_MARGIN + 12;
|
||||||
|
const left = bb.l - fr.l <= near;
|
||||||
|
const right = fr.r - bb.r <= near;
|
||||||
|
if (left && !right) return "left";
|
||||||
|
if (right && !left) return "right";
|
||||||
|
return "center";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** the gap above a row, from what came before it */
|
||||||
|
function gapBefore(prev: Unit[] | null, row: Unit[]): number {
|
||||||
|
if (!prev) return 0;
|
||||||
|
/* two stacked runs of one family were left separate on purpose: keep them beyond the joining distance */
|
||||||
|
if (prev.length === 1 && row.length === 1 && canJoin(prev[0].probe, row[0].probe) && connectSpecOf(prev[0].probe)?.axis === "y") return APART_GAP_Y;
|
||||||
|
const pk = prev.length === 1 ? prev[0].kind : null;
|
||||||
|
const k = row.length === 1 ? row[0].kind : null;
|
||||||
|
if (pk === "text") return TIGHT_GAP;
|
||||||
|
if (pk === "divider" || k === "divider") return TIGHT_GAP;
|
||||||
|
if (pk && k && pk === k && LIST_KINDS.has(k)) return TIGHT_GAP;
|
||||||
|
return ROW_GAP;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A group moved the least distance that puts it inside a screen; an edge-to-edge
|
||||||
|
* part sits on the left edge. A group that fits already is returned as is. */
|
||||||
|
export function pullInto(g: Group, frame: Frame, widths: Record<string, number>): Group {
|
||||||
|
const fr = frameRect(frame);
|
||||||
|
const bb = groupBounds(g, widths);
|
||||||
|
let dx = bb.r > fr.r ? Math.max(fr.l - bb.l, fr.r - bb.r) : bb.l < fr.l ? fr.l - bb.l : 0;
|
||||||
|
const dy = bb.b > fr.b ? Math.max(fr.t - bb.t, fr.b - bb.b) : bb.t < fr.t ? fr.t - bb.t : 0;
|
||||||
|
/* a bar as wide as the screen sits on its left edge; a narrower one keeps its place */
|
||||||
|
if (g.items.length === 1 && FULL_WIDTH.includes(g.items[0].kind) && bb.r - bb.l >= fr.r - fr.l) dx = fr.l - bb.l;
|
||||||
|
return dx || dy ? { ...g, x: g.x + Math.round(dx), y: g.y + Math.round(dy) } : g;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A screen changing size, and everything that follows from it: the screens to its
|
||||||
|
* right move over with their parts so nothing overlaps, the parts of the screen
|
||||||
|
* take the sizes the new screen calls for, anything that fell outside is pulled
|
||||||
|
* back in, and the screen is laid out again by the rules above. */
|
||||||
|
export function carryFrame(groups: Group[], frame: Frame, to: Frame, frames: Frame[], widths: Record<string, number>): { frames: Frame[]; groups: Group[] } {
|
||||||
|
const from = frameSizeOf(frame);
|
||||||
|
const after = frameSizeOf(to);
|
||||||
|
const owner = new Map(groups.map((g) => [g.id, frameOfGroup(g, frames, widths)?.id] as const));
|
||||||
|
/* screens whose left edge is past the old right edge keep their distance from it */
|
||||||
|
const shift = after.w - from.w;
|
||||||
|
const moved = new Map(frames.filter((f) => f.id !== frame.id && f.x >= frame.x + from.w).map((f) => [f.id, shift] as const));
|
||||||
|
const nextFrames = frames.map((f) => (f.id === to.id ? to : moved.has(f.id) ? { ...f, x: f.x + shift } : f));
|
||||||
|
/* The navigation bar of a compact screen is the rail of an expanded one, and back: M3 has
|
||||||
|
* no rail below 840dp and no bar above it, so a rail placed on a phone becomes a bar too.
|
||||||
|
* Only a bar or rail standing on its own is swapped, and only the first of them: one
|
||||||
|
* inside a hand-made group is part of that group's drawing, and a screen has one rail. */
|
||||||
|
const expanded = isExpanded(after.w);
|
||||||
|
const mine = groups.filter((g) => owner.get(g.id) === frame.id);
|
||||||
|
const standsAlone = (g: Group, kind: Kind) => g.items.length === 1 && g.items[0].kind === kind;
|
||||||
|
const navGroup = mine.find((g) => standsAlone(g, "bottomNav") || standsAlone(g, "navRail"));
|
||||||
|
const swapNav = (g: Group, it: Item): Item => {
|
||||||
|
if (g !== navGroup) return it;
|
||||||
|
if (expanded && it.kind === "bottomNav") return { ...it, kind: "navRail", size: undefined, size2: after.h, radiusTop: it.radiusBottom, radiusBottom: it.radiusTop };
|
||||||
|
if (!expanded && it.kind === "navRail") return { ...it, kind: "bottomNav", size: after.w, size2: undefined, radiusTop: it.radiusBottom, radiusBottom: it.radiusTop };
|
||||||
|
return it;
|
||||||
|
};
|
||||||
|
const railBefore = mine.some((g) => standsAlone(g, "navRail")) ? RAIL_W : 0;
|
||||||
|
const railAfter = expanded && navGroup ? RAIL_W : 0;
|
||||||
|
/* a rail keeps the side it stood on; one that grows out of a bar starts on the left */
|
||||||
|
const side = navGroup && standsAlone(navGroup, "navRail") ? railSide(navGroup, frame, widths) : "left";
|
||||||
|
const leftBefore = side === "left" ? railBefore : 0;
|
||||||
|
const leftAfter = side === "left" ? railAfter : 0;
|
||||||
|
/* parts live in the body beside the rail: their widths follow the body, and their
|
||||||
|
* positions are measured from its left edge, on both ends of the change */
|
||||||
|
const fromBody = { w: from.w - railBefore, h: from.h };
|
||||||
|
const toBody = { w: after.w - railAfter, h: after.h };
|
||||||
|
const resized = groups.map((g) => {
|
||||||
|
const o = owner.get(g.id);
|
||||||
|
if (o === frame.id) {
|
||||||
|
const isRail = g === navGroup && railAfter > 0;
|
||||||
|
const items = g.items.map((it) => swapNav(g, carryItemSize(it, isRail ? from : fromBody, isRail ? after : toBody)));
|
||||||
|
const x = isRail ? (side === "right" ? to.x + after.w - RAIL_W : to.x) : g.x + leftAfter - leftBefore;
|
||||||
|
return pullInto({ ...g, x, items }, to, widths);
|
||||||
|
}
|
||||||
|
const dx = o ? moved.get(o) : undefined;
|
||||||
|
return dx ? { ...g, x: g.x + dx } : g;
|
||||||
|
});
|
||||||
|
return { frames: nextFrames, groups: tidyFrame(resized, to, nextFrames, widths) ?? resized };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** the edge a rail belongs to: whichever side of the screen its middle is nearer */
|
||||||
|
export function railSide(g: Group, frame: Frame, widths: Record<string, number>): "left" | "right" {
|
||||||
|
const fr = frameRect(frame);
|
||||||
|
const bb = groupBounds(g, widths);
|
||||||
|
return (bb.l + bb.r) / 2 > (fr.l + fr.r) / 2 ? "right" : "left";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** where a bar dropped on a screen goes: the screen's width less its rails, starting after a rail on the left */
|
||||||
|
export function barSlotOf(groups: Group[], frame: Frame, frames: Frame[], widths: Record<string, number>): { x: number; w: number } {
|
||||||
|
const { w } = frameSizeOf(frame);
|
||||||
|
const rails = groups.filter((g) => frameOfGroup(g, frames, widths)?.id === frame.id && g.items.length === 1 && g.items[0].kind === "navRail");
|
||||||
|
const left = rails.filter((g) => railSide(g, frame, widths) === "left").length;
|
||||||
|
return { x: frame.x + left * RAIL_W, w: w - rails.length * RAIL_W };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The value most of a set share, within `tol`: the biggest cluster wins; `prefer` breaks a tie, else the first seen. */
|
||||||
|
function commonValue(values: number[], tol: number, prefer?: number): number | null {
|
||||||
|
let best: { v: number; n: number } | null = null;
|
||||||
|
for (const v of values) {
|
||||||
|
const n = values.filter((w) => Math.abs(w - v) <= tol).length;
|
||||||
|
if (!best || n > best.n || (n === best.n && v === prefer && best.v !== prefer)) best = { v, n };
|
||||||
|
}
|
||||||
|
return best && best.n >= 2 ? best.v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs inside one unit whose left, right or top edges nearly agree move to the shared line.
|
||||||
|
* A run is only ever nudged by up to SNAP, so the picture stays what the author drew. */
|
||||||
|
function snapEdges(unit: Unit, groups: Map<string, Group>, widths: Record<string, number>): boolean {
|
||||||
|
if (unit.ids.length < 2) return false;
|
||||||
|
const runs = unit.ids.map((id) => groups.get(id)!);
|
||||||
|
const rects = new Map(runs.map((g) => [g.id, groupBounds(g, widths)]));
|
||||||
|
/* the container (the biggest run) sets the line; the parts on it follow */
|
||||||
|
const container = runs.reduce((a, b) => (area(rects.get(a.id)!) >= area(rects.get(b.id)!) ? a : b));
|
||||||
|
const others = runs.filter((g) => g !== container);
|
||||||
|
if (!others.length) return false;
|
||||||
|
let moved = false;
|
||||||
|
const nudge = (g: Group, dx: number, dy: number) => {
|
||||||
|
if (!dx && !dy) return;
|
||||||
|
groups.set(g.id, { ...groups.get(g.id)!, x: groups.get(g.id)!.x + dx, y: groups.get(g.id)!.y + dy });
|
||||||
|
moved = true;
|
||||||
|
};
|
||||||
|
const lefts = commonValue(others.map((g) => rects.get(g.id)!.l), SNAP);
|
||||||
|
const rights = commonValue(others.map((g) => rects.get(g.id)!.r), SNAP);
|
||||||
|
for (const g of others) {
|
||||||
|
const r = rects.get(g.id)!;
|
||||||
|
/* a run already on one shared line is left alone, so a left snap never turns into a right snap later */
|
||||||
|
if (r.l === lefts || r.r === rights) continue;
|
||||||
|
const dl = lefts === null ? Infinity : Math.abs(r.l - lefts);
|
||||||
|
const dr = rights === null ? Infinity : Math.abs(r.r - rights);
|
||||||
|
if (dl <= SNAP && dl <= dr) nudge(g, Math.round(lefts! - r.l), 0);
|
||||||
|
else if (dr <= SNAP) nudge(g, Math.round(rights! - r.r), 0);
|
||||||
|
}
|
||||||
|
/* parts side by side that nearly share a top edge: the neighbours set the line, not the part itself */
|
||||||
|
for (const g of others) {
|
||||||
|
const r = groupBounds(groups.get(g.id)!, widths);
|
||||||
|
const beside = others.filter((o) => o !== g && share(r.t, r.b, rects.get(o.id)!.t, rects.get(o.id)!.b) > 0.5).map((o) => groupBounds(groups.get(o.id)!, widths).t);
|
||||||
|
const tops = beside.length === 1 ? beside[0] : commonValue(beside, SNAP);
|
||||||
|
if (tops !== null && r.t !== tops && Math.abs(r.t - tops) <= SNAP) nudge(g, 0, Math.round(tops - r.t));
|
||||||
|
}
|
||||||
|
return moved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Boxes on the screen whose corner radii almost agree take the common radius, and so do
|
||||||
|
* cards, each kind among its own. A card still on the shape scale is not touched: writing a
|
||||||
|
* radius into it would cut it loose from the Shape control. */
|
||||||
|
function evenCorners(groups: Map<string, Group>): boolean {
|
||||||
|
const boxes: { g: Group; it: Item; r: number }[] = [];
|
||||||
|
const cards: { g: Group; it: Item; r: number }[] = [];
|
||||||
|
for (const g of groups.values())
|
||||||
|
for (const it of g.items) {
|
||||||
|
if (it.kind === "card" && it.radiusTop !== undefined) cards.push({ g, it, r: it.radiusTop });
|
||||||
|
else if (it.kind === "box" && !it.corners && (it.radiusTop ?? 0) === (it.radiusBottom ?? 0)) boxes.push({ g, it, r: it.radiusTop ?? 0 });
|
||||||
|
}
|
||||||
|
let changed = false;
|
||||||
|
const even = (list: typeof boxes, prefer: number) => {
|
||||||
|
const common = commonValue(list.map((b) => b.r), RADIUS_SNAP, prefer);
|
||||||
|
if (common === null) return;
|
||||||
|
for (const { g, it, r } of list) {
|
||||||
|
if (r === common || Math.abs(r - common) > RADIUS_SNAP) continue;
|
||||||
|
const cur = groups.get(g.id)!;
|
||||||
|
groups.set(g.id, { ...cur, items: cur.items.map((x) => (x.id === it.id ? (x.kind === "card" ? { ...x, radiusTop: common } : { ...x, radiusTop: common, radiusBottom: common }) : x)) });
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
even(boxes, 28);
|
||||||
|
even(cards, scaleR(KIND_SPEC.card.radius));
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tidied groups of the document, or null when `frame` is already tidy. */
|
||||||
|
export function tidyFrame(groups: Group[], frame: Frame, frames: Frame[], widths: Record<string, number>): Group[] | null {
|
||||||
|
const screen: Rect = frameRect(frame);
|
||||||
|
const mineIds = new Set(groups.filter((g) => frameOfGroup(g, frames, widths)?.id === frame.id).map((g) => g.id));
|
||||||
|
if (!mineIds.size) return null;
|
||||||
|
|
||||||
|
/* joining rewrites the list; the other screens' groups keep their slots */
|
||||||
|
const before = groups.filter((g) => mineIds.has(g.id));
|
||||||
|
/* a labelled switch standing on its own on a phone screen reads as a settings row: it takes
|
||||||
|
* the content width, label left, switch right. One on a box, in a group or on a desktop
|
||||||
|
* screen keeps its size. */
|
||||||
|
let widened = false;
|
||||||
|
const alone = (g: Group) => {
|
||||||
|
const r = groupBounds(g, widths);
|
||||||
|
/* nothing on it, and nothing beside it on the same row, which would have to share the width */
|
||||||
|
return !before.some((o) => {
|
||||||
|
if (o === g) return false;
|
||||||
|
const ob = groupBounds(o, widths);
|
||||||
|
return overlap(ob, r) || share(r.t, r.b, ob.t, ob.b) > 0.5;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const spanned = before.map((g) => {
|
||||||
|
const it = g.items[0];
|
||||||
|
if (!isPhoneFrame(frame) || g.items.length !== 1 || it.kind !== "switch" || !it.label.trim() || it.size || !alone(g)) return g;
|
||||||
|
widened = true;
|
||||||
|
return { ...g, items: [{ ...it, size: CONTENT_W }] };
|
||||||
|
});
|
||||||
|
const mine = joinRuns(spanned, widths);
|
||||||
|
const joined = mine.length !== before.length || widened;
|
||||||
|
|
||||||
|
const units = clusters(mine, widths);
|
||||||
|
const target = new Map<Unit, { l: number; t: number }>();
|
||||||
|
|
||||||
|
/* a navigation rail stands on the edge it is nearer to, left or right (a second one beside it);
|
||||||
|
* the rest of the screen is the body between them */
|
||||||
|
const rails = units.filter(isRail).sort((a, b) => a.bb.l - b.bb.l || a.bb.t - b.bb.t);
|
||||||
|
const onRight = (u: Unit) => (u.bb.l + u.bb.r) / 2 > (screen.l + screen.r) / 2;
|
||||||
|
const leftRails = rails.filter((u) => !onRight(u));
|
||||||
|
const rightRails = rails.filter(onRight);
|
||||||
|
leftRails.forEach((u, i) => target.set(u, { l: screen.l + i * RAIL_W, t: screen.t }));
|
||||||
|
rightRails.forEach((u, i) => target.set(u, { l: screen.r - (i + 1) * RAIL_W, t: screen.t }));
|
||||||
|
const fr: Rect = { ...screen, l: screen.l + leftRails.length * RAIL_W, r: screen.r - rightRails.length * RAIL_W };
|
||||||
|
const frameW = fr.r - fr.l;
|
||||||
|
const frameH = fr.b - fr.t;
|
||||||
|
|
||||||
|
let top = fr.t;
|
||||||
|
for (const u of units.filter(isTop).sort((a, b) => a.bb.t - b.bb.t)) {
|
||||||
|
target.set(u, { l: fr.l, t: top });
|
||||||
|
top += u.bb.b - u.bb.t;
|
||||||
|
}
|
||||||
|
let bottom = fr.b;
|
||||||
|
for (const u of units.filter(isBottomBar).sort((a, b) => b.bb.t - a.bb.t)) {
|
||||||
|
bottom -= u.bb.b - u.bb.t;
|
||||||
|
target.set(u, { l: fr.l + Math.max(0, Math.round((frameW - (u.bb.r - u.bb.l)) / 2)), t: bottom });
|
||||||
|
}
|
||||||
|
for (const u of units.filter(isFloatingBottom).sort((a, b) => b.bb.t - a.bb.t)) {
|
||||||
|
const h = u.bb.b - u.bb.t;
|
||||||
|
const w = u.bb.r - u.bb.l;
|
||||||
|
bottom -= PHONE_MARGIN + h;
|
||||||
|
target.set(u, { l: fr.l + Math.round((frameW - w) / 2), t: bottom });
|
||||||
|
}
|
||||||
|
let fabBottom = bottom;
|
||||||
|
for (const u of units.filter(isFab).sort((a, b) => b.bb.t - a.bb.t)) {
|
||||||
|
const h = u.bb.b - u.bb.t;
|
||||||
|
const w = u.bb.r - u.bb.l;
|
||||||
|
fabBottom -= PHONE_MARGIN + h;
|
||||||
|
target.set(u, { l: fr.r - PHONE_MARGIN - w, t: fabBottom });
|
||||||
|
}
|
||||||
|
for (const u of units.filter(isOverlay)) {
|
||||||
|
target.set(u, { l: fr.l + Math.round((frameW - (u.bb.r - u.bb.l)) / 2), t: fr.t + Math.round((frameH - (u.bb.b - u.bb.t)) / 2) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* everything else flows from the top on the layout margins, down to the bottom bars;
|
||||||
|
* rows that would not fit are left where they are rather than pushed off the screen */
|
||||||
|
const rows = rowsOf(units.filter((u) => !target.has(u)));
|
||||||
|
const limit = bottom - PHONE_MARGIN;
|
||||||
|
let y = top + PHONE_MARGIN;
|
||||||
|
let prev: Unit[] | null = null;
|
||||||
|
for (const row of rows) {
|
||||||
|
y += gapBefore(prev, row);
|
||||||
|
const rowH = Math.max(...row.map((u) => u.bb.b - u.bb.t));
|
||||||
|
if (y + rowH > limit) break;
|
||||||
|
const inner = frameW - PHONE_MARGIN * 2;
|
||||||
|
if (row.length === 1) {
|
||||||
|
const u = row[0];
|
||||||
|
const w = u.bb.r - u.bb.l;
|
||||||
|
const a = align(u.bb, fr);
|
||||||
|
/* a run nearly as wide as the content (a button row, a chip row) sits on the left margin,
|
||||||
|
* so its edge lines up with the cards and lists above and below it */
|
||||||
|
const wide = w >= inner * 0.7;
|
||||||
|
const l = wide || a === "left" ? fr.l + PHONE_MARGIN : a === "right" ? fr.r - PHONE_MARGIN - w : fr.l + Math.round((frameW - w) / 2);
|
||||||
|
target.set(u, { l, t: y });
|
||||||
|
} else if (row.length === 2 && row.some(isLabel) && row.some(isControl)) {
|
||||||
|
/* a label with its control: the label on the left margin, the control on the right, like a settings row */
|
||||||
|
const label = row.find(isLabel)!;
|
||||||
|
const control = row.find(isControl)!;
|
||||||
|
const cw = control.bb.r - control.bb.l;
|
||||||
|
target.set(label, { l: fr.l + PHONE_MARGIN, t: y + Math.round((rowH - (label.bb.b - label.bb.t)) / 2) });
|
||||||
|
target.set(control, { l: fr.r - PHONE_MARGIN - cw, t: y + Math.round((rowH - (control.bb.b - control.bb.t)) / 2) });
|
||||||
|
} else {
|
||||||
|
const ws = row.map((u) => u.bb.r - u.bb.l);
|
||||||
|
const total = ws.reduce((s, w) => s + w, 0);
|
||||||
|
/* neighbours of one family that were not joined were kept apart on purpose */
|
||||||
|
const gaps = row.slice(1).map((u, i) => (canJoin(row[i].probe, u.probe) ? APART_GAP_X : ROW_ITEM_GAP));
|
||||||
|
const minPacked = total + gaps.reduce((s, g) => s + g, 0);
|
||||||
|
const span = row[row.length - 1].bb.r - row[0].bb.l;
|
||||||
|
const spread = span >= inner * 0.7 && minPacked <= inner;
|
||||||
|
const packed = spread ? inner : minPacked;
|
||||||
|
const extra = spread ? (inner - minPacked) / gaps.length : 0;
|
||||||
|
const a = spread ? "left" : align({ l: row[0].bb.l, t: 0, r: row[row.length - 1].bb.r, b: 0 }, fr);
|
||||||
|
let x = a === "right" ? fr.r - PHONE_MARGIN - packed : a === "center" ? fr.l + (frameW - packed) / 2 : fr.l + PHONE_MARGIN;
|
||||||
|
row.forEach((u, i) => {
|
||||||
|
const h = u.bb.b - u.bb.t;
|
||||||
|
target.set(u, { l: Math.round(x), t: y + Math.round((rowH - h) / 2) });
|
||||||
|
x += ws[i] + (gaps[i] ?? 0) + extra;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
y += rowH;
|
||||||
|
prev = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* apply each unit's shift to every group it holds */
|
||||||
|
const shift = new Map<string, { dx: number; dy: number }>();
|
||||||
|
for (const [u, to] of target) {
|
||||||
|
const dx = Math.round(to.l - u.bb.l);
|
||||||
|
const dy = Math.round(to.t - u.bb.t);
|
||||||
|
for (const id of u.ids) shift.set(id, { dx, dy });
|
||||||
|
}
|
||||||
|
let moved = joined;
|
||||||
|
const placed = new Map(
|
||||||
|
mine.map((g) => {
|
||||||
|
const s = shift.get(g.id);
|
||||||
|
if (!s || (s.dx === 0 && s.dy === 0)) return [g.id, g] as const;
|
||||||
|
moved = true;
|
||||||
|
return [g.id, { ...g, x: g.x + s.dx, y: g.y + s.dy }] as const;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const u of units) if (snapEdges(u, placed, widths)) moved = true;
|
||||||
|
if (evenCorners(placed)) moved = true;
|
||||||
|
if (!moved) return null;
|
||||||
|
/* keep canvas order: a joined run takes the slot of its last original member */
|
||||||
|
const survivorOf = new Map<string, Group>();
|
||||||
|
for (const m of mine) for (const it of m.items) survivorOf.set(it.id, m);
|
||||||
|
const lastSlot = new Map<string, number>();
|
||||||
|
groups.forEach((g, i) => {
|
||||||
|
if (mineIds.has(g.id)) lastSlot.set(survivorOf.get(g.items[0].id)!.id, i);
|
||||||
|
});
|
||||||
|
const out: Group[] = [];
|
||||||
|
groups.forEach((g, i) => {
|
||||||
|
if (!mineIds.has(g.id)) {
|
||||||
|
out.push(g);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const m = survivorOf.get(g.items[0].id)!;
|
||||||
|
if (lastSlot.get(m.id) === i) out.push(placed.get(m.id)!);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
Vendored
+69
@@ -0,0 +1,69 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { DEFAULT_THEME, R_FULL, baseRadii, makeItem, normalizeTheme, runCorners, scaleR, setGlobalShape } from "./tokens";
|
||||||
|
|
||||||
|
afterEach(() => setGlobalShape("rounded")); // restore the module default
|
||||||
|
|
||||||
|
describe("setGlobalShape / scaleR", () => {
|
||||||
|
it("shrinks radii for the square scale and grows them for full", () => {
|
||||||
|
setGlobalShape("square");
|
||||||
|
expect(scaleR(R_FULL)).toBe(Math.round(R_FULL * 0.35));
|
||||||
|
setGlobalShape("full");
|
||||||
|
expect(scaleR(R_FULL)).toBe(Math.round(R_FULL * 1.6));
|
||||||
|
setGlobalShape("rounded");
|
||||||
|
expect(scaleR(R_FULL)).toBe(R_FULL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flows into a part's default corners", () => {
|
||||||
|
setGlobalShape("rounded");
|
||||||
|
const rounded = baseRadii(makeItem("button")).tl;
|
||||||
|
setGlobalShape("square");
|
||||||
|
expect(baseRadii(makeItem("button")).tl).toBeLessThan(rounded);
|
||||||
|
setGlobalShape("full");
|
||||||
|
expect(baseRadii(makeItem("button")).tl).toBeGreaterThan(rounded);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a radius the author typed in, whatever the scale", () => {
|
||||||
|
const card = { ...makeItem("card"), radiusTop: 12 };
|
||||||
|
setGlobalShape("full");
|
||||||
|
expect(baseRadii(card)).toEqual({ tl: 12, tr: 12, bl: 12, br: 12 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runCorners", () => {
|
||||||
|
const outer = 28;
|
||||||
|
const inner = 8;
|
||||||
|
|
||||||
|
it("puts the outer corners at the ends of a horizontal run, inner between parts", () => {
|
||||||
|
expect(runCorners("x", true, false, outer, inner)).toEqual({ tl: outer, bl: outer, tr: inner, br: inner });
|
||||||
|
expect(runCorners("x", false, true, outer, inner)).toEqual({ tl: inner, bl: inner, tr: outer, br: outer });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the outer corners at the ends of a vertical run, inner between parts", () => {
|
||||||
|
expect(runCorners("y", true, false, outer, inner)).toEqual({ tl: outer, tr: outer, bl: inner, br: inner });
|
||||||
|
expect(runCorners("y", false, true, outer, inner)).toEqual({ tl: inner, tr: inner, bl: outer, br: outer });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds a lone part all over and a middle part nowhere", () => {
|
||||||
|
expect(runCorners("x", true, true, outer, inner)).toEqual({ tl: outer, tr: outer, bl: outer, br: outer });
|
||||||
|
expect(runCorners("y", false, false, outer, inner)).toEqual({ tl: inner, tr: inner, bl: inner, br: inner });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeTheme", () => {
|
||||||
|
it("returns the defaults untouched for undefined or empty input", () => {
|
||||||
|
expect(normalizeTheme(undefined)).toEqual(DEFAULT_THEME);
|
||||||
|
expect(normalizeTheme({})).toEqual(DEFAULT_THEME);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the valid fields of a partial theme and fills in the rest", () => {
|
||||||
|
expect(normalizeTheme({ dark: true, shape: "full" })).toEqual({ ...DEFAULT_THEME, dark: true, shape: "full" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces unknown option values with the default", () => {
|
||||||
|
const t = normalizeTheme({ contrast: "blaring" as never, font: "papyrus" as never, shape: "pointy" as never });
|
||||||
|
expect(t.contrast).toBe(DEFAULT_THEME.contrast);
|
||||||
|
expect(t.font).toBe(DEFAULT_THEME.font);
|
||||||
|
expect(t.shape).toBe(DEFAULT_THEME.shape);
|
||||||
|
});
|
||||||
|
});
|
||||||
Vendored
+1662
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user