feat:加上 Vue 学习站与可嵌入机芯
站点按 bloub 三栏做自定义、动画时间线和设置,支持序列导出。 形体只留常见几种并配中文名;动画页右栏拉满时间线旁空位,滚动条隐藏。 机芯补 GROK.create / <grok-bot>,以及暂停冻结。
This commit is contained in:
+8
-1
@@ -1,5 +1,10 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Local study notes (full personal README)
|
||||||
|
README.local.md
|
||||||
|
|
||||||
# Local-only character data. Do not commit extracted or third-party geometry.
|
# Local-only character data. Do not commit extracted or third-party geometry.
|
||||||
replica/geometry-data.js
|
replica/geometry-data.js
|
||||||
@@ -9,5 +14,7 @@ replica/grok-blob-idle.svg
|
|||||||
/tmp/
|
/tmp/
|
||||||
snap-out/
|
snap-out/
|
||||||
|
|
||||||
# Never commit unpacked app dumps
|
# Never commit unpacked app dumps, unpack scripts, or third-party marks
|
||||||
extracted/
|
extracted/
|
||||||
|
extract-asar.js
|
||||||
|
assets/
|
||||||
|
|||||||
@@ -3,53 +3,69 @@
|
|||||||
Unofficial study of a spring-driven character engine.
|
Unofficial study of a spring-driven character engine.
|
||||||
非官方学习项目,与 xAI / Grok 无关。本仓库只开源机芯,不含角色几何、商标或第三方应用源码。
|
非官方学习项目,与 xAI / Grok 无关。本仓库只开源机芯,不含角色几何、商标或第三方应用源码。
|
||||||
|
|
||||||
登录页那种会眨眼、换情绪、被弹簧拉着走的角色,不是 GIF / Lottie,而是状态机 + 弹簧 + 多边形眼睛。这里把机芯拆开,方便阅读和改。
|
The **site** follows [bloub](https://github.com/jeremy-prt/bloub)’s presentation: a left rail, one character in the middle, Customise / Animations / Settings. The **motion** is ours — springs, 39 states, polygon eyes, overlays, login wrap. Not bloub’s measured 14-state engine.
|
||||||
|
|
||||||
## 打开
|
## Running it
|
||||||
|
|
||||||
用浏览器打开 [`replica/index.html`](./replica/index.html)。
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
克隆下来默认**看不到完整角色外形**。没有 `replica/geometry-data.js` 时,页面会停在说明态,不会报错崩掉。
|
Then open http://localhost:5191.
|
||||||
|
|
||||||
自备几何的接口见 [`replica/geometry.schema.md`](./replica/geometry.schema.md)。把符合接口的文件放到 `replica/geometry-data.js` 即可本地预览。该路径已被 git 忽略。
|
Clone the repo and the chrome still loads. Without `replica/geometry-data.js` the character stays off; the layout does not fall over. Put a file that matches [`replica/geometry.schema.md`](./replica/geometry.schema.md) at `replica/geometry-data.js` (gitignored) to see the bot.
|
||||||
|
|
||||||
不要把从商业应用抽出的几何、图标或主包提交到本仓库,也不要再分发。
|
The original lab is still there:
|
||||||
|
|
||||||
## 机芯在做什么
|
- [`replica/index.html`](./replica/index.html) — playground + HUD
|
||||||
|
- [`replica/showcase.html`](./replica/showcase.html) — recording layouts
|
||||||
|
- [`replica/embed.html`](./replica/embed.html) — `<grok-bot>`
|
||||||
|
|
||||||
- 39 个情绪 / 生命周期状态,每态有眼睛 playlist、眨眼间隔、注视节奏
|
Do not commit extracted geometry, icons, or a third-party app bundle.
|
||||||
- 弹簧驱动位移、挤压、旋转、眨眼、换形
|
|
||||||
- 眼睛是多边形 morph,不是贴图
|
|
||||||
- 部分状态会切 overlay(thinking / orbit / writing / loading 等)
|
|
||||||
- 登录包装带 `pose.scale`、指针跟随、onboarding 轮换
|
|
||||||
|
|
||||||
登录页常见参数:`sizePx: 64`,`color: "black"`,`shape: "blob"`。
|
## What's on the site
|
||||||
`pjn(n) = n % 2 === 0 ? idle : onboarding[(n-1)/2]`,间隔 1200ms。
|
|
||||||
playground 有几何时从 `curious` 起播;点「登录轮换」才按 `pjn(0)=idle` 重来。
|
|
||||||
|
|
||||||
## 目录
|
The rail switches three views. **Customise** is 18 body shapes, 11 inks and the rest-face playlist. **Animations** is bloub’s timeline editor on our 39 states: drag to reorder, pull a card to set duration, save named cycles in `localStorage`. The right-hand tiles append a block. **Settings** is language (French, English, Chinese) and credits. GIF/MP4 export is not in this tree.
|
||||||
|
|
||||||
| 路径 | 内容 |
|
The centre avatar is always `GrokCharacter` in `hold` mode. Tiles paint one frame and stop. Nothing from bloub’s `src/bot/` is in this tree.
|
||||||
|
|
||||||
|
## What the engine does
|
||||||
|
|
||||||
|
- 39 mood / lifecycle states, each with an eye playlist, blink cadence, gaze
|
||||||
|
- Springs on move, squash, spin, blink, shape change
|
||||||
|
- Eyes are polygon morphs, not sprites
|
||||||
|
- Some states switch overlays (thinking / orbit / writing / loading / …)
|
||||||
|
- Login wrap carries `pose.scale`, pointer follow, onboarding
|
||||||
|
|
||||||
|
Login-disk defaults: `sizePx: 64`, `color: "black"`, `shape: "blob"`.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| Path | What |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `replica/index.html` | playground。无几何时显示说明 |
|
| `src/` | Vue site (bloub-like chrome) |
|
||||||
| `replica/geometry.schema.md` | `window.GROK_GEO` 接口,不含数据 |
|
| `replica/index.html` | playground |
|
||||||
| `replica/src/math.js` | 弹簧、插值、多边形 |
|
| `replica/geometry.schema.md` | `window.GROK_GEO` contract, no data |
|
||||||
| `replica/src/tables.js` | 状态表、playlist、弹簧参数 |
|
| `replica/src/math.js` | springs, lerp, polygons |
|
||||||
| `replica/src/pose.js` | 姿态 / 注视 |
|
| `replica/src/tables.js` | states, playlists, spring constants |
|
||||||
| `replica/src/tricks.js` | 转圈、跳跃等特技 |
|
| `replica/src/pose.js` | pose / gaze |
|
||||||
| `replica/src/fx.js` | overlay 与粒子 |
|
| `replica/src/tricks.js` | spin, hop |
|
||||||
| `replica/src/eyes.js` | 眨眼、wink、眼睛绘制 |
|
| `replica/src/fx.js` | overlays and particles |
|
||||||
| `replica/src/character.js` | `GrokCharacter` 编排 |
|
| `replica/src/eyes.js` | blink, wink, eye paint |
|
||||||
| `replica/snap.mjs` | 可选的 headless 快照脚本 |
|
| `replica/src/character.js` | `GrokCharacter` |
|
||||||
|
| `replica/src/api.js` | `GROK.create` / `<grok-bot>` |
|
||||||
|
|
||||||
本仓库故意不含:
|
This repo deliberately does not contain:
|
||||||
|
|
||||||
- 角色几何(`geometry-data.js`)
|
- character geometry (`geometry-data.js`)
|
||||||
- 官方图标 / iconset
|
- official icons / iconset
|
||||||
- 从应用包抽出的主包或切片
|
- unpacked app slices
|
||||||
- 解包脚本
|
- unpack scripts
|
||||||
|
|
||||||
## 许可
|
## License
|
||||||
|
|
||||||
[MIT](./LICENSE) 只覆盖本仓库里的原创机芯、页面和文档。
|
[MIT](./LICENSE) covers the original engine, site and docs in this repository.
|
||||||
它不授权任何第三方角色造型、商标或应用源码;那些东西也不在这个仓库里。
|
It does not grant rights to third-party character art, trademarks or application source; those are not in this repository.
|
||||||
|
|
||||||
|
Site chrome is arranged after bloub’s three-column scene. bloub is MIT; “Grok” and “x.ai” belong to their owners. Not affiliated with either.
|
||||||
|
|||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>study — spring character</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Unofficial study of a spring-driven character engine. Site layout after bloub; motion from the replica engine."
|
||||||
|
/>
|
||||||
|
<meta name="theme-color" content="#f9f9f9" media="(prefers-color-scheme: light)" />
|
||||||
|
<meta name="theme-color" content="#0a0a0c" media="(prefers-color-scheme: dark)" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "grok-icon-study",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Study site for a spring-driven character engine. Bloub-like presentation, replica motion.",
|
||||||
|
"license": "MIT",
|
||||||
|
"packageManager": "[email protected]",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"mediabunny": "^1.55.1",
|
||||||
|
"vue": "^3.5.41"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@types/node": "^26.0.0",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.8",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^8.2.1",
|
||||||
|
"vue-tsc": "^3.3.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1078
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Grok Bot · 最小嵌入</title>
|
||||||
|
<style>
|
||||||
|
:root { --disk: #f3efe6; --ink: #161513; --mute: #6d675d; --paper: #ece7dc; }
|
||||||
|
html, body { margin: 0; min-height: 100%; background: #1a1916; color: var(--paper); }
|
||||||
|
body {
|
||||||
|
font: 14px/1.55 "SF Pro Text", "PingFang SC", sans-serif;
|
||||||
|
padding: 40px 28px 64px;
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
h1 { margin: 0 0 6px; font-size: 14px; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||||
|
p { margin: 0 0 22px; color: var(--mute); font-size: 13px; }
|
||||||
|
a { color: #e8d7a4; }
|
||||||
|
.disk {
|
||||||
|
width: 96px; height: 96px; border-radius: 50%;
|
||||||
|
background: var(--disk);
|
||||||
|
display: grid; place-items: center;
|
||||||
|
box-shadow: inset 0 -8px 18px rgba(40, 30, 10, 0.08), 0 12px 28px rgba(0,0,0,.3);
|
||||||
|
}
|
||||||
|
grok-bot { color-scheme: light; }
|
||||||
|
pre {
|
||||||
|
margin: 22px 0 0;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: #22211d;
|
||||||
|
border: 1px solid #2e2c27;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: auto;
|
||||||
|
font: 12px/1.5 ui-monospace, Menlo, monospace;
|
||||||
|
color: #d8d0c0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>最小嵌入</h1>
|
||||||
|
<p>
|
||||||
|
登录页原参数:64px · blob · black · <code>loginWrap</code>。
|
||||||
|
<a href="index.html">回 playground</a>
|
||||||
|
</p>
|
||||||
|
<div class="disk">
|
||||||
|
<grok-bot state="idle" shape="blob" color="black" size="64" mode="hold"></grok-bot>
|
||||||
|
</div>
|
||||||
|
<pre>
|
||||||
|
<!-- 任意页面 -->
|
||||||
|
<script src="geometry-data.js"></script>
|
||||||
|
<script src="src/math.js"></script>
|
||||||
|
<script src="src/tables.js"></script>
|
||||||
|
<script src="src/pose.js"></script>
|
||||||
|
<script src="src/tricks.js"></script>
|
||||||
|
<script src="src/fx.js"></script>
|
||||||
|
<script src="src/eyes.js"></script>
|
||||||
|
<script src="src/character.js"></script>
|
||||||
|
<script src="src/api.js"></script>
|
||||||
|
|
||||||
|
<grok-bot state="idle" shape="blob" color="black" size="64"></grok-bot>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const el = document.querySelector("grok-bot");
|
||||||
|
el.setState("playful");
|
||||||
|
el.spin(1);
|
||||||
|
|
||||||
|
// 或命令式
|
||||||
|
const { setState, spin, destroy } = GROK.create("#host", {
|
||||||
|
sizePx: 64, shape: "blob", color: "black", loginWrap: true, mode: "hold",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 换皮:只覆盖你有的部分
|
||||||
|
GROK.create("#other", {
|
||||||
|
eyes: MY_EYES,
|
||||||
|
shapes: { blob: MY_BLOB },
|
||||||
|
playlist: { idle: [0], playful: [1] },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</pre>
|
||||||
|
<script src="geometry-data.js"></script>
|
||||||
|
<script src="src/math.js"></script>
|
||||||
|
<script src="src/tables.js"></script>
|
||||||
|
<script src="src/pose.js"></script>
|
||||||
|
<script src="src/tricks.js"></script>
|
||||||
|
<script src="src/fx.js"></script>
|
||||||
|
<script src="src/eyes.js"></script>
|
||||||
|
<script src="src/character.js"></script>
|
||||||
|
<script src="src/api.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+135
-127
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Character Engine Playground</title>
|
<title>Grok Bot 角色 · 源码复刻</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--paper: #ece7dc;
|
--paper: #ece7dc;
|
||||||
@@ -73,18 +73,14 @@
|
|||||||
box-shadow: inset 0 -8px 18px rgba(40, 30, 10, 0.08), 0 12px 28px rgba(0,0,0,.3);
|
box-shadow: inset 0 -8px 18px rgba(40, 30, 10, 0.08), 0 12px 28px rgba(0,0,0,.3);
|
||||||
}
|
}
|
||||||
.disk-login svg { width: 64px; height: 64px; overflow: visible; color-scheme: light; --bg: var(--disk); --fg: #111; }
|
.disk-login svg { width: 64px; height: 64px; overflow: visible; color-scheme: light; --bg: var(--disk); --fg: #111; }
|
||||||
.missing {
|
.disk-login span {
|
||||||
width: min(100%, 280px);
|
display: block;
|
||||||
margin: 0 auto;
|
margin-top: 8px;
|
||||||
padding: 22px 18px;
|
font-size: 10px;
|
||||||
border-radius: 16px;
|
color: #8d877b;
|
||||||
background: #2a2823;
|
text-align: center;
|
||||||
color: #d8d0c0;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
}
|
||||||
.missing strong { display: block; margin-bottom: 8px; color: var(--accent); font-weight: 560; }
|
.readout, .hud {
|
||||||
.missing a { color: var(--accent); }
|
|
||||||
.readout {
|
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
@@ -93,8 +89,15 @@
|
|||||||
color: #b7b1a4;
|
color: #b7b1a4;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.readout b { color: var(--paper); font-weight: 560; }
|
.hud {
|
||||||
.modes { display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
|
margin-top: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid #2e2c27;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.readout b, .hud b { color: var(--paper); font-weight: 560; }
|
||||||
|
.modes { display: flex; gap: 8px; margin-top: 16px; }
|
||||||
button {
|
button {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
border: 1px solid #3a372f;
|
border: 1px solid #3a372f;
|
||||||
@@ -107,7 +110,6 @@
|
|||||||
}
|
}
|
||||||
button[aria-pressed="true"] { background: var(--accent); color: #1a1916; border-color: var(--accent); }
|
button[aria-pressed="true"] { background: var(--accent); color: #1a1916; border-color: var(--accent); }
|
||||||
button:hover { border-color: #6a6458; }
|
button:hover { border-color: #6a6458; }
|
||||||
button:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
||||||
h2 {
|
h2 {
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -142,13 +144,19 @@
|
|||||||
border: 2px solid #111; cursor: pointer; padding: 0;
|
border: 2px solid #111; cursor: pointer; padding: 0;
|
||||||
}
|
}
|
||||||
.swatch[aria-pressed="true"] { outline: 2px solid var(--accent); outline-offset: 2px; }
|
.swatch[aria-pressed="true"] { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.icons { display: flex; gap: 16px; align-items: center; }
|
||||||
|
.icons figure { margin: 0; text-align: center; }
|
||||||
|
.icons img {
|
||||||
|
width: 72px; height: 72px; border-radius: 16px;
|
||||||
|
background: #111; display: block;
|
||||||
|
}
|
||||||
|
.icons figcaption { margin-top: 6px; font-size: 11px; color: var(--mute); }
|
||||||
.note {
|
.note {
|
||||||
color: #8d877b;
|
color: #8d877b;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
max-width: 62ch;
|
max-width: 62ch;
|
||||||
}
|
}
|
||||||
.note code { color: #d8d0c0; font-size: 11px; }
|
.note code { color: #d8d0c0; font-size: 11px; }
|
||||||
[hidden] { display: none !important; }
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
main { grid-template-columns: 1fr; }
|
main { grid-template-columns: 1fr; }
|
||||||
.stage { position: static; }
|
.stage { position: static; }
|
||||||
@@ -157,29 +165,21 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h1>Character Engine</h1>
|
<h1>Grok Bot Character</h1>
|
||||||
<p>非官方弹簧角色机芯 · 几何不随仓库分发</p>
|
<p>从 Grok Bot.app v0.18.0 的 app.asar 抽出 · 个人学习用 · <a href="showcase.html" style="color:#e8d7a4">录屏展示</a> · <a href="embed.html" style="color:#e8d7a4">最小嵌入</a></p>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<aside class="stage">
|
<aside class="stage">
|
||||||
<div id="ready-stage">
|
<div class="stage-disks">
|
||||||
<div class="stage-disks">
|
<div class="disk" id="disk">
|
||||||
<div class="disk" id="disk">
|
<svg id="bot" role="img" aria-label="Grok Bot character"></svg>
|
||||||
<svg id="bot" role="img" aria-label="Character preview"></svg>
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="disk-login">
|
||||||
|
<svg id="bot64" role="img" aria-label="Login size 64px"></svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<span style="display:block;margin-top:8px;font-size:10px;color:#8d877b;text-align:center">登录 sizePx: 64</span>
|
||||||
<div class="disk-login">
|
|
||||||
<svg id="bot64" role="img" aria-label="Login size 64px"></svg>
|
|
||||||
</div>
|
|
||||||
<span style="display:block;margin-top:8px;font-size:10px;color:#8d877b;text-align:center">登录 sizePx: 64</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="missing" id="missing-geo" hidden>
|
|
||||||
<strong>几何未加载</strong>
|
|
||||||
本仓库不包含 <code>geometry-data.js</code>。把符合
|
|
||||||
<a href="geometry.schema.md">geometry.schema.md</a>
|
|
||||||
的数据放到同目录后刷新,即可本地预览。不要提交或再分发从商业应用抽出的造型。
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modes">
|
<div class="modes">
|
||||||
<button id="mode-onboard" aria-pressed="true">登录轮换</button>
|
<button id="mode-onboard" aria-pressed="true">登录轮换</button>
|
||||||
@@ -193,33 +193,57 @@
|
|||||||
<button id="act-burst">粒子</button>
|
<button id="act-burst">粒子</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="readout">
|
<div class="readout">
|
||||||
<div>状态 <b id="r-state">—</b></div>
|
<div>状态 <b id="r-state">idle</b></div>
|
||||||
<div>眼睛 <b id="r-eye">—</b></div>
|
<div>眼睛 <b id="r-eye">0 → 0</b></div>
|
||||||
<div>旋转 <b id="r-spin">—</b></div>
|
<div>旋转 <b id="r-spin">0.00°</b></div>
|
||||||
<div>挤压 <b id="r-squash">—</b></div>
|
<div>挤压 <b id="r-squash">1.000</b></div>
|
||||||
<div>overlay <b id="r-ov">—</b></div>
|
<div>overlay <b id="r-ov">—</b></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="hud" id="hud">
|
||||||
|
<div>overlay.x <b id="h-ox">0</b></div>
|
||||||
|
<div>trick <b id="h-trick">—</b></div>
|
||||||
|
<div>shapeSpring <b id="h-shape">1</b></div>
|
||||||
|
<div>notify <b id="h-notify">0</b></div>
|
||||||
|
<div>blink <b id="h-blink">1</b></div>
|
||||||
|
<div>badge <b id="h-badge">—</b></div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<div>
|
<div>
|
||||||
<section>
|
<section>
|
||||||
<h2>情绪状态 · 39 态</h2>
|
<h2>情绪状态 · 源码 39 态</h2>
|
||||||
<div id="states"></div>
|
<div id="states"></div>
|
||||||
</section>
|
</section>
|
||||||
<section id="shapes-section" hidden>
|
<section>
|
||||||
<h2>身形</h2>
|
<h2>身形 · Jo 几何生成器</h2>
|
||||||
<div class="shapes" id="shapes"></div>
|
<div class="shapes" id="shapes"></div>
|
||||||
</section>
|
</section>
|
||||||
<section id="swatches-section" hidden>
|
<section>
|
||||||
<h2>墨色</h2>
|
<h2>墨色 · snt 调色板</h2>
|
||||||
<div class="swatches" id="swatches"></div>
|
<div class="swatches" id="swatches"></div>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<h2>这是机芯,不是素材包</h2>
|
<h2>Dock 静态商标(位图,不是角色引擎)</h2>
|
||||||
|
<div class="icons">
|
||||||
|
<figure>
|
||||||
|
<img src="../assets/icon.iconset/[email protected]" alt="Dock icon 256" />
|
||||||
|
<figcaption>icon.icns 256</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure>
|
||||||
|
<img src="../assets/app-icon-256.png" alt="In-app icon" />
|
||||||
|
<figcaption>asar app-icon</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>这不是截图描摹</h2>
|
||||||
<p class="note">
|
<p class="note">
|
||||||
引擎拆在 <code>src/</code>:math / tables / pose / tricks / eyes / fx / character。
|
引擎按源码 <code>$_t</code> + 登录包装 <code>sd()</code> 拆在
|
||||||
有几何时,右侧 64px 带登录包装;thinking / orbit / writing / loading 等会切 overlay。
|
<code>src/</code>:math / tables / pose / tricks / eyes / fx / character。
|
||||||
首屏从 curious 起播,点「登录轮换」则按 onboarding 从 idle 重来。
|
右侧 64px 带 <code>pose.scale ≈ 1.04</code>。thinking / orbit / writing / loading
|
||||||
大预览默认开指针跟随。
|
等会切 overlay;celebrate 会 <code>spinWild</code>;换身形走弹簧并轮换一记特技。
|
||||||
|
大预览从 curious 起播;右侧 64px 登录盘按 <code>pjn(0)=idle</code>。点「登录轮换」两颗都从 idle 重来。
|
||||||
|
大预览默认开指针跟随;源码 <code>sd().isFollowingPointer</code> 默认关。
|
||||||
|
别的项目请看 <a href="embed.html"><code>embed.html</code></a> 和 <code>GROK.create</code>。
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -232,30 +256,20 @@
|
|||||||
<script src="src/fx.js"></script>
|
<script src="src/fx.js"></script>
|
||||||
<script src="src/eyes.js"></script>
|
<script src="src/eyes.js"></script>
|
||||||
<script src="src/character.js"></script>
|
<script src="src/character.js"></script>
|
||||||
|
<script src="src/api.js"></script>
|
||||||
<script>
|
<script>
|
||||||
function hasUsableGeo(geo) {
|
|
||||||
return !!(
|
|
||||||
geo &&
|
|
||||||
typeof geo.Re === "number" &&
|
|
||||||
Array.isArray(geo.eyes) &&
|
|
||||||
geo.eyes.length > 0 &&
|
|
||||||
geo.shapes &&
|
|
||||||
Object.keys(geo.shapes).length > 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ready = hasUsableGeo(window.GROK_GEO);
|
|
||||||
document.getElementById("missing-geo").hidden = ready;
|
|
||||||
document.getElementById("ready-stage").hidden = !ready;
|
|
||||||
document.querySelectorAll("#mode-onboard, #mode-hold, #opt-follow, #opt-emphasis, #act-spin, #act-bounce, #act-burst")
|
|
||||||
.forEach((el) => { el.disabled = !ready; });
|
|
||||||
|
|
||||||
const readout = {
|
const readout = {
|
||||||
state: document.getElementById("r-state"),
|
state: document.getElementById("r-state"),
|
||||||
eye: document.getElementById("r-eye"),
|
eye: document.getElementById("r-eye"),
|
||||||
spin: document.getElementById("r-spin"),
|
spin: document.getElementById("r-spin"),
|
||||||
squash: document.getElementById("r-squash"),
|
squash: document.getElementById("r-squash"),
|
||||||
ov: document.getElementById("r-ov"),
|
ov: document.getElementById("r-ov"),
|
||||||
|
ox: document.getElementById("h-ox"),
|
||||||
|
trick: document.getElementById("h-trick"),
|
||||||
|
shape: document.getElementById("h-shape"),
|
||||||
|
notify: document.getElementById("h-notify"),
|
||||||
|
blink: document.getElementById("h-blink"),
|
||||||
|
badge: document.getElementById("h-badge"),
|
||||||
};
|
};
|
||||||
const stateRoot = document.getElementById("states");
|
const stateRoot = document.getElementById("states");
|
||||||
function paintReadout(s) {
|
function paintReadout(s) {
|
||||||
@@ -264,6 +278,13 @@
|
|||||||
readout.spin.textContent = `${s.spin.toFixed(2)}°`;
|
readout.spin.textContent = `${s.spin.toFixed(2)}°`;
|
||||||
readout.squash.textContent = s.squash.toFixed(3);
|
readout.squash.textContent = s.squash.toFixed(3);
|
||||||
if (readout.ov) readout.ov.textContent = s.overlay || "—";
|
if (readout.ov) readout.ov.textContent = s.overlay || "—";
|
||||||
|
if (!readout.ox) return;
|
||||||
|
readout.ox.textContent = (s.overlayX ?? 0).toFixed(3);
|
||||||
|
readout.trick.textContent = s.trick || "—";
|
||||||
|
readout.shape.textContent = (s.shapeSpring ?? 1).toFixed(3);
|
||||||
|
readout.notify.textContent = (s.notify ?? 0).toFixed(3);
|
||||||
|
readout.blink.textContent = (s.blink ?? 1).toFixed(3);
|
||||||
|
readout.badge.textContent = s.badge ? `${s.badge.x.toFixed(1)}, ${s.badge.y.toFixed(1)}` : "—";
|
||||||
}
|
}
|
||||||
function markState(name) {
|
function markState(name) {
|
||||||
stateRoot.querySelectorAll("button").forEach((b) => {
|
stateRoot.querySelectorAll("button").forEach((b) => {
|
||||||
@@ -275,8 +296,7 @@
|
|||||||
document.getElementById("mode-hold").setAttribute("aria-pressed", mode === "hold");
|
document.getElementById("mode-hold").setAttribute("aria-pressed", mode === "hold");
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups = window.GROK_META?.groups || window.GROK_TABLES?.GROUPS || [];
|
GROK_META.groups.forEach((group) => {
|
||||||
groups.forEach((group) => {
|
|
||||||
const h = document.createElement("h2");
|
const h = document.createElement("h2");
|
||||||
h.textContent = group.label;
|
h.textContent = group.label;
|
||||||
h.style.marginTop = "8px";
|
h.style.marginTop = "8px";
|
||||||
@@ -287,10 +307,8 @@
|
|||||||
const b = document.createElement("button");
|
const b = document.createElement("button");
|
||||||
b.textContent = name;
|
b.textContent = name;
|
||||||
b.dataset.state = name;
|
b.dataset.state = name;
|
||||||
b.disabled = !ready;
|
|
||||||
b.addEventListener("click", () => {
|
b.addEventListener("click", () => {
|
||||||
if (!window.__bots) return;
|
bots.forEach((el) => {
|
||||||
window.__bots.forEach((el) => {
|
|
||||||
el.setMode("hold");
|
el.setMode("hold");
|
||||||
el.setState(name, { resetEyes: false });
|
el.setState(name, { resetEyes: false });
|
||||||
});
|
});
|
||||||
@@ -301,84 +319,74 @@
|
|||||||
stateRoot.appendChild(row);
|
stateRoot.appendChild(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!ready) {
|
const shared = { mode: "onboarding", shape: "blob", color: "black", scheme: "light", loginWrap: true };
|
||||||
// Engine tables still render as documentation. Live character stays off.
|
const stage = GROK.create(document.getElementById("bot"), {
|
||||||
} else {
|
...shared,
|
||||||
const shared = { mode: "onboarding", shape: "blob", color: "black", scheme: "light", loginWrap: true };
|
followPointer: true,
|
||||||
const bot = new GrokCharacter(document.getElementById("bot"), {
|
onChange(snap) { paintReadout(snap); markState(snap.state); },
|
||||||
...shared,
|
});
|
||||||
followPointer: true,
|
const login = GROK.create(document.getElementById("bot64"), { ...shared, sizePx: 64 });
|
||||||
onChange(snap) { paintReadout(snap); markState(snap.state); },
|
const bot = stage.bot;
|
||||||
});
|
const bot64 = login.bot;
|
||||||
const bot64 = new GrokCharacter(document.getElementById("bot64"), { ...shared, sizePx: 64 });
|
const bots = [bot, bot64];
|
||||||
const bots = [bot, bot64];
|
window.__bots = bots;
|
||||||
window.__bots = bots;
|
bot.moodN = 1;
|
||||||
bots.forEach((el) => {
|
bot.setState("curious", { resetEyes: true });
|
||||||
el.moodN = 1;
|
setInterval(() => paintReadout(bot.snapshot()), 80);
|
||||||
el.setState("curious", { resetEyes: true });
|
|
||||||
});
|
|
||||||
setInterval(() => paintReadout(bot.snapshot()), 80);
|
|
||||||
|
|
||||||
const shapeRoot = document.getElementById("shapes");
|
const shapeRoot = document.getElementById("shapes");
|
||||||
document.getElementById("shapes-section").hidden = false;
|
Object.entries(GROK_GEO.shapes).forEach(([id, shape]) => {
|
||||||
Object.entries(GROK_GEO.shapes).forEach(([id, shape]) => {
|
const b = document.createElement("button");
|
||||||
const b = document.createElement("button");
|
b.className = "shape";
|
||||||
b.className = "shape";
|
b.dataset.shape = id;
|
||||||
b.dataset.shape = id;
|
b.innerHTML = `<svg viewBox="-15 -15 259 259"><path d="${shape.path}" style="fill:var(--fg)"></path></svg><span>${shape.label}</span>`;
|
||||||
b.innerHTML = `<svg viewBox="-15 -15 259 259"><path d="${shape.path}" style="fill:var(--fg)"></path></svg><span>${shape.label}</span>`;
|
b.addEventListener("click", () => {
|
||||||
b.addEventListener("click", () => {
|
bots.forEach((el) => el.setShape(id));
|
||||||
bots.forEach((el) => el.setShape(id));
|
shapeRoot.querySelectorAll("button").forEach((el) => {
|
||||||
shapeRoot.querySelectorAll("button").forEach((el) => {
|
el.setAttribute("aria-pressed", el === b ? "true" : "false");
|
||||||
el.setAttribute("aria-pressed", el === b ? "true" : "false");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
if (id === "blob") b.setAttribute("aria-pressed", "true");
|
|
||||||
shapeRoot.appendChild(b);
|
|
||||||
});
|
});
|
||||||
|
if (id === "blob") b.setAttribute("aria-pressed", "true");
|
||||||
|
shapeRoot.appendChild(b);
|
||||||
|
});
|
||||||
|
|
||||||
const swatchRoot = document.getElementById("swatches");
|
const swatchRoot = document.getElementById("swatches");
|
||||||
document.getElementById("swatches-section").hidden = false;
|
Object.entries(GROK_GEO.palette).forEach(([id, pal]) => {
|
||||||
Object.entries(GROK_GEO.palette).forEach(([id, pal]) => {
|
const b = document.createElement("button");
|
||||||
const b = document.createElement("button");
|
b.className = "swatch";
|
||||||
b.className = "swatch";
|
b.title = id;
|
||||||
b.title = id;
|
b.style.background = pal.light;
|
||||||
b.style.background = pal.light;
|
b.addEventListener("click", () => {
|
||||||
b.addEventListener("click", () => {
|
bots.forEach((el) => el.setColor(id, "light"));
|
||||||
bots.forEach((el) => el.setColor(id, "light"));
|
swatchRoot.querySelectorAll("button").forEach((el) => {
|
||||||
swatchRoot.querySelectorAll("button").forEach((el) => {
|
el.setAttribute("aria-pressed", el === b ? "true" : "false");
|
||||||
el.setAttribute("aria-pressed", el === b ? "true" : "false");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
if (id === "black") b.setAttribute("aria-pressed", "true");
|
|
||||||
swatchRoot.appendChild(b);
|
|
||||||
});
|
});
|
||||||
}
|
if (id === "black") b.setAttribute("aria-pressed", "true");
|
||||||
|
swatchRoot.appendChild(b);
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById("mode-onboard").onclick = () => {
|
document.getElementById("mode-onboard").onclick = () => {
|
||||||
if (!window.__bots) return;
|
bots.forEach((el) => el.setMode("onboarding"));
|
||||||
window.__bots.forEach((el) => el.setMode("onboarding"));
|
|
||||||
syncModes("onboarding");
|
syncModes("onboarding");
|
||||||
};
|
};
|
||||||
document.getElementById("mode-hold").onclick = () => {
|
document.getElementById("mode-hold").onclick = () => {
|
||||||
if (!window.__bots) return;
|
bots.forEach((el) => el.setMode("hold"));
|
||||||
window.__bots.forEach((el) => el.setMode("hold"));
|
|
||||||
syncModes("hold");
|
syncModes("hold");
|
||||||
};
|
};
|
||||||
document.getElementById("opt-follow").onclick = () => {
|
document.getElementById("opt-follow").onclick = () => {
|
||||||
if (!window.__bots) return;
|
|
||||||
const on = document.getElementById("opt-follow").getAttribute("aria-pressed") !== "true";
|
const on = document.getElementById("opt-follow").getAttribute("aria-pressed") !== "true";
|
||||||
document.getElementById("opt-follow").setAttribute("aria-pressed", on ? "true" : "false");
|
document.getElementById("opt-follow").setAttribute("aria-pressed", on ? "true" : "false");
|
||||||
window.__bots.forEach((el) => el.setFollowPointer(on));
|
bots.forEach((el) => el.setFollowPointer(on));
|
||||||
};
|
};
|
||||||
document.getElementById("opt-emphasis").onclick = () => {
|
document.getElementById("opt-emphasis").onclick = () => {
|
||||||
if (!window.__bots) return;
|
|
||||||
const on = document.getElementById("opt-emphasis").getAttribute("aria-pressed") !== "true";
|
const on = document.getElementById("opt-emphasis").getAttribute("aria-pressed") !== "true";
|
||||||
document.getElementById("opt-emphasis").setAttribute("aria-pressed", on ? "true" : "false");
|
document.getElementById("opt-emphasis").setAttribute("aria-pressed", on ? "true" : "false");
|
||||||
window.__bots.forEach((el) => el.setEmphasis(on));
|
bots.forEach((el) => el.setEmphasis(on));
|
||||||
};
|
};
|
||||||
document.getElementById("act-spin").onclick = () => window.__bots?.forEach((el) => el.spinOnce(1));
|
document.getElementById("act-spin").onclick = () => bots.forEach((el) => el.spinOnce(1));
|
||||||
document.getElementById("act-bounce").onclick = () => window.__bots?.forEach((el) => el.bounceOnce());
|
document.getElementById("act-bounce").onclick = () => bots.forEach((el) => el.bounceOnce());
|
||||||
document.getElementById("act-burst").onclick = () => window.__bots?.forEach((el) => el.burstOnce());
|
document.getElementById("act-burst").onclick = () => bots.forEach((el) => el.burstOnce());
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Executable → Regular
+232
-4
@@ -146,14 +146,21 @@ async function main() {
|
|||||||
if (ready) break;
|
if (ready) break;
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
}
|
}
|
||||||
|
const report = [];
|
||||||
|
report.push(await cdp.eval(`(() => {
|
||||||
|
const [stage, login] = window.__bots || [];
|
||||||
|
return {
|
||||||
|
label: "boot-modes",
|
||||||
|
stage: { state: stage?.state, mode: stage?.mode, moodN: stage?.moodN },
|
||||||
|
login: { state: login?.state, mode: login?.mode, moodN: login?.moodN },
|
||||||
|
};
|
||||||
|
})()`));
|
||||||
await click(cdp, "#mode-hold");
|
await click(cdp, "#mode-hold");
|
||||||
await click(cdp, "#opt-follow"); // default on → off for stable transforms
|
await click(cdp, "#opt-follow"); // default on → off for stable transforms
|
||||||
await sleep(80);
|
await sleep(80);
|
||||||
|
|
||||||
const report = [];
|
|
||||||
const states = [
|
const states = [
|
||||||
"playful", "laughing", "sleeping", "idle", "celebrate",
|
"playful", "laughing", "sleeping", "waking", "idle", "celebrate",
|
||||||
"thinking", "writing", "orbit", "humming", "loading",
|
"thinking", "writing", "orbit", "humming", "loading", "notifying",
|
||||||
];
|
];
|
||||||
for (const name of states) {
|
for (const name of states) {
|
||||||
await click(cdp, `button[data-state="${name}"]`);
|
await click(cdp, `button[data-state="${name}"]`);
|
||||||
@@ -201,6 +208,227 @@ async function main() {
|
|||||||
await shot(cdp, `${shape}-spin`);
|
await shot(cdp, `${shape}-spin`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gr() Lt/Mt: locals default to 1 each frame (not prev)
|
||||||
|
await cdp.eval(`(() => {
|
||||||
|
const b = window.__bots[0];
|
||||||
|
b.setMode("hold");
|
||||||
|
b.setState("laughing", { resetEyes: false });
|
||||||
|
b.poseOut.lid = 0.7;
|
||||||
|
b.poseOut.eyeBoost = 1.12;
|
||||||
|
return true;
|
||||||
|
})()`);
|
||||||
|
await sleep(80);
|
||||||
|
await click(cdp, 'button[data-state="loading"]');
|
||||||
|
await sleep(80);
|
||||||
|
report.push(await cdp.eval(`(() => {
|
||||||
|
const b = window.__bots[0];
|
||||||
|
return {
|
||||||
|
label: "loading-after-laughing",
|
||||||
|
lid: b.poseOut.lid,
|
||||||
|
eyeBoost: b.poseOut.eyeBoost,
|
||||||
|
trick: b.trick?.kind || null,
|
||||||
|
spinTurn: !!b.spinTurn,
|
||||||
|
};
|
||||||
|
})()`));
|
||||||
|
|
||||||
|
await click(cdp, 'button[data-state="excited"]');
|
||||||
|
await sleep(80);
|
||||||
|
await click(cdp, 'button[data-state="waking"]');
|
||||||
|
await sleep(1600);
|
||||||
|
report.push(await cdp.eval(`(() => {
|
||||||
|
const b = window.__bots[0];
|
||||||
|
return {
|
||||||
|
label: "[email protected]",
|
||||||
|
dt: (performance.now() - b.stateAt) / 1000,
|
||||||
|
lid: b.poseOut.lid,
|
||||||
|
eyeBoost: b.poseOut.eyeBoost,
|
||||||
|
ty: b.poseOut.ty,
|
||||||
|
squash: b.poseOut.squash,
|
||||||
|
};
|
||||||
|
})()`));
|
||||||
|
|
||||||
|
await click(cdp, 'button[data-state="dragging"]');
|
||||||
|
await sleep(50);
|
||||||
|
const drag = await cdp.eval(`(() => {
|
||||||
|
const b = window.__bots[0];
|
||||||
|
const now = performance.now();
|
||||||
|
const dt = (now - b.stateAt) / 1000;
|
||||||
|
const a = window.GROK_POSE.applyPose("dragging", 0, 0.05, now, b.ctx, { prev: { spin: 0, tx: 0, ty: 0, squash: 1, lid: 1, eyeBoost: 1.06 } });
|
||||||
|
const mid = window.GROK_POSE.applyPose("dragging", 0, 1.2, now, b.ctx, { prev: { spin: 0, tx: 0, ty: 0, squash: 1, lid: 1, eyeBoost: 1 } });
|
||||||
|
const late = window.GROK_POSE.applyPose("dragging", 0, 2.5, now, b.ctx, { prev: { spin: 0, tx: 0, ty: 0, squash: 1, lid: 1, eyeBoost: 1.06 } });
|
||||||
|
return { label: "dragging-Lt", dt, a: a.eyeBoost, mid: mid.eyeBoost, late: late.eyeBoost };
|
||||||
|
})()`);
|
||||||
|
report.push(drag);
|
||||||
|
|
||||||
|
// hold 切态 must not tear rn/ys
|
||||||
|
await click(cdp, 'button[data-state="idle"]');
|
||||||
|
await sleep(60);
|
||||||
|
await click(cdp, "#act-spin");
|
||||||
|
await sleep(40);
|
||||||
|
const beforeHold = await cdp.eval(`({ spinTurn: !!window.__bots[0].spinTurn, trick: window.__bots[0].trick?.kind || null })`);
|
||||||
|
await click(cdp, 'button[data-state="thinking"]');
|
||||||
|
await sleep(40);
|
||||||
|
report.push(await cdp.eval(`(() => {
|
||||||
|
const b = window.__bots[0];
|
||||||
|
return {
|
||||||
|
label: "hold-keeps-pn",
|
||||||
|
before: ${JSON.stringify(beforeHold)},
|
||||||
|
state: b.state,
|
||||||
|
spinTurn: !!b.spinTurn,
|
||||||
|
trick: b.trick?.kind || null,
|
||||||
|
};
|
||||||
|
})()`));
|
||||||
|
|
||||||
|
// notifying badge = rest ring 7/8
|
||||||
|
await click(cdp, 'button[data-state="notifying"]');
|
||||||
|
await sleep(400);
|
||||||
|
report.push(await cdp.eval(`(() => {
|
||||||
|
const b = window.__bots[0];
|
||||||
|
const R = b.geo.Re;
|
||||||
|
const ring = window.GROK_FX.shapeRing(b.geo.shapes[b.shapeName].path, R);
|
||||||
|
const Yl = ring[Math.round(ring.length * 7 / 8) % ring.length];
|
||||||
|
const badge = b.badge;
|
||||||
|
const vis = badge && badge.style.display !== "none";
|
||||||
|
const cx = vis ? +badge.getAttribute("cx") : null;
|
||||||
|
const cy = vis ? +badge.getAttribute("cy") : null;
|
||||||
|
return {
|
||||||
|
label: "notifying-Yl",
|
||||||
|
vis,
|
||||||
|
cx, cy,
|
||||||
|
Yl,
|
||||||
|
dx: vis ? Math.abs(cx - Yl[0]) : null,
|
||||||
|
dy: vis ? Math.abs(cy - Yl[1]) : null,
|
||||||
|
notifyX: b.notify.x,
|
||||||
|
};
|
||||||
|
})()`));
|
||||||
|
|
||||||
|
// excited pn must survive 1200ms onboard cut to idle
|
||||||
|
await cdp.eval(`(() => {
|
||||||
|
const b = window.__bots[0];
|
||||||
|
b.setMode("hold");
|
||||||
|
b.moodN = 7;
|
||||||
|
b.setState("excited", { resetEyes: false });
|
||||||
|
b._pn(1);
|
||||||
|
b.mode = "onboarding";
|
||||||
|
b.stateAt = performance.now() - 1190;
|
||||||
|
return true;
|
||||||
|
})()`);
|
||||||
|
await sleep(80);
|
||||||
|
report.push(await cdp.eval(`(() => {
|
||||||
|
const b = window.__bots[0];
|
||||||
|
return {
|
||||||
|
label: "onboard-excited-to-idle",
|
||||||
|
state: b.state,
|
||||||
|
moodN: b.moodN,
|
||||||
|
spinTurn: !!b.spinTurn,
|
||||||
|
trick: b.trick?.kind || null,
|
||||||
|
};
|
||||||
|
})()`));
|
||||||
|
|
||||||
|
await click(cdp, 'button[data-state="idle"]');
|
||||||
|
await sleep(40);
|
||||||
|
await cdp.eval(`(() => {
|
||||||
|
const b = window.__bots[0];
|
||||||
|
b.trick = null;
|
||||||
|
b.spinOnce(1);
|
||||||
|
return true;
|
||||||
|
})()`);
|
||||||
|
await sleep(50);
|
||||||
|
report.push(await cdp.eval(`(() => {
|
||||||
|
const b = window.__bots[0];
|
||||||
|
const rn = b.spinTurn;
|
||||||
|
const turn = b.extras.turn;
|
||||||
|
return {
|
||||||
|
label: "pn-post-spring",
|
||||||
|
hasRn: !!rn,
|
||||||
|
turn,
|
||||||
|
rnX: rn ? rn.x : null,
|
||||||
|
match: rn ? Math.abs(turn - rn.x) < 1e-9 : turn == null,
|
||||||
|
};
|
||||||
|
})()`));
|
||||||
|
|
||||||
|
await cdp.eval(`(() => {
|
||||||
|
const b = window.__bots[0];
|
||||||
|
b.setFollowPointer(true);
|
||||||
|
b.pointerRaw = { x: 0, y: 0 };
|
||||||
|
b.pointer.tx = 12;
|
||||||
|
b.pointer.ty = 8;
|
||||||
|
b.pointer.x = 12;
|
||||||
|
b.pointer.y = 8;
|
||||||
|
return true;
|
||||||
|
})()`);
|
||||||
|
await sleep(30);
|
||||||
|
await cdp.eval(`(() => {
|
||||||
|
const b = window.__bots[0];
|
||||||
|
b.pointerRaw = null;
|
||||||
|
return true;
|
||||||
|
})()`);
|
||||||
|
await sleep(80);
|
||||||
|
report.push(await cdp.eval(`(() => {
|
||||||
|
const b = window.__bots[0];
|
||||||
|
return {
|
||||||
|
label: "pointer-leave-decay",
|
||||||
|
tracking: !!(b.gazeTarget || (b.followPointer && b.pointerRaw)),
|
||||||
|
x: b.pointer.x,
|
||||||
|
tx: b.pointer.tx,
|
||||||
|
decaying: b.pointer.tx === 0 && b.pointer.x > 0.05 && b.pointer.x < 12,
|
||||||
|
};
|
||||||
|
})()`));
|
||||||
|
|
||||||
|
await click(cdp, 'button[data-state="writing"]');
|
||||||
|
await sleep(400);
|
||||||
|
await cdp.eval(`(() => {
|
||||||
|
const b = window.__bots[0];
|
||||||
|
b.fx.ink = [[10, 10], [20, 20]];
|
||||||
|
b.fx.recvTick = 3;
|
||||||
|
b.fx.recvDir = 1.2;
|
||||||
|
b.setState("writing", { resetEyes: false });
|
||||||
|
return true;
|
||||||
|
})()`);
|
||||||
|
report.push(await cdp.eval(`(() => {
|
||||||
|
const b = window.__bots[0];
|
||||||
|
return {
|
||||||
|
label: "setState-resets-ink-recv",
|
||||||
|
ink: b.fx.ink.length,
|
||||||
|
recvTick: b.fx.recvTick,
|
||||||
|
recvDir: b.fx.recvDir,
|
||||||
|
};
|
||||||
|
})()`));
|
||||||
|
|
||||||
|
await cdp.eval(`(() => {
|
||||||
|
const b = window.__bots[0];
|
||||||
|
b.setMode("hold");
|
||||||
|
b.trick = null;
|
||||||
|
b.spinTurn = null;
|
||||||
|
b.hopAt = -1;
|
||||||
|
b.particles.clear();
|
||||||
|
b.overlay.x = 0; b.overlay.v = 0; b.overlay.t = 0;
|
||||||
|
b.overlayTurn.x = 0; b.overlayTurn.v = 0; b.overlayTurn.t = 0;
|
||||||
|
b.overlayMix.x = 1; b.overlayMix.v = 0; b.overlayMix.t = 1;
|
||||||
|
b.shapeSpring.x = 1; b.shapeSpring.v = 0; b.shapeSpring.t = 1;
|
||||||
|
b.eyeMorph.x = 1; b.eyeMorph.v = 0; b.eyeMorph.t = 1;
|
||||||
|
b.ovOn = false;
|
||||||
|
b.ovKind = null;
|
||||||
|
b.ovTarget = null;
|
||||||
|
b.ovPrev = null;
|
||||||
|
b.setState("sleeping", { resetEyes: false });
|
||||||
|
b.setPaused(true);
|
||||||
|
return true;
|
||||||
|
})()`);
|
||||||
|
await sleep(200);
|
||||||
|
report.push(await cdp.eval(`(() => {
|
||||||
|
const b = window.__bots[0];
|
||||||
|
return {
|
||||||
|
label: "pause-settle-snap",
|
||||||
|
raf: b._raf,
|
||||||
|
eyeFrom: b.eyeFrom,
|
||||||
|
eyeTo: b.eyeTo,
|
||||||
|
poseOut: b.poseOut,
|
||||||
|
fromPolys: b._fromPolys,
|
||||||
|
};
|
||||||
|
})()`));
|
||||||
|
await cdp.eval(`window.__bots[0].setPaused(false)`);
|
||||||
|
|
||||||
await writeFile(join(OUT, "report.json"), JSON.stringify(report, null, 2));
|
await writeFile(join(OUT, "report.json"), JSON.stringify(report, null, 2));
|
||||||
console.log(JSON.stringify(report, null, 2));
|
console.log(JSON.stringify(report, null, 2));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/* L2 — public factory + <grok-bot>. Skin: eyes / shapes / playlist / palette. */
|
||||||
|
(function (g) {
|
||||||
|
const ATTRS = ["state", "shape", "color", "size", "paused", "follow", "mode", "emphasis"];
|
||||||
|
|
||||||
|
function mergeSkin(opts = {}) {
|
||||||
|
const base = opts.geo || g.GROK_GEO;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
eyes: opts.eyes || base.eyes,
|
||||||
|
shapes: opts.shapes ? { ...base.shapes, ...opts.shapes } : base.shapes,
|
||||||
|
palette: opts.palette ? { ...base.palette, ...opts.palette } : base.palette,
|
||||||
|
solids: opts.solids || base.solids,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSvg(target) {
|
||||||
|
if (!target) throw new Error("GROK.create: missing target");
|
||||||
|
if (typeof target === "string") {
|
||||||
|
const el = document.querySelector(target);
|
||||||
|
if (!el) throw new Error("GROK.create: no match for " + target);
|
||||||
|
target = el;
|
||||||
|
}
|
||||||
|
if (target.tagName === "SVG" || target instanceof SVGSVGElement) return target;
|
||||||
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
|
target.appendChild(svg);
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCharacter(target, opts = {}) {
|
||||||
|
const svg = resolveSvg(target);
|
||||||
|
const geo = mergeSkin(opts);
|
||||||
|
const playlist = opts.playlist
|
||||||
|
? { ...g.GROK_TABLES.EYE_PLAYLIST, ...opts.playlist }
|
||||||
|
: g.GROK_TABLES.EYE_PLAYLIST;
|
||||||
|
const bot = new g.GrokCharacter(svg, { mode: "hold", ...opts, geo, playlist });
|
||||||
|
return {
|
||||||
|
bot,
|
||||||
|
svg,
|
||||||
|
setState: (name, o) => bot.setState(name, o),
|
||||||
|
setShape: (name) => bot.setShape(name),
|
||||||
|
setColor: (id, scheme) => bot.setColor(id, scheme),
|
||||||
|
setPaused: (v) => bot.setPaused(v),
|
||||||
|
setMode: (m) => bot.setMode(m),
|
||||||
|
setFollowPointer: (v) => bot.setFollowPointer(v),
|
||||||
|
setEmphasis: (v) => bot.setEmphasis(v),
|
||||||
|
spin: (n) => bot.spinOnce(n),
|
||||||
|
bounce: () => bot.bounceOnce(),
|
||||||
|
burst: () => bot.burstOnce(),
|
||||||
|
destroy: () => bot.destroy(),
|
||||||
|
snapshot: () => bot.snapshot(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class GrokBotElement extends HTMLElement {
|
||||||
|
static get observedAttributes() { return ATTRS; }
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
if (this._handle) return;
|
||||||
|
const root = this.attachShadow({ mode: "open" });
|
||||||
|
const box = document.createElement("div");
|
||||||
|
box.setAttribute("part", "disk");
|
||||||
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
|
svg.setAttribute("role", "img");
|
||||||
|
svg.setAttribute("aria-label", this.getAttribute("label") || "character");
|
||||||
|
box.appendChild(svg);
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.textContent = `:host{display:inline-block;line-height:0}div{width:100%;height:100%}svg{width:100%;height:100%;overflow:visible}`;
|
||||||
|
root.append(style, box);
|
||||||
|
this._applySize();
|
||||||
|
this._handle = createCharacter(svg, this._opts());
|
||||||
|
this.bot = this._handle.bot;
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
this._handle?.destroy();
|
||||||
|
this._handle = null;
|
||||||
|
this.bot = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeChangedCallback(name, prev, next) {
|
||||||
|
if (!this._handle || prev === next) return;
|
||||||
|
this._applySize();
|
||||||
|
if (name === "state") this._handle.setState(next || "idle");
|
||||||
|
else if (name === "shape") this._handle.setShape(next || "blob");
|
||||||
|
else if (name === "color") this._handle.setColor(next || "black");
|
||||||
|
else if (name === "paused") this._handle.setPaused(this.hasAttribute("paused"));
|
||||||
|
else if (name === "follow") this._handle.setFollowPointer(next === "true");
|
||||||
|
else if (name === "emphasis") this._handle.setEmphasis(this.hasAttribute("emphasis"));
|
||||||
|
else if (name === "mode") this._handle.setMode(next || "hold");
|
||||||
|
}
|
||||||
|
|
||||||
|
_applySize() {
|
||||||
|
const n = Number(this.getAttribute("size"));
|
||||||
|
if (n > 0) {
|
||||||
|
this.style.width = `${n}px`;
|
||||||
|
this.style.height = `${n}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_opts() {
|
||||||
|
const size = Number(this.getAttribute("size"));
|
||||||
|
return {
|
||||||
|
state: this.getAttribute("state") || "idle",
|
||||||
|
shape: this.getAttribute("shape") || "blob",
|
||||||
|
color: this.getAttribute("color") || "black",
|
||||||
|
sizePx: size > 0 ? size : null,
|
||||||
|
paused: this.hasAttribute("paused"),
|
||||||
|
followPointer: this.getAttribute("follow") === "true",
|
||||||
|
emphasis: this.hasAttribute("emphasis"),
|
||||||
|
mode: this.getAttribute("mode") || "hold",
|
||||||
|
loginWrap: this.getAttribute("login") !== "false",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(name) { this._handle?.setState(name); }
|
||||||
|
setShape(name) { this._handle?.setShape(name); }
|
||||||
|
setColor(id) { this._handle?.setColor(id); }
|
||||||
|
setPaused(v) { this._handle?.setPaused(v); }
|
||||||
|
spin(n) { this._handle?.spin(n); }
|
||||||
|
bounce() { this._handle?.bounce(); }
|
||||||
|
destroy() { this._handle?.destroy(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function define(name = "grok-bot") {
|
||||||
|
if (!customElements.get(name)) customElements.define(name, GrokBotElement);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
define();
|
||||||
|
g.GROK = {
|
||||||
|
create: createCharacter,
|
||||||
|
define,
|
||||||
|
mergeSkin,
|
||||||
|
Character: g.GrokCharacter,
|
||||||
|
};
|
||||||
|
})(window);
|
||||||
+223
-52
@@ -17,15 +17,31 @@
|
|||||||
VIEW, VIEW_HALF, VIEW_MID, inkFg, inkCss, EYE_BG,
|
VIEW, VIEW_HALF, VIEW_MID, inkFg, inkCss, EYE_BG,
|
||||||
} = T;
|
} = T;
|
||||||
|
|
||||||
|
const snapSpring = (s, v) => {
|
||||||
|
s.x = s.t = v;
|
||||||
|
s.v = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const HOLD_GAZE = {
|
||||||
|
curious: { x: 12, y: -6 }, sad: { x: 2, y: 8 }, shy: { x: 10, y: 7 },
|
||||||
|
proud: { x: 0, y: -6 }, bored: { x: 12, y: 6 }, suspicious: { x: -15, y: 3 },
|
||||||
|
confused: { x: 12, y: 3 }, thinking: { x: 12, y: -7 }, angry: { x: 0, y: 2 },
|
||||||
|
drowsy: { x: 4, y: 7 }, searching: { x: 14, y: -4 }, working: { x: 3, y: 7 },
|
||||||
|
notifying: { x: 7, y: -3 }, playful: { x: 10, y: -4 }, happy: { x: -6, y: -4 },
|
||||||
|
laughing: { x: 4, y: -4 }, listening: { x: 5, y: -2 }, scared: { x: 12, y: 2 },
|
||||||
|
};
|
||||||
|
|
||||||
class GrokCharacter {
|
class GrokCharacter {
|
||||||
constructor(svg, opts = {}) {
|
constructor(svg, opts = {}) {
|
||||||
this.svg = svg;
|
this.svg = svg;
|
||||||
this.shapeName = opts.shape || "blob";
|
this.shapeName = opts.shape || "blob";
|
||||||
this.colorId = opts.color || "black";
|
this.colorId = opts.color || "black";
|
||||||
this.scheme = opts.scheme || "light";
|
this.scheme = opts.scheme || "light";
|
||||||
this.mode = opts.mode || "onboarding";
|
this.mode = opts.mode || "hold";
|
||||||
this.state = opts.state || "idle";
|
this.state = opts.state || "idle";
|
||||||
this.onChange = opts.onChange || (() => {});
|
this.onChange = opts.onChange || (() => {});
|
||||||
|
this.frameHalf = opts.frameHalf || VIEW_HALF;
|
||||||
|
this.fitBox = !!opts.fitBox;
|
||||||
this.loginWrap = opts.loginWrap !== false;
|
this.loginWrap = opts.loginWrap !== false;
|
||||||
this.eyeTopology = opts.eyeTopology ?? this.loginWrap;
|
this.eyeTopology = opts.eyeTopology ?? this.loginWrap;
|
||||||
this.faceTune = opts.faceTune ?? (this.loginWrap ? FACE_TUNE : null);
|
this.faceTune = opts.faceTune ?? (this.loginWrap ? FACE_TUNE : null);
|
||||||
@@ -36,12 +52,16 @@
|
|||||||
this.emphasis = !!opts.emphasis;
|
this.emphasis = !!opts.emphasis;
|
||||||
this.followPointer = !!opts.followPointer;
|
this.followPointer = !!opts.followPointer;
|
||||||
this.gazeTarget = opts.gazeTarget || null;
|
this.gazeTarget = opts.gazeTarget || null;
|
||||||
|
this.frozen = opts.paused === "hold-pose";
|
||||||
|
this.driven = !!opts.driven;
|
||||||
this.paused = !!opts.paused;
|
this.paused = !!opts.paused;
|
||||||
this.reduceMotion = opts.reduceMotion ?? (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches);
|
this.reduceMotion = opts.reduceMotion ?? (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches);
|
||||||
this.badgeColor = opts.badgeColor || "var(--gb-badge, #1d9bf0)";
|
this.badgeColor = opts.badgeColor || "var(--gb-badge, #1d9bf0)";
|
||||||
this.sizePx = opts.sizePx || null;
|
this.sizePx = opts.sizePx || null;
|
||||||
this.eyeColor = opts.eyeColor || null;
|
this.eyeColor = opts.eyeColor || null;
|
||||||
this.inkFlat = opts.inkFlat || null;
|
this.inkFlat = opts.inkFlat || null;
|
||||||
|
this.geo = opts.geo || g.GROK_GEO;
|
||||||
|
this.playlists = opts.playlist || EYE_PLAYLIST;
|
||||||
|
|
||||||
this.spin = spring(0);
|
this.spin = spring(0);
|
||||||
this.tx = spring(0);
|
this.tx = spring(0);
|
||||||
@@ -102,19 +122,27 @@
|
|||||||
this.ovTarget = null;
|
this.ovTarget = null;
|
||||||
this.ovRest = false;
|
this.ovRest = false;
|
||||||
this.ovRestAt = 0;
|
this.ovRestAt = 0;
|
||||||
this.pxW = 190;
|
this.pxW = opts.sizePx || 190;
|
||||||
this.pxAt = 0;
|
this.pxAt = 0;
|
||||||
this.partScale = 1;
|
this.partScale = 1;
|
||||||
this.celebrateAt = -1;
|
this.celebrateAt = -1;
|
||||||
|
this.gazeSpin = 0;
|
||||||
this.extras = { turn: null, Kr: 0, yi: 0, ki: 0, Yr: 0, Zr: 0, wi: 0, hop: 0 };
|
this.extras = { turn: null, Kr: 0, yi: 0, ki: 0, Yr: 0, Zr: 0, wi: 0, hop: 0 };
|
||||||
|
this.poseOut = { spin: 0, tx: 0, ty: 0, squash: 1, lid: 1, eyeBoost: 1 };
|
||||||
|
|
||||||
this._build();
|
this._build();
|
||||||
this.setColor(this.colorId, this.scheme);
|
this.setColor(this.colorId, this.scheme);
|
||||||
this._applyPoseScale();
|
this._applyPoseScale();
|
||||||
this.setState(this.state, { resetEyes: true });
|
this.setState(this.state, { resetEyes: true });
|
||||||
this._bindPointer();
|
if (!this.driven) this._bindPointer();
|
||||||
this._paint(this.t0);
|
if (this.frozen) {
|
||||||
this._raf = requestAnimationFrame((t) => this._tick(t));
|
this.holdFrame();
|
||||||
|
} else if (this.driven) {
|
||||||
|
this._paint(this.t0);
|
||||||
|
} else {
|
||||||
|
this._paint(this.t0);
|
||||||
|
this._armTick();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
@@ -125,7 +153,7 @@
|
|||||||
|
|
||||||
_freshCtx(now) {
|
_freshCtx(now) {
|
||||||
return {
|
return {
|
||||||
nodUntil: now + 1800,
|
nodUntil: now + rand(1200, 2200),
|
||||||
nodEnd: 0,
|
nodEnd: 0,
|
||||||
angryShakeUntil: 0,
|
angryShakeUntil: 0,
|
||||||
impulseAt: now + rand(500, 1200),
|
impulseAt: now + rand(500, 1200),
|
||||||
@@ -156,7 +184,28 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
setPaused(v) {
|
setPaused(v) {
|
||||||
|
if (v === "hold-pose") {
|
||||||
|
this.holdFrame();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.frozen = false;
|
||||||
this.paused = !!v;
|
this.paused = !!v;
|
||||||
|
if (!this.paused) this._armTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
_armTick() {
|
||||||
|
if (this.frozen || this.driven || this._raf) return;
|
||||||
|
this.last = performance.now();
|
||||||
|
this._raf = requestAnimationFrame((t) => this._tick(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
_wake() {
|
||||||
|
if (this.frozen || this.driven) return;
|
||||||
|
if (this.paused) this._armTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
step(dt) {
|
||||||
|
this._tick(this.last + Math.max(0, dt) * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
setEmphasis(v) {
|
setEmphasis(v) {
|
||||||
@@ -176,10 +225,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
setShape(name) {
|
setShape(name) {
|
||||||
if (!g.GROK_GEO.shapes[name] || name === this.shapeName) return;
|
if (!this.geo.shapes[name] || name === this.shapeName) return;
|
||||||
const R = g.GROK_GEO.Re;
|
const R = this.geo.Re;
|
||||||
const k = K2(clamp(this.shapeSpring.x, 0, 1));
|
const k = K2(clamp(this.shapeSpring.x, 0, 1));
|
||||||
const rest = FX.shapeMetrics(g.GROK_GEO.shapes[this.shapeName], R);
|
const rest = FX.shapeMetrics(this.geo.shapes[this.shapeName], R);
|
||||||
if (k >= 1 || !this.prevFace || !this.prevRing) {
|
if (k >= 1 || !this.prevFace || !this.prevRing) {
|
||||||
this.prevFace = rest.face;
|
this.prevFace = rest.face;
|
||||||
this.prevRing = rest.ring;
|
this.prevRing = rest.ring;
|
||||||
@@ -199,6 +248,7 @@
|
|||||||
if (this.loginWrap) this.eyeScaleProp = shapeEyeScale(name);
|
if (this.loginWrap) this.eyeScaleProp = shapeEyeScale(name);
|
||||||
this._applyPoseScale();
|
this._applyPoseScale();
|
||||||
this._cycleShapeTrick();
|
this._cycleShapeTrick();
|
||||||
|
this._wake();
|
||||||
}
|
}
|
||||||
|
|
||||||
setColor(id, scheme) {
|
setColor(id, scheme) {
|
||||||
@@ -209,7 +259,7 @@
|
|||||||
} else if (this.loginWrap) {
|
} else if (this.loginWrap) {
|
||||||
this.svg.style.setProperty("--fg", inkFg(id));
|
this.svg.style.setProperty("--fg", inkFg(id));
|
||||||
} else {
|
} else {
|
||||||
const pal = g.GROK_GEO.palette[id] || g.GROK_GEO.palette.black;
|
const pal = this.geo.palette[id] || this.geo.palette.black;
|
||||||
this.svg.style.setProperty("--fg", this.scheme === "dark" ? pal.dark : pal.light);
|
this.svg.style.setProperty("--fg", this.scheme === "dark" ? pal.dark : pal.light);
|
||||||
}
|
}
|
||||||
this.svg.style.setProperty("--ink", inkCss(id));
|
this.svg.style.setProperty("--ink", inkCss(id));
|
||||||
@@ -227,10 +277,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
setState(name, { resetEyes = false } = {}) {
|
setState(name, { resetEyes = false } = {}) {
|
||||||
if (!EYE_PLAYLIST[name]) return;
|
if (!this.playlists[name]) return;
|
||||||
this.state = name;
|
this.state = name;
|
||||||
this.stateAt = performance.now();
|
this.stateAt = performance.now();
|
||||||
const list = EYE_PLAYLIST[name];
|
const list = this.playlists[name];
|
||||||
this.eyeIdx = 0;
|
this.eyeIdx = 0;
|
||||||
if (resetEyes) {
|
if (resetEyes) {
|
||||||
this.eyeFrom = list[0];
|
this.eyeFrom = list[0];
|
||||||
@@ -255,14 +305,12 @@
|
|||||||
: rand(6000, 10000)
|
: rand(6000, 10000)
|
||||||
);
|
);
|
||||||
this.celebrateAt = name === "celebrate" ? this.stateAt + 140 : -1;
|
this.celebrateAt = name === "celebrate" ? this.stateAt + 140 : -1;
|
||||||
this.trick = null;
|
this.fx?.resetInk();
|
||||||
this.spinTurn = null;
|
this.fx?.resetRecv();
|
||||||
this.hopAt = -1;
|
|
||||||
this.wildWide = false;
|
|
||||||
if (name !== "writing") this.fx?.resetInk();
|
|
||||||
if (name !== "waking" && name !== "sleeping" && name !== "drowsy") {
|
if (name !== "waking" && name !== "sleeping" && name !== "drowsy") {
|
||||||
EY.queueBlink(this.blinkQueue, this.stateAt);
|
EY.queueBlink(this.blinkQueue, this.stateAt);
|
||||||
}
|
}
|
||||||
|
this._wake();
|
||||||
try {
|
try {
|
||||||
this.onChange(this.snapshot());
|
this.onChange(this.snapshot());
|
||||||
} catch (_) { /* host UI may not be ready */ }
|
} catch (_) { /* host UI may not be ready */ }
|
||||||
@@ -283,17 +331,80 @@
|
|||||||
squash: this.squash.x,
|
squash: this.squash.x,
|
||||||
blink: this.blink.x,
|
blink: this.blink.x,
|
||||||
overlay: this.ovKind,
|
overlay: this.ovKind,
|
||||||
|
overlayX: this.overlay.x,
|
||||||
|
notify: this.notify.x,
|
||||||
|
trick: this.trick?.kind || (this.spinTurn ? "pn" : null),
|
||||||
|
shapeSpring: this.shapeSpring.x,
|
||||||
|
badge: this.badge && this.badge.style.display !== "none"
|
||||||
|
? { x: +this.badge.getAttribute("cx"), y: +this.badge.getAttribute("cy") }
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
spinOnce(turns = 1) {
|
spinOnce(turns = 1) {
|
||||||
|
this._wake();
|
||||||
this._pn(turns);
|
this._pn(turns);
|
||||||
}
|
}
|
||||||
bounceOnce() {
|
bounceOnce() {
|
||||||
|
this._wake();
|
||||||
this._hop(performance.now());
|
this._hop(performance.now());
|
||||||
}
|
}
|
||||||
burstOnce() {
|
burstOnce() {
|
||||||
if (!this.reduceMotion) this.particles.burst(22, 1.1, 0.3);
|
if (!this.reduceMotion && !this.paused) this.particles.burst(22, 1.1, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
orbitGaze(ms = 1500) {
|
||||||
|
if (this.reduceMotion || this.frozen) return;
|
||||||
|
this.gazeOrbit = { at: performance.now(), dur: Math.max(ms, 1), from: Math.PI * 2 };
|
||||||
|
this._armTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
holdFrame(at = 900) {
|
||||||
|
this.frozen = true;
|
||||||
|
this.paused = true;
|
||||||
|
if (this._raf) {
|
||||||
|
cancelAnimationFrame(this._raf);
|
||||||
|
this._raf = 0;
|
||||||
|
}
|
||||||
|
const now = this.t0 + at;
|
||||||
|
const mt = at / 1000;
|
||||||
|
const pose = applyPose(this.state, mt, mt, now, this.ctx, {
|
||||||
|
eyeTo: this.eyeTo,
|
||||||
|
eyeMorphX: 1,
|
||||||
|
blinkX: this.blink.x,
|
||||||
|
playlist: this.playlists,
|
||||||
|
prev: this.poseOut,
|
||||||
|
});
|
||||||
|
this.ctx.wantPn = null;
|
||||||
|
this.ctx.wantBurst = null;
|
||||||
|
this.ctx.tyKick = 0;
|
||||||
|
this.ctx.spinKick = 0;
|
||||||
|
this.poseOut = pose;
|
||||||
|
snapSpring(this.spin, pose.spin);
|
||||||
|
snapSpring(this.tx, pose.tx);
|
||||||
|
snapSpring(this.ty, pose.ty);
|
||||||
|
snapSpring(this.squash, pose.squash);
|
||||||
|
snapSpring(this.eyeScale, pose.eyeBoost);
|
||||||
|
snapSpring(this.blink, pose.lid);
|
||||||
|
this._stepOverlay(now);
|
||||||
|
if (this.ovKind) {
|
||||||
|
snapSpring(this.overlay, this.overlay.t);
|
||||||
|
snapSpring(this.overlayMix, 1);
|
||||||
|
snapSpring(this.overlayTurn, this.overlayTurn.t);
|
||||||
|
this.fx.overlayAt = this.t0;
|
||||||
|
}
|
||||||
|
snapSpring(this.notify, this.state === "notifying" ? 1 : 0);
|
||||||
|
snapSpring(this.humDots, this.state === "humming" ? 1 : 0);
|
||||||
|
if (this.state === "humming" || this.state === "loading") this.ovSpin = 2.1;
|
||||||
|
const gz = HOLD_GAZE[this.state] || { x: 0, y: 0 };
|
||||||
|
snapSpring(this.gazeX, gz.x);
|
||||||
|
snapSpring(this.gazeY, gz.y);
|
||||||
|
this.eyeMorph.x = 1;
|
||||||
|
this.eyeMorph.v = 0;
|
||||||
|
this.eyeMorph.t = 1;
|
||||||
|
this._fromPolys = null;
|
||||||
|
this._paint(now);
|
||||||
|
return this.snapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
_applyPoseScale() {
|
_applyPoseScale() {
|
||||||
@@ -303,7 +414,8 @@
|
|||||||
this.svg.style.width = `${this.sizePx}px`;
|
this.svg.style.width = `${this.sizePx}px`;
|
||||||
this.svg.style.height = `${this.sizePx}px`;
|
this.svg.style.height = `${this.sizePx}px`;
|
||||||
}
|
}
|
||||||
if (Math.abs(sc - 1) > 0.001) {
|
const boxed = this.fitBox || (this.sizePx && this.sizePx < 120);
|
||||||
|
if (!boxed && Math.abs(sc - 1) > 0.001) {
|
||||||
this.svg.style.transform = `scale(${sc})`;
|
this.svg.style.transform = `scale(${sc})`;
|
||||||
this.svg.style.transformOrigin = "50% 50%";
|
this.svg.style.transformOrigin = "50% 50%";
|
||||||
} else {
|
} else {
|
||||||
@@ -329,7 +441,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
_build() {
|
_build() {
|
||||||
const geo = g.GROK_GEO;
|
const geo = this.geo;
|
||||||
const vb = geo.viewBox;
|
const vb = geo.viewBox;
|
||||||
this.svg.setAttribute("viewBox", `${vb.minX} ${vb.minY} ${vb.width} ${vb.height}`);
|
this.svg.setAttribute("viewBox", `${vb.minX} ${vb.minY} ${vb.width} ${vb.height}`);
|
||||||
this.svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
this.svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
||||||
@@ -400,7 +512,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
_currentPolys(t) {
|
_currentPolys(t) {
|
||||||
const eyes = g.GROK_GEO.eyes;
|
const eyes = this.geo.eyes;
|
||||||
const from = this._fromPolys || eyes[this.eyeFrom];
|
const from = this._fromPolys || eyes[this.eyeFrom];
|
||||||
const to = eyes[this.eyeTo];
|
const to = eyes[this.eyeTo];
|
||||||
return [lerpPoly(from[0], to[0], t), lerpPoly(from[1], to[1], t)];
|
return [lerpPoly(from[0], to[0], t), lerpPoly(from[1], to[1], t)];
|
||||||
@@ -412,7 +524,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
_hop(now) {
|
_hop(now) {
|
||||||
if (this.hopAt < 0) this.hopAt = now;
|
if (this.reduceMotion || this.paused || this.hopAt >= 0) return;
|
||||||
|
this.hopAt = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
_cycleShapeTrick() {
|
_cycleShapeTrick() {
|
||||||
@@ -423,9 +536,11 @@
|
|||||||
else if (this.trickCycle === 1) {
|
else if (this.trickCycle === 1) {
|
||||||
this.wildWide = true;
|
this.wildWide = true;
|
||||||
this._pn(2);
|
this._pn(2);
|
||||||
} else if (this.trickCycle === 2) this.trick = TR.startTrick("spinBounce", this.reduceMotion);
|
} else if (this.trickCycle === 2) {
|
||||||
else if (this.trickCycle === 3) this.trick = TR.startTrick("spinDizzy", this.reduceMotion);
|
if (!this.trick) this.trick = TR.startTrick("spinBounce", this.reduceMotion);
|
||||||
else {
|
} else if (this.trickCycle === 3) {
|
||||||
|
if (!this.trick) this.trick = TR.startTrick("spinDizzy", this.reduceMotion);
|
||||||
|
} else {
|
||||||
this._pn(1);
|
this._pn(1);
|
||||||
this.particles.burst(16, 0.95, 0.3);
|
this.particles.burst(16, 0.95, 0.3);
|
||||||
}
|
}
|
||||||
@@ -499,21 +614,17 @@
|
|||||||
this.pointer.tx = 0;
|
this.pointer.tx = 0;
|
||||||
this.pointer.ty = 0;
|
this.pointer.ty = 0;
|
||||||
}
|
}
|
||||||
const z = Rn(0.16);
|
|
||||||
this.pointer.x += (this.pointer.tx - this.pointer.x) * z;
|
|
||||||
this.pointer.y += (this.pointer.ty - this.pointer.y) * z;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_tick(now) {
|
_tick(now) {
|
||||||
|
if (this.frozen) {
|
||||||
|
this._raf = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const dt = Math.min((now - this.last) / 1000, 0.1);
|
const dt = Math.min((now - this.last) / 1000, 0.1);
|
||||||
this.last = now;
|
this.last = now;
|
||||||
|
|
||||||
if (this.paused) {
|
if (!this.paused && this.mode === "onboarding" && now - this.stateAt >= ONBOARDING_MS) {
|
||||||
this._raf = requestAnimationFrame((t) => this._tick(t));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.mode === "onboarding" && now - this.stateAt >= ONBOARDING_MS) {
|
|
||||||
this.moodN += 1;
|
this.moodN += 1;
|
||||||
this.setState(onboardMood(this.moodN));
|
this.setState(onboardMood(this.moodN));
|
||||||
}
|
}
|
||||||
@@ -524,7 +635,10 @@
|
|||||||
eyeTo: this.eyeTo,
|
eyeTo: this.eyeTo,
|
||||||
eyeMorphX: this.eyeMorph.x,
|
eyeMorphX: this.eyeMorph.x,
|
||||||
blinkX: this.blink.x,
|
blinkX: this.blink.x,
|
||||||
|
playlist: this.playlists,
|
||||||
|
prev: this.poseOut,
|
||||||
});
|
});
|
||||||
|
this.poseOut = pose;
|
||||||
this.spin.t = pose.spin;
|
this.spin.t = pose.spin;
|
||||||
this.tx.t = pose.tx;
|
this.tx.t = pose.tx;
|
||||||
this.ty.t = pose.ty;
|
this.ty.t = pose.ty;
|
||||||
@@ -567,8 +681,8 @@
|
|||||||
|
|
||||||
this._stepOverlay(now);
|
this._stepOverlay(now);
|
||||||
|
|
||||||
if (this.celebrateAt > 0 && now >= this.celebrateAt && !this.trick && !this.spinTurn) {
|
if (this.celebrateAt > 0 && now >= this.celebrateAt && !this.trick) {
|
||||||
this.trick = TR.startTrick("spinWild", this.reduceMotion);
|
this.trick = TR.startTrick("spinWild", this.reduceMotion || this.paused);
|
||||||
this.celebrateAt = now + 6200;
|
this.celebrateAt = now + 6200;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,10 +691,10 @@
|
|||||||
const z = Math.random();
|
const z = Math.random();
|
||||||
if (V_T.has(this.state)) {
|
if (V_T.has(this.state)) {
|
||||||
if (z < 0.55) this._pn(1);
|
if (z < 0.55) this._pn(1);
|
||||||
else this.trick = TR.startTrick("spinBounce", this.reduceMotion);
|
else this.trick = TR.startTrick("spinBounce", this.reduceMotion || this.paused);
|
||||||
} else if (z < 0.34) this.trick = TR.startTrick("spinBounce", this.reduceMotion);
|
} else if (z < 0.34) this.trick = TR.startTrick("spinBounce", this.reduceMotion || this.paused);
|
||||||
else if (z < 0.62) this._hop(now);
|
else if (z < 0.62) this._hop(now);
|
||||||
else if (z < 0.86) this.trick = TR.startTrick("spinDizzy", this.reduceMotion);
|
else if (z < 0.86) this.trick = TR.startTrick("spinDizzy", this.reduceMotion || this.paused);
|
||||||
else this._pn(1);
|
else this._pn(1);
|
||||||
}
|
}
|
||||||
this.trickAt = now + rand(9000, 18000);
|
this.trickAt = now + rand(9000, 18000);
|
||||||
@@ -594,17 +708,12 @@
|
|||||||
this.hopAt = -1;
|
this.hopAt = -1;
|
||||||
hop = 0;
|
hop = 0;
|
||||||
}
|
}
|
||||||
let turn = tf.turn;
|
this.extras = { ...tf, turn: tf.turn, hop };
|
||||||
if (this.spinTurn) {
|
|
||||||
turn = (turn ?? 0) + this.spinTurn.x;
|
|
||||||
if (TR.spinTurnSettled(this.spinTurn)) this.spinTurn = null;
|
|
||||||
}
|
|
||||||
this.extras = { ...tf, turn, hop };
|
|
||||||
|
|
||||||
if (this.extras.eyeBoost != null) this.eyeScale.t = this.extras.eyeBoost;
|
if (this.extras.eyeBoost != null) this.eyeScale.t = this.extras.eyeBoost;
|
||||||
|
|
||||||
if (this.state !== "waking" && this.state !== "sleeping" && now >= this.eyeUntil) {
|
if (this.state !== "waking" && this.state !== "sleeping" && now >= this.eyeUntil) {
|
||||||
const list = EYE_PLAYLIST[this.state];
|
const list = this.playlists[this.state];
|
||||||
this.eyeIdx = (this.eyeIdx + 1 + Math.floor(rand(0, list.length - 1))) % list.length;
|
this.eyeIdx = (this.eyeIdx + 1 + Math.floor(rand(0, list.length - 1))) % list.length;
|
||||||
const stiff = this.state === "searching" || this.state === "excited" ? 10 : 6;
|
const stiff = this.state === "searching" || this.state === "excited" ? 10 : 6;
|
||||||
this._morphEyes(list[this.eyeIdx], stiff);
|
this._morphEyes(list[this.eyeIdx], stiff);
|
||||||
@@ -649,7 +758,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.reduceMotion) {
|
if (this.reduceMotion) {
|
||||||
this._morphEyes(EYE_PLAYLIST[this.state][0]);
|
this._morphEyes(this.playlists[this.state][0]);
|
||||||
this.spin.t = 0; this.tx.t = 0; this.ty.t = 0;
|
this.spin.t = 0; this.tx.t = 0; this.ty.t = 0;
|
||||||
this.squash.t = 1; this.blink.t = 1; this.eyeScale.t = 1;
|
this.squash.t = 1; this.blink.t = 1; this.eyeScale.t = 1;
|
||||||
}
|
}
|
||||||
@@ -679,6 +788,25 @@
|
|||||||
this.overlayTurn.x = this.overlayTurn.t;
|
this.overlayTurn.x = this.overlayTurn.t;
|
||||||
this.overlay.x = this.overlay.t;
|
this.overlay.x = this.overlay.t;
|
||||||
}
|
}
|
||||||
|
if (this.spinTurn) {
|
||||||
|
if (TR.spinTurnSettled(this.spinTurn)) {
|
||||||
|
this.spinTurn = null;
|
||||||
|
this.wildWide = false;
|
||||||
|
} else {
|
||||||
|
this.extras = { ...this.extras, turn: (this.extras.turn ?? 0) + this.spinTurn.x };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.gazeOrbit) {
|
||||||
|
const u = clamp((now - this.gazeOrbit.at) / this.gazeOrbit.dur, 0, 1);
|
||||||
|
const ease = u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2;
|
||||||
|
this.gazeSpin = this.gazeOrbit.from * (1 - ease);
|
||||||
|
if (u >= 1) {
|
||||||
|
this.gazeOrbit = null;
|
||||||
|
this.gazeSpin = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.gazeSpin = 0;
|
||||||
|
}
|
||||||
this.notify.t = this.state === "notifying" ? 1 : 0;
|
this.notify.t = this.state === "notifying" ? 1 : 0;
|
||||||
this.humDots.t = this.state === "humming" ? 1 : 0;
|
this.humDots.t = this.state === "humming" ? 1 : 0;
|
||||||
|
|
||||||
@@ -703,11 +831,52 @@
|
|||||||
|
|
||||||
this._updatePointer(now);
|
this._updatePointer(now);
|
||||||
this._paint(now);
|
this._paint(now);
|
||||||
|
|
||||||
|
if (this.driven) {
|
||||||
|
this._raf = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.paused
|
||||||
|
&& !this.trick
|
||||||
|
&& !this.spinTurn
|
||||||
|
&& this.hopAt < 0
|
||||||
|
&& !this.particles.hasLife()
|
||||||
|
&& Math.abs(this.overlay.x - this.overlay.t) < 0.001
|
||||||
|
&& Math.abs(this.overlayTurn.t - this.overlayTurn.x) < 0.01
|
||||||
|
&& this.overlayMix.x > 0.996
|
||||||
|
&& Math.abs(this.shapeSpring.x - this.shapeSpring.t) < 0.001
|
||||||
|
&& Math.abs(this.eyeMorph.x - this.eyeMorph.t) < 0.001
|
||||||
|
) {
|
||||||
|
for (const s of [this.spin, this.tx, this.ty, this.gazeX, this.gazeY]) {
|
||||||
|
s.x = 0; s.v = 0; s.t = 0;
|
||||||
|
}
|
||||||
|
for (const s of [this.squash, this.blink, this.eyeScale]) {
|
||||||
|
s.x = 1; s.v = 0; s.t = 1;
|
||||||
|
}
|
||||||
|
this.shapeSpring.x = this.shapeSpring.t;
|
||||||
|
this.shapeSpring.v = 0;
|
||||||
|
this.blinkQueue = [];
|
||||||
|
this.winkAt = -1e9;
|
||||||
|
const home = this.playlists[this.state]?.[0] ?? 0;
|
||||||
|
this.eyeFrom = home;
|
||||||
|
this.eyeTo = home;
|
||||||
|
this._fromPolys = null;
|
||||||
|
this.eyeMorph.x = 1;
|
||||||
|
this.eyeMorph.v = 0;
|
||||||
|
this.eyeMorph.t = 1;
|
||||||
|
this.poseOut = { spin: 0, tx: 0, ty: 0, squash: 1, lid: 1, eyeBoost: 1 };
|
||||||
|
this._paint(0);
|
||||||
|
this._raf = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this._raf = requestAnimationFrame((t) => this._tick(t));
|
this._raf = requestAnimationFrame((t) => this._tick(t));
|
||||||
}
|
}
|
||||||
|
|
||||||
_paint(now) {
|
_paint(now) {
|
||||||
const geo = g.GROK_GEO;
|
const geo = this.geo;
|
||||||
const R = geo.Re;
|
const R = geo.Re;
|
||||||
const shape = geo.shapes[this.shapeName];
|
const shape = geo.shapes[this.shapeName];
|
||||||
const morphK = K2(clamp(this.shapeSpring.x, 0, 1));
|
const morphK = K2(clamp(this.shapeSpring.x, 0, 1));
|
||||||
@@ -744,7 +913,7 @@
|
|||||||
: FX.shapeRing(shape.path, R);
|
: FX.shapeRing(shape.path, R);
|
||||||
let liveRing = restRing;
|
let liveRing = restRing;
|
||||||
let turned = false;
|
let turned = false;
|
||||||
const turnAt = !morphing && spinning ? FX.turnAtOf(this.shapeName, shape.path, R) : null;
|
const turnAt = !morphing && spinning ? FX.turnAtOf(this.shapeName, shape.path, R, geo.solids) : null;
|
||||||
if (turnAt) {
|
if (turnAt) {
|
||||||
liveRing = turnAt(spinAmt);
|
liveRing = turnAt(spinAmt);
|
||||||
turned = true;
|
turned = true;
|
||||||
@@ -778,7 +947,7 @@
|
|||||||
const zCur = overlayViewZoom(this.ovKind, pScale);
|
const zCur = overlayViewZoom(this.ovKind, pScale);
|
||||||
const zPrev = overlayViewZoom(this.ovPrev, pScale);
|
const zPrev = overlayViewZoom(this.ovPrev, pScale);
|
||||||
const zoom = 1 + (zCur * mix + zPrev * (1 - mix) - 1) * yl * shrink;
|
const zoom = 1 + (zCur * mix + zPrev * (1 - mix) - 1) * yl * shrink;
|
||||||
const half = VIEW_HALF / zoom;
|
const half = this.frameHalf / zoom;
|
||||||
this.svg.setAttribute("viewBox", `${(VIEW_MID - half).toFixed(2)} ${(VIEW_MID - half).toFixed(2)} ${(half * 2).toFixed(2)} ${(half * 2).toFixed(2)}`);
|
this.svg.setAttribute("viewBox", `${(VIEW_MID - half).toFixed(2)} ${(VIEW_MID - half).toFixed(2)} ${(half * 2).toFixed(2)} ${(half * 2).toFixed(2)}`);
|
||||||
|
|
||||||
const morphT = clamp(this.eyeMorph.x, 0, 1);
|
const morphT = clamp(this.eyeMorph.x, 0, 1);
|
||||||
@@ -787,8 +956,9 @@
|
|||||||
const overlayLive = yl > 0.001 || Math.abs(this.overlayTurn.t - this.overlayTurn.x) > 0.01;
|
const overlayLive = yl > 0.001 || Math.abs(this.overlayTurn.t - this.overlayTurn.x) > 0.01;
|
||||||
let cyl = overlayLive ? this.overlayTurn.x : null;
|
let cyl = overlayLive ? this.overlayTurn.x : null;
|
||||||
if (ex.turn != null) cyl = (cyl ?? 0) + ex.turn;
|
if (ex.turn != null) cyl = (cyl ?? 0) + ex.turn;
|
||||||
|
if (this.gazeSpin) cyl = (cyl ?? 0) + this.gazeSpin;
|
||||||
const ringHint = morphing || turned ? liveRing : null;
|
const ringHint = morphing || turned ? liveRing : null;
|
||||||
const hasPtr = !!(this.gazeTarget || (this.followPointer && this.pointerRaw));
|
const tracking = !!(this.gazeTarget || (this.followPointer && this.pointerRaw));
|
||||||
EY.paintEyes({
|
EY.paintEyes({
|
||||||
now,
|
now,
|
||||||
polys,
|
polys,
|
||||||
@@ -806,7 +976,8 @@
|
|||||||
winkEye: this.winkEye,
|
winkEye: this.winkEye,
|
||||||
turn: cyl,
|
turn: cyl,
|
||||||
cr,
|
cr,
|
||||||
pointer: hasPtr ? this.pointer : null,
|
pointer: this.pointer,
|
||||||
|
tracking,
|
||||||
notifyX: this.notify.x,
|
notifyX: this.notify.x,
|
||||||
overlayX: this.overlay.x,
|
overlayX: this.overlay.x,
|
||||||
eyeEls: this.eyeEls,
|
eyeEls: this.eyeEls,
|
||||||
|
|||||||
+4
-1
@@ -69,7 +69,7 @@
|
|||||||
const midY = (cents[0][1] + cents[1][1]) / 2;
|
const midY = (cents[0][1] + cents[1][1]) / 2;
|
||||||
const pullX = (Re - midX) * 0.42 * Vn;
|
const pullX = (Re - midX) * 0.42 * Vn;
|
||||||
const pullY = (Re - midY) * 0.42 * Vn;
|
const pullY = (Re - midY) * 0.42 * Vn;
|
||||||
const gazeW = pointer ? 0.2 : 1;
|
const gazeW = opt.tracking ? 0.2 : 1;
|
||||||
const badgeRing = opt.badgeRing || ringHint;
|
const badgeRing = opt.badgeRing || ringHint;
|
||||||
const Yl = badgeRing
|
const Yl = badgeRing
|
||||||
? badgeRing[Math.round(badgeRing.length * 7 / 8) % badgeRing.length]
|
? badgeRing[Math.round(badgeRing.length * 7 / 8) % badgeRing.length]
|
||||||
@@ -138,6 +138,9 @@
|
|||||||
let Kj = Math.sin(now * 42e-5 + i) * 1.4 + Math.sin(now * 0.001 + i * 2) * 0.5;
|
let Kj = Math.sin(now * 42e-5 + i) * 1.4 + Math.sin(now * 0.001 + i * 2) * 0.5;
|
||||||
let Ko = Math.sin(now * 58e-5 + i) * 0.9;
|
let Ko = Math.sin(now * 58e-5 + i) * 0.9;
|
||||||
if (pointer) {
|
if (pointer) {
|
||||||
|
const Zl = Rn(0.16);
|
||||||
|
pointer.x += (pointer.tx - pointer.x) * Zl;
|
||||||
|
pointer.y += (pointer.ty - pointer.y) * Zl;
|
||||||
Kj += pointer.x * (1 - 0.6 * Vn) + pullX;
|
Kj += pointer.x * (1 - 0.6 * Vn) + pullX;
|
||||||
Ko += pointer.y * (1 - 0.6 * Vn) + pullY;
|
Ko += pointer.y * (1 - 0.6 * Vn) + pullY;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+3
-2
@@ -838,12 +838,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
resetInk() { this.ink = []; }
|
resetInk() { this.ink = []; }
|
||||||
|
resetRecv() { this.recvTick = -1; this.recvDir = -0.7; }
|
||||||
}
|
}
|
||||||
|
|
||||||
const turnAtCache = new Map();
|
const turnAtCache = new Map();
|
||||||
function turnAtOf(name, path, R) {
|
function turnAtOf(name, path, R, solids) {
|
||||||
if (turnAtCache.has(name)) return turnAtCache.get(name);
|
if (turnAtCache.has(name)) return turnAtCache.get(name);
|
||||||
const solid = global.GROK_GEO.solids?.[name];
|
const solid = (solids || global.GROK_GEO.solids)?.[name];
|
||||||
if (!solid || !global.GROK_MATH?.makeTurnAt) {
|
if (!solid || !global.GROK_MATH?.makeTurnAt) {
|
||||||
turnAtCache.set(name, null);
|
turnAtCache.set(name, null);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+4
-2
@@ -5,9 +5,10 @@
|
|||||||
|
|
||||||
function applyPose(state, mt, dtState, now, ctx, extra = {}) {
|
function applyPose(state, mt, dtState, now, ctx, extra = {}) {
|
||||||
const pt = 0;
|
const pt = 0;
|
||||||
|
const prev = extra.prev || { spin: pt, tx: 0, ty: 0, squash: 1 };
|
||||||
|
let { spin, tx, ty, squash } = prev;
|
||||||
let lid = 1;
|
let lid = 1;
|
||||||
let eyeBoost = 1;
|
let eyeBoost = 1;
|
||||||
let spin = pt, tx = 0, ty = 0, squash = 1;
|
|
||||||
|
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case "sleeping": {
|
case "sleeping": {
|
||||||
@@ -17,7 +18,8 @@
|
|||||||
tx = -2 * En;
|
tx = -2 * En;
|
||||||
ty = 8 * En + Math.sin(mt * 0.55) * 3 - Zt * 5;
|
ty = 8 * En + Math.sin(mt * 0.55) * 3 - Zt * 5;
|
||||||
squash = 1 + Math.sin(mt * 0.55) * 0.016 + Zt * 0.05;
|
squash = 1 + Math.sin(mt * 0.55) * 0.016 + Zt * 0.05;
|
||||||
if (EYE_PLAYLIST.sleeping.includes(extra.eyeTo)) {
|
const sleepEyes = extra.playlist?.sleeping || EYE_PLAYLIST.sleeping;
|
||||||
|
if (sleepEyes.includes(extra.eyeTo)) {
|
||||||
lid = extra.eyeMorphX > 0.85 ? 1 : 0.08;
|
lid = extra.eyeMorphX > 0.85 ? 1 : 0.08;
|
||||||
} else if (dtState < 1.2) {
|
} else if (dtState < 1.2) {
|
||||||
const dn = Math.min(1, dtState / 1);
|
const dn = Math.min(1, dtState / 1);
|
||||||
|
|||||||
+463
@@ -0,0 +1,463 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import AnimPanel from '@/components/AnimPanel.vue'
|
||||||
|
import Customizer from '@/components/Customizer.vue'
|
||||||
|
import CycleDialog from '@/components/CycleDialog.vue'
|
||||||
|
import ExportBar from '@/components/ExportBar.vue'
|
||||||
|
import GifDialog from '@/components/GifDialog.vue'
|
||||||
|
import Hero from '@/components/Hero.vue'
|
||||||
|
import Settings from '@/components/Settings.vue'
|
||||||
|
import SideRail, { type ViewId } from '@/components/SideRail.vue'
|
||||||
|
import Timeline from '@/components/Timeline.vue'
|
||||||
|
import {
|
||||||
|
blockAt,
|
||||||
|
blocksWith,
|
||||||
|
defaultCycle,
|
||||||
|
parseCycles,
|
||||||
|
totalDuration,
|
||||||
|
type Cycle
|
||||||
|
} from '@/editor/cycles'
|
||||||
|
import { nomDeCycle, t } from '@/i18n'
|
||||||
|
import { DEFAULT_COLOR, DEFAULT_EXPRESSION, DEFAULT_SHAPE, estForme } from '@/replica/catalog'
|
||||||
|
import {
|
||||||
|
copie,
|
||||||
|
copieTexte,
|
||||||
|
cycleVersGif,
|
||||||
|
cycleVersMp4,
|
||||||
|
svgAutonome,
|
||||||
|
telecharge,
|
||||||
|
versGifAnime,
|
||||||
|
versPng,
|
||||||
|
versSvgAnime
|
||||||
|
} from '@/ui/capture'
|
||||||
|
import {
|
||||||
|
ACTION_BY_ID,
|
||||||
|
ANIM_IMAGES,
|
||||||
|
ANIM_PAS,
|
||||||
|
BLANC,
|
||||||
|
CYCLE_TAILLE,
|
||||||
|
FOND_GIF_DEFAUT,
|
||||||
|
FORMAT_CYCLE_DEFAUT,
|
||||||
|
GIF_IMAGES,
|
||||||
|
GIF_PAS,
|
||||||
|
Abandon,
|
||||||
|
couleurDeFond,
|
||||||
|
cycleImages,
|
||||||
|
cyclePas,
|
||||||
|
nomFichier,
|
||||||
|
tailleAction,
|
||||||
|
type ActionId,
|
||||||
|
type EtatExport,
|
||||||
|
type FondGif,
|
||||||
|
type FormatCycle
|
||||||
|
} from '@/ui/export'
|
||||||
|
import { ecris, lis } from '@/ui/stockage'
|
||||||
|
|
||||||
|
const view = ref<ViewId>('personnaliser')
|
||||||
|
const preview = ref(false)
|
||||||
|
const ready = ref(false)
|
||||||
|
const savedShape = lis('forme')
|
||||||
|
const shape = ref(savedShape && estForme(savedShape) ? savedShape : DEFAULT_SHAPE)
|
||||||
|
const color = ref(lis('couleur') || DEFAULT_COLOR)
|
||||||
|
const expression = ref(lis('expression') || DEFAULT_EXPRESSION)
|
||||||
|
const intro = ref(true)
|
||||||
|
const hero = ref<{
|
||||||
|
spin: () => void
|
||||||
|
orbitGaze: (ms?: number) => void
|
||||||
|
svg: () => SVGSVGElement | null
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const restored = parseCycles(lis('cycles'))
|
||||||
|
const cycles = ref<Cycle[]>(restored.length ? restored : [defaultCycle()])
|
||||||
|
const activeId = ref(
|
||||||
|
(() => {
|
||||||
|
const v = lis('cycle')
|
||||||
|
return v && cycles.value.some((c) => c.id === v) ? v : cycles.value[0]!.id
|
||||||
|
})()
|
||||||
|
)
|
||||||
|
const block = ref(0)
|
||||||
|
const elapsed = ref(0)
|
||||||
|
const playing = ref(false)
|
||||||
|
let seeking = false
|
||||||
|
|
||||||
|
const cycle = computed(() => cycles.value.find((c) => c.id === activeId.value) ?? cycles.value[0]!)
|
||||||
|
const state = ref(intro.value ? 'idle' : (cycle.value.blocks[block.value]?.state ?? 'idle'))
|
||||||
|
|
||||||
|
const gauche = computed(() => view.value === 'reglages' && !preview.value)
|
||||||
|
const droite = computed(() => view.value !== 'reglages' && !preview.value && !intro.value)
|
||||||
|
const follow = computed(() => view.value === 'reglages' && !preview.value)
|
||||||
|
const nue = computed(() => intro.value && !preview.value)
|
||||||
|
const playedState = computed(() => {
|
||||||
|
if (view.value === 'animations' || preview.value) return state.value
|
||||||
|
return expression.value
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(shape, (id) => ecris('forme', id))
|
||||||
|
watch(color, (id) => ecris('couleur', id))
|
||||||
|
watch(expression, (id) => {
|
||||||
|
ecris('expression', id)
|
||||||
|
if (view.value !== 'animations') state.value = id
|
||||||
|
})
|
||||||
|
|
||||||
|
let pending: ReturnType<typeof setTimeout>
|
||||||
|
function enregistreCycles() {
|
||||||
|
clearTimeout(pending)
|
||||||
|
ecris('cycles', JSON.stringify(cycles.value))
|
||||||
|
}
|
||||||
|
watch(cycles, () => {
|
||||||
|
clearTimeout(pending)
|
||||||
|
pending = setTimeout(enregistreCycles, 250)
|
||||||
|
})
|
||||||
|
watch(activeId, (v) => {
|
||||||
|
ecris('cycle', v)
|
||||||
|
block.value = 0
|
||||||
|
elapsed.value = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(block, (i) => {
|
||||||
|
if (seeking) {
|
||||||
|
seeking = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
elapsed.value = 0
|
||||||
|
state.value = cycle.value.blocks[i]?.state ?? 'idle'
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => cycle.value.blocks.length,
|
||||||
|
(n) => {
|
||||||
|
if (block.value >= n) block.value = Math.max(0, n - 1)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function addBlock(id: string) {
|
||||||
|
cycles.value = cycles.value.map((c) =>
|
||||||
|
c.id === cycle.value.id ? { ...c, blocks: blocksWith(c.blocks, id) } : c
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSeek(t: number) {
|
||||||
|
const { index, elapsed: offset } = blockAt(cycle.value.blocks, t)
|
||||||
|
seeking = true
|
||||||
|
block.value = index
|
||||||
|
elapsed.value = offset
|
||||||
|
state.value = cycle.value.blocks[index]?.state ?? 'idle'
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(view, (now) => {
|
||||||
|
intro.value = false
|
||||||
|
if (now === 'animations') {
|
||||||
|
playing.value = true
|
||||||
|
state.value = cycle.value.blocks[block.value]?.state ?? 'idle'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
playing.value = now === 'reglages'
|
||||||
|
state.value = expression.value
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(preview, (on) => {
|
||||||
|
if (on) playing.value = true
|
||||||
|
})
|
||||||
|
|
||||||
|
let raf = 0
|
||||||
|
let last = 0
|
||||||
|
function tick(ms: number) {
|
||||||
|
raf = requestAnimationFrame(tick)
|
||||||
|
const dt = last ? Math.min((ms - last) / 1000, 0.064) : 0
|
||||||
|
last = ms
|
||||||
|
if (!playing.value || (view.value !== 'animations' && !preview.value)) return
|
||||||
|
const blocs = cycle.value.blocks
|
||||||
|
const cur = blocs[block.value]
|
||||||
|
if (!cur) return
|
||||||
|
elapsed.value += dt
|
||||||
|
if (elapsed.value >= cur.duration) {
|
||||||
|
const next = (block.value + 1) % blocs.length
|
||||||
|
seeking = true
|
||||||
|
block.value = next
|
||||||
|
elapsed.value = 0
|
||||||
|
state.value = blocs[next]!.state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') preview.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageHide() {
|
||||||
|
clearTimeout(pending)
|
||||||
|
ecris('cycles', JSON.stringify(cycles.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const RETARD_ARRIVEE = 400
|
||||||
|
const barreCachee = ref(false)
|
||||||
|
let minuteurBarre: ReturnType<typeof setTimeout> | undefined
|
||||||
|
watch(nue, (encore, avant) => {
|
||||||
|
if (!avant || encore) return
|
||||||
|
barreCachee.value = true
|
||||||
|
clearTimeout(minuteurBarre)
|
||||||
|
minuteurBarre = setTimeout(() => (barreCachee.value = false), RETARD_ARRIVEE)
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialogueCycle = ref(false)
|
||||||
|
const formatCycle = ref<FormatCycle>(FORMAT_CYCLE_DEFAUT)
|
||||||
|
const fondCycle = ref<FondGif>(FOND_GIF_DEFAUT)
|
||||||
|
const avancementCycle = ref<number | null>(null)
|
||||||
|
const erreurCycle = ref(false)
|
||||||
|
let abandonCycle: AbortController | null = null
|
||||||
|
|
||||||
|
async function exporteCycle() {
|
||||||
|
if (avancementCycle.value !== null) return
|
||||||
|
erreurCycle.value = false
|
||||||
|
const controle = new AbortController()
|
||||||
|
abandonCycle = controle
|
||||||
|
const blocs = cycle.value.blocks
|
||||||
|
const format = formatCycle.value
|
||||||
|
const images = cycleImages(totalDuration(blocs), format)
|
||||||
|
const pas = cyclePas(format)
|
||||||
|
const taille = CYCLE_TAILLE[format]
|
||||||
|
const reglages = { shape: shape.value, color: color.value, expression: expression.value }
|
||||||
|
const suit = (fait: number, total: number) => (avancementCycle.value = fait / total)
|
||||||
|
avancementCycle.value = 0
|
||||||
|
try {
|
||||||
|
const mp4 = format === 'mp4'
|
||||||
|
const fichier = mp4
|
||||||
|
? await cycleVersMp4(reglages, blocs, taille, images, pas, BLANC, suit, controle.signal)
|
||||||
|
: await cycleVersGif(
|
||||||
|
reglages,
|
||||||
|
blocs,
|
||||||
|
taille,
|
||||||
|
images,
|
||||||
|
pas,
|
||||||
|
couleurDeFond(fondCycle.value),
|
||||||
|
suit,
|
||||||
|
controle.signal
|
||||||
|
)
|
||||||
|
telecharge(fichier, nomFichier(nomDeCycle(cycle.value), '', '', mp4 ? 'mp4' : 'gif'))
|
||||||
|
dialogueCycle.value = false
|
||||||
|
} catch (e) {
|
||||||
|
if (!(e instanceof Abandon)) erreurCycle.value = true
|
||||||
|
} finally {
|
||||||
|
avancementCycle.value = null
|
||||||
|
abandonCycle = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function annuleCycle() {
|
||||||
|
abandonCycle?.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(dialogueCycle, (ouverte) => {
|
||||||
|
if (ouverte) erreurCycle.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
const CONFIRMATION_MS = 1800
|
||||||
|
const etatExport = ref<EtatExport>('pret')
|
||||||
|
let confirmation: ReturnType<typeof setTimeout> | undefined
|
||||||
|
const fondGif = ref<FondGif>(FOND_GIF_DEFAUT)
|
||||||
|
const dialogueGif = ref(false)
|
||||||
|
|
||||||
|
async function exporte(id: ActionId, confirme = false) {
|
||||||
|
if (etatExport.value === 'occupe') return
|
||||||
|
if (!confirme && ACTION_BY_ID.get(id)?.mode === 'gif') {
|
||||||
|
dialogueGif.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const action = ACTION_BY_ID.get(id)
|
||||||
|
const svg = hero.value?.svg()
|
||||||
|
if (!action || !svg) return
|
||||||
|
clearTimeout(confirmation)
|
||||||
|
etatExport.value = 'occupe'
|
||||||
|
const cote = tailleAction(action)
|
||||||
|
const nom = () =>
|
||||||
|
nomFichier(shape.value, expression.value, color.value, action.extension, action.suffixe)
|
||||||
|
try {
|
||||||
|
if (action.mode === 'anime') {
|
||||||
|
const reglages = { shape: shape.value, color: color.value, expression: expression.value }
|
||||||
|
telecharge(await versSvgAnime(reglages, cote, ANIM_IMAGES, ANIM_PAS), nom())
|
||||||
|
etatExport.value = 'exporte'
|
||||||
|
} else if (action.mode === 'gif') {
|
||||||
|
const reglages = { shape: shape.value, color: color.value, expression: expression.value }
|
||||||
|
telecharge(
|
||||||
|
await versGifAnime(reglages, action.taille, GIF_IMAGES, GIF_PAS, couleurDeFond(fondGif.value)),
|
||||||
|
nom()
|
||||||
|
)
|
||||||
|
etatExport.value = 'exporte'
|
||||||
|
} else {
|
||||||
|
const markup = svgAutonome(svg, cote)
|
||||||
|
if (action.mode === 'copieImage') {
|
||||||
|
await copie(versPng(markup, cote))
|
||||||
|
etatExport.value = 'copie'
|
||||||
|
} else if (action.mode === 'copieTexte') {
|
||||||
|
await copieTexte(markup)
|
||||||
|
etatExport.value = 'copie'
|
||||||
|
} else {
|
||||||
|
const fichier =
|
||||||
|
action.extension === 'svg'
|
||||||
|
? new Blob([markup], { type: 'image/svg+xml' })
|
||||||
|
: await versPng(markup, cote)
|
||||||
|
telecharge(fichier, nom())
|
||||||
|
etatExport.value = 'exporte'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
etatExport.value = 'erreur'
|
||||||
|
}
|
||||||
|
confirmation = setTimeout(() => (etatExport.value = 'pret'), CONFIRMATION_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHeroReady(ok: boolean) {
|
||||||
|
ready.value = ok
|
||||||
|
if (!intro.value) return
|
||||||
|
if (!ok) {
|
||||||
|
intro.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hero.value?.orbitGaze(1500)
|
||||||
|
window.setTimeout(() => {
|
||||||
|
intro.value = false
|
||||||
|
}, 1800)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
window.addEventListener('pagehide', onPageHide)
|
||||||
|
raf = requestAnimationFrame(tick)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', onKey)
|
||||||
|
window.removeEventListener('pagehide', onPageHide)
|
||||||
|
cancelAnimationFrame(raf)
|
||||||
|
clearTimeout(pending)
|
||||||
|
clearTimeout(minuteurBarre)
|
||||||
|
clearTimeout(confirmation)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1 class="sr-only">{{ t('app.name') }}</h1>
|
||||||
|
<SideRail v-if="!preview" v-model="view" class="rail" :inert="nue || undefined" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="preview"
|
||||||
|
type="button"
|
||||||
|
class="fixed top-5 right-5 z-30 flex cursor-pointer items-center gap-1.5 rounded-lg bg-white/80 px-2.5 py-1.5 text-xs text-[var(--muted)] shadow-sm backdrop-blur transition hover:text-[var(--ink)]"
|
||||||
|
@click="preview = false"
|
||||||
|
>
|
||||||
|
{{ t('preview.exit') }}
|
||||||
|
<kbd class="rounded bg-black/5 px-1 py-0.5 text-[10px]">{{ t('preview.key') }}</kbd>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="scene min-h-full items-stretch justify-center p-8 max-lg:flex max-lg:flex-col max-lg:gap-10 max-lg:px-5"
|
||||||
|
:class="[
|
||||||
|
!preview && view === 'animations' && 'scene--timeline pb-[calc(var(--timeline)_+_1rem)]',
|
||||||
|
!preview && 'max-lg:pt-20',
|
||||||
|
nue || preview ? 'scene--seule' : view === 'reglages' && 'scene--gauche'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<aside
|
||||||
|
v-if="!preview"
|
||||||
|
class="panneau scene__gauche w-full lg:flex lg:h-[calc(100dvh_-_3rem_-_var(--timeline))] lg:w-80 lg:shrink-0 lg:flex-col lg:justify-center lg:self-start lg:-translate-y-12 lg:pl-14"
|
||||||
|
:class="gauche ? 'panneau--ouvert max-lg:order-2' : 'max-lg:hidden'"
|
||||||
|
>
|
||||||
|
<Settings :ready="ready" />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main
|
||||||
|
class="scene__avatar relative flex flex-1 items-center justify-center max-lg:order-1 max-lg:flex-col max-lg:gap-4 lg:self-start"
|
||||||
|
:class="
|
||||||
|
preview
|
||||||
|
? 'lg:min-h-[calc(100dvh_-_4rem)]'
|
||||||
|
: 'lg:min-h-[calc(100dvh_-_3rem_-_var(--timeline))]'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="avatar flex aspect-square w-full items-center justify-center"
|
||||||
|
:class="[
|
||||||
|
preview
|
||||||
|
? 'max-w-[min(560px,calc(100dvh_-_6rem))]'
|
||||||
|
: 'max-w-[min(460px,calc(100dvh_-_var(--timeline)_-_7rem))]',
|
||||||
|
nue && 'avatar--intro',
|
||||||
|
view === 'reglages' && !preview && 'avatar--geant'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<Hero
|
||||||
|
ref="hero"
|
||||||
|
class="h-auto max-w-full"
|
||||||
|
:size="preview ? 560 : 440"
|
||||||
|
:shape="shape"
|
||||||
|
:color="color"
|
||||||
|
:state="playedState"
|
||||||
|
:follow="follow"
|
||||||
|
@ready="onHeroReady"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="view === 'personnaliser' && !preview"
|
||||||
|
class="barre-export flex justify-center"
|
||||||
|
:class="(nue || barreCachee) && 'barre-export--cachee'"
|
||||||
|
:inert="nue || barreCachee || undefined"
|
||||||
|
>
|
||||||
|
<ExportBar :etat="etatExport" @exporter="exporte" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CycleDialog
|
||||||
|
v-if="view === 'animations' && !preview"
|
||||||
|
v-model:open="dialogueCycle"
|
||||||
|
v-model:format="formatCycle"
|
||||||
|
v-model:fond="fondCycle"
|
||||||
|
:avancement="avancementCycle"
|
||||||
|
:erreur="erreurCycle"
|
||||||
|
@confirm="exporteCycle"
|
||||||
|
@annuler="annuleCycle"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GifDialog
|
||||||
|
v-if="view === 'personnaliser' && !preview"
|
||||||
|
v-model:open="dialogueGif"
|
||||||
|
v-model:fond="fondGif"
|
||||||
|
@confirm="exporte('gif', true)"
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
v-if="!preview"
|
||||||
|
class="panneau scene__droite w-full lg:w-80 lg:shrink-0"
|
||||||
|
:class="droite ? 'panneau--ouvert max-lg:order-2' : 'max-lg:hidden'"
|
||||||
|
>
|
||||||
|
<Customizer
|
||||||
|
v-if="view === 'personnaliser'"
|
||||||
|
v-model:shape="shape"
|
||||||
|
v-model:color="color"
|
||||||
|
v-model:expression="expression"
|
||||||
|
:ready="ready"
|
||||||
|
/>
|
||||||
|
<AnimPanel
|
||||||
|
v-else-if="view === 'animations'"
|
||||||
|
v-model="state"
|
||||||
|
:shape="shape"
|
||||||
|
:color="color"
|
||||||
|
:ready="ready"
|
||||||
|
@pick="addBlock"
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Timeline
|
||||||
|
v-if="view === 'animations' && !preview"
|
||||||
|
v-model:cycles="cycles"
|
||||||
|
v-model:active-id="activeId"
|
||||||
|
v-model:block="block"
|
||||||
|
v-model:playing="playing"
|
||||||
|
:elapsed="elapsed"
|
||||||
|
:shape="shape"
|
||||||
|
:color="color"
|
||||||
|
:expression="expression"
|
||||||
|
@seek="onSeek"
|
||||||
|
@preview="preview = true"
|
||||||
|
@exporter="dialogueCycle = true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p v-if="view === 'reglages' && !preview" class="wordmark" aria-hidden="true">STUDY</p>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import BotTile from '@/components/BotTile.vue'
|
||||||
|
import { stateName, t } from '@/i18n'
|
||||||
|
import { stateGroups } from '@/replica/catalog'
|
||||||
|
|
||||||
|
const emit = defineEmits<{ pick: [state: string] }>()
|
||||||
|
const current = defineModel<string>({ required: true })
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
shape: string
|
||||||
|
color: string
|
||||||
|
ready?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const groups = computed(() => {
|
||||||
|
void props.ready
|
||||||
|
return stateGroups()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-sm font-semibold">{{ t('panel.animations') }}</h2>
|
||||||
|
<section v-for="group in groups" :key="group.key" class="mt-4">
|
||||||
|
<h3 class="text-xs font-medium tracking-wide text-[var(--muted)] uppercase">
|
||||||
|
{{
|
||||||
|
group.key === 'lifecycle'
|
||||||
|
? t('panel.group_lifecycle')
|
||||||
|
: group.key === 'reactions'
|
||||||
|
? t('panel.group_reactions')
|
||||||
|
: group.key === 'agent'
|
||||||
|
? t('panel.group_agent')
|
||||||
|
: t('panel.group_product')
|
||||||
|
}}
|
||||||
|
</h3>
|
||||||
|
<div class="mt-2 grid grid-cols-4 gap-1.5">
|
||||||
|
<BotTile
|
||||||
|
v-for="id in group.states"
|
||||||
|
:key="id"
|
||||||
|
:label="stateName(id)"
|
||||||
|
:selected="id === current"
|
||||||
|
:state="id"
|
||||||
|
:shape="shape"
|
||||||
|
:color="color"
|
||||||
|
@click="emit('pick', id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import BotTile from '@/components/BotTile.vue'
|
||||||
|
import { catalogue, type StateId } from '@/editor/cycles'
|
||||||
|
import { stateName, t } from '@/i18n'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carte « + » de la piste et sa palette. Ajouter depuis la piste evite d'aller
|
||||||
|
* jusqu'au panneau de droite quand on monte.
|
||||||
|
*/
|
||||||
|
defineProps<{ shape: string; color: string; expression: string }>()
|
||||||
|
const emit = defineEmits<{ pick: [state: StateId] }>()
|
||||||
|
|
||||||
|
/** Les animations dans l'ordre de la video. */
|
||||||
|
const PALETTE = catalogue()
|
||||||
|
|
||||||
|
/** Largeur de la palette, en pixels — elle sert aussi a la caler. */
|
||||||
|
const WIDTH = 288
|
||||||
|
|
||||||
|
const trigger = ref<HTMLButtonElement | null>(null)
|
||||||
|
const panel = ref<HTMLElement | null>(null)
|
||||||
|
const position = ref<Record<string, string>>({})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La palette est-elle ouverte ? Pour `aria-expanded`, que le declencheur doit porter comme
|
||||||
|
* ceux de la barre d'export et du menu de cycles.
|
||||||
|
*
|
||||||
|
* Suivi par l'evenement `toggle` du popover et non par notre propre `toggle()` : la
|
||||||
|
* fermeture LEGERE — clic a cote, Echap — est faite par le navigateur, donc un drapeau que
|
||||||
|
* nous tiendrions nous-memes resterait bloque sur « ouvert ».
|
||||||
|
*/
|
||||||
|
const ouvert = ref(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ouvre la palette au-dessus du « + ». Elle vit dans la couche superieure du
|
||||||
|
* navigateur, donc sa position se calcule ici, en coordonnees d'ecran, et se
|
||||||
|
* borne pour ne pas sortir par la droite quand le bouton est en bout de piste.
|
||||||
|
*/
|
||||||
|
function toggle() {
|
||||||
|
const bouton = trigger.value
|
||||||
|
const boite = panel.value
|
||||||
|
if (!bouton || !boite) return
|
||||||
|
// au clavier, `Entree` ne declenche pas la fermeture legere (qui ecoute le
|
||||||
|
// pointeur) : sans ce garde on rouvrirait une palette deja ouverte, et
|
||||||
|
// `showPopover` leve une exception dans ce cas
|
||||||
|
if (boite.matches(':popover-open')) {
|
||||||
|
boite.hidePopover()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const r = bouton.getBoundingClientRect()
|
||||||
|
position.value = {
|
||||||
|
position: 'fixed',
|
||||||
|
// les styles par defaut d'un popover posent `inset: 0` : sans remettre le
|
||||||
|
// haut et la droite a `auto`, le calage est sur-contraint et c'est `top: 0`
|
||||||
|
// qui gagne — la palette se colle en haut de l'ecran
|
||||||
|
top: 'auto',
|
||||||
|
right: 'auto',
|
||||||
|
left: `${Math.max(8, Math.min(r.right - WIDTH, window.innerWidth - WIDTH - 8))}px`,
|
||||||
|
bottom: `${window.innerHeight - r.top + 8}px`
|
||||||
|
}
|
||||||
|
boite.showPopover()
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(state: StateId) {
|
||||||
|
emit('pick', state)
|
||||||
|
panel.value?.hidePopover()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
ref="trigger"
|
||||||
|
type="button"
|
||||||
|
class="flex h-full w-full cursor-pointer items-center justify-center rounded-lg border-2 border-dashed border-[var(--line)] text-lg leading-none text-[var(--muted)] transition hover:border-[var(--muted)] hover:text-[var(--ink)]"
|
||||||
|
:aria-label="t('timeline.addAnimation')"
|
||||||
|
aria-haspopup="true"
|
||||||
|
:aria-expanded="ouvert"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
`popover` promeut la palette dans la couche superieure du navigateur : c'est
|
||||||
|
ce qui la fait echapper au conteneur de la piste, qui rogne verticalement —
|
||||||
|
elle y etait coupee en deux. En prime, le clic a cote et Echap la referment
|
||||||
|
sans code a nous. `m-0` : un popover est centre par une marge auto, comme
|
||||||
|
une modale.
|
||||||
|
-->
|
||||||
|
<div
|
||||||
|
ref="panel"
|
||||||
|
popover
|
||||||
|
@toggle="ouvert = ($event as ToggleEvent).newState === 'open'"
|
||||||
|
class="m-0 w-72 rounded-xl bg-white p-2 shadow-lg ring-1 ring-black/5"
|
||||||
|
:style="position"
|
||||||
|
>
|
||||||
|
<div class="sans-barre grid max-h-80 grid-cols-4 gap-1.5 overflow-y-auto">
|
||||||
|
<BotTile
|
||||||
|
v-for="id in PALETTE"
|
||||||
|
:key="id"
|
||||||
|
:label="stateName(id)"
|
||||||
|
:selected="false"
|
||||||
|
:state="id"
|
||||||
|
:shape="shape"
|
||||||
|
:color="color"
|
||||||
|
@click="pick(id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, watch } from 'vue'
|
||||||
|
import { paintTile } from '@/replica/host'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
selected: boolean
|
||||||
|
state?: string
|
||||||
|
shape?: string
|
||||||
|
color?: string
|
||||||
|
size?: number
|
||||||
|
interactive?: boolean
|
||||||
|
showLabel?: boolean
|
||||||
|
}>(),
|
||||||
|
{ interactive: true, showLabel: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const svg = ref<SVGSVGElement | null>(null)
|
||||||
|
const painted = ref(false)
|
||||||
|
|
||||||
|
async function paint() {
|
||||||
|
const node = svg.value
|
||||||
|
if (!node) return
|
||||||
|
node.innerHTML = ''
|
||||||
|
const mount = await paintTile(node, {
|
||||||
|
state: props.state ?? 'idle',
|
||||||
|
shape: props.shape ?? 'blob',
|
||||||
|
color: props.color ?? 'black',
|
||||||
|
follow: false,
|
||||||
|
paper: '#f9f9f9',
|
||||||
|
size: props.size ?? 60
|
||||||
|
})
|
||||||
|
painted.value = !!mount
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void paint()
|
||||||
|
})
|
||||||
|
watch(() => [props.state, props.shape, props.color], () => {
|
||||||
|
void paint()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="interactive ? 'button' : 'div'"
|
||||||
|
:type="interactive ? 'button' : undefined"
|
||||||
|
class="flex flex-col items-center rounded-xl border-2 p-1 transition"
|
||||||
|
:class="[
|
||||||
|
interactive ? 'cursor-pointer' : '',
|
||||||
|
selected ? 'border-[var(--ink)]' : 'border-transparent',
|
||||||
|
interactive && !selected ? 'hover:border-[var(--line)]' : ''
|
||||||
|
]"
|
||||||
|
:aria-label="interactive ? label : undefined"
|
||||||
|
:aria-pressed="interactive ? selected : undefined"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
ref="svg"
|
||||||
|
class="shrink-0 overflow-visible"
|
||||||
|
:width="size ?? 60"
|
||||||
|
:height="size ?? 60"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-if="showLabel"
|
||||||
|
class="text-center text-xs leading-tight text-[var(--muted)]"
|
||||||
|
>{{ label }}</span
|
||||||
|
>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useTemplateRef } from 'vue'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { useModalDialog } from '@/ui/useModalDialog'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirmation d'une action destructrice. Le focus s'ouvre sur « Annuler » :
|
||||||
|
* sur une suppression, la touche Entree ne doit pas detruire. L'animation vient
|
||||||
|
* de `styles.css`, le reste du comportement modal de `useModalDialog`.
|
||||||
|
*/
|
||||||
|
const props = defineProps<{ title: string; detail: string; confirmLabel: string }>()
|
||||||
|
const open = defineModel<boolean>('open', { required: true })
|
||||||
|
const emit = defineEmits<{ confirm: [] }>()
|
||||||
|
|
||||||
|
const boite = useTemplateRef<HTMLDialogElement>('boite')
|
||||||
|
useModalDialog(open, boite)
|
||||||
|
|
||||||
|
function confirm() {
|
||||||
|
emit('confirm')
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<dialog
|
||||||
|
ref="boite"
|
||||||
|
class="dialogue m-auto w-80 rounded-2xl bg-white p-5 text-[var(--ink)] shadow-xl"
|
||||||
|
:aria-label="props.title"
|
||||||
|
@close="open = false"
|
||||||
|
@cancel.prevent="open = false"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h2 class="text-sm font-semibold">{{ props.title }}</h2>
|
||||||
|
<p class="text-xs text-[var(--muted)]">{{ props.detail }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
autofocus
|
||||||
|
class="h-8 cursor-pointer rounded-lg px-3 text-xs text-[var(--muted)] transition hover:bg-black/5 hover:text-[var(--ink)]"
|
||||||
|
@click="open = false"
|
||||||
|
>
|
||||||
|
{{ t('dialog.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-8 cursor-pointer rounded-lg bg-[var(--danger)] px-3 text-xs text-white transition hover:opacity-90 active:scale-95"
|
||||||
|
@click="confirm"
|
||||||
|
>
|
||||||
|
{{ props.confirmLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import BotTile from '@/components/BotTile.vue'
|
||||||
|
import { shapeName, stateName, t } from '@/i18n'
|
||||||
|
import { COLOR_IDS, EXPRESSION_IDS, colorFill, liveShapes } from '@/replica/catalog'
|
||||||
|
|
||||||
|
const props = defineProps<{ ready?: boolean }>()
|
||||||
|
const shape = defineModel<string>('shape', { required: true })
|
||||||
|
const color = defineModel<string>('color', { required: true })
|
||||||
|
const expression = defineModel<string>('expression', { required: true })
|
||||||
|
const shapes = computed(() => {
|
||||||
|
void props.ready
|
||||||
|
return liveShapes()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-sm font-semibold">{{ t('panel.shape') }}</h2>
|
||||||
|
<div class="mt-2 grid grid-cols-4 gap-1.5">
|
||||||
|
<BotTile
|
||||||
|
v-for="id in shapes"
|
||||||
|
:key="id"
|
||||||
|
:label="shapeName(id)"
|
||||||
|
:selected="id === shape"
|
||||||
|
:shape="id"
|
||||||
|
:color="color"
|
||||||
|
:state="expression"
|
||||||
|
@click="shape = id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="mt-5 text-sm font-semibold">{{ t('panel.expression') }}</h2>
|
||||||
|
<div class="mt-2 grid grid-cols-4 gap-1.5">
|
||||||
|
<BotTile
|
||||||
|
v-for="id in EXPRESSION_IDS"
|
||||||
|
:key="id"
|
||||||
|
:label="stateName(id)"
|
||||||
|
:selected="id === expression"
|
||||||
|
:shape="shape"
|
||||||
|
:color="color"
|
||||||
|
:state="id"
|
||||||
|
@click="expression = id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="mt-5 text-sm font-semibold">{{ t('panel.color') }}</h2>
|
||||||
|
<div class="mt-2 grid grid-cols-6 gap-1.5">
|
||||||
|
<button
|
||||||
|
v-for="id in COLOR_IDS"
|
||||||
|
:key="id"
|
||||||
|
type="button"
|
||||||
|
class="flex aspect-square cursor-pointer items-center justify-center rounded-full border-2 transition"
|
||||||
|
:class="
|
||||||
|
id === color ? 'border-[var(--ink)]' : 'border-transparent hover:border-[var(--line)]'
|
||||||
|
"
|
||||||
|
:aria-label="id"
|
||||||
|
:aria-pressed="id === color"
|
||||||
|
:title="id"
|
||||||
|
@click="color = id"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="block h-[78%] w-[78%] rounded-full ring-1 ring-black/10 ring-inset"
|
||||||
|
:style="{ background: colorFill(id) }"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, useTemplateRef } from 'vue'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import {
|
||||||
|
FONDS_GIF,
|
||||||
|
FORMATS_CYCLE,
|
||||||
|
cycleAccepteTransparence,
|
||||||
|
videoPossible,
|
||||||
|
type FondGif,
|
||||||
|
type FormatCycle
|
||||||
|
} from '@/ui/export'
|
||||||
|
import { useModalDialog } from '@/ui/useModalDialog'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choix du format avant d'exporter le montage.
|
||||||
|
*
|
||||||
|
* Le groupe « fond » n'apparait QUE pour le GIF, au lieu d'etre affiche grise :
|
||||||
|
* la video n'a pas de canal alpha du tout, donc il n'y a pas de choix a refuser,
|
||||||
|
* il n'y a pas de choix.
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
avancement: number | null
|
||||||
|
/** true = le dernier essai a echoue. La boite le dit et propose de reessayer. */
|
||||||
|
erreur: boolean
|
||||||
|
}>()
|
||||||
|
const open = defineModel<boolean>('open', { required: true })
|
||||||
|
const format = defineModel<FormatCycle>('format', { required: true })
|
||||||
|
const fond = defineModel<FondGif>('fond', { required: true })
|
||||||
|
const emit = defineEmits<{ confirm: []; annuler: [] }>()
|
||||||
|
|
||||||
|
const boite = useTemplateRef<HTMLDialogElement>('boite')
|
||||||
|
useModalDialog(open, boite)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La video n'est proposee que la ou le navigateur sait encoder — meme regle que
|
||||||
|
* la copie d'image dans la barre d'export. Sans ce filtre, choisir MP4 sur un
|
||||||
|
* navigateur sans `VideoEncoder` echouait sur l'erreur generique.
|
||||||
|
*
|
||||||
|
* Le garde vient de `export.ts` et SURTOUT PAS de `video.ts` : l'importer d'ici
|
||||||
|
* suffirait a ramener mediabunny dans le chunk d'entree (voir `videoPossible`).
|
||||||
|
*/
|
||||||
|
const formats = FORMATS_CYCLE.filter((f) => f !== 'mp4' || videoPossible())
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Le modele est corrige, pas seulement la liste. Le defaut est `mp4`, donc sur un
|
||||||
|
* navigateur sans `VideoEncoder` la boite n'affichait qu'une radio « GIF » et elle etait
|
||||||
|
* DECOCHEE : l'option etait cachee mais la valeur, elle, gagnait, et l'export partait
|
||||||
|
* quand meme sur l'encodeur video pour echouer aussitot. Cacher un choix ne suffit pas, il
|
||||||
|
* faut aussi ne plus le porter.
|
||||||
|
*/
|
||||||
|
if (!formats.includes(format.value)) format.value = formats[0]!
|
||||||
|
|
||||||
|
const occupe = computed(() => props.avancement !== null)
|
||||||
|
const pourcent = computed(() => Math.round((props.avancement ?? 0) * 100))
|
||||||
|
|
||||||
|
function confirm() {
|
||||||
|
// La boite reste ouverte pendant l'encodage : c'est elle qui porte la
|
||||||
|
// progression, et un cycle de trente secondes ne s'exporte pas instantanement.
|
||||||
|
if (!occupe.value) emit('confirm')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fermer, c'est ABANDONNER quand l'encodage tourne.
|
||||||
|
*
|
||||||
|
* Sans ca, Echap ou le bouton refermaient la boite pendant que l'export continuait
|
||||||
|
* jusqu'au bout et declenchait le telechargement d'un fichier que plus personne
|
||||||
|
* n'attendait.
|
||||||
|
*/
|
||||||
|
function ferme() {
|
||||||
|
if (occupe.value) emit('annuler')
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<dialog
|
||||||
|
ref="boite"
|
||||||
|
class="dialogue m-auto w-80 rounded-2xl bg-white p-5 text-[var(--ink)] shadow-xl"
|
||||||
|
:aria-label="t('timeline.export')"
|
||||||
|
@close="open = false"
|
||||||
|
@cancel.prevent="ferme"
|
||||||
|
>
|
||||||
|
<form class="flex flex-col gap-4" @submit.prevent="confirm">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h2 class="text-sm font-semibold">{{ t('timeline.export') }}</h2>
|
||||||
|
<p class="text-xs text-[var(--muted)]">{{ t('export.cycleDetail') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="flex flex-col gap-1" :disabled="occupe">
|
||||||
|
<legend class="sr-only">{{ t('export.cycleFormat') }}</legend>
|
||||||
|
<label
|
||||||
|
v-for="(choix, i) in formats"
|
||||||
|
:key="choix"
|
||||||
|
class="flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm transition hover:bg-black/5"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="format"
|
||||||
|
type="radio"
|
||||||
|
name="format"
|
||||||
|
:value="choix"
|
||||||
|
:autofocus="i === 0"
|
||||||
|
class="accent-[var(--ink)]"
|
||||||
|
/>
|
||||||
|
<span class="flex flex-col">
|
||||||
|
{{ t(`export.cycle_${choix}`) }}
|
||||||
|
<span class="text-xs text-[var(--muted)]">{{ t(`export.cycle_${choix}_aide`) }}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- seul le GIF a un alpha a offrir : cf. la doc du composant -->
|
||||||
|
<fieldset
|
||||||
|
v-if="cycleAccepteTransparence(format)"
|
||||||
|
class="flex flex-col gap-1"
|
||||||
|
:disabled="occupe"
|
||||||
|
>
|
||||||
|
<legend class="sr-only">{{ t('export.gifBackground') }}</legend>
|
||||||
|
<label
|
||||||
|
v-for="choix in FONDS_GIF"
|
||||||
|
:key="choix"
|
||||||
|
class="flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm transition hover:bg-black/5"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="fond"
|
||||||
|
type="radio"
|
||||||
|
name="fondCycle"
|
||||||
|
:value="choix"
|
||||||
|
class="accent-[var(--ink)]"
|
||||||
|
/>
|
||||||
|
{{ t(`export.fond_${choix}`) }}
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- la progression remplace les boutons : rien d'autre a faire qu'attendre -->
|
||||||
|
<!-- l'echec s'affiche ICI et pas dans la barre d'export : celle-la n'est rendue
|
||||||
|
que dans la vue Personnaliser, alors que cette boite vit dans les Animations,
|
||||||
|
donc le message partait dans un composant absent de l'ecran -->
|
||||||
|
<p v-if="erreur && !occupe" class="text-xs text-[var(--danger)]" role="alert">
|
||||||
|
{{ t('export.failed') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="occupe" class="flex flex-col gap-1.5">
|
||||||
|
<!--
|
||||||
|
Pas de `transition` sur la largeur : une transition sur `width` passe par
|
||||||
|
le layout, donc par le thread principal — que l'encodage sature. La barre
|
||||||
|
restait figee sur sa premiere valeur pendant que le pourcentage, lui,
|
||||||
|
avancait. Et elle n'apporte rien : la valeur change des centaines de fois.
|
||||||
|
-->
|
||||||
|
<div class="h-1.5 overflow-hidden rounded-full bg-black/10">
|
||||||
|
<div class="h-full rounded-full bg-[var(--ink)]" :style="{ width: `${pourcent}%` }" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<p class="text-xs tabular-nums text-[var(--muted)]">
|
||||||
|
{{ t('export.cycleProgress') }} {{ pourcent }} %
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-7 cursor-pointer rounded-lg px-2 text-xs text-[var(--muted)] transition hover:bg-black/5 hover:text-[var(--ink)]"
|
||||||
|
@click="ferme"
|
||||||
|
>
|
||||||
|
{{ t('dialog.cancel') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-8 cursor-pointer rounded-lg px-3 text-xs text-[var(--muted)] transition hover:bg-black/5 hover:text-[var(--ink)]"
|
||||||
|
@click="ferme"
|
||||||
|
>
|
||||||
|
{{ t('dialog.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="h-8 cursor-pointer rounded-lg bg-[var(--ink)] px-3 text-xs text-[var(--paper)] transition hover:opacity-90 active:scale-95"
|
||||||
|
>
|
||||||
|
{{ erreur ? t('export.cycleReessayer') : t('export.gifConfirm') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import type { Cycle } from '@/editor/cycles'
|
||||||
|
import { nomDeCycle, t } from '@/i18n'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choix du montage. Un `<select>` natif ne peut pas porter la ligne "Nouveau"
|
||||||
|
* ni la suppression d'une entree, d'ou ce menu — et il s'ouvre vers le haut,
|
||||||
|
* la barre etant en bas de l'ecran.
|
||||||
|
*/
|
||||||
|
defineProps<{ cycles: Cycle[]; current: Cycle }>()
|
||||||
|
const activeId = defineModel<string>('activeId', { required: true })
|
||||||
|
const emit = defineEmits<{ create: []; remove: [id: string]; rename: [id: string] }>()
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
|
const root = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
function choose(id: string) {
|
||||||
|
activeId.value = id
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function create() {
|
||||||
|
open.value = false
|
||||||
|
emit('create')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOutside(e: PointerEvent) {
|
||||||
|
if (!root.value?.contains(e.target as Node)) open.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(open, (on) => {
|
||||||
|
if (on) window.addEventListener('pointerdown', onOutside)
|
||||||
|
else window.removeEventListener('pointerdown', onOutside)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => window.removeEventListener('pointerdown', onOutside))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!--
|
||||||
|
`text-left` sur le declencheur : les navigateurs centrent le texte des
|
||||||
|
boutons. Ca ne se voyait pas avec l'ancienne troncature, qui remplissait
|
||||||
|
toute la boite ; la troncature au mot laisse une ligne plus courte, et le
|
||||||
|
nom se retrouvait au milieu.
|
||||||
|
-->
|
||||||
|
<div ref="root" class="relative" @keydown.esc="open = false">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex max-w-56 cursor-pointer items-center gap-1.5 rounded-lg px-2 py-1 text-left text-sm font-medium transition hover:bg-black/5"
|
||||||
|
aria-haspopup="true"
|
||||||
|
:aria-expanded="open"
|
||||||
|
:title="nomDeCycle(current)"
|
||||||
|
@click="open = !open"
|
||||||
|
>
|
||||||
|
<span class="tronque">{{ nomDeCycle(current) }}</span>
|
||||||
|
<svg
|
||||||
|
width="10"
|
||||||
|
height="10"
|
||||||
|
viewBox="0 0 10 10"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="shrink-0 text-[var(--muted)]"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M2 3.5 5 6.5l3-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.4"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="open"
|
||||||
|
class="absolute bottom-full left-0 z-10 mb-2 w-56 rounded-xl bg-white p-1 shadow-lg ring-1 ring-black/5"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="c in cycles"
|
||||||
|
:key="c.id"
|
||||||
|
class="group/row flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<!-- `min-w-0` sur le bouton : sans lui, un nom long pousse la ligne au
|
||||||
|
lieu d'etre coupe, et deborde du menu avec ses deux actions -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-xs transition hover:bg-black/5"
|
||||||
|
:title="nomDeCycle(c)"
|
||||||
|
@click="choose(c.id)"
|
||||||
|
>
|
||||||
|
<span class="w-3 shrink-0 text-[var(--ink)]">{{ c.id === activeId ? '✓' : '' }}</span>
|
||||||
|
<span class="tronque">{{ nomDeCycle(c) }}</span>
|
||||||
|
</button>
|
||||||
|
<!-- renommer et supprimer vivent ici : la barre n'a pas a porter deux
|
||||||
|
boutons de plus pour une action qu'on fait une fois -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-[var(--muted)] opacity-0 transition group-hover/row:opacity-100 hover:bg-black/5 hover:text-[var(--ink)] focus-visible:opacity-100"
|
||||||
|
:aria-label="t('cycles.menuRenameAria', { name: nomDeCycle(c) })"
|
||||||
|
@click="((open = false), emit('rename', c.id))"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M8.2 1.8 10.2 3.8 4.4 9.6 1.8 10.2 2.4 7.6z"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.1"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<!-- le dernier montage ne se supprime pas : il n'y aurait plus rien a jouer -->
|
||||||
|
<button
|
||||||
|
v-if="cycles.length > 1"
|
||||||
|
type="button"
|
||||||
|
class="mr-1 flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-[var(--muted)] opacity-0 transition group-hover/row:opacity-100 hover:bg-black/5 hover:text-[var(--danger)] focus-visible:opacity-100"
|
||||||
|
:aria-label="t('cycles.menuRemoveAria', { name: nomDeCycle(c) })"
|
||||||
|
@click="((open = false), emit('remove', c.id))"
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 14 14" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M2.8 3.9h8.4M5.5 3.9V2.7h3v1.2M4.1 3.9l.5 7.4h4.8l.5-7.4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.1"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="my-1 h-px bg-[var(--line)]" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-xs transition hover:bg-black/5"
|
||||||
|
@click="create"
|
||||||
|
>
|
||||||
|
<span class="w-3 shrink-0 text-[var(--muted)]">+</span>
|
||||||
|
{{ t('cycles.menuNew') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { copiePossible } from '@/ui/capture'
|
||||||
|
import { ACTIONS, ACTION_DEFAUT, type ActionId, type EtatExport } from '@/ui/export'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Barre d'export de l'avatar : un bouton pour le format courant, et une flèche
|
||||||
|
* accolee pour les autres. Purement presentationnel, comme `Customizer.vue` —
|
||||||
|
* c'est `App.vue` qui possede le SVG a capturer, donc qui fait le travail.
|
||||||
|
*
|
||||||
|
* Le menu s'ouvre vers le HAUT : la barre est en bas de la colonne de l'avatar,
|
||||||
|
* un menu vers le bas sortirait de la fenetre sur un ecran court.
|
||||||
|
*/
|
||||||
|
const props = defineProps<{ etat: EtatExport }>()
|
||||||
|
const emit = defineEmits<{ exporter: [ActionId] }>()
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
|
const root = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La copie d'IMAGE n'est pas proposee la ou le navigateur ne sait pas en ecrire.
|
||||||
|
* La copie du SVG, elle, passe par `writeText` et marche partout.
|
||||||
|
*/
|
||||||
|
const actions = ACTIONS.filter((a) => a.mode !== 'copieImage' || copiePossible())
|
||||||
|
|
||||||
|
const occupe = computed(() => props.etat === 'occupe')
|
||||||
|
|
||||||
|
/** Le bouton principal rend compte de ce qui vient de se passer. */
|
||||||
|
const libelle = computed(() => {
|
||||||
|
if (props.etat === 'exporte') return t('export.done')
|
||||||
|
if (props.etat === 'copie') return t('export.copied')
|
||||||
|
if (props.etat === 'erreur') return t('export.failed')
|
||||||
|
return t('export.action')
|
||||||
|
})
|
||||||
|
|
||||||
|
const confirme = computed(() => props.etat === 'exporte' || props.etat === 'copie')
|
||||||
|
|
||||||
|
function lance(id: ActionId) {
|
||||||
|
open.value = false
|
||||||
|
emit('exporter', id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOutside(e: PointerEvent) {
|
||||||
|
if (!root.value?.contains(e.target as Node)) open.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(open, (on) => {
|
||||||
|
if (on) window.addEventListener('pointerdown', onOutside)
|
||||||
|
else window.removeEventListener('pointerdown', onOutside)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => window.removeEventListener('pointerdown', onOutside))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="root" class="relative" @keydown.esc="open = false">
|
||||||
|
<div
|
||||||
|
class="flex overflow-hidden rounded-xl bg-[var(--ink)] text-[var(--paper)] shadow-sm transition"
|
||||||
|
:class="occupe && 'opacity-60'"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex cursor-pointer items-center gap-2 py-2.5 pr-3 pl-3.5 text-sm font-medium transition hover:bg-white/10 disabled:cursor-default"
|
||||||
|
:disabled="occupe"
|
||||||
|
@click="lance(ACTION_DEFAUT)"
|
||||||
|
>
|
||||||
|
<!-- solar:check-circle-linear -->
|
||||||
|
<svg
|
||||||
|
v-if="confirme"
|
||||||
|
width="17"
|
||||||
|
height="17"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M8.5 12.5L10.5 14.5L15.5 9.5" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<!-- solar:download-minimalistic-linear -->
|
||||||
|
<svg v-else width="17" height="17" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<g
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3 15C3 17.8284 3 19.2426 3.87868 20.1213C4.75736 21 6.17157 21 9 21H15C17.8284 21 19.2426 21 20.1213 20.1213C21 19.2426 21 17.8284 21 15"
|
||||||
|
/>
|
||||||
|
<path d="M12 3V16M8 11.625L12 16L16 11.625" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
{{ libelle }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- pleine hauteur : le conteneur est en `flex`, donc l'etirement suffit,
|
||||||
|
sans marge verticale qui la ferait paraitre flottante -->
|
||||||
|
<div class="w-px self-stretch bg-current opacity-25"></div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex cursor-pointer items-center px-2.5 transition hover:bg-white/10 disabled:cursor-default"
|
||||||
|
:disabled="occupe"
|
||||||
|
:aria-label="t('export.more')"
|
||||||
|
aria-haspopup="true"
|
||||||
|
:aria-expanded="open"
|
||||||
|
@click="open = !open"
|
||||||
|
>
|
||||||
|
<!-- solar:alt-arrow-down-linear -->
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="transition-transform"
|
||||||
|
:class="open && 'rotate-180'"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="1.5"
|
||||||
|
d="M19 9L12 15L5 9"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="open"
|
||||||
|
class="absolute right-0 bottom-full z-10 mb-2 w-60 rounded-xl border border-[var(--line)] bg-[var(--paper)] p-1 shadow-lg"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="action in actions"
|
||||||
|
:key="action.id"
|
||||||
|
type="button"
|
||||||
|
class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-sm transition hover:bg-black/5"
|
||||||
|
:class="action.id === 'copie' && 'mt-1 border-t border-[var(--line)] pt-2.5'"
|
||||||
|
@click="lance(action.id)"
|
||||||
|
>
|
||||||
|
<!-- solar:copy-linear -->
|
||||||
|
<svg
|
||||||
|
v-if="action.mode === 'copieImage' || action.mode === 'copieTexte'"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="shrink-0 text-[var(--muted)]"
|
||||||
|
>
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path
|
||||||
|
d="M6 11C6 8.17157 6 6.75736 6.87868 5.87868C7.75736 5 9.17157 5 12 5H15C17.8284 5 19.2426 5 20.1213 5.87868C21 6.75736 21 8.17157 21 11V16C21 18.8284 21 20.2426 20.1213 21.1213C19.2426 22 17.8284 22 15 22H12C9.17157 22 7.75736 22 6.87868 21.1213C6 20.2426 6 18.8284 6 16V11Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M6 19C4.34315 19 3 17.6569 3 16V10C3 6.22876 3 4.34315 4.17157 3.17157C5.34315 2 7.22876 2 11 2H15C16.6569 2 18 3.34315 18 5"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<!-- solar:download-minimalistic-linear — les deux telechargements
|
||||||
|
portent la meme icone : ce qui les distingue est le format, dit par
|
||||||
|
le libelle, pas la nature de l'action -->
|
||||||
|
<svg
|
||||||
|
v-else
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="shrink-0 text-[var(--muted)]"
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3 15C3 17.8284 3 19.2426 3.87868 20.1213C4.75736 21 6.17157 21 9 21H15C17.8284 21 19.2426 21 20.1213 20.1213C21 19.2426 21 17.8284 21 15"
|
||||||
|
/>
|
||||||
|
<path d="M12 3V16M8 11.625L12 16L16 11.625" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
{{ t(`export.${action.id}`) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useTemplateRef } from 'vue'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { FONDS_GIF, type FondGif } from '@/ui/export'
|
||||||
|
import { useModalDialog } from '@/ui/useModalDialog'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choix du fond avant de telecharger le GIF.
|
||||||
|
*
|
||||||
|
* Ce format est le seul a poser la question : sa transparence n'a qu'un bit, donc
|
||||||
|
* son bord transparent est dur et se voit. Le fond plein le lisse, en echange
|
||||||
|
* d'une couleur cuite dans l'image — aucun des deux ne gagne dans tous les cas,
|
||||||
|
* d'ou le choix laisse a l'utilisateur.
|
||||||
|
*
|
||||||
|
* De vrais `<input type="radio">` et non des boutons : le navigateur donne le
|
||||||
|
* groupe, la navigation aux fleches et l'annonce « 1 sur 2 » au lecteur d'ecran.
|
||||||
|
* Le comportement modal vient de `useModalDialog`, l'animation de `styles.css`.
|
||||||
|
*/
|
||||||
|
const open = defineModel<boolean>('open', { required: true })
|
||||||
|
const fond = defineModel<FondGif>('fond', { required: true })
|
||||||
|
const emit = defineEmits<{ confirm: [] }>()
|
||||||
|
|
||||||
|
const boite = useTemplateRef<HTMLDialogElement>('boite')
|
||||||
|
useModalDialog(open, boite)
|
||||||
|
|
||||||
|
function confirm() {
|
||||||
|
emit('confirm')
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<dialog
|
||||||
|
ref="boite"
|
||||||
|
class="dialogue m-auto w-80 rounded-2xl bg-white p-5 text-[var(--ink)] shadow-xl"
|
||||||
|
:aria-label="t('export.gifTitle')"
|
||||||
|
@close="open = false"
|
||||||
|
@cancel.prevent="open = false"
|
||||||
|
>
|
||||||
|
<form class="flex flex-col gap-4" @submit.prevent="confirm">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h2 class="text-sm font-semibold">{{ t('export.gifTitle') }}</h2>
|
||||||
|
<p class="text-xs text-[var(--muted)]">{{ t('export.gifDetail') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="flex flex-col gap-1">
|
||||||
|
<legend class="sr-only">{{ t('export.gifBackground') }}</legend>
|
||||||
|
<label
|
||||||
|
v-for="(choix, i) in FONDS_GIF"
|
||||||
|
:key="choix"
|
||||||
|
class="flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm transition hover:bg-black/5"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="fond"
|
||||||
|
type="radio"
|
||||||
|
name="fond"
|
||||||
|
:value="choix"
|
||||||
|
:autofocus="i === 0"
|
||||||
|
class="accent-[var(--ink)]"
|
||||||
|
/>
|
||||||
|
<span class="flex flex-col">
|
||||||
|
{{ t(`export.fond_${choix}`) }}
|
||||||
|
<span class="text-xs text-[var(--muted)]">{{ t(`export.fond_${choix}_aide`) }}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-8 cursor-pointer rounded-lg px-3 text-xs text-[var(--muted)] transition hover:bg-black/5 hover:text-[var(--ink)]"
|
||||||
|
@click="open = false"
|
||||||
|
>
|
||||||
|
{{ t('dialog.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="h-8 cursor-pointer rounded-lg bg-[var(--ink)] px-3 text-xs text-[var(--paper)] transition hover:opacity-90 active:scale-95"
|
||||||
|
>
|
||||||
|
{{ t('export.gifConfirm') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { mountReplica, type ReplicaMount } from '@/replica/host'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
size?: number
|
||||||
|
shape?: string
|
||||||
|
color?: string
|
||||||
|
state?: string
|
||||||
|
follow?: boolean
|
||||||
|
paper?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
size: 440,
|
||||||
|
shape: 'blob',
|
||||||
|
color: 'black',
|
||||||
|
state: 'idle',
|
||||||
|
follow: false,
|
||||||
|
paper: '#f9f9f9'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{ ready: [ok: boolean] }>()
|
||||||
|
|
||||||
|
const svg = ref<SVGSVGElement | null>(null)
|
||||||
|
const live = ref(false)
|
||||||
|
const tried = ref(false)
|
||||||
|
let replica: ReplicaMount | null = null
|
||||||
|
let alive = true
|
||||||
|
|
||||||
|
function input() {
|
||||||
|
return {
|
||||||
|
state: props.state,
|
||||||
|
shape: props.shape,
|
||||||
|
color: props.color,
|
||||||
|
follow: props.follow,
|
||||||
|
paper: props.paper,
|
||||||
|
size: props.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attach() {
|
||||||
|
const node = svg.value
|
||||||
|
if (!node) return
|
||||||
|
const mount = await mountReplica(node, input(), true)
|
||||||
|
if (!alive) {
|
||||||
|
mount?.destroy()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
replica = mount
|
||||||
|
live.value = !!replica
|
||||||
|
tried.value = true
|
||||||
|
replica?.apply(input())
|
||||||
|
emit('ready', live.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void attach()
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
alive = false
|
||||||
|
replica?.destroy()
|
||||||
|
replica = null
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.state, props.shape, props.color, props.follow, props.paper, props.size],
|
||||||
|
() => replica?.apply(input())
|
||||||
|
)
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
spin: () => replica?.spin(),
|
||||||
|
orbitGaze: (ms?: number) => replica?.orbitGaze(ms),
|
||||||
|
svg: () => svg.value
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative inline-block aspect-square max-w-full" :style="{ width: `${props.size}px` }">
|
||||||
|
<svg
|
||||||
|
ref="svg"
|
||||||
|
class="absolute inset-0 h-full w-full overflow-visible"
|
||||||
|
:width="props.size"
|
||||||
|
:height="props.size"
|
||||||
|
role="img"
|
||||||
|
:aria-label="t('app.botAria')"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="tried && !live"
|
||||||
|
class="absolute inset-6 flex items-center justify-center text-center text-xs text-[var(--muted)]"
|
||||||
|
>
|
||||||
|
{{ t('app.missingGeo') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, ref, useTemplateRef, watch } from 'vue'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { useModalDialog } from '@/ui/useModalDialog'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boite de dialogue de nommage. Le comportement modal vient de
|
||||||
|
* `useModalDialog` ; l'animation, elle, est dans `styles.css` : elle a besoin
|
||||||
|
* de `@starting-style`, hors de portee des classes.
|
||||||
|
*/
|
||||||
|
const props = defineProps<{ title: string; label: string; submitLabel: string }>()
|
||||||
|
const open = defineModel<boolean>('open', { required: true })
|
||||||
|
const value = defineModel<string>('value', { required: true })
|
||||||
|
const emit = defineEmits<{ submit: [name: string] }>()
|
||||||
|
|
||||||
|
const boite = useTemplateRef<HTMLDialogElement>('boite')
|
||||||
|
useModalDialog(open, boite)
|
||||||
|
const field = ref<HTMLInputElement | null>(null)
|
||||||
|
const draft = ref('')
|
||||||
|
|
||||||
|
// le nom propose arrive avec l'ouverture, et le champ s'offre deja selectionne
|
||||||
|
watch(open, async (on) => {
|
||||||
|
if (!on) return
|
||||||
|
draft.value = value.value
|
||||||
|
await nextTick()
|
||||||
|
field.value?.select()
|
||||||
|
})
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
const clean = draft.value.trim()
|
||||||
|
// un nom vide ne veut rien dire : on garde la main plutot que de valider
|
||||||
|
if (!clean) {
|
||||||
|
field.value?.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('submit', clean)
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- `m-auto` remet le centrage natif de la modale : le reset de Tailwind
|
||||||
|
passe `margin: 0` sur tout, et c'est cette marge auto qui centre -->
|
||||||
|
<dialog
|
||||||
|
ref="boite"
|
||||||
|
class="dialogue m-auto w-80 rounded-2xl bg-white p-5 text-[var(--ink)] shadow-xl"
|
||||||
|
:aria-label="props.title"
|
||||||
|
@close="open = false"
|
||||||
|
@cancel.prevent="open = false"
|
||||||
|
>
|
||||||
|
<form class="flex flex-col gap-4" @submit.prevent="submit">
|
||||||
|
<h2 class="text-sm font-semibold">{{ props.title }}</h2>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1.5 text-xs text-[var(--muted)]">
|
||||||
|
{{ props.label }}
|
||||||
|
<input
|
||||||
|
ref="field"
|
||||||
|
v-model="draft"
|
||||||
|
class="h-9 rounded-lg bg-black/5 px-2.5 text-sm text-[var(--ink)] outline-none focus-visible:ring-2 focus-visible:ring-[var(--ink)]"
|
||||||
|
type="text"
|
||||||
|
maxlength="40"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-8 cursor-pointer rounded-lg px-3 text-xs text-[var(--muted)] transition hover:bg-black/5 hover:text-[var(--ink)]"
|
||||||
|
@click="open = false"
|
||||||
|
>
|
||||||
|
{{ t('dialog.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="h-8 cursor-pointer rounded-lg bg-[var(--ink)] px-3 text-xs text-[var(--paper)] transition hover:opacity-90 active:scale-95"
|
||||||
|
>
|
||||||
|
{{ props.submitLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { langue, LANGUES, t } from '@/i18n'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
ready: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function auClavier(event: KeyboardEvent, index: number) {
|
||||||
|
const pas = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key]
|
||||||
|
if (!pas) return
|
||||||
|
event.preventDefault()
|
||||||
|
const cible = LANGUES[(index + pas + LANGUES.length) % LANGUES.length]!
|
||||||
|
langue.value = cible.id
|
||||||
|
const boutons = (event.currentTarget as HTMLElement).parentElement?.children
|
||||||
|
const suivant = boutons?.[LANGUES.indexOf(cible)]
|
||||||
|
if (suivant instanceof HTMLElement) suivant.focus()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-sm font-semibold">{{ t('settings.language') }}</h2>
|
||||||
|
<div class="mt-2 flex flex-col gap-1" role="radiogroup" :aria-label="t('settings.language')">
|
||||||
|
<button
|
||||||
|
v-for="(l, i) in LANGUES"
|
||||||
|
:key="l.id"
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
:aria-checked="l.id === langue"
|
||||||
|
:aria-label="l.nom"
|
||||||
|
:lang="l.tag"
|
||||||
|
:tabindex="l.id === langue ? 0 : -1"
|
||||||
|
class="flex cursor-pointer items-center gap-2.5 rounded-xl border px-3 py-2 text-left text-sm transition"
|
||||||
|
:class="
|
||||||
|
l.id === langue
|
||||||
|
? 'border-[var(--ink)] bg-white font-medium'
|
||||||
|
: 'border-[var(--line)] text-[var(--muted)] hover:border-[var(--muted)] hover:text-[var(--ink)]'
|
||||||
|
"
|
||||||
|
@keydown="auClavier($event, i)"
|
||||||
|
@click="langue = l.id"
|
||||||
|
>
|
||||||
|
<span class="text-base leading-none" aria-hidden="true">{{ l.emoji }}</span>
|
||||||
|
<span class="flex-1">{{ l.nom }}</span>
|
||||||
|
<svg
|
||||||
|
v-if="l.id === langue"
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="shrink-0"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M2.5 6.4 4.8 8.7 9.5 3.6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.6"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="mt-6 text-sm font-semibold">{{ t('settings.about') }}</h2>
|
||||||
|
<a
|
||||||
|
class="mt-2 flex items-center gap-2 rounded-xl border border-[var(--line)] px-3 py-2 text-sm transition hover:border-[var(--muted)]"
|
||||||
|
href="/replica/index.html"
|
||||||
|
:aria-label="t('settings.labAria')"
|
||||||
|
>
|
||||||
|
<span class="flex-1">{{ t('settings.lab') }}</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
class="mt-1.5 flex items-center gap-2 rounded-xl border border-[var(--line)] px-3 py-2 text-sm transition hover:border-[var(--muted)]"
|
||||||
|
href="/replica/showcase.html"
|
||||||
|
:aria-label="t('settings.showcaseAria')"
|
||||||
|
>
|
||||||
|
<span class="flex-1">{{ t('settings.showcase') }}</span>
|
||||||
|
</a>
|
||||||
|
<p class="mt-4 text-xs text-[var(--muted)]">{{ t('settings.credits') }}</p>
|
||||||
|
<p v-if="!ready" class="mt-2 text-xs text-[var(--muted)]">{{ t('settings.missing') }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
export type ViewId = 'animations' | 'personnaliser' | 'reglages'
|
||||||
|
|
||||||
|
const view = defineModel<ViewId>({ default: 'personnaliser' })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `computed` et pas une constante de module : les libelles changent avec la
|
||||||
|
* langue, et un tableau construit a l'import resterait dans celle du demarrage.
|
||||||
|
*/
|
||||||
|
const ITEMS = computed<Array<{ id: ViewId; label: string }>>(() => [
|
||||||
|
{ id: 'personnaliser', label: t('rail.customize') },
|
||||||
|
{ id: 'animations', label: t('rail.animations') },
|
||||||
|
{ id: 'reglages', label: t('rail.settings') }
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icone dont l'infobulle est masquee jusqu'a ce que le pointeur reparte : une
|
||||||
|
* fois le clic fait la vue a change, l'infobulle n'a plus rien a annoncer et
|
||||||
|
* rester affichee sous le curseur donne l'impression qu'elle est coincee.
|
||||||
|
* `pointerdown` et pas `click` : au clavier (Entree) rien n'est masque, la
|
||||||
|
* l'infobulle est la seule indication de ce qu'on vient de choisir.
|
||||||
|
*/
|
||||||
|
const muted = ref<ViewId | null>(null)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!--
|
||||||
|
Deux dispositions, et c'est la mise en page de la page qui les impose.
|
||||||
|
|
||||||
|
A partir de 64rem la scene est une grille de trois colonnes bordee de marges
|
||||||
|
larges : le rail flotte dans celle de gauche sans rien recouvrir. En dessous
|
||||||
|
tout s'empile sur la largeur entiere, et ce meme flottement tombait en plein
|
||||||
|
milieu du contenu — mesure a 375 px : la pilule couvrait le titre du panneau
|
||||||
|
des animations et le flanc gauche de l'avatar dans les reglages.
|
||||||
|
|
||||||
|
Il passe donc en BANDE HAUTE, horizontale et centree, et la scene lui reserve
|
||||||
|
sa hauteur (`max-lg:pt-20` dans `App.vue`). En haut et pas en bas : la barre
|
||||||
|
de montage occupe deja le bas de la fenetre dans les animations, et empiler
|
||||||
|
les deux prenait 260 px sur 812.
|
||||||
|
-->
|
||||||
|
<nav
|
||||||
|
class="fixed top-3 left-1/2 z-20 -translate-x-1/2 rounded-2xl border border-[var(--line)] bg-white/85 p-1.5 shadow-sm backdrop-blur lg:top-1/2 lg:left-4 lg:translate-x-0 lg:-translate-y-1/2"
|
||||||
|
:aria-label="t('rail.nav')"
|
||||||
|
>
|
||||||
|
<ul class="flex gap-1 lg:flex-col">
|
||||||
|
<li
|
||||||
|
v-for="item in ITEMS"
|
||||||
|
:key="item.id"
|
||||||
|
class="group relative"
|
||||||
|
@pointerleave="muted = null"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="peer flex h-10 w-10 cursor-pointer items-center justify-center rounded-xl transition"
|
||||||
|
:class="
|
||||||
|
view === item.id
|
||||||
|
? 'bg-[var(--ink)] text-[var(--paper)]'
|
||||||
|
: 'text-[var(--muted)] hover:bg-black/5 hover:text-[var(--ink)]'
|
||||||
|
"
|
||||||
|
:aria-label="item.label"
|
||||||
|
:aria-current="view === item.id ? 'page' : undefined"
|
||||||
|
@pointerdown="muted = item.id"
|
||||||
|
@click="view = item.id"
|
||||||
|
>
|
||||||
|
<!--
|
||||||
|
Les trois icones viennent de bibliotheques, pas d'un trace maison :
|
||||||
|
les formes pleines et arrondies tiennent a 18 px, ce que ne faisaient
|
||||||
|
pas les contours fins d'avant. Les traces sont recopies tels quels
|
||||||
|
depuis les paquets Iconify — pour en changer, prendre le corps du
|
||||||
|
`<symbol>` dans `@iconify-json/<paquet>`, pas le redessiner. Ce sont
|
||||||
|
les seules formes du projet qui ne sont PAS relevees sur la video.
|
||||||
|
|
||||||
|
Animations : le clap de Solar (`solar:clapperboard-play-bold`).
|
||||||
|
-->
|
||||||
|
<svg
|
||||||
|
v-if="item.id === 'animations'"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<g fill="currentColor">
|
||||||
|
<path d="M12 2C13.8452 2 15.3293 2 16.5401 2.08783L13.0986 7.25002H8.40139L11.9014 2H12Z" />
|
||||||
|
<path d="M3.46447 3.46447C4.71683 2.2121 6.62194 2.03072 10.0957 2.00445L6.59861 7.25002H2.10418C2.25143 5.48593 2.6068 4.32213 3.46447 3.46447Z" />
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 12C2 10.7633 2 9.68875 2.02644 8.75002H21.9736C22 9.68875 22 10.7633 22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12ZM13.014 12.5852C14.338 13.4395 15 13.8666 15 14.5C15 15.1334 14.338 15.5605 13.014 16.4148C11.6719 17.2807 11.0008 17.7137 10.5004 17.3958C10 17.0779 10 16.2186 10 14.5C10 12.7814 10 11.9221 10.5004 11.6042C11.0008 11.2863 11.6719 11.7193 13.014 12.5852Z" />
|
||||||
|
<path d="M21.8958 7.25002C21.7486 5.48593 21.3932 4.32213 20.5355 3.46447C19.9382 2.86714 19.1924 2.51345 18.1987 2.30403L14.9014 7.25002H21.8958Z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<!--
|
||||||
|
Reglages : la roue dentee de Solar (`solar:settings-bold`). Elle est
|
||||||
|
centree sur x = 12,5 et non 12 dans sa boite : c'est le trace de la
|
||||||
|
bibliotheque, ne pas le « recentrer », le decalage est invisible a
|
||||||
|
18 px et toucher aux coordonnees deforme les dents.
|
||||||
|
-->
|
||||||
|
<svg
|
||||||
|
v-else-if="item.id === 'reglages'"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
d="M14.2788 2.15224C13.9085 2 13.439 2 12.5 2C11.561 2 11.0915 2 10.7212 2.15224C10.2274 2.35523 9.83509 2.74458 9.63056 3.23463C9.53719 3.45834 9.50065 3.7185 9.48635 4.09799C9.46534 4.65568 9.17716 5.17189 8.69017 5.45093C8.20318 5.72996 7.60864 5.71954 7.11149 5.45876C6.77318 5.2813 6.52789 5.18262 6.28599 5.15102C5.75609 5.08178 5.22018 5.22429 4.79616 5.5472C4.47814 5.78938 4.24339 6.1929 3.7739 6.99993C3.30441 7.80697 3.06967 8.21048 3.01735 8.60491C2.94758 9.1308 3.09118 9.66266 3.41655 10.0835C3.56506 10.2756 3.77377 10.437 4.0977 10.639C4.57391 10.936 4.88032 11.4419 4.88029 12C4.88026 12.5581 4.57386 13.0639 4.0977 13.3608C3.77372 13.5629 3.56497 13.7244 3.41645 13.9165C3.09108 14.3373 2.94749 14.8691 3.01725 15.395C3.06957 15.7894 3.30432 16.193 3.7738 17C4.24329 17.807 4.47804 18.2106 4.79606 18.4527C5.22008 18.7756 5.75599 18.9181 6.28589 18.8489C6.52778 18.8173 6.77305 18.7186 7.11133 18.5412C7.60852 18.2804 8.2031 18.27 8.69012 18.549C9.17714 18.8281 9.46533 19.3443 9.48635 19.9021C9.50065 20.2815 9.53719 20.5417 9.63056 20.7654C9.83509 21.2554 10.2274 21.6448 10.7212 21.8478C11.0915 22 11.561 22 12.5 22C13.439 22 13.9085 22 14.2788 21.8478C14.7726 21.6448 15.1649 21.2554 15.3694 20.7654C15.4628 20.5417 15.4994 20.2815 15.5137 19.902C15.5347 19.3443 15.8228 18.8281 16.3098 18.549C16.7968 18.2699 17.3914 18.2804 17.8886 18.5412C18.2269 18.7186 18.4721 18.8172 18.714 18.8488C19.2439 18.9181 19.7798 18.7756 20.2038 18.4527C20.5219 18.2105 20.7566 17.807 21.2261 16.9999C21.6956 16.1929 21.9303 15.7894 21.9827 15.395C22.0524 14.8691 21.9088 14.3372 21.5835 13.9164C21.4349 13.7243 21.2262 13.5628 20.9022 13.3608C20.4261 13.0639 20.1197 12.558 20.1197 11.9999C20.1197 11.4418 20.4261 10.9361 20.9022 10.6392C21.2263 10.4371 21.435 10.2757 21.5836 10.0835C21.9089 9.66273 22.0525 9.13087 21.9828 8.60497C21.9304 8.21055 21.6957 7.80703 21.2262 7C20.7567 6.19297 20.522 5.78945 20.2039 5.54727C19.7799 5.22436 19.244 5.08185 18.7141 5.15109C18.4722 5.18269 18.2269 5.28136 17.8887 5.4588C17.3915 5.71959 16.7969 5.73002 16.3099 5.45096C15.8229 5.17191 15.5347 4.65566 15.5136 4.09794C15.4993 3.71848 15.4628 3.45833 15.3694 3.23463C15.1649 2.74458 14.7726 2.35523 14.2788 2.15224ZM12.5 15C14.1695 15 15.5228 13.6569 15.5228 12C15.5228 10.3431 14.1695 9 12.5 9C10.8305 9 9.47716 10.3431 9.47716 12C9.47716 13.6569 10.8305 15 12.5 15Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<!--
|
||||||
|
Personnaliser : la palette de Remix (`ri:palette-fill`) et pas celle
|
||||||
|
de Solar, qui se lit mal a cette taille. Melange de bibliotheques
|
||||||
|
assume : c'est la seule des trois ou Solar ne convenait pas.
|
||||||
|
-->
|
||||||
|
<svg v-else width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 2c5.522 0 10 3.978 10 8.889a5.56 5.56 0 0 1-5.556 5.555h-1.966c-.922 0-1.667.745-1.667 1.667c0 .422.167.811.422 1.1c.267.3.434.689.434 1.122C13.667 21.256 12.9 22 12 22C6.478 22 2 17.522 2 12S6.478 2 12 2M7.5 12a1.5 1.5 0 1 0 0-3a1.5 1.5 0 0 0 0 3m9 0a1.5 1.5 0 1 0 0-3a1.5 1.5 0 0 0 0 3M12 9a1.5 1.5 0 1 0 0-3a1.5 1.5 0 0 0 0 3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Infobulle au survol ET au focus clavier : sinon la barre n'est
|
||||||
|
utilisable qu'a la souris. `peer-focus-visible` et pas `focus-within` :
|
||||||
|
apres un clic souris le bouton garde le focus, et l'infobulle restait
|
||||||
|
affichee alors que le pointeur etait deja parti. Passer par `peer`
|
||||||
|
(frere du bouton) plutot que par `group-has-[:focus-visible]` : dans
|
||||||
|
`:has()`, Chromium ne reevalue pas `:focus-visible` et l'infobulle
|
||||||
|
restait collee a l'ecran.
|
||||||
|
-->
|
||||||
|
<!--
|
||||||
|
L'infobulle suit le rail : sous le bouton quand la barre est en haut, a
|
||||||
|
sa droite quand elle est sur le flanc. Elle ne fait que se FONDRE sur
|
||||||
|
petit ecran, la ou elle glisse a partir de 64rem : le glissement s'ecrit
|
||||||
|
sur `translate-x`, deja pris par le centrage horizontal en bande haute,
|
||||||
|
et deux valeurs sur le meme axe s'ecrasent au lieu de se composer.
|
||||||
|
-->
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute top-full left-1/2 z-10 mt-2 -translate-x-1/2 rounded-lg bg-[var(--ink)] px-2.5 py-1.5 text-xs whitespace-nowrap text-[var(--paper)] opacity-0 transition peer-focus-visible:opacity-100 group-hover:opacity-100 lg:top-1/2 lg:left-full lg:mt-0 lg:ml-2 lg:-translate-y-1/2 lg:translate-x-1 lg:peer-focus-visible:translate-x-0 lg:group-hover:translate-x-0"
|
||||||
|
:class="muted === item.id && 'opacity-0! lg:translate-x-1!'"
|
||||||
|
role="tooltip"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||||
|
import CycleMenu from '@/components/CycleMenu.vue'
|
||||||
|
import NameDialog from '@/components/NameDialog.vue'
|
||||||
|
import TimelineTrack from '@/components/TimelineTrack.vue'
|
||||||
|
import ZoomSlider from '@/components/ZoomSlider.vue'
|
||||||
|
import {
|
||||||
|
blocksWith,
|
||||||
|
makeBlock,
|
||||||
|
nextCycleId,
|
||||||
|
offsetOf,
|
||||||
|
totalDuration,
|
||||||
|
uniqueName,
|
||||||
|
type Block,
|
||||||
|
type Cycle
|
||||||
|
} from '@/editor/cycles'
|
||||||
|
import type { StateId } from '@/editor/cycles'
|
||||||
|
import { MAX_ZOOM, MIN_ZOOM, mmss } from '@/ui/timeline'
|
||||||
|
import { nomDeCycle, pluriel, t } from '@/i18n'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Barre de montage : elle tient les cycles (choix, creation, renommage,
|
||||||
|
* suppression) et la lecture. La piste elle-meme est dans `TimelineTrack` —
|
||||||
|
* ici on ne sait rien des gestes, la-bas on ne sait rien des cycles.
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
/** temps ecoule dans le bloc courant, pour le compteur */
|
||||||
|
elapsed: number
|
||||||
|
shape: string
|
||||||
|
color: string
|
||||||
|
expression: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `seek` : deplacement de la tete de lecture, seul le lecteur sait recaler le
|
||||||
|
* moteur. `preview` : la page entiere se met en scene, c'est elle qui decide.
|
||||||
|
*/
|
||||||
|
const emit = defineEmits<{ seek: [seconds: number]; preview: []; exporter: [] }>()
|
||||||
|
|
||||||
|
const cycles = defineModel<Cycle[]>('cycles', { required: true })
|
||||||
|
const activeId = defineModel<string>('activeId', { required: true })
|
||||||
|
const block = defineModel<number>('block', { required: true })
|
||||||
|
const playing = defineModel<boolean>('playing', { required: true })
|
||||||
|
|
||||||
|
const zoom = ref(1)
|
||||||
|
|
||||||
|
const cycle = computed(() => cycles.value.find((c) => c.id === activeId.value) ?? cycles.value[0]!)
|
||||||
|
const blocks = computed(() => cycle.value.blocks)
|
||||||
|
const total = computed(() => totalDuration(blocks.value))
|
||||||
|
const at = computed(() => offsetOf(blocks.value, block.value) + props.elapsed)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nommage d'un cycle : meme boite pour la creation et le renommage, le second
|
||||||
|
* cas portant l'id vise. La creation n'a lieu qu'a la validation — annuler ne
|
||||||
|
* doit pas laisser un cycle vide derriere.
|
||||||
|
*/
|
||||||
|
const naming = ref<{ mode: 'create' | 'rename'; id?: string } | null>(null)
|
||||||
|
const nameDraft = ref('')
|
||||||
|
const nameOpen = ref(false)
|
||||||
|
|
||||||
|
/** Montage en attente de confirmation de suppression. */
|
||||||
|
const removing = ref<Cycle | null>(null)
|
||||||
|
const confirmOpen = ref(false)
|
||||||
|
const removingDetail = computed(() =>
|
||||||
|
pluriel('dialog.removeDetail', removing.value?.blocks.length ?? 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Remplace le cycle courant : les cycles sont des valeurs, jamais mutees. */
|
||||||
|
function edit(next: Partial<Cycle>) {
|
||||||
|
cycles.value = cycles.value.map((c) => (c.id === cycle.value.id ? { ...c, ...next } : c))
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(id: string) {
|
||||||
|
activeId.value = id
|
||||||
|
block.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function askCreate() {
|
||||||
|
naming.value = { mode: 'create' }
|
||||||
|
nameDraft.value = uniqueName(t('cycles.newName'), cycles.value)
|
||||||
|
nameOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function askRename(id: string) {
|
||||||
|
naming.value = { mode: 'rename', id }
|
||||||
|
const vise = cycles.value.find((c) => c.id === id)
|
||||||
|
// le montage d'amorce n'a pas de nom propre : on part de celui qui s'affiche,
|
||||||
|
// sinon renommer commencerait sur un champ vide
|
||||||
|
nameDraft.value = vise ? nomDeCycle(vise) : ''
|
||||||
|
nameOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNamed(name: string) {
|
||||||
|
const demande = naming.value
|
||||||
|
naming.value = null
|
||||||
|
if (!demande) return
|
||||||
|
if (demande.mode === 'create') {
|
||||||
|
// jamais de cycle vide : le lecteur aurait un montage sans rien a jouer
|
||||||
|
const neuf: Cycle = {
|
||||||
|
id: nextCycleId(cycles.value),
|
||||||
|
name: uniqueName(name, cycles.value),
|
||||||
|
blocks: [makeBlock('idle')]
|
||||||
|
}
|
||||||
|
cycles.value = [...cycles.value, neuf]
|
||||||
|
select(neuf.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const autres = cycles.value.filter((c) => c.id !== demande.id)
|
||||||
|
const unique = uniqueName(name, autres)
|
||||||
|
cycles.value = cycles.value.map((c) => (c.id === demande.id ? { ...c, name: unique } : c))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suppression d'un montage : jamais sans confirmation, c'est irreversible. */
|
||||||
|
function askRemove(id: string) {
|
||||||
|
removing.value = cycles.value.find((c) => c.id === id) ?? null
|
||||||
|
confirmOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRemove() {
|
||||||
|
const cible = removing.value
|
||||||
|
removing.value = null
|
||||||
|
if (!cible) return
|
||||||
|
const reste = cycles.value.filter((c) => c.id !== cible.id)
|
||||||
|
cycles.value = reste
|
||||||
|
if (cible.id === activeId.value) select(reste[0]!.id)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!--
|
||||||
|
Barre de montage : fixee en bas, sans fond ni cadre — elle doit se lire
|
||||||
|
comme une partie de la page, au meme titre que le panneau de droite, dont
|
||||||
|
elle s'arrete avant la colonne (largeur du panneau + gouttiere + marge).
|
||||||
|
La scene lui reserve sa hauteur (`--timeline`) dans cette vue, et la bande de
|
||||||
|
l'avatar la reserve partout ailleurs — sinon l'avatar centre sauterait d'un
|
||||||
|
onglet a l'autre.
|
||||||
|
|
||||||
|
`lg:left-[4.5rem]` repond a `lg:right-[24.5rem]` : la bande couvre EXACTEMENT
|
||||||
|
la colonne de l'avatar, donc le bouton de lecture tombe pile sous la boule.
|
||||||
|
Sans lui la bande partait du bord de la fenetre et son centre etait 20 px a
|
||||||
|
cote.
|
||||||
|
|
||||||
|
4,5rem = la marge de la scene (2rem) + la gouttiere de la grille (2,5rem) : la
|
||||||
|
colonne de gauche a une largeur NULLE en dehors des reglages, mais la grille
|
||||||
|
garde son `column-gap`, donc la colonne de l'avatar commence a 72 px et non a
|
||||||
|
32. C'est la symetrie de 24,5rem = panneau (20) + gouttiere (2,5) + marge (2).
|
||||||
|
|
||||||
|
Le FOND n'apparait que sous 64rem, et ce n'est pas un choix d'habillage.
|
||||||
|
Au-dessus, la page ne defile pas (`#app { overflow: clip }`) et la scene
|
||||||
|
reserve exactement cette bande : rien ne passe jamais derriere, donc une barre
|
||||||
|
transparente se lit comme une partie de la page. En dessous la page defile
|
||||||
|
pour de vrai, et la palette d'animations remontait mot pour mot au travers de
|
||||||
|
la piste — le curseur de zoom et les etiquettes des vignettes se superposaient.
|
||||||
|
Le filet du haut dit ou la page s'arrete de defiler.
|
||||||
|
-->
|
||||||
|
<div
|
||||||
|
class="fixed inset-x-0 bottom-0 z-30 h-[var(--timeline)] px-6 pt-3 pb-5 max-lg:border-t max-lg:border-[var(--line)] max-lg:bg-[var(--paper)] max-lg:px-5 lg:right-[24.5rem] lg:left-[4.5rem]"
|
||||||
|
>
|
||||||
|
<!-- lecture : flottante au-dessus de la piste, au centre, le temps ecoule a
|
||||||
|
gauche et la duree totale a droite.
|
||||||
|
|
||||||
|
Les deux compteurs disparaissent sous 64rem : ils depassent du haut de la
|
||||||
|
barre, donc du fond qui la rend lisible, et ils tombaient sur la palette
|
||||||
|
qui defile derriere. Le bouton, lui, reste — un disque plein se lit
|
||||||
|
par-dessus n'importe quoi, et il est la commande principale de la vue. La
|
||||||
|
meme paire de valeurs est de toute facon affichee dans la barre d'outils,
|
||||||
|
en bas a droite. -->
|
||||||
|
<div class="absolute -top-5 left-1/2 flex -translate-x-1/2 items-center gap-3">
|
||||||
|
<span class="text-sm font-medium tabular-nums max-lg:hidden">{{ mmss(at) }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-11 w-11 cursor-pointer items-center justify-center rounded-full bg-[var(--ink)] text-[var(--paper)] shadow-sm transition hover:scale-105 active:scale-95"
|
||||||
|
:aria-label="playing ? t('timeline.pause') : t('timeline.play')"
|
||||||
|
@click="playing = !playing"
|
||||||
|
>
|
||||||
|
<!--
|
||||||
|
Lecture / pause : Solar plein (`solar:play-bold`, `solar:pause-bold`),
|
||||||
|
la meme bibliotheque que la barre laterale. Traces recopies tels quels
|
||||||
|
depuis Iconify, comme la-bas — ne pas les redessiner. Meme taille pour
|
||||||
|
les deux : leurs boites font toutes deux 20 unites de haut sur 24, donc
|
||||||
|
les deux glyphes ont la meme hauteur optique. Le triangle est decentre
|
||||||
|
vers la droite dans sa boite (milieu a x = 13,45) et c'est voulu : le
|
||||||
|
centre de masse d'un triangle est a gauche de son cadre, le « recentrer »
|
||||||
|
le ferait paraitre trop a gauche.
|
||||||
|
-->
|
||||||
|
<svg v-if="!playing" width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M21.4086 9.35258C23.5305 10.5065 23.5305 13.4935 21.4086 14.6474L8.59662 21.6145C6.53435 22.736 4 21.2763 4 18.9671L4 5.0329C4 2.72368 6.53435 1.26402 8.59661 2.38548L21.4086 9.35258Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<svg v-else width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<g fill="currentColor">
|
||||||
|
<path d="M2 6C2 4.11438 2 3.17157 2.58579 2.58579C3.17157 2 4.11438 2 6 2C7.88562 2 8.82843 2 9.41421 2.58579C10 3.17157 10 4.11438 10 6V18C10 19.8856 10 20.8284 9.41421 21.4142C8.82843 22 7.88562 22 6 22C4.11438 22 3.17157 22 2.58579 21.4142C2 20.8284 2 19.8856 2 18V6Z" />
|
||||||
|
<path d="M14 6C14 4.11438 14 3.17157 14.5858 2.58579C15.1716 2 16.1144 2 18 2C19.8856 2 20.8284 2 21.4142 2.58579C22 3.17157 22 4.11438 22 6V18C22 19.8856 22 20.8284 21.4142 21.4142C20.8284 22 19.8856 22 18 22C16.1144 22 15.1716 22 14.5858 21.4142C14 20.8284 14 19.8856 14 18V6Z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span class="text-sm tabular-nums text-[var(--muted)] max-lg:hidden">{{ mmss(total) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- rien ne se selectionne dans une barre d'outils : ca ne sert a personne
|
||||||
|
et ca surligne tout des qu'on glisse une carte ou la tete de lecture -->
|
||||||
|
<div class="flex h-full flex-col gap-2 select-none">
|
||||||
|
<!--
|
||||||
|
Le nom du montage a gauche, ce qu'on en fait a droite : l'export est une
|
||||||
|
action sur le CYCLE, comme le renommer ou le supprimer, pas un reglage
|
||||||
|
d'affichage. En bas il voisinait la loupe et l'apercu, qui eux ne changent
|
||||||
|
que la facon de regarder — et il s'y lisait comme une icone de plus.
|
||||||
|
-->
|
||||||
|
<div class="flex items-center justify-between gap-1">
|
||||||
|
<CycleMenu
|
||||||
|
v-model:active-id="activeId"
|
||||||
|
:cycles="cycles"
|
||||||
|
:current="cycle"
|
||||||
|
@create="askCreate"
|
||||||
|
@rename="askRename"
|
||||||
|
@remove="askRemove"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-8 shrink-0 cursor-pointer items-center gap-2 rounded-xl bg-[var(--ink)] pr-3.5 pl-3 text-sm font-medium text-[var(--paper)] shadow-sm transition hover:opacity-90 active:scale-95 max-sm:w-8 max-sm:justify-center max-sm:px-0"
|
||||||
|
@click="emit('exporter')"
|
||||||
|
>
|
||||||
|
<!-- solar:download-minimalistic-linear, la meme que la barre d'export -->
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<g
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3 15C3 17.8284 3 19.2426 3.87868 20.1213C4.75736 21 6.17157 21 9 21H15C17.8284 21 19.2426 21 20.1213 20.1213C21 19.2426 21 17.8284 21 15"
|
||||||
|
/>
|
||||||
|
<path d="M12 3V16M8 11.625L12 16L16 11.625" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<!-- `sr-only` et pas `hidden` : sous 40rem le bouton se reduit a son
|
||||||
|
icone, mais le libelle reste son NOM accessible — un bouton dont
|
||||||
|
tout le contenu est masque n'est plus annonce du tout, et il
|
||||||
|
n'aurait alors ni `aria-label` ni texte. -->
|
||||||
|
<span class="max-sm:sr-only">{{ t('timeline.export') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TimelineTrack
|
||||||
|
v-model:block="block"
|
||||||
|
v-model:zoom="zoom"
|
||||||
|
:blocks="blocks"
|
||||||
|
:elapsed="elapsed"
|
||||||
|
:shape="shape"
|
||||||
|
:color="color"
|
||||||
|
:expression="expression"
|
||||||
|
@update:blocks="(b: Block[]) => edit({ blocks: b })"
|
||||||
|
@add="(s: StateId) => edit({ blocks: blocksWith(blocks, s) })"
|
||||||
|
@seek="emit('seek', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- barre d'outils, dans le coin : loupe, compteur, aperçu -->
|
||||||
|
<div class="flex shrink-0 items-center justify-end gap-4 max-sm:gap-2">
|
||||||
|
<ZoomSlider v-model:zoom="zoom" :min="MIN_ZOOM" :max="MAX_ZOOM" />
|
||||||
|
|
||||||
|
<p class="text-xs tabular-nums text-[var(--muted)]">
|
||||||
|
<span class="text-[var(--ink)]">{{ mmss(at) }}</span> / {{ mmss(total) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- infobulle au survol ET au focus clavier, comme la barre laterale -->
|
||||||
|
<span class="group relative flex">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg text-[var(--muted)] transition hover:bg-black/5 hover:text-[var(--ink)]"
|
||||||
|
:aria-label="t('timeline.preview')"
|
||||||
|
@click="emit('preview')"
|
||||||
|
>
|
||||||
|
<!--
|
||||||
|
Apercu : l'oeil plein de Solar (`solar:eye-bold`), meme
|
||||||
|
bibliotheque que la barre laterale. La paupiere est evidee autour
|
||||||
|
de la pupille (`fill-rule="evenodd"`) : le rond plein qui la
|
||||||
|
remplit est le second trace, ne pas fusionner les deux.
|
||||||
|
-->
|
||||||
|
<svg width="17" height="17" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<g fill="currentColor">
|
||||||
|
<path d="M9.75 12C9.75 10.7574 10.7574 9.75 12 9.75C13.2426 9.75 14.25 10.7574 14.25 12C14.25 13.2426 13.2426 14.25 12 14.25C10.7574 14.25 9.75 13.2426 9.75 12Z" />
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
d="M2 12C2 13.6394 2.42496 14.1915 3.27489 15.2957C4.97196 17.5004 7.81811 20 12 20C16.1819 20 19.028 17.5004 20.7251 15.2957C21.575 14.1915 22 13.6394 22 12C22 10.3606 21.575 9.80853 20.7251 8.70433C19.028 6.49956 16.1819 4 12 4C7.81811 4 4.97196 6.49956 3.27489 8.70433C2.42496 9.80853 2 10.3606 2 12ZM12 8.25C9.92893 8.25 8.25 9.92893 8.25 12C8.25 14.0711 9.92893 15.75 12 15.75C14.0711 15.75 15.75 14.0711 15.75 12C15.75 9.92893 14.0711 8.25 12 8.25Z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2 translate-y-1 rounded-lg bg-[var(--ink)] px-2.5 py-1.5 text-xs whitespace-nowrap text-[var(--paper)] opacity-0 transition group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:translate-y-0 group-focus-within:opacity-100"
|
||||||
|
role="tooltip"
|
||||||
|
>
|
||||||
|
{{ t('timeline.preview') }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NameDialog
|
||||||
|
v-model:open="nameOpen"
|
||||||
|
v-model:value="nameDraft"
|
||||||
|
:title="naming?.mode === 'rename' ? t('dialog.nameRenameTitle') : t('dialog.nameCreateTitle')"
|
||||||
|
:label="t('dialog.nameField')"
|
||||||
|
:submit-label="naming?.mode === 'rename' ? t('dialog.nameRename') : t('dialog.nameCreate')"
|
||||||
|
@submit="onNamed"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
v-model:open="confirmOpen"
|
||||||
|
:title="t('dialog.removeTitle', { name: removing ? nomDeCycle(removing) : '' })"
|
||||||
|
:detail="removingDetail"
|
||||||
|
:confirm-label="t('dialog.removeConfirm')"
|
||||||
|
@confirm="onRemove"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
playing: boolean
|
||||||
|
state: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
toggle: []
|
||||||
|
preview: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="fixed right-0 bottom-0 left-0 z-20 border-t border-[var(--line)] bg-[var(--paper)]/90 px-4 py-3 backdrop-blur max-lg:px-5 lg:left-[4.5rem] lg:right-[24.5rem]"
|
||||||
|
>
|
||||||
|
<div class="mx-auto flex max-w-[460px] items-center justify-between gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-xl border border-[var(--line)] px-3 py-1.5 text-sm transition hover:border-[var(--muted)]"
|
||||||
|
:aria-label="playing ? t('timeline.pause') : t('timeline.play')"
|
||||||
|
@click="emit('toggle')"
|
||||||
|
>
|
||||||
|
{{ playing ? t('timeline.pause') : t('timeline.play') }}
|
||||||
|
</button>
|
||||||
|
<span class="tronque text-sm font-medium">{{ state }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-xl border border-[var(--line)] px-3 py-1.5 text-sm transition hover:border-[var(--muted)]"
|
||||||
|
:aria-label="t('preview.open')"
|
||||||
|
@click="emit('preview')"
|
||||||
|
>
|
||||||
|
{{ t('preview.open') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
|
import BlockPicker from '@/components/BlockPicker.vue'
|
||||||
|
import BotTile from '@/components/BotTile.vue'
|
||||||
|
import {
|
||||||
|
clampDuration,
|
||||||
|
moveBlock,
|
||||||
|
offsetOf,
|
||||||
|
STEP,
|
||||||
|
totalDuration,
|
||||||
|
type Block,
|
||||||
|
type StateId
|
||||||
|
} from '@/editor/cycles'
|
||||||
|
import { BASE_SCALE, clampZoom, ticksFor } from '@/ui/timeline'
|
||||||
|
import { secondes, secondesCourtes, stateName, t } from '@/i18n'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La piste : une regle graduee, les cartes du montage, et les gestes qui vont
|
||||||
|
* avec (deplacer, etirer, promener la tete de lecture, zoomer). Elle ne connait
|
||||||
|
* ni les cycles ni le lecteur — elle recoit une suite de blocs et rend celle
|
||||||
|
* qu'on obtient apres le geste.
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
blocks: Block[]
|
||||||
|
/** temps ecoule dans le bloc courant, pour la tete de lecture */
|
||||||
|
elapsed: number
|
||||||
|
shape: string
|
||||||
|
color: string
|
||||||
|
expression: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:blocks': [blocks: Block[]]
|
||||||
|
/** date visee sur la regle */
|
||||||
|
seek: [seconds: number]
|
||||||
|
add: [state: StateId]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/** Curseur de lecture et loupe : la barre les affiche, la piste les manipule. */
|
||||||
|
const block = defineModel<number>('block', { required: true })
|
||||||
|
const zoom = defineModel<number>('zoom', { required: true })
|
||||||
|
|
||||||
|
const scale = computed(() => BASE_SCALE * zoom.value)
|
||||||
|
const total = computed(() => totalDuration(props.blocks))
|
||||||
|
const at = computed(() => offsetOf(props.blocks, block.value) + props.elapsed)
|
||||||
|
const ticks = computed(() => ticksFor(total.value, scale.value))
|
||||||
|
const exact = computed(() => secondes(at.value))
|
||||||
|
|
||||||
|
const track = ref<HTMLElement | null>(null)
|
||||||
|
/** Debordement de la piste, pour n'afficher les degrades que s'ils servent. */
|
||||||
|
const overflow = ref({ left: false, right: false })
|
||||||
|
/**
|
||||||
|
* Defilement de la piste. L'infobulle de temps ne peut pas vivre dedans — le
|
||||||
|
* conteneur rogne ce qui depasse en hauteur, et elle flotte au-dessus de la
|
||||||
|
* regle — donc elle se positionne dehors, et doit retrancher ce defilement.
|
||||||
|
*/
|
||||||
|
const scrolled = ref(0)
|
||||||
|
|
||||||
|
function width(index: number) {
|
||||||
|
return props.blocks[index]!.duration * scale.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function label(index: number) {
|
||||||
|
return stateName(props.blocks[index]!.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------- defilement, loupe */
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
const el = track.value
|
||||||
|
if (!el) return
|
||||||
|
scrolled.value = el.scrollLeft
|
||||||
|
overflow.value = {
|
||||||
|
left: el.scrollLeft > 4,
|
||||||
|
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point d'ancrage du prochain changement d'echelle, en coordonnees d'ecran :
|
||||||
|
* la seconde qui s'y trouve doit y rester. Sans ca, zoomer sur une carte
|
||||||
|
* precise la fait fuir hors de l'ecran. `null` = le centre de ce qu'on voit,
|
||||||
|
* c'est le bon compromis quand le zoom vient du curseur de la barre.
|
||||||
|
*/
|
||||||
|
let anchorX: number | null = null
|
||||||
|
|
||||||
|
function setZoom(next: number, clientX?: number) {
|
||||||
|
anchorX = clientX ?? null
|
||||||
|
zoom.value = clampZoom(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(scale, (now, before) => {
|
||||||
|
const el = track.value
|
||||||
|
if (!el) return
|
||||||
|
const x = (anchorX ?? el.getBoundingClientRect().left + el.clientWidth / 2) -
|
||||||
|
el.getBoundingClientRect().left
|
||||||
|
const seconde = (el.scrollLeft + x) / before
|
||||||
|
anchorX = null
|
||||||
|
nextTick(() => {
|
||||||
|
el.scrollLeft = seconde * now - x
|
||||||
|
onScroll()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Molette et trackpad sur la piste :
|
||||||
|
* - pincement du trackpad (le navigateur l'annonce comme une molette + `ctrl`,
|
||||||
|
* c'est la convention) ou `ctrl`/`cmd` + molette → loupe ;
|
||||||
|
* - deux doigts a l'horizontale → defilement, c'est deja `deltaX` ;
|
||||||
|
* - molette de souris, qui n'a pas d'axe horizontal → on renvoie son `deltaY`
|
||||||
|
* sur le defilement de la piste, sinon elle ne servirait a rien ici.
|
||||||
|
* `deltaMode` vaut 1 quand le systeme compte en lignes et pas en pixels (des
|
||||||
|
* souris sous Firefox) : sans le facteur, le geste serait quinze fois trop lent.
|
||||||
|
*/
|
||||||
|
function onWheel(e: WheelEvent) {
|
||||||
|
const el = track.value
|
||||||
|
if (!el) return
|
||||||
|
const unit = e.deltaMode === 1 ? 16 : 1
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
setZoom(zoom.value * Math.exp((-e.deltaY * unit) / 180), e.clientX)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (el.scrollWidth <= el.clientWidth) return
|
||||||
|
const d = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY
|
||||||
|
if (!d) return
|
||||||
|
e.preventDefault()
|
||||||
|
el.scrollLeft += d * unit
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(onScroll)
|
||||||
|
watch(() => [total.value, props.blocks] as const, () => nextTick(onScroll))
|
||||||
|
|
||||||
|
// La carte courante reste visible quand la piste deborde de la fenetre.
|
||||||
|
watch(block, () => {
|
||||||
|
const el = track.value
|
||||||
|
if (!el) return
|
||||||
|
const x = offsetOf(props.blocks, block.value) * scale.value
|
||||||
|
if (x < el.scrollLeft || x + width(block.value) > el.scrollLeft + el.clientWidth) {
|
||||||
|
el.scrollTo({ left: Math.max(0, x - 24), behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- montage */
|
||||||
|
|
||||||
|
function removeBlock(index: number) {
|
||||||
|
// la derniere carte ne part pas : un montage vide n'aurait rien a jouer
|
||||||
|
if (props.blocks.length < 2) return
|
||||||
|
emit(
|
||||||
|
'update:blocks',
|
||||||
|
props.blocks.filter((_, i) => i !== index)
|
||||||
|
)
|
||||||
|
// le curseur suit : une carte retiree avant lui le decale d'un cran, et il ne
|
||||||
|
// doit jamais pointer au-dela de la piste
|
||||||
|
if (index < block.value) block.value -= 1
|
||||||
|
else if (block.value >= props.blocks.length - 1) block.value = props.blocks.length - 2
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDuration(index: number, wanted: number) {
|
||||||
|
const b = props.blocks[index]
|
||||||
|
if (!b) return
|
||||||
|
const duration = clampDuration(b.state, wanted)
|
||||||
|
if (duration === b.duration) return
|
||||||
|
emit(
|
||||||
|
'update:blocks',
|
||||||
|
props.blocks.map((old, i) => (i === index ? { ...old, duration } : old))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------ glisser / etirer */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Glisser-deposer : pendant le geste, le montage n'est PAS modifie. La carte
|
||||||
|
* saisie suit le pointeur et les autres s'ecartent de sa largeur — c'est ce qui
|
||||||
|
* donne la sensation de deplacer un objet. Le montage n'est recompose qu'au
|
||||||
|
* lacher : reordonner en direct ferait sauter la carte d'un emplacement a
|
||||||
|
* l'autre sous le doigt.
|
||||||
|
*/
|
||||||
|
type Drag = { from: number; to: number; startX: number; dx: number; moved: boolean }
|
||||||
|
type Resize = { index: number; startX: number; startDuration: number }
|
||||||
|
|
||||||
|
const drag = ref<Drag | null>(null)
|
||||||
|
const resize = ref<Resize | null>(null)
|
||||||
|
const scrubbing = ref(false)
|
||||||
|
|
||||||
|
/** Decalage a appliquer a une carte pendant qu'on en deplace une autre. */
|
||||||
|
function shiftOf(i: number) {
|
||||||
|
const d = drag.value
|
||||||
|
if (!d?.moved) return 0
|
||||||
|
if (i === d.from) return d.dx
|
||||||
|
const w = width(d.from)
|
||||||
|
if (d.to > d.from && i > d.from && i <= d.to) return -w
|
||||||
|
if (d.to < d.from && i >= d.to && i < d.from) return w
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vrai pour la carte qu'on est en train de deplacer : elle se souleve. */
|
||||||
|
function lifted(i: number) {
|
||||||
|
return Boolean(drag.value?.moved) && i === drag.value?.from
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index de la carte sous une position, en secondes depuis le debut de la piste. */
|
||||||
|
function indexAt(t: number) {
|
||||||
|
let acc = 0
|
||||||
|
for (let i = 0; i < props.blocks.length; i++) {
|
||||||
|
acc += props.blocks[i]!.duration
|
||||||
|
if (t < acc) return i
|
||||||
|
}
|
||||||
|
return props.blocks.length - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointerSeconds(e: PointerEvent) {
|
||||||
|
const box = track.value?.getBoundingClientRect()
|
||||||
|
if (!box) return 0
|
||||||
|
return (e.clientX - box.left + (track.value?.scrollLeft ?? 0)) / scale.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBlockDown(index: number, e: PointerEvent) {
|
||||||
|
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||||
|
drag.value = { from: index, to: index, startX: e.clientX, dx: 0, moved: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBlockMove(e: PointerEvent) {
|
||||||
|
const d = drag.value
|
||||||
|
if (!d) return
|
||||||
|
// quelques pixels de tolerance : un clic tremble toujours un peu
|
||||||
|
if (!d.moved && Math.abs(e.clientX - d.startX) <= 4) return
|
||||||
|
d.moved = true
|
||||||
|
d.dx = e.clientX - d.startX
|
||||||
|
d.to = Math.max(0, indexAt(pointerSeconds(e)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBlockUp(index: number) {
|
||||||
|
const d = drag.value
|
||||||
|
drag.value = null
|
||||||
|
if (!d) return
|
||||||
|
// un clic sans deplacement, c'est un saut de la tete de lecture
|
||||||
|
if (!d.moved) {
|
||||||
|
block.value = index
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (d.to === d.from) return
|
||||||
|
// le curseur suit la carte qu'on deplace, sinon la lecture sauterait ailleurs
|
||||||
|
const suivi = block.value === d.from ? d.to : block.value
|
||||||
|
emit('update:blocks', moveBlock(props.blocks, d.from, d.to))
|
||||||
|
block.value = suivi
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResizeDown(index: number, e: PointerEvent) {
|
||||||
|
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||||
|
resize.value = { index, startX: e.clientX, startDuration: props.blocks[index]!.duration }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResizeMove(e: PointerEvent) {
|
||||||
|
const r = resize.value
|
||||||
|
if (!r) return
|
||||||
|
setDuration(r.index, r.startDuration + (e.clientX - r.startX) / scale.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Le clavier etire aussi : la poignee est un bouton, pas seulement une zone. */
|
||||||
|
function onResizeKey(index: number, delta: number) {
|
||||||
|
setDuration(index, props.blocks[index]!.duration + delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- au clavier */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reordonner et pointer AU CLAVIER.
|
||||||
|
*
|
||||||
|
* Le glisser-deposer n'avait aucun equivalent : on pouvait ajouter, supprimer et etirer un
|
||||||
|
* bloc — la poignee gere deja les fleches — mais jamais le REORDONNER, et le pointage
|
||||||
|
* precis n'existait qu'au pointeur, sur la regle. C'etait le seul geste de l'editeur
|
||||||
|
* inaccessible.
|
||||||
|
*
|
||||||
|
* `Alt` + fleches deplace la carte, les fleches nues promenent la tete de lecture de `STEP`.
|
||||||
|
* Alt et pas les fleches nues pour le deplacement : une carte est un bouton dans une liste,
|
||||||
|
* et les fleches y servent d'abord a se deplacer.
|
||||||
|
*
|
||||||
|
* Le focus SUIT la carte deplacee, sinon on se retrouve a en pousser une autre au coup
|
||||||
|
* suivant. Il faut attendre le rendu : la liste est recomposee, donc le bouton d'arrivee
|
||||||
|
* n'existe pas encore.
|
||||||
|
*
|
||||||
|
* La carte porte `aria-keyshortcuts` : un raccourci que rien n'annonce n'est pas une
|
||||||
|
* affordance. C'est l'attribut fait pour ca, et il evite d'allonger l'etiquette, qui est
|
||||||
|
* relue a chaque carte de la piste.
|
||||||
|
*/
|
||||||
|
async function onCardKey(index: number, e: KeyboardEvent) {
|
||||||
|
const sens = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0
|
||||||
|
if (!sens) return
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!e.altKey) {
|
||||||
|
// pointage : la tete de lecture avance d'un pas, dans tout le montage
|
||||||
|
emit('seek', Math.max(0, Math.min(total.value - 0.001, at.value + sens * STEP)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const cible = index + sens
|
||||||
|
if (cible < 0 || cible >= props.blocks.length) return
|
||||||
|
emit('update:blocks', moveBlock(props.blocks, index, cible))
|
||||||
|
block.value = cible
|
||||||
|
await nextTick()
|
||||||
|
const liste = track.value?.querySelectorAll<HTMLButtonElement>('[data-carte]')
|
||||||
|
liste?.[cible]?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------- scrub */
|
||||||
|
|
||||||
|
function scrubTo(e: PointerEvent) {
|
||||||
|
emit('seek', Math.max(0, Math.min(total.value - 0.001, pointerSeconds(e))))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRulerDown(e: PointerEvent) {
|
||||||
|
// sans ca, promener la tete de lecture surligne les graduations au passage :
|
||||||
|
// le navigateur demarre une selection de texte sur le `mousedown` induit
|
||||||
|
e.preventDefault()
|
||||||
|
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||||
|
scrubbing.value = true
|
||||||
|
scrubTo(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRulerMove(e: PointerEvent) {
|
||||||
|
if (scrubbing.value) scrubTo(e)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<div
|
||||||
|
ref="track"
|
||||||
|
class="sans-barre h-full overflow-x-auto overflow-y-hidden"
|
||||||
|
@scroll="onScroll"
|
||||||
|
@wheel="onWheel"
|
||||||
|
>
|
||||||
|
<!-- la piste garde de la place pour la carte « + » a sa suite -->
|
||||||
|
<div class="relative flex h-full flex-col" :style="{ width: `${total * scale + 76}px` }">
|
||||||
|
<!--
|
||||||
|
Regle graduee : elle sert aussi de zone de deplacement. On attrape
|
||||||
|
n'importe ou dessus pour promener la tete de lecture, comme sur un
|
||||||
|
montage video — c'est la seule facon d'atteindre un point PRECIS
|
||||||
|
d'une carte, le clic sur une carte ne fait que sauter a son debut.
|
||||||
|
-->
|
||||||
|
<div
|
||||||
|
class="relative h-7 shrink-0 cursor-ew-resize pt-1 select-none"
|
||||||
|
@pointerdown="onRulerDown"
|
||||||
|
@pointermove="onRulerMove"
|
||||||
|
@pointerup="scrubbing = false"
|
||||||
|
@pointercancel="scrubbing = false"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="tick in ticks"
|
||||||
|
:key="tick.t"
|
||||||
|
class="absolute bottom-1.5 flex items-end gap-1"
|
||||||
|
:style="{ transform: `translateX(${tick.t * scale}px)` }"
|
||||||
|
>
|
||||||
|
<span class="block w-px bg-[var(--line)]" :class="tick.major ? 'h-3' : 'h-1.5'" />
|
||||||
|
<span v-if="tick.major" class="-mb-0.5 text-xs leading-none text-[var(--muted)]">
|
||||||
|
{{ secondesCourtes(tick.t, Number.isInteger(tick.t) ? 0 : 1) }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="flex flex-1 items-stretch">
|
||||||
|
<!--
|
||||||
|
La largeur du <li> vaut exactement la duree de la carte : la
|
||||||
|
gouttiere est un padding interne, sinon les cartes decaleraient la
|
||||||
|
piste et la tete de lecture ne tomberait plus en face.
|
||||||
|
-->
|
||||||
|
<li
|
||||||
|
v-for="(b, i) in blocks"
|
||||||
|
:key="`${i}-${b.state}`"
|
||||||
|
class="group relative shrink-0 pr-1"
|
||||||
|
:class="lifted(i) ? 'z-20' : 'transition-transform duration-150 ease-out'"
|
||||||
|
:style="{
|
||||||
|
width: `${b.duration * scale}px`,
|
||||||
|
transform: shiftOf(i) ? `translateX(${shiftOf(i)}px)` : undefined
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-full w-full cursor-grab flex-col justify-between overflow-hidden rounded-lg px-1.5 py-1 text-left transition select-none active:cursor-grabbing"
|
||||||
|
:class="[
|
||||||
|
i === block
|
||||||
|
? 'bg-white ring-2 ring-[var(--ink)] ring-inset'
|
||||||
|
: 'bg-black/[0.045] hover:bg-black/[0.08]',
|
||||||
|
lifted(i) ? 'scale-[1.02] opacity-75 shadow-lg' : ''
|
||||||
|
]"
|
||||||
|
:aria-label="t('timeline.blockAria', { state: label(i), duration: secondes(b.duration) })"
|
||||||
|
:aria-current="i === block ? 'true' : undefined"
|
||||||
|
@pointerdown="onBlockDown(i, $event)"
|
||||||
|
@pointermove="onBlockMove"
|
||||||
|
@pointerup="onBlockUp(i)"
|
||||||
|
@pointercancel="drag = null"
|
||||||
|
data-carte
|
||||||
|
aria-keyshortcuts="Alt+ArrowLeft Alt+ArrowRight ArrowLeft ArrowRight"
|
||||||
|
@keydown.enter.prevent="block = i"
|
||||||
|
@keydown.space.prevent="block = i"
|
||||||
|
@keydown.left="onCardKey(i, $event)"
|
||||||
|
@keydown.right="onCardKey(i, $event)"
|
||||||
|
>
|
||||||
|
<!-- la miniature EST l'identite de la carte, comme la vignette
|
||||||
|
d'une page : le nom n'apprendrait rien de plus, il ne reste
|
||||||
|
que dans l'etiquette du bouton, pour le lecteur d'ecran -->
|
||||||
|
<span class="flex min-w-0 flex-1 items-center justify-center">
|
||||||
|
<BotTile
|
||||||
|
v-if="width(i) > 44"
|
||||||
|
class="shrink-0 pointer-events-none"
|
||||||
|
:label="label(i)"
|
||||||
|
:selected="false"
|
||||||
|
:interactive="false"
|
||||||
|
:show-label="false"
|
||||||
|
:state="b.state"
|
||||||
|
:size="Math.min(56, Math.max(30, width(i) * 0.5))"
|
||||||
|
:shape="shape"
|
||||||
|
:color="color"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="width(i) > 50"
|
||||||
|
class="tronque text-center text-xs leading-none font-semibold tabular-nums"
|
||||||
|
:class="i === block ? 'text-[var(--ink)]' : 'text-[var(--muted)]'"
|
||||||
|
>
|
||||||
|
{{ secondes(b.duration) }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- poignee de duree : bouton a part entiere, donc utilisable au clavier -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute inset-y-2 right-0.5 w-1 cursor-ew-resize rounded-full bg-[var(--muted)] opacity-0 transition group-hover:opacity-60 hover:opacity-100! focus-visible:opacity-100"
|
||||||
|
:aria-label="
|
||||||
|
t('timeline.blockDurationAria', {
|
||||||
|
state: label(i),
|
||||||
|
duration: secondes(b.duration)
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@pointerdown="onResizeDown(i, $event)"
|
||||||
|
@pointermove="onResizeMove"
|
||||||
|
@pointerup="resize = null"
|
||||||
|
@pointercancel="resize = null"
|
||||||
|
@keydown.left.prevent="onResizeKey(i, -STEP)"
|
||||||
|
@keydown.right.prevent="onResizeKey(i, STEP)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="blocks.length > 1"
|
||||||
|
type="button"
|
||||||
|
class="absolute top-1 right-2 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/10 text-[var(--ink)] opacity-0 transition group-hover:opacity-100 hover:bg-black/20 focus-visible:opacity-100"
|
||||||
|
:aria-label="t('timeline.blockRemoveAria', { state: label(i) })"
|
||||||
|
@click="removeBlock(i)"
|
||||||
|
>
|
||||||
|
<!-- croix dessinee : le glyphe « × » de la police ne tombe pas au
|
||||||
|
centre optique de la pastille -->
|
||||||
|
<svg width="9" height="9" viewBox="0 0 10 10" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M2.6 2.6 7.4 7.4M7.4 2.6 2.6 7.4"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="w-[72px] shrink-0 pl-1">
|
||||||
|
<BlockPicker
|
||||||
|
:shape="shape"
|
||||||
|
:color="color"
|
||||||
|
:expression="expression"
|
||||||
|
@pick="emit('add', $event)"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Tete de lecture : seule sa transformation change d'une image a l'autre,
|
||||||
|
et elle vit dans la piste, donc elle defile avec elle. Sa poignee est
|
||||||
|
dans la regle, la ou on l'attrape.
|
||||||
|
-->
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute inset-y-0 left-0 w-0.5 rounded-full bg-[var(--ink)]"
|
||||||
|
:style="{ transform: `translateX(${at * scale}px)` }"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute -top-0.5 -left-[5px] h-3 w-3 rounded-full border-2 border-[var(--paper)] bg-[var(--ink)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Temps exact pendant le deplacement : au dixieme, la ou le compteur de la
|
||||||
|
barre arrondit a la seconde. Il flotte au-dessus de la regle, donc hors du
|
||||||
|
conteneur qui defile — d'ou le `scrolled` retranche.
|
||||||
|
-->
|
||||||
|
<div
|
||||||
|
v-if="scrubbing"
|
||||||
|
class="pointer-events-none absolute top-0 left-0 z-10"
|
||||||
|
:style="{ transform: `translate(${at * scale - scrolled}px, -70%)` }"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="block -translate-x-1/2 rounded-md bg-[var(--ink)] px-2 py-1 text-xs tabular-nums text-[var(--paper)] shadow-sm"
|
||||||
|
>
|
||||||
|
{{ exact }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- degrades de debordement : la piste continue par la -->
|
||||||
|
<div
|
||||||
|
v-if="overflow.left"
|
||||||
|
class="pointer-events-none absolute inset-y-0 left-0 w-10 bg-gradient-to-r from-[var(--paper)] to-transparent"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="overflow.right"
|
||||||
|
class="pointer-events-none absolute inset-y-0 right-0 w-10 bg-gradient-to-l from-[var(--paper)] to-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { pourcentage, t } from '@/i18n'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reglage de la loupe de la piste. Un `<input type="range">` natif plutot qu'un
|
||||||
|
* curseur maison : il apporte le clavier (fleches, Origine/Fin), le pas, et
|
||||||
|
* l'annonce aux lecteurs d'ecran. Seule l'apparence est reprise.
|
||||||
|
*
|
||||||
|
* La valeur n'est pas ecrite directement : le parent la recoit et decide du
|
||||||
|
* point d'ancrage du zoom, pour que la piste ne parte pas ailleurs.
|
||||||
|
*/
|
||||||
|
const props = defineProps<{ zoom: number; min: number; max: number }>()
|
||||||
|
const emit = defineEmits<{ 'update:zoom': [value: number] }>()
|
||||||
|
|
||||||
|
const percent = computed(() => pourcentage(props.zoom))
|
||||||
|
|
||||||
|
function onInput(e: Event) {
|
||||||
|
emit('update:zoom', Number((e.target as HTMLInputElement).value))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<!-- les deux pastilles disent le sens : petit a gauche, grand a droite -->
|
||||||
|
<span class="h-1 w-1 shrink-0 rounded-full bg-[var(--muted)]" aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
class="h-1 w-28 cursor-pointer accent-[var(--ink)] max-sm:w-20"
|
||||||
|
:min="props.min"
|
||||||
|
:max="props.max"
|
||||||
|
step="0.01"
|
||||||
|
:value="props.zoom"
|
||||||
|
:aria-label="t('timeline.zoom')"
|
||||||
|
:aria-valuetext="percent"
|
||||||
|
@input="onInput"
|
||||||
|
/>
|
||||||
|
<span class="h-2 w-2 shrink-0 rounded-full bg-[var(--muted)]" aria-hidden="true" />
|
||||||
|
<!-- largeur fixe pour que la barre ne bouge pas quand le nombre change de
|
||||||
|
chiffres, mais aligne a GAUCHE : cale a droite, le pourcentage
|
||||||
|
s'eloignait du curseur des qu'il perdait un chiffre -->
|
||||||
|
<span class="w-11 text-left text-xs tabular-nums text-[var(--muted)]">{{ percent }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { defaultCycle as seedBlocks, stateGroups } from '@/replica/catalog'
|
||||||
|
|
||||||
|
export type StateId = string
|
||||||
|
|
||||||
|
export interface Block {
|
||||||
|
state: StateId
|
||||||
|
duration: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Cycle {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
blocks: Block[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MIN_BLOCK = 0.6
|
||||||
|
export const MAX_BLOCK = 10
|
||||||
|
export const MAX_BLOCS = 200
|
||||||
|
export const MAX_CYCLES = 50
|
||||||
|
export const STEP = 0.1
|
||||||
|
|
||||||
|
const DEFAULT_CYCLE_ID = 'defaut'
|
||||||
|
|
||||||
|
export function catalogue(): StateId[] {
|
||||||
|
return stateGroups().flatMap((g) => g.states)
|
||||||
|
}
|
||||||
|
|
||||||
|
const KNOWN = new Set(catalogue())
|
||||||
|
|
||||||
|
export function isState(id: string): id is StateId {
|
||||||
|
return KNOWN.has(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function minDurationOf(_state: StateId): number {
|
||||||
|
return MIN_BLOCK
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampDuration(state: StateId, seconds: number): number {
|
||||||
|
const snapped = Math.round(seconds / STEP) * STEP
|
||||||
|
const bounded = Math.min(MAX_BLOCK, Math.max(minDurationOf(state), snapped))
|
||||||
|
return Math.round(bounded * 100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeBlock(state: StateId): Block {
|
||||||
|
const seed = seedBlocks().find((b) => b.state === state)
|
||||||
|
return { state, duration: clampDuration(state, seed?.duration ?? 2.4) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultCycle(): Cycle {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
id: DEFAULT_CYCLE_ID,
|
||||||
|
blocks: seedBlocks().map((b) => ({
|
||||||
|
state: b.state,
|
||||||
|
duration: clampDuration(b.state, b.duration)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function totalDuration(blocks: Block[]): number {
|
||||||
|
return blocks.reduce((sum, b) => sum + b.duration, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function offsetOf(blocks: Block[], index: number): number {
|
||||||
|
let acc = 0
|
||||||
|
for (let i = 0; i < index && i < blocks.length; i++) acc += blocks[i]!.duration
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blockAt(blocks: Block[], t: number): { index: number; elapsed: number } {
|
||||||
|
const total = totalDuration(blocks)
|
||||||
|
if (!blocks.length || total <= 0) return { index: 0, elapsed: 0 }
|
||||||
|
const wrapped = t >= 0 && t < total ? t : ((t % total) + total) % total
|
||||||
|
let acc = 0
|
||||||
|
for (let i = 0; i < blocks.length; i++) {
|
||||||
|
const end = acc + blocks[i]!.duration
|
||||||
|
if (wrapped < end) return { index: i, elapsed: wrapped - acc }
|
||||||
|
acc = end
|
||||||
|
}
|
||||||
|
return { index: blocks.length - 1, elapsed: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blocksWith(blocks: Block[], state: StateId): Block[] {
|
||||||
|
if (blocks.length >= MAX_BLOCS) return blocks
|
||||||
|
return [...blocks, makeBlock(state)]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveBlock(blocks: Block[], from: number, to: number): Block[] {
|
||||||
|
const next = blocks.slice()
|
||||||
|
const [moved] = next.splice(from, 1)
|
||||||
|
if (!moved) return blocks
|
||||||
|
next.splice(Math.min(Math.max(to, 0), next.length), 0, moved)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uniqueName(base: string, cycles: Cycle[]): string {
|
||||||
|
const taken = new Set(cycles.map((c) => c.name))
|
||||||
|
if (!taken.has(base)) return base
|
||||||
|
let n = 2
|
||||||
|
while (taken.has(`${base} ${n}`)) n++
|
||||||
|
return `${base} ${n}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextCycleId(cycles: Cycle[]): string {
|
||||||
|
const taken = new Set(cycles.map((c) => c.id))
|
||||||
|
let n = 1
|
||||||
|
while (taken.has(`c${n}`)) n++
|
||||||
|
return `c${n}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBlock(raw: unknown): Block | null {
|
||||||
|
if (typeof raw !== 'object' || raw === null) return null
|
||||||
|
const { state, duration } = raw as { state?: unknown; duration?: unknown }
|
||||||
|
if (typeof state !== 'string' || !isState(state)) return null
|
||||||
|
if (typeof duration !== 'number' || !Number.isFinite(duration)) return null
|
||||||
|
return { state, duration: clampDuration(state, duration) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCycle(raw: unknown, seen: Cycle[]): Cycle | null {
|
||||||
|
if (typeof raw !== 'object' || raw === null) return null
|
||||||
|
const { id, name, blocks } = raw as { id?: unknown; name?: unknown; blocks?: unknown }
|
||||||
|
if (typeof id !== 'string' || !id) return null
|
||||||
|
if (typeof name !== 'string') return null
|
||||||
|
if (!Array.isArray(blocks)) return null
|
||||||
|
const kept = blocks
|
||||||
|
.slice(0, MAX_BLOCS)
|
||||||
|
.map(parseBlock)
|
||||||
|
.filter((b): b is Block => b !== null)
|
||||||
|
if (!kept.length) return null
|
||||||
|
if (seen.some((c) => c.id === id)) return null
|
||||||
|
return { id, name, blocks: kept }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCycles(raw: string | null): Cycle[] {
|
||||||
|
if (!raw) return []
|
||||||
|
let data: unknown
|
||||||
|
try {
|
||||||
|
data = JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (!Array.isArray(data)) return []
|
||||||
|
const out: Cycle[] = []
|
||||||
|
for (const item of data.slice(0, MAX_CYCLES)) {
|
||||||
|
const cycle = parseCycle(item, out)
|
||||||
|
if (cycle) out.push(cycle)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
const SEPARATEUR = ' | '
|
||||||
|
|
||||||
|
export function interpoler(texte: string, valeurs?: Record<string, string | number>): string {
|
||||||
|
if (!valeurs) return texte
|
||||||
|
let sortie = texte
|
||||||
|
for (const [nom, valeur] of Object.entries(valeurs)) {
|
||||||
|
sortie = sortie.replaceAll(`{${nom}}`, String(valeur))
|
||||||
|
}
|
||||||
|
return sortie
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formePlurielle(gabarit: string, n: number, tag: string): string {
|
||||||
|
const formes = gabarit.split(SEPARATEUR)
|
||||||
|
if (formes.length < 2) return formes[0]!
|
||||||
|
const index = new Intl.PluralRules(tag).select(n) === 'one' ? 0 : 1
|
||||||
|
return formes[index] ?? formes[0]!
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { computed, ref, watchEffect } from 'vue'
|
||||||
|
import { ecris, lis } from '@/ui/stockage'
|
||||||
|
import { formePlurielle, interpoler } from './format'
|
||||||
|
import { choisirLangue, estLangue, type Langue, tagDe } from './langues'
|
||||||
|
import fr from './locales/fr'
|
||||||
|
import en from './locales/en'
|
||||||
|
import zh from './locales/zh'
|
||||||
|
|
||||||
|
export { LANGUES, type Langue } from './langues'
|
||||||
|
|
||||||
|
const dictionnaires: Record<Langue, typeof fr> = { fr, en, zh }
|
||||||
|
|
||||||
|
type Chemins<T, P extends string = ''> = {
|
||||||
|
[K in keyof T & string]: T[K] extends string ? `${P}${K}` : Chemins<T[K], `${P}${K}.`>
|
||||||
|
}[keyof T & string]
|
||||||
|
|
||||||
|
export type Cle = Chemins<typeof fr>
|
||||||
|
|
||||||
|
const courante = ref<Langue>(
|
||||||
|
choisirLangue(lis('langue'), typeof navigator === 'undefined' ? [] : (navigator.languages ?? [navigator.language]))
|
||||||
|
)
|
||||||
|
|
||||||
|
export const langue = computed<Langue>({
|
||||||
|
get: () => courante.value,
|
||||||
|
set: (valeur) => {
|
||||||
|
if (!estLangue(valeur)) return
|
||||||
|
courante.value = valeur
|
||||||
|
ecris('langue', valeur)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const dictionnaire = computed(() => dictionnaires[courante.value])
|
||||||
|
const tag = computed(() => tagDe(courante.value))
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
document.documentElement.lang = tag.value
|
||||||
|
document.title = t('app.title')
|
||||||
|
})
|
||||||
|
|
||||||
|
function brut(cle: Cle): string {
|
||||||
|
const noeud = cle
|
||||||
|
.split('.')
|
||||||
|
.reduce<unknown>((n, k) => (n as Record<string, unknown>)[k], dictionnaire.value)
|
||||||
|
return noeud as string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function t(cle: Cle, valeurs?: Record<string, string | number>): string {
|
||||||
|
return interpoler(brut(cle), valeurs)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pluriel(cle: Cle, n: number, valeurs?: Record<string, string | number>): string {
|
||||||
|
return interpoler(formePlurielle(brut(cle), n, tag.value), { n, ...valeurs })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nomDeCycle(cycle: { name: string }): string {
|
||||||
|
return cycle.name || t('cycles.defaultName')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formateurs = new Map<string, Intl.NumberFormat>()
|
||||||
|
|
||||||
|
function formateur(cle: string, options: Intl.NumberFormatOptions): Intl.NumberFormat {
|
||||||
|
const memo = `${tag.value}:${cle}`
|
||||||
|
let f = formateurs.get(memo)
|
||||||
|
if (!f) {
|
||||||
|
f = new Intl.NumberFormat(tag.value, options)
|
||||||
|
formateurs.set(memo, f)
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nombre(valeur: number, decimales = 0): string {
|
||||||
|
return formateur(`n${decimales}`, {
|
||||||
|
minimumFractionDigits: decimales,
|
||||||
|
maximumFractionDigits: decimales
|
||||||
|
}).format(valeur)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pourcentage(fraction: number): string {
|
||||||
|
return formateur('%', { style: 'percent', maximumFractionDigits: 0 }).format(fraction)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function secondes(valeur: number): string {
|
||||||
|
return t('units.seconds', { n: nombre(valeur, 1) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function secondesCourtes(valeur: number, decimales: number): string {
|
||||||
|
return t('units.secondsShort', { n: nombre(valeur, decimales) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stateName(id: string): string {
|
||||||
|
const dict = dictionnaire.value.states as Record<string, string>
|
||||||
|
return dict[id] ?? id
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shapeName(id: string): string {
|
||||||
|
const dict = dictionnaire.value.shapes as Record<string, string>
|
||||||
|
return dict[id] ?? id
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export const LANGUES = [
|
||||||
|
{ id: 'fr', tag: 'fr', emoji: '🇫🇷', nom: 'Français' },
|
||||||
|
{ id: 'en', tag: 'en', emoji: '🇬🇧', nom: 'English' },
|
||||||
|
{ id: 'zh', tag: 'zh-Hans', emoji: '🇨🇳', nom: '简体中文' }
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type Langue = (typeof LANGUES)[number]['id']
|
||||||
|
|
||||||
|
export const LANGUE_PAR_DEFAUT: Langue = 'en'
|
||||||
|
|
||||||
|
export function estLangue(valeur: string | null | undefined): valeur is Langue {
|
||||||
|
return LANGUES.some((l) => l.id === valeur)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tagDe(langue: Langue): string {
|
||||||
|
return LANGUES.find((l) => l.id === langue)!.tag
|
||||||
|
}
|
||||||
|
|
||||||
|
export function choisirLangue(memorisee: string | null, preferences: readonly string[]): Langue {
|
||||||
|
if (estLangue(memorisee)) return memorisee
|
||||||
|
for (const tag of preferences) {
|
||||||
|
let base: string
|
||||||
|
try {
|
||||||
|
base = new Intl.Locale(tag).language
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (estLangue(base)) return base
|
||||||
|
}
|
||||||
|
return LANGUE_PAR_DEFAUT
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import type fr from './fr'
|
||||||
|
|
||||||
|
const en: typeof fr = {
|
||||||
|
app: {
|
||||||
|
name: 'study',
|
||||||
|
title: 'study — spring character',
|
||||||
|
botAria: 'Spring-driven character',
|
||||||
|
missingGeo: 'Local geometry is missing. The chrome still works; the character stays off. See replica/geometry.schema.md.'
|
||||||
|
},
|
||||||
|
rail: {
|
||||||
|
nav: 'Sections',
|
||||||
|
customize: 'Customise',
|
||||||
|
animations: 'Animations',
|
||||||
|
settings: 'Settings'
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
animations: 'Animation',
|
||||||
|
shape: 'Shape',
|
||||||
|
expression: 'Expression',
|
||||||
|
color: 'Colour',
|
||||||
|
group_lifecycle: 'Lifecycle',
|
||||||
|
group_reactions: 'Reactions',
|
||||||
|
group_agent: 'Agent',
|
||||||
|
group_product: 'Product'
|
||||||
|
},
|
||||||
|
shapes: {
|
||||||
|
blob: 'Blob',
|
||||||
|
bean: 'Bean',
|
||||||
|
egg: 'Egg',
|
||||||
|
teardrop: 'Drop',
|
||||||
|
cloud: 'Cloud',
|
||||||
|
leaf: 'Leaf',
|
||||||
|
squircle: 'Cube',
|
||||||
|
capsule: 'Capsule'
|
||||||
|
},
|
||||||
|
export: {
|
||||||
|
action: 'Export PNG',
|
||||||
|
more: 'Other formats',
|
||||||
|
png: 'Download PNG',
|
||||||
|
svg: 'Download SVG',
|
||||||
|
anime: 'Download animated SVG',
|
||||||
|
gif: 'Download animated GIF',
|
||||||
|
cycleDetail: 'The video is lighter and smoother; the GIF plays anywhere.',
|
||||||
|
cycleFormat: 'Format',
|
||||||
|
cycle_mp4: 'MP4 video',
|
||||||
|
cycle_mp4_aide: 'Light and smooth, needs a background',
|
||||||
|
cycle_gif: 'Animated GIF',
|
||||||
|
cycle_gif_aide: 'Plays anywhere, heavier',
|
||||||
|
cycleProgress: 'Exporting…',
|
||||||
|
cycleReessayer: 'Try again',
|
||||||
|
gifTitle: 'Download animated GIF',
|
||||||
|
gifDetail:
|
||||||
|
'GIF transparency is all-or-nothing: with no background, the ball’s edge comes out a little hard.',
|
||||||
|
gifBackground: 'Background',
|
||||||
|
fond_blanc: 'White background',
|
||||||
|
fond_blanc_aide: 'Smooth edge, for light surfaces',
|
||||||
|
fond_transparent: 'Transparent background',
|
||||||
|
fond_transparent_aide: 'Fits any background, edge a little hard',
|
||||||
|
gifConfirm: 'Download',
|
||||||
|
copie: 'Copy image',
|
||||||
|
copieSvg: 'Copy SVG',
|
||||||
|
done: 'Exported',
|
||||||
|
copied: 'Copied',
|
||||||
|
failed: 'Export failed'
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
exit: 'Exit preview',
|
||||||
|
open: 'Preview',
|
||||||
|
key: 'Esc'
|
||||||
|
},
|
||||||
|
timeline: {
|
||||||
|
play: 'Start playback',
|
||||||
|
pause: 'Stop playback',
|
||||||
|
addAnimation: 'Add an animation',
|
||||||
|
preview: 'Preview',
|
||||||
|
export: 'Export animation sequence',
|
||||||
|
zoom: 'Track zoom',
|
||||||
|
blockAria: '{state}, {duration}',
|
||||||
|
blockDurationAria: 'Duration of {state}, {duration}',
|
||||||
|
blockRemoveAria: 'Remove {state}'
|
||||||
|
},
|
||||||
|
dialog: {
|
||||||
|
cancel: 'Cancel',
|
||||||
|
nameCreateTitle: 'New cycle',
|
||||||
|
nameRenameTitle: 'Rename cycle',
|
||||||
|
nameField: 'Cycle name',
|
||||||
|
nameCreate: 'Create',
|
||||||
|
nameRename: 'Rename',
|
||||||
|
removeTitle: 'Delete “{name}”?',
|
||||||
|
removeDetail:
|
||||||
|
'This sequence will be lost, along with its animation. | This sequence will be lost, along with its {n} animations.',
|
||||||
|
removeConfirm: 'Delete'
|
||||||
|
},
|
||||||
|
cycles: {
|
||||||
|
defaultName: 'Default cycle',
|
||||||
|
newName: 'My cycle',
|
||||||
|
menuNew: 'New cycle',
|
||||||
|
menuRenameAria: 'Rename {name}',
|
||||||
|
menuRemoveAria: 'Delete {name}'
|
||||||
|
},
|
||||||
|
states: {
|
||||||
|
sleeping: 'Sleeping',
|
||||||
|
waking: 'Waking',
|
||||||
|
idle: 'Idle',
|
||||||
|
listening: 'Listening',
|
||||||
|
thinking: 'Thinking',
|
||||||
|
searching: 'Searching',
|
||||||
|
working: 'Working',
|
||||||
|
excited: 'Excited',
|
||||||
|
surprised: 'Surprised',
|
||||||
|
suspicious: 'Suspicious',
|
||||||
|
angry: 'Angry',
|
||||||
|
drowsy: 'Drowsy',
|
||||||
|
happy: 'Happy',
|
||||||
|
curious: 'Curious',
|
||||||
|
confused: 'Confused',
|
||||||
|
bored: 'Bored',
|
||||||
|
proud: 'Proud',
|
||||||
|
shy: 'Shy',
|
||||||
|
sad: 'Sad',
|
||||||
|
laughing: 'Laughing',
|
||||||
|
scared: 'Scared',
|
||||||
|
playful: 'Playful',
|
||||||
|
celebrate: 'Celebrate',
|
||||||
|
orbit: 'Orbit',
|
||||||
|
radar: 'Radar',
|
||||||
|
progress: 'Progress',
|
||||||
|
spawning: 'Spawning',
|
||||||
|
humming: 'Humming',
|
||||||
|
loading: 'Loading',
|
||||||
|
dictating: 'Dictating',
|
||||||
|
writing: 'Writing',
|
||||||
|
sending: 'Sending',
|
||||||
|
receiving: 'Receiving',
|
||||||
|
uploading: 'Uploading',
|
||||||
|
notifying: 'Notifying',
|
||||||
|
alerting: 'Alerting',
|
||||||
|
dragging: 'Dragging',
|
||||||
|
bouncing: 'Bouncing',
|
||||||
|
'powering-down': 'Powering down'
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
language: 'Language',
|
||||||
|
about: 'About',
|
||||||
|
lab: 'Engine lab',
|
||||||
|
labAria: 'Open the original playground',
|
||||||
|
showcase: 'Showcase',
|
||||||
|
showcaseAria: 'Open the recording showcase',
|
||||||
|
credits: 'Unofficial study. Motion from the replica engine; layout after bloub. Not affiliated with xAI.',
|
||||||
|
missing: 'No geometry-data.js — stock chrome, no character.'
|
||||||
|
},
|
||||||
|
units: {
|
||||||
|
seconds: '{n} s',
|
||||||
|
secondsShort: '{n}s'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default en
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
const fr = {
|
||||||
|
app: {
|
||||||
|
name: 'study',
|
||||||
|
title: 'study — personnage à ressorts',
|
||||||
|
botAria: 'Personnage animé par ressorts',
|
||||||
|
missingGeo:
|
||||||
|
'Géométrie locale absente. L’interface reste là ; le personnage non. Voir replica/geometry.schema.md.'
|
||||||
|
},
|
||||||
|
rail: {
|
||||||
|
nav: 'Sections',
|
||||||
|
customize: 'Personnaliser',
|
||||||
|
animations: 'Animations',
|
||||||
|
settings: 'Réglages'
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
animations: 'Animation',
|
||||||
|
shape: 'Forme',
|
||||||
|
expression: 'Expression',
|
||||||
|
color: 'Couleur',
|
||||||
|
group_lifecycle: 'Cycle',
|
||||||
|
group_reactions: 'Réactions',
|
||||||
|
group_agent: 'Agent',
|
||||||
|
group_product: 'Produit'
|
||||||
|
},
|
||||||
|
shapes: {
|
||||||
|
blob: 'Blob',
|
||||||
|
bean: 'Haricot',
|
||||||
|
egg: 'Œuf',
|
||||||
|
teardrop: 'Goutte',
|
||||||
|
cloud: 'Nuage',
|
||||||
|
leaf: 'Feuille',
|
||||||
|
squircle: 'Cube',
|
||||||
|
capsule: 'Capsule'
|
||||||
|
},
|
||||||
|
export: {
|
||||||
|
action: 'Exporter en PNG',
|
||||||
|
more: 'Autres formats',
|
||||||
|
png: 'Télécharger le PNG',
|
||||||
|
svg: 'Télécharger le SVG',
|
||||||
|
anime: "Télécharger l'animation SVG",
|
||||||
|
gif: 'Télécharger le GIF animé',
|
||||||
|
cycleDetail: 'La vidéo est plus légère et plus fluide ; le GIF passe partout.',
|
||||||
|
cycleFormat: 'Format',
|
||||||
|
cycle_mp4: 'Vidéo MP4',
|
||||||
|
cycle_mp4_aide: 'Léger et fluide, fond obligatoire',
|
||||||
|
cycle_gif: 'GIF animé',
|
||||||
|
cycle_gif_aide: 'Lu partout, plus lourd',
|
||||||
|
cycleProgress: 'Export en cours…',
|
||||||
|
cycleReessayer: 'Réessayer',
|
||||||
|
gifTitle: 'Télécharger le GIF animé',
|
||||||
|
gifDetail:
|
||||||
|
"Le GIF ne gère la transparence qu'en tout ou rien : sans fond, le contour de la boule est un peu dur.",
|
||||||
|
gifBackground: 'Fond',
|
||||||
|
fond_blanc: 'Fond blanc',
|
||||||
|
fond_blanc_aide: 'Contour lisse, à poser sur du clair',
|
||||||
|
fond_transparent: 'Fond transparent',
|
||||||
|
fond_transparent_aide: "S'adapte à tout fond, contour un peu dur",
|
||||||
|
gifConfirm: 'Télécharger',
|
||||||
|
copie: "Copier l'image",
|
||||||
|
copieSvg: 'Copier le SVG',
|
||||||
|
done: 'Exporté',
|
||||||
|
copied: 'Copié',
|
||||||
|
failed: "Échec de l'export"
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
exit: 'Quitter l’aperçu',
|
||||||
|
open: 'Aperçu',
|
||||||
|
key: 'Échap'
|
||||||
|
},
|
||||||
|
timeline: {
|
||||||
|
play: 'Lancer la lecture',
|
||||||
|
pause: 'Arrêter la lecture',
|
||||||
|
addAnimation: 'Ajouter une animation',
|
||||||
|
preview: 'Aperçu',
|
||||||
|
export: "Exporter la séquence d'animation",
|
||||||
|
zoom: 'Zoom de la piste',
|
||||||
|
blockAria: '{state}, {duration}',
|
||||||
|
blockDurationAria: 'Durée de {state}, {duration}',
|
||||||
|
blockRemoveAria: 'Retirer {state}'
|
||||||
|
},
|
||||||
|
dialog: {
|
||||||
|
cancel: 'Annuler',
|
||||||
|
nameCreateTitle: 'Nouveau cycle',
|
||||||
|
nameRenameTitle: 'Renommer le cycle',
|
||||||
|
nameField: 'Nom du cycle',
|
||||||
|
nameCreate: 'Créer',
|
||||||
|
nameRename: 'Renommer',
|
||||||
|
removeTitle: 'Supprimer « {name} » ?',
|
||||||
|
removeDetail:
|
||||||
|
'Ce montage sera perdu, avec son animation. | Ce montage sera perdu, avec ses {n} animations.',
|
||||||
|
removeConfirm: 'Supprimer'
|
||||||
|
},
|
||||||
|
cycles: {
|
||||||
|
defaultName: 'Cycle par défaut',
|
||||||
|
newName: 'Mon cycle',
|
||||||
|
menuNew: 'Nouveau cycle',
|
||||||
|
menuRenameAria: 'Renommer {name}',
|
||||||
|
menuRemoveAria: 'Supprimer {name}'
|
||||||
|
},
|
||||||
|
states: {
|
||||||
|
sleeping: 'Sommeil',
|
||||||
|
waking: 'Réveil',
|
||||||
|
idle: 'Repos',
|
||||||
|
listening: 'Écoute',
|
||||||
|
thinking: 'Réflexion',
|
||||||
|
searching: 'Recherche',
|
||||||
|
working: 'Travail',
|
||||||
|
excited: 'Excité',
|
||||||
|
surprised: 'Surpris',
|
||||||
|
suspicious: 'Méfiant',
|
||||||
|
angry: 'En colère',
|
||||||
|
drowsy: 'Somnolent',
|
||||||
|
happy: 'Heureux',
|
||||||
|
curious: 'Curieux',
|
||||||
|
confused: 'Confus',
|
||||||
|
bored: 'Blasé',
|
||||||
|
proud: 'Fier',
|
||||||
|
shy: 'Timide',
|
||||||
|
sad: 'Triste',
|
||||||
|
laughing: 'Rire',
|
||||||
|
scared: 'Effrayé',
|
||||||
|
playful: 'Joueur',
|
||||||
|
celebrate: 'Célébration',
|
||||||
|
orbit: 'Orbite',
|
||||||
|
radar: 'Radar',
|
||||||
|
progress: 'Progrès',
|
||||||
|
spawning: 'Apparition',
|
||||||
|
humming: 'Bourdonnement',
|
||||||
|
loading: 'Chargement',
|
||||||
|
dictating: 'Dictée',
|
||||||
|
writing: 'Écriture',
|
||||||
|
sending: 'Envoi',
|
||||||
|
receiving: 'Réception',
|
||||||
|
uploading: 'Téléversement',
|
||||||
|
notifying: 'Notification',
|
||||||
|
alerting: 'Alerte',
|
||||||
|
dragging: 'Glisser',
|
||||||
|
bouncing: 'Rebond',
|
||||||
|
'powering-down': 'Extinction'
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
language: 'Langue',
|
||||||
|
about: 'À propos',
|
||||||
|
lab: 'Laboratoire',
|
||||||
|
labAria: 'Ouvrir le playground d’origine',
|
||||||
|
showcase: 'Showcase',
|
||||||
|
showcaseAria: 'Ouvrir le showcase d’enregistrement',
|
||||||
|
credits:
|
||||||
|
'Étude non officielle. Le mouvement vient du moteur replica ; la mise en page, de bloub. Sans lien avec xAI.',
|
||||||
|
missing: 'Pas de geometry-data.js — chrome seul, pas de personnage.'
|
||||||
|
},
|
||||||
|
units: {
|
||||||
|
seconds: '{n} s',
|
||||||
|
secondsShort: '{n}s'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default fr
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import type fr from './fr'
|
||||||
|
|
||||||
|
const zh: typeof fr = {
|
||||||
|
app: {
|
||||||
|
name: 'study',
|
||||||
|
title: 'study — 弹簧角色',
|
||||||
|
botAria: '弹簧驱动的角色',
|
||||||
|
missingGeo: '本地几何不在。页面壳还在,角色不画。见 replica/geometry.schema.md。'
|
||||||
|
},
|
||||||
|
rail: {
|
||||||
|
nav: '分区',
|
||||||
|
customize: '自定义',
|
||||||
|
animations: '动画',
|
||||||
|
settings: '设置'
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
animations: '动画',
|
||||||
|
shape: '形体',
|
||||||
|
expression: '表情',
|
||||||
|
color: '颜色',
|
||||||
|
group_lifecycle: '生命周期',
|
||||||
|
group_reactions: '反应',
|
||||||
|
group_agent: '代理',
|
||||||
|
group_product: '产品'
|
||||||
|
},
|
||||||
|
shapes: {
|
||||||
|
blob: '团子',
|
||||||
|
bean: '豆',
|
||||||
|
egg: '蛋',
|
||||||
|
teardrop: '水滴',
|
||||||
|
cloud: '云',
|
||||||
|
leaf: '叶子',
|
||||||
|
squircle: '正方体',
|
||||||
|
capsule: '胶囊'
|
||||||
|
},
|
||||||
|
export: {
|
||||||
|
action: '导出 PNG',
|
||||||
|
more: '其他格式',
|
||||||
|
png: '下载 PNG',
|
||||||
|
svg: '下载 SVG',
|
||||||
|
anime: '下载 SVG 动图',
|
||||||
|
gif: '下载 GIF 动图',
|
||||||
|
cycleDetail: '视频更轻更流畅;GIF 到处都能播放。',
|
||||||
|
cycleFormat: '格式',
|
||||||
|
cycle_mp4: 'MP4 视频',
|
||||||
|
cycle_mp4_aide: '轻巧流畅,必须有背景',
|
||||||
|
cycle_gif: 'GIF 动图',
|
||||||
|
cycle_gif_aide: '到处可播,体积更大',
|
||||||
|
cycleProgress: '正在导出…',
|
||||||
|
cycleReessayer: '重试',
|
||||||
|
gifTitle: '下载 GIF 动图',
|
||||||
|
gifDetail: 'GIF 的透明只有全有或全无:不加背景时,球体边缘会略显生硬。',
|
||||||
|
gifBackground: '背景',
|
||||||
|
fond_blanc: '白色背景',
|
||||||
|
fond_blanc_aide: '边缘平滑,适合浅色底',
|
||||||
|
fond_transparent: '透明背景',
|
||||||
|
fond_transparent_aide: '适配任何背景,边缘略硬',
|
||||||
|
gifConfirm: '下载',
|
||||||
|
copie: '复制图片',
|
||||||
|
copieSvg: '复制 SVG',
|
||||||
|
done: '已导出',
|
||||||
|
copied: '已复制',
|
||||||
|
failed: '导出失败'
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
exit: '退出预览',
|
||||||
|
open: '预览',
|
||||||
|
key: 'Esc'
|
||||||
|
},
|
||||||
|
timeline: {
|
||||||
|
play: '开始播放',
|
||||||
|
pause: '停止播放',
|
||||||
|
addAnimation: '添加动画',
|
||||||
|
preview: '预览',
|
||||||
|
export: '导出动画序列',
|
||||||
|
zoom: '轨道缩放',
|
||||||
|
blockAria: '{state},{duration}',
|
||||||
|
blockDurationAria: '{state} 的时长,{duration}',
|
||||||
|
blockRemoveAria: '移除 {state}'
|
||||||
|
},
|
||||||
|
dialog: {
|
||||||
|
cancel: '取消',
|
||||||
|
nameCreateTitle: '新建循环',
|
||||||
|
nameRenameTitle: '重命名循环',
|
||||||
|
nameField: '循环名称',
|
||||||
|
nameCreate: '创建',
|
||||||
|
nameRename: '重命名',
|
||||||
|
removeTitle: '删除「{name}」?',
|
||||||
|
removeDetail: '这段蒙太奇会连同动画一起丢掉。 | 这段蒙太奇会连同它的 {n} 段动画一起丢掉。',
|
||||||
|
removeConfirm: '删除'
|
||||||
|
},
|
||||||
|
cycles: {
|
||||||
|
defaultName: '默认循环',
|
||||||
|
newName: '我的循环',
|
||||||
|
menuNew: '新建循环',
|
||||||
|
menuRenameAria: '重命名 {name}',
|
||||||
|
menuRemoveAria: '删除 {name}'
|
||||||
|
},
|
||||||
|
states: {
|
||||||
|
sleeping: '睡着',
|
||||||
|
waking: '醒来',
|
||||||
|
idle: '休息',
|
||||||
|
listening: '倾听',
|
||||||
|
thinking: '思考',
|
||||||
|
searching: '搜索',
|
||||||
|
working: '工作',
|
||||||
|
excited: '兴奋',
|
||||||
|
surprised: '惊讶',
|
||||||
|
suspicious: '怀疑',
|
||||||
|
angry: '生气',
|
||||||
|
drowsy: '困倦',
|
||||||
|
happy: '高兴',
|
||||||
|
curious: '好奇',
|
||||||
|
confused: '困惑',
|
||||||
|
bored: '无聊',
|
||||||
|
proud: '得意',
|
||||||
|
shy: '害羞',
|
||||||
|
sad: '难过',
|
||||||
|
laughing: '大笑',
|
||||||
|
scared: '害怕',
|
||||||
|
playful: '调皮',
|
||||||
|
celebrate: '庆祝',
|
||||||
|
orbit: '环绕',
|
||||||
|
radar: '雷达',
|
||||||
|
progress: '进度',
|
||||||
|
spawning: '生成',
|
||||||
|
humming: '嗡鸣',
|
||||||
|
loading: '加载',
|
||||||
|
dictating: '口述',
|
||||||
|
writing: '书写',
|
||||||
|
sending: '发送',
|
||||||
|
receiving: '接收',
|
||||||
|
uploading: '上传',
|
||||||
|
notifying: '通知',
|
||||||
|
alerting: '警报',
|
||||||
|
dragging: '拖拽',
|
||||||
|
bouncing: '弹跳',
|
||||||
|
'powering-down': '关机'
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
language: '语言',
|
||||||
|
about: '关于',
|
||||||
|
lab: '机芯实验室',
|
||||||
|
labAria: '打开原来的 playground',
|
||||||
|
showcase: '展示页',
|
||||||
|
showcaseAria: '打开录屏展示',
|
||||||
|
credits: '非官方学习稿。动效是 replica 机芯,版式参考 bloub。与 xAI 无关。',
|
||||||
|
missing: '没有 geometry-data.js — 只有壳,没有角色。'
|
||||||
|
},
|
||||||
|
units: {
|
||||||
|
seconds: '{n} 秒',
|
||||||
|
secondsShort: '{n}秒'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default zh
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/** Load local geometry, then the replica IIFE engine. Missing geometry keeps the chrome. */
|
||||||
|
|
||||||
|
export type ReplicaBot = {
|
||||||
|
setState: (name: string, opts?: { resetEyes?: boolean }) => void
|
||||||
|
setShape: (name: string) => void
|
||||||
|
setColor: (id: string, scheme?: string) => void
|
||||||
|
setFollowPointer: (on: boolean) => void
|
||||||
|
setEyeColor: (color: string | null) => void
|
||||||
|
setPaused: (on: boolean | 'hold-pose') => void
|
||||||
|
spinOnce: (turns?: number) => void
|
||||||
|
bounceOnce: () => void
|
||||||
|
burstOnce: () => void
|
||||||
|
orbitGaze: (ms?: number) => void
|
||||||
|
holdFrame: (at?: number) => void
|
||||||
|
step: (dt: number) => void
|
||||||
|
destroy: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
GROK_GEO?: {
|
||||||
|
Re?: number
|
||||||
|
shapes?: Record<string, unknown>
|
||||||
|
palette?: Record<string, { light: string; dark: string }>
|
||||||
|
}
|
||||||
|
GROK_TABLES?: {
|
||||||
|
GROUPS: Array<{ label: string; states: string[] }>
|
||||||
|
INK: Record<string, { lightFrom?: string; lightTo: string; darkFrom?: string; darkTo?: string }>
|
||||||
|
}
|
||||||
|
GrokCharacter?: new (svg: SVGSVGElement, opts: Record<string, unknown>) => ReplicaBot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pending: Promise<boolean> | null = null
|
||||||
|
|
||||||
|
export function replicaReady(): boolean {
|
||||||
|
return typeof window !== 'undefined' && !!window.GROK_GEO?.shapes && typeof window.GrokCharacter === 'function'
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadScript(src: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const existing = document.querySelector(`script[data-replica="${src}"]`)
|
||||||
|
if (existing) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const el = document.createElement('script')
|
||||||
|
el.src = src
|
||||||
|
el.dataset.replica = src
|
||||||
|
el.onload = () => resolve()
|
||||||
|
el.onerror = () => reject(new Error(`replica: failed to load ${src}`))
|
||||||
|
document.head.appendChild(el)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOptional(src: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(src)
|
||||||
|
if (!res.ok) return false
|
||||||
|
if (document.querySelector(`script[data-replica="${src}"]`)) return true
|
||||||
|
const el = document.createElement('script')
|
||||||
|
el.dataset.replica = src
|
||||||
|
el.textContent = await res.text()
|
||||||
|
document.head.appendChild(el)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadReplica(): Promise<boolean> {
|
||||||
|
if (replicaReady()) return Promise.resolve(true)
|
||||||
|
if (pending) return pending
|
||||||
|
pending = (async () => {
|
||||||
|
try {
|
||||||
|
const geo = await loadOptional('/replica/geometry-data.js')
|
||||||
|
if (!geo || !window.GROK_GEO) return false
|
||||||
|
for (const name of ['math', 'tables', 'pose', 'tricks', 'fx', 'eyes', 'character']) {
|
||||||
|
await loadScript(`/replica/src/${name}.js`)
|
||||||
|
}
|
||||||
|
return replicaReady()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return pending
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
/** Replica catalogues. Ids stay English; the chrome never uses Bloub's 14-state set. */
|
||||||
|
|
||||||
|
export const SHAPE_IDS = [
|
||||||
|
'blob',
|
||||||
|
'bean',
|
||||||
|
'egg',
|
||||||
|
'teardrop',
|
||||||
|
'cloud',
|
||||||
|
'leaf',
|
||||||
|
'squircle',
|
||||||
|
'capsule'
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const COLOR_IDS = [
|
||||||
|
'black',
|
||||||
|
'brown',
|
||||||
|
'red',
|
||||||
|
'orange',
|
||||||
|
'yellow',
|
||||||
|
'green',
|
||||||
|
'cyan',
|
||||||
|
'blue',
|
||||||
|
'violet',
|
||||||
|
'magenta',
|
||||||
|
'gray'
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const EXPRESSION_IDS = [
|
||||||
|
'idle',
|
||||||
|
'listening',
|
||||||
|
'excited',
|
||||||
|
'surprised',
|
||||||
|
'suspicious',
|
||||||
|
'angry',
|
||||||
|
'drowsy',
|
||||||
|
'happy',
|
||||||
|
'curious',
|
||||||
|
'confused',
|
||||||
|
'bored',
|
||||||
|
'proud',
|
||||||
|
'shy',
|
||||||
|
'sad',
|
||||||
|
'laughing',
|
||||||
|
'scared',
|
||||||
|
'playful'
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const GROUP_KEYS = ['lifecycle', 'reactions', 'agent', 'product'] as const
|
||||||
|
|
||||||
|
export type ShapeId = (typeof SHAPE_IDS)[number]
|
||||||
|
export type ColorId = (typeof COLOR_IDS)[number]
|
||||||
|
export type ExpressionId = (typeof EXPRESSION_IDS)[number]
|
||||||
|
|
||||||
|
export const DEFAULT_SHAPE: ShapeId = 'blob'
|
||||||
|
export const DEFAULT_COLOR: ColorId = 'black'
|
||||||
|
export const DEFAULT_EXPRESSION: ExpressionId = 'curious'
|
||||||
|
|
||||||
|
const FALLBACK_HEX: Record<string, string> = {
|
||||||
|
black: '#111111',
|
||||||
|
brown: '#855C36',
|
||||||
|
red: '#E02135',
|
||||||
|
orange: '#E05B00',
|
||||||
|
yellow: '#E08600',
|
||||||
|
green: '#009957',
|
||||||
|
cyan: '#1aa8c4',
|
||||||
|
blue: '#0E74E0',
|
||||||
|
violet: '#804EE0',
|
||||||
|
magenta: '#E02A88',
|
||||||
|
gray: '#696969'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function colorHex(id: string): string {
|
||||||
|
const ink = window.GROK_TABLES?.INK?.[id]
|
||||||
|
if (ink?.lightTo) return ink.lightTo
|
||||||
|
return FALLBACK_HEX[id] ?? '#111111'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function colorFill(id: string): string {
|
||||||
|
const ink = window.GROK_TABLES?.INK?.[id]
|
||||||
|
if (ink?.lightFrom && ink?.lightTo) {
|
||||||
|
return `linear-gradient(135deg, ${ink.lightFrom}, ${ink.lightTo})`
|
||||||
|
}
|
||||||
|
return colorHex(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function estForme(id: string): id is ShapeId {
|
||||||
|
return (SHAPE_IDS as readonly string[]).includes(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function liveShapes(): string[] {
|
||||||
|
const geo = window.GROK_GEO?.shapes
|
||||||
|
if (!geo) return [...SHAPE_IDS]
|
||||||
|
return SHAPE_IDS.filter((id) => id in geo)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stateGroups(): Array<{ key: (typeof GROUP_KEYS)[number]; states: string[] }> {
|
||||||
|
const groups = window.GROK_TABLES?.GROUPS
|
||||||
|
if (groups?.length) {
|
||||||
|
return groups.map((g, i) => ({
|
||||||
|
key: GROUP_KEYS[i] ?? 'lifecycle',
|
||||||
|
states: g.states
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ key: 'lifecycle', states: ['sleeping', 'waking', 'idle', 'listening', 'thinking', 'searching', 'working'] },
|
||||||
|
{
|
||||||
|
key: 'reactions',
|
||||||
|
states: [
|
||||||
|
'excited',
|
||||||
|
'surprised',
|
||||||
|
'suspicious',
|
||||||
|
'angry',
|
||||||
|
'drowsy',
|
||||||
|
'happy',
|
||||||
|
'curious',
|
||||||
|
'confused',
|
||||||
|
'bored',
|
||||||
|
'proud',
|
||||||
|
'shy',
|
||||||
|
'sad',
|
||||||
|
'laughing',
|
||||||
|
'scared',
|
||||||
|
'playful',
|
||||||
|
'celebrate'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ key: 'agent', states: ['orbit', 'radar', 'progress'] },
|
||||||
|
{
|
||||||
|
key: 'product',
|
||||||
|
states: [
|
||||||
|
'spawning',
|
||||||
|
'humming',
|
||||||
|
'loading',
|
||||||
|
'dictating',
|
||||||
|
'writing',
|
||||||
|
'sending',
|
||||||
|
'receiving',
|
||||||
|
'uploading',
|
||||||
|
'notifying',
|
||||||
|
'alerting',
|
||||||
|
'dragging',
|
||||||
|
'bouncing',
|
||||||
|
'powering-down'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultCycle(): Array<{ state: string; duration: number }> {
|
||||||
|
return [
|
||||||
|
{ state: 'idle', duration: 2.4 },
|
||||||
|
{ state: 'curious', duration: 2.2 },
|
||||||
|
{ state: 'thinking', duration: 2.6 },
|
||||||
|
{ state: 'orbit', duration: 3.2 },
|
||||||
|
{ state: 'writing', duration: 2.8 },
|
||||||
|
{ state: 'celebrate', duration: 2.6 },
|
||||||
|
{ state: 'sending', duration: 2.4 }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { loadReplica, replicaReady, type ReplicaBot } from './boot'
|
||||||
|
|
||||||
|
export type ReplicaMount = {
|
||||||
|
apply: (input: ReplicaInput) => void
|
||||||
|
spin: () => void
|
||||||
|
orbitGaze: (ms?: number) => void
|
||||||
|
step: (dt: number) => void
|
||||||
|
setState: (name: string) => void
|
||||||
|
destroy: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReplicaInput = {
|
||||||
|
state: string
|
||||||
|
shape: string
|
||||||
|
color: string
|
||||||
|
follow: boolean
|
||||||
|
paper: string
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let tileQueue: Promise<unknown> = Promise.resolve()
|
||||||
|
|
||||||
|
/** One-frame tiles share a queue so first paint does not spawn 30 engines at once. */
|
||||||
|
export function paintTile(svg: SVGSVGElement, input: ReplicaInput): Promise<ReplicaMount | null> {
|
||||||
|
const job = tileQueue.then(() => mountReplica(svg, input, false))
|
||||||
|
tileQueue = job.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined
|
||||||
|
)
|
||||||
|
return job
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mountReplica(
|
||||||
|
svg: SVGSVGElement,
|
||||||
|
input: ReplicaInput,
|
||||||
|
live: boolean,
|
||||||
|
extra?: { driven?: boolean }
|
||||||
|
): Promise<ReplicaMount | null> {
|
||||||
|
const ok = await loadReplica()
|
||||||
|
if (!ok || !window.GrokCharacter) return null
|
||||||
|
const Re = window.GROK_GEO?.Re ?? 114.27
|
||||||
|
const driven = !!extra?.driven
|
||||||
|
const bot: ReplicaBot = new window.GrokCharacter(svg, {
|
||||||
|
state: input.state,
|
||||||
|
shape: input.shape,
|
||||||
|
color: input.color,
|
||||||
|
sizePx: input.size,
|
||||||
|
mode: 'hold',
|
||||||
|
loginWrap: true,
|
||||||
|
fitBox: true,
|
||||||
|
frameHalf: Re * 1.58,
|
||||||
|
followPointer: live && input.follow && !driven,
|
||||||
|
eyeColor: input.paper,
|
||||||
|
paused: driven ? false : live ? false : 'hold-pose',
|
||||||
|
driven
|
||||||
|
})
|
||||||
|
const noop = {
|
||||||
|
apply: () => {},
|
||||||
|
spin: () => {},
|
||||||
|
orbitGaze: () => {},
|
||||||
|
step: () => {},
|
||||||
|
setState: () => {},
|
||||||
|
destroy: () => {}
|
||||||
|
}
|
||||||
|
if (!live && !driven) {
|
||||||
|
bot.destroy()
|
||||||
|
return noop
|
||||||
|
}
|
||||||
|
|
||||||
|
let last = { ...input }
|
||||||
|
return {
|
||||||
|
apply(next) {
|
||||||
|
if (next.state !== last.state) bot.setState(next.state, { resetEyes: true })
|
||||||
|
if (next.shape !== last.shape) bot.setShape(next.shape)
|
||||||
|
if (next.color !== last.color) bot.setColor(next.color)
|
||||||
|
if (next.follow !== last.follow) bot.setFollowPointer(next.follow)
|
||||||
|
if (next.paper !== last.paper) bot.setEyeColor(next.paper)
|
||||||
|
last = { ...next }
|
||||||
|
},
|
||||||
|
spin: () => bot.spinOnce(1),
|
||||||
|
orbitGaze: (ms) => bot.orbitGaze(ms),
|
||||||
|
step: (dt) => bot.step(dt),
|
||||||
|
setState: (name) => {
|
||||||
|
if (name === last.state) return
|
||||||
|
bot.setState(name, { resetEyes: true })
|
||||||
|
last = { ...last, state: name }
|
||||||
|
},
|
||||||
|
destroy: () => bot.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { replicaReady }
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
declare module '../../replica/src/*.js'
|
||||||
+628
@@ -0,0 +1,628 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Fond releve au pixel sur la video de reference. */
|
||||||
|
--paper: #f9f9f9;
|
||||||
|
/*
|
||||||
|
* Couleur principale de l'INTERFACE : un bleu nuit choisi, pas releve. Le
|
||||||
|
* noir de la video, lui, n'a pas bouge — c'est celui du bot, dans skins.ts
|
||||||
|
* (`encre`, #0a0a0c). Les deux ne doivent pas etre confondus : changer
|
||||||
|
* celle-ci retouche le decor, changer l'autre casse la ressemblance.
|
||||||
|
*/
|
||||||
|
--ink: #17203a;
|
||||||
|
--line: #e3e5ea;
|
||||||
|
--muted: #6e7382;
|
||||||
|
/* Rouge d'interface pour les actions destructrices — choisi, comme --ink. */
|
||||||
|
--danger: #d63c34;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hauteur de la barre de montage. Elle est fixee en bas, donc c'est la scene
|
||||||
|
* qui lui reserve sa place — dans les DEUX vues, sinon l'avatar centre change
|
||||||
|
* de position en passant a la personnalisation.
|
||||||
|
*/
|
||||||
|
--timeline: 236px;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duree et courbe du passage d'une vue a l'autre : le glissement de la scene et
|
||||||
|
* la croissance de la boule. Le tour du regard, lui, a sa propre duree
|
||||||
|
* (`TURN_TIME` de `src/ui/gaze.ts`), volontairement un peu plus longue — les
|
||||||
|
* yeux finissent de se poser apres que la boule a fini de grandir, ce qui fait
|
||||||
|
* lire les deux comme un seul mouvement qui se termine. Ease-out sans
|
||||||
|
* depassement, comme tout le reste du projet.
|
||||||
|
*/
|
||||||
|
--bascule: 1s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* La meme barre, sur une fenetre etroite : 236 px, c'est 29 % d'un ecran de
|
||||||
|
* telephone, et la hauteur qu'elle prend est retranchee de tout ce qui reste.
|
||||||
|
* 200 px laissent la regle (28 px) et des cartes de 64 px, soit la vignette a sa
|
||||||
|
* taille maximale — en dessous les cartes ne montrent plus rien.
|
||||||
|
*
|
||||||
|
* La variable est lue par la reserve de la scene et par le plafond de l'avatar,
|
||||||
|
* qui valent a toutes les largeurs. Les calages fins de `.barre-export` et des
|
||||||
|
* colonnes, eux, sont dans la requete `>= 64rem` : ils ne voient jamais cette
|
||||||
|
* valeur.
|
||||||
|
*/
|
||||||
|
@media (width < 64rem) {
|
||||||
|
:root {
|
||||||
|
--timeline: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
html::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* La boule des reglages sort volontairement par la droite. Sans ce rognage, son
|
||||||
|
* debordement deviendrait une zone defilable et la page partirait en largeur —
|
||||||
|
* y compris pendant l'animation de RETOUR, ou la boule redescend alors que la
|
||||||
|
* vue a deja change. D'ou un rognage permanent, et pas seulement sur cette vue.
|
||||||
|
*
|
||||||
|
* Sur `#app` et non sur `body` : l'`overflow` du corps est PROPAGE a la fenetre
|
||||||
|
* quand la racine est en `visible`, donc l'y poser ne rogne rien du tout. `#app`
|
||||||
|
* est un conteneur ordinaire, lui rogne pour de bon.
|
||||||
|
*
|
||||||
|
* `clip` et pas `hidden` : `hidden` en ferait un conteneur de defilement, donc
|
||||||
|
* rendrait le debordement navigable au lieu de le supprimer. Ici sur le seul axe
|
||||||
|
* x, parce que sous 64rem la mise en page s'empile et doit defiler ; sur grand
|
||||||
|
* ecran une seconde regle rogne aussi la hauteur (voir plus bas).
|
||||||
|
*/
|
||||||
|
#app {
|
||||||
|
overflow-x: clip;
|
||||||
|
/* repere du grand mot du pied de page, qui s'y cale en `absolute` */
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Troncature d'une ligne. Volontairement pas `truncate` (text-overflow) : celui
|
||||||
|
* -la coupe au milieu d'un mot et garde l'espace avant les points de suspension
|
||||||
|
* quand la coupe tombe entre deux mots. `line-clamp` coupe au mot et colle les
|
||||||
|
* points au texte, ce qui est la seule facon lisible d'ecrire « Mon cycle… ».
|
||||||
|
*/
|
||||||
|
@utility sans-barre {
|
||||||
|
scrollbar-width: none;
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility tronque {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Ouverture de la boite de dialogue. Une transition sur un element qui passe de
|
||||||
|
* `display: none` a visible a besoin de `@starting-style` pour avoir un etat de
|
||||||
|
* depart, et de `allow-discrete` pour que `display` et `overlay` attendent la
|
||||||
|
* fin de l'animation a la fermeture. C'est pour ca que ce n'est pas ecrit en
|
||||||
|
* classes utilitaires.
|
||||||
|
*/
|
||||||
|
.dialogue,
|
||||||
|
.dialogue::backdrop {
|
||||||
|
opacity: 0;
|
||||||
|
transition:
|
||||||
|
opacity 0.18s ease-out,
|
||||||
|
transform 0.18s ease-out,
|
||||||
|
overlay 0.18s ease-out allow-discrete,
|
||||||
|
display 0.18s ease-out allow-discrete;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogue {
|
||||||
|
transform: translateY(8px) scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogue[open],
|
||||||
|
.dialogue[open]::backdrop {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogue[open] {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogue::backdrop {
|
||||||
|
background: rgb(10 12 20 / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@starting-style {
|
||||||
|
.dialogue[open],
|
||||||
|
.dialogue[open]::backdrop {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogue[open] {
|
||||||
|
transform: translateY(8px) scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Le nom du projet en grand, au pied des reglages. Reprise de la landing page de
|
||||||
|
* Yuzu (`yuzu-releases/index.html`), transposee sur nos couleurs.
|
||||||
|
*
|
||||||
|
* Le fondu est un DEGRADE DE COULEUR revele au travers des lettres
|
||||||
|
* (`background-clip: text`), et non un `mask-image` : le texte n'est jamais peint,
|
||||||
|
* c'est le fond qu'on voit a travers lui. Il part deja faible (22 %) et s'eteint
|
||||||
|
* completement a 88 % de la hauteur, donc le bas des lettres est invisible avant
|
||||||
|
* meme d'etre rogne.
|
||||||
|
*
|
||||||
|
* `line-height` sous 1 fait deborder les glyphes de leur boite : c'est ce qui les
|
||||||
|
* fait rogner par le panneau et donne l'impression que le mot sort de la page.
|
||||||
|
* D'ou `pointer-events: none` — sans lui, les lettres qui remontent volent le clic
|
||||||
|
* des liens du dessus, piege deja tombe sur la landing page.
|
||||||
|
*
|
||||||
|
* `-webkit-text-fill-color` en plus de `color` : Safari ne rend pas le texte
|
||||||
|
* transparent avec le seul `color`. Tailwind n'a pas d'utilitaire pour cette
|
||||||
|
* propriete, d'ou cette regle ecrite a la main plutot qu'en classes.
|
||||||
|
*/
|
||||||
|
.wordmark {
|
||||||
|
/*
|
||||||
|
* `absolute` dans `#app`, et non `fixed`. `#app` fait exactement la hauteur de
|
||||||
|
* la fenetre, donc le bas est le meme — mais un element fixe est SORTI du
|
||||||
|
* document : il ne suit pas le rebond elastique quand on tente de defiler une
|
||||||
|
* page qui ne defile pas, alors que le mot de la landing page de Yuzu, lui, est
|
||||||
|
* dans le flux et accompagne le geste. En `absolute` il accompagne la page.
|
||||||
|
*/
|
||||||
|
position: absolute;
|
||||||
|
/* degage le rail, qui flotte (`fixed left-4`) et ne reserve rien dans la scene */
|
||||||
|
left: 6rem;
|
||||||
|
/* legerement sous le bord : c'est ce qui fait mordre les lettres par l'ecran */
|
||||||
|
bottom: -0.1em;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
line-height: 0.78;
|
||||||
|
/*
|
||||||
|
* Dimensionne d'apres la place REELLEMENT disponible, et non en `vw` seul : le
|
||||||
|
* mot part a 6rem du bord gauche, donc une taille proportionnelle a la fenetre
|
||||||
|
* finissait par pousser le dernier B au-dela du bord droit. 3,05 est la largeur
|
||||||
|
* de « BLOUB » en cadratins a ce poids et cet interlettrage — mesuree, pas
|
||||||
|
* estimee — et le reste est la marge qu'on lui laisse a droite.
|
||||||
|
*/
|
||||||
|
font-size: clamp(80px, calc((100vw - 7rem) / 3.05), 268px);
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: -0.045em;
|
||||||
|
/*
|
||||||
|
* Marge a droite DANS la boite, et non un espacement autour.
|
||||||
|
*
|
||||||
|
* `background-clip: text` peint le degrade sur la boite de l'element et le
|
||||||
|
* revele au travers des lettres : tout glyphe qui depasse cette boite n'a donc
|
||||||
|
* aucun degrade a montrer, et `-webkit-text-fill-color: transparent` le rend
|
||||||
|
* purement invisible. Or l'interlettrage negatif retranche 0,045 cadratin apres
|
||||||
|
* le DERNIER glyphe aussi, et la panse droite d'un B depasse deja son avance :
|
||||||
|
* le B final se coupait net, quelle que soit la largeur de la fenetre. C'est le
|
||||||
|
* padding qui elargit la boite du degrade, pas la taille du texte.
|
||||||
|
*/
|
||||||
|
padding-right: 0.1em;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgb(23 32 58 / 0.22) 0%,
|
||||||
|
rgb(23 32 58 / 0.1) 48%,
|
||||||
|
rgb(23 32 58 / 0) 88%
|
||||||
|
);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
color: transparent;
|
||||||
|
/* il arrive avec le panneau, pas avant : meme retard que `.panneau--ouvert` */
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.35s ease-out 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@starting-style {
|
||||||
|
.wordmark {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Le mot sous 64rem : DANS LE FLUX, en bas de la page et centre.
|
||||||
|
*
|
||||||
|
* Le calage sorti du flux ne tient que tant que `#app` fait exactement la hauteur
|
||||||
|
* de la fenetre, ce qui est vrai au-dessus de 64rem — rien n'y defile. En dessous
|
||||||
|
* la page defile pour de vrai, et le mot restait a 812 px du haut du DOCUMENT :
|
||||||
|
* par-dessus les credits au repos (mesure a 375 px : panneau jusqu'a 780, mot des
|
||||||
|
* 753), puis en plein milieu de l'ecran une fois la page deroulee. Le remettre
|
||||||
|
* dans le flux le rend a la page : il vient apres la scene, donc en dernier, et
|
||||||
|
* rien ne peut plus lui passer dessus puisqu'il n'y a plus de recouvrement.
|
||||||
|
*
|
||||||
|
* Centre et non cale a gauche : les 6rem de marge degageaient le rail, qui est
|
||||||
|
* passe en bande HAUTE a cette largeur. Il n'y a plus rien a contourner, et un mot
|
||||||
|
* pleine largeur pose sous une colonne centree se lit de travers s'il ne l'est
|
||||||
|
* pas. La marge droite de 0,1em disparait avec le calage : le centrage laisse du
|
||||||
|
* jeu des deux cotes, donc le dernier B n'a plus de bord ou se couper.
|
||||||
|
*
|
||||||
|
* Le plafond descend a 200 px : sur une fenetre de tablette, 268 px de decoration
|
||||||
|
* mangeaient un quart de la page. Et le diviseur perd les 6rem de marge gauche,
|
||||||
|
* qui n'existent plus ici — le mot occupe la largeur entiere moins les gouttieres
|
||||||
|
* de la scene.
|
||||||
|
*/
|
||||||
|
@media (width < 64rem) {
|
||||||
|
.wordmark {
|
||||||
|
position: static;
|
||||||
|
text-align: center;
|
||||||
|
padding-right: 0;
|
||||||
|
font-size: clamp(80px, calc((100vw - 2.5rem) / 3.05), 200px);
|
||||||
|
/*
|
||||||
|
* Les lettres touchent le bord bas de la page.
|
||||||
|
*
|
||||||
|
* Une marge negative n'y suffit pas : la zone defilable est l'union des BOITES
|
||||||
|
* des descendants, les marges n'y entrent pas, et le talon de la fonte —
|
||||||
|
* l'espace des jambages, vide pour BLOUB qui n'en a aucun — depassait la boite
|
||||||
|
* de 21 px que la page comptait quand meme. D'ou 21 px de blanc sous le mot.
|
||||||
|
*
|
||||||
|
* On rogne donc a la LIGNE DE BASE : plus rien ne deborde, la page s'arrete ou
|
||||||
|
* les lettres s'arretent. C'est ce que fait le rognage de `#app` au-dessus de
|
||||||
|
* 64rem, ou le mot est simplement pose sous le bord.
|
||||||
|
*/
|
||||||
|
height: 0.69em;
|
||||||
|
overflow: hidden;
|
||||||
|
/*
|
||||||
|
* Le degrade est peint sur la BOITE et revele au travers des lettres : rogner
|
||||||
|
* la boite l'aurait eteint plus haut (il s'annule a 88 % de sa hauteur). On lui
|
||||||
|
* rend donc la hauteur de la boite pleine, independamment du rognage.
|
||||||
|
*/
|
||||||
|
background-size: 100% 0.78em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Deplacement de l'avatar d'une vue a l'autre.
|
||||||
|
*
|
||||||
|
* La scene est une grille de trois colonnes : panneau gauche, avatar, panneau
|
||||||
|
* droit. Une seule des deux colonnes de panneau a une largeur a la fois, et
|
||||||
|
* c'est l'INTERPOLATION DE CES LARGEURS qui deplace l'avatar — les pistes de
|
||||||
|
* grille s'animent, alors que `order` et `flex-direction` non. D'ou cette
|
||||||
|
* grille plutot qu'une inversion de sens du conteneur, et d'ou son ecriture
|
||||||
|
* ici : les deux etats doivent etre des valeurs litterales pour que la
|
||||||
|
* transition ait de quoi interpoler.
|
||||||
|
*
|
||||||
|
* Les gouttieres restent constantes dans les deux etats : la colonne de
|
||||||
|
* l'avatar garde donc exactement la meme largeur, et son centre se decale de la
|
||||||
|
* largeur d'un panneau, ni plus ni moins.
|
||||||
|
*
|
||||||
|
* En dessous de 64rem la grille n'existe pas : tout s'empile, et il n'y a plus
|
||||||
|
* de glissement a animer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* La TRANSITION de l'avatar est declaree ici, hors de la requete de largeur, la
|
||||||
|
* ou ses positions sont dedans. Deux raisons : l'arrivee sur le site fait
|
||||||
|
* apparaitre la boule a toutes les tailles, et une transition redeclaree plus bas
|
||||||
|
* en chasserait l'opacite — une propriete absente de la liste ne transitionne
|
||||||
|
* plus du tout.
|
||||||
|
*
|
||||||
|
* L'opacite prend son temps (0,8 s) : c'est une boule qui parait, pas un element
|
||||||
|
* qui se charge. Elle reste plus courte que la bascule pour que la boule soit
|
||||||
|
* entierement opaque avant d'avoir fini de s'installer — l'inverse donnerait un
|
||||||
|
* fondu qui traine sur une forme deja immobile.
|
||||||
|
*/
|
||||||
|
.avatar {
|
||||||
|
transition:
|
||||||
|
transform var(--bascule),
|
||||||
|
opacity 0.8s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Arrivee de la barre d'export : elle monte en se revelant, APRES que l'avatar a
|
||||||
|
* pris sa place.
|
||||||
|
*
|
||||||
|
* C'est une TRANSITION et pas une animation, exactement comme `.panneau` — et
|
||||||
|
* c'est ce qui la fait se taire quand il le faut. Une transition ne joue pas sur
|
||||||
|
* le premier style calcule d'un element : au rechargement, ou en revenant de la
|
||||||
|
* vue Animations, la barre nait deja visible et rien ne bouge. Une animation,
|
||||||
|
* elle, rejouait a chaque montage, donc a chaque changement de vue.
|
||||||
|
*
|
||||||
|
* D'ou aussi le fait que la barre soit montee PENDANT l'arrivee, juste masquee :
|
||||||
|
* sans etat de depart present a l'ecran, il n'y aurait rien a interpoler.
|
||||||
|
*
|
||||||
|
* Le RETARD, lui, n'est pas ici mais dans `App.vue` (`RETARD_ARRIVEE`), et il ne
|
||||||
|
* sert QU'A la fin de l'arrivee : le temps que l'avatar rejoigne sa place. En
|
||||||
|
* venant d'une autre vue il n'y a rien a attendre, la barre etant posee sur la
|
||||||
|
* fenetre elle nait deja a sa place. En CSS il aurait fallu une valeur unique
|
||||||
|
* pour les deux cas, donc fausse dans l'un des deux.
|
||||||
|
*
|
||||||
|
* C'est `transform` et non `translate` qui porte la montee : le centrage
|
||||||
|
* horizontal occupe deja `translate`, et les deux proprietes se composent au lieu
|
||||||
|
* de s'ecraser.
|
||||||
|
*/
|
||||||
|
.barre-export {
|
||||||
|
transition:
|
||||||
|
opacity 0.6s ease-out,
|
||||||
|
transform 0.6s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barre-export--cachee {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(0.85rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width >= 64rem) {
|
||||||
|
/*
|
||||||
|
* Sur grand ecran la mise en page est calee sur la fenetre : rien ne doit faire
|
||||||
|
* defiler la PAGE. Le seul debordement vertical vient de la boule geante, dont
|
||||||
|
* la boite grandit avec elle (2,5 fois 460 px) alors que seule la boule est
|
||||||
|
* peinte — du vide qui allongeait la page de deux cents pixels.
|
||||||
|
*
|
||||||
|
* `clip` sur les deux axes ici, et non `hidden` : `hidden` ferait de `#app` un
|
||||||
|
* conteneur de defilement, donc rendrait ce debordement navigable au lieu de le
|
||||||
|
* supprimer. En dessous de 64rem la regle ne s'applique pas — la mise en page
|
||||||
|
* s'y empile et doit defiler pour de vrai.
|
||||||
|
*/
|
||||||
|
#app {
|
||||||
|
overflow: clip;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 0 minmax(0, 1fr) 20rem;
|
||||||
|
column-gap: 2.5rem;
|
||||||
|
transition: grid-template-columns var(--bascule);
|
||||||
|
/*
|
||||||
|
* Bornee a la fenetre. C'est ce qui donne aux panneaux une hauteur CONTRAINTE,
|
||||||
|
* sans quoi leur `overflow-y: auto` ne sert a rien : une piste de grille sans
|
||||||
|
* plafond prend la hauteur de son contenu, la boite ne deborde jamais d'
|
||||||
|
* elle-meme, et le panneau se retrouvait coupe par le rognage de `#app` sans
|
||||||
|
* pouvoir defiler. Sur une fenetre basse, c'est le panneau qui defile.
|
||||||
|
*/
|
||||||
|
max-height: 100%;
|
||||||
|
/*
|
||||||
|
* Piste unique DEFINIE, et non automatique. Une piste automatique prend la
|
||||||
|
* hauteur de son contenu et ignore le plafond du conteneur : le panneau
|
||||||
|
* gardait ses 773 px dans une fenetre de 620, se faisait rogner par `#app` et
|
||||||
|
* n'avait rien a defiler puisque sa boite ne debordait pas d'elle-meme.
|
||||||
|
*/
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene--gauche {
|
||||||
|
grid-template-columns: 20rem minmax(0, 1fr) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* La boule SEULE en scene : aucune colonne de panneau. Deux cas, et il fallait
|
||||||
|
* bien que ce soit la meme regle —
|
||||||
|
*
|
||||||
|
* - l'arrivee sur le site, ou c'est le RETOUR a `.scene` (la colonne de droite
|
||||||
|
* qui reprend ses 20rem) qui fait glisser la boule a sa place ;
|
||||||
|
* - l'apercu, ou les panneaux sont demontes. Sans cette classe la grille leur
|
||||||
|
* reserve quand meme leur piste — une colonne de 20rem plus sa gouttiere —
|
||||||
|
* et l'avatar se retrouve centre dans une colonne decalee, donc a 180 px a
|
||||||
|
* gauche du centre de l'ecran. Demonter un panneau ne retire pas sa piste.
|
||||||
|
*
|
||||||
|
* Les deux gouttieres subsistent et sont egales, donc la colonne du milieu
|
||||||
|
* reste centree au pixel.
|
||||||
|
*/
|
||||||
|
.scene--seule {
|
||||||
|
grid-template-columns: 0 minmax(0, 1fr) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* La boule dans les reglages : elle grandit jusqu'a sortir de l'ecran par la
|
||||||
|
* droite, et descend assez pour degager le haut de la page : sans cet air
|
||||||
|
* au-dessus d'elle, la vue se lit comme une image tronquee et non comme un
|
||||||
|
* cadrage.
|
||||||
|
*
|
||||||
|
* Un `transform` et pas la taille du SVG : la mise en page ne bouge pas, donc
|
||||||
|
* rien ne se remet en page a chaque image, et le trace restant vectoriel il
|
||||||
|
* reste net a n'importe quelle echelle.
|
||||||
|
*
|
||||||
|
* Ici, QUE la taille et la place. Le tour sur soi-meme, lui, n'est pas un
|
||||||
|
* miroir CSS : il est joue par le regard, les yeux faisant reellement le tour
|
||||||
|
* de la sphere (`src/ui/gaze.ts`). Retourner l'image par une echelle negative
|
||||||
|
* aurait couche les yeux en `//` au lieu de `\\` et aplati la boule au passage
|
||||||
|
* par zero — deux choses que le projet refuse.
|
||||||
|
*
|
||||||
|
* Les deux etats declarent la meme suite de fonctions, sinon le navigateur n'a
|
||||||
|
* rien a interpoler et le changement se ferait d'un coup.
|
||||||
|
*/
|
||||||
|
.avatar {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar--geant {
|
||||||
|
transform: translate(30vw, 14vh) scale(2.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene__gauche {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene__avatar {
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene__droite {
|
||||||
|
grid-column: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* La barre de montage s'arrete avant cette colonne. La scene reserve quand
|
||||||
|
* meme `--timeline` pour que l'avatar reste centre au-dessus, ce qui
|
||||||
|
* laissait un vide sous le panneau, a cote de la piste. On reprend cette
|
||||||
|
* reserve ici : le panneau redescend a la meme marge basse que partout
|
||||||
|
* ailleurs (les 2 rem de `.scene`).
|
||||||
|
*/
|
||||||
|
.scene--timeline .scene__droite {
|
||||||
|
margin-bottom: calc(1rem - var(--timeline));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Un panneau dans une colonne de largeur nulle doit disparaitre sans elargir sa
|
||||||
|
* piste : `min-width: 0` l'empeche de reclamer la largeur de son contenu, et le
|
||||||
|
* rognage coupe ce qui depasse pendant le glissement.
|
||||||
|
*/
|
||||||
|
.panneau {
|
||||||
|
min-width: 0;
|
||||||
|
/*
|
||||||
|
* `hidden` en largeur pour que la colonne qui se referme ecrase son contenu,
|
||||||
|
* `auto` en hauteur pour que ce soit le PANNEAU qui defile et non la page :
|
||||||
|
* la grille des vignettes du personnalisateur est plus haute qu'une fenetre
|
||||||
|
* basse, et la page ne defile plus (voir `#app` ci-dessus).
|
||||||
|
*/
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panneau::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panneau--ouvert {
|
||||||
|
opacity: 1;
|
||||||
|
/* le contenu n'apparait qu'une fois la place faite, sinon il se lit pendant
|
||||||
|
qu'il est encore ecrase */
|
||||||
|
transition: opacity 0.35s ease-out 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Barre d'export : calee sur le bas de la BOITE de l'avatar, pas sur le bas de
|
||||||
|
* la colonne.
|
||||||
|
*
|
||||||
|
* La boite est carree et centree dans la colonne, mais la silhouette n'occupe
|
||||||
|
* qu'environ 65 % de son cote — le viewBox du bot garde de quoi loger les
|
||||||
|
* anneaux des etats animes, qui n'existent pas au repos. Viser le bas de la
|
||||||
|
* colonne laissait donc 176 px de vide mesures entre la boule et le bouton, et
|
||||||
|
* la barre se lisait comme detachee de l'avatar.
|
||||||
|
*
|
||||||
|
* Le `min()` est exactement celui du `max-w` de `.avatar` : c'est la meme
|
||||||
|
* boite, donc la barre suit son retrecissement sur une fenetre basse au lieu de
|
||||||
|
* lui passer dessus.
|
||||||
|
*
|
||||||
|
* Rien n'est reserve ici : `--timeline` est deja soustrait de la hauteur de la
|
||||||
|
* colonne dans les deux vues, donc la bande du bas est libre et l'avatar ne
|
||||||
|
* bouge pas d'un pixel.
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
* Calee comme la barre de MONTAGE, et pas dans la colonne de l'avatar.
|
||||||
|
*
|
||||||
|
* C'est ce qui lui evite de bouger. Ancree dans la colonne, elle suivait
|
||||||
|
* l'interpolation de la grille : en venant des reglages, le panneau de gauche se
|
||||||
|
* referme et le centre de la colonne se deplace de 320 px — la barre glissait
|
||||||
|
* donc jusqu'a sa place au lieu d'y etre. La barre de montage, elle, ne bouge
|
||||||
|
* jamais parce qu'elle est posee sur la FENETRE.
|
||||||
|
*
|
||||||
|
* `left`/`right` sont exactement les siens : la bande couvre la colonne de
|
||||||
|
* l'avatar (4,5rem = marge + gouttiere ; 24,5rem = panneau + gouttiere + marge)
|
||||||
|
* et centre son contenu. La vue Personnaliser n'a qu'une seule disposition de
|
||||||
|
* grille, donc cette position est constante.
|
||||||
|
*/
|
||||||
|
.barre-export {
|
||||||
|
position: fixed;
|
||||||
|
left: 4.5rem;
|
||||||
|
right: 24.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
/*
|
||||||
|
* Juste sous la boite de l'avatar : haut de la bande + demi-bande (le centre
|
||||||
|
* de la boite, qui y est centree) + demi-boite. Le `min()` est celui du
|
||||||
|
* `max-w` de `.avatar`, donc la barre suit son retrecissement sur une fenetre
|
||||||
|
* basse. Le demi-rem final la fait mordre dans la marge morte du bas de la
|
||||||
|
* boite, ou la silhouette ne va pas.
|
||||||
|
*/
|
||||||
|
top: calc(
|
||||||
|
2rem + (100dvh - 3rem - var(--timeline)) / 2 +
|
||||||
|
min(460px, 100dvh - var(--timeline) - 7rem) / 2 - 0.5rem
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Arrivee sur le site (voir `src/ui/intro.ts` pour le montage et la regle).
|
||||||
|
*
|
||||||
|
* La boule parait seule au centre d'une page vide, a peine plus grande que sa
|
||||||
|
* taille de croisiere, prend le temps de s'installer, puis redescend a l'echelle 1
|
||||||
|
* en glissant vers sa place. Le glissement, lui, n'est pas ici : il vient des
|
||||||
|
* colonnes de `.scene--seule` qui se rouvrent, comme un changement de vue.
|
||||||
|
*
|
||||||
|
* Les ecarts sont volontairement FAIBLES — 0,9 a l'apparition, 1,06 en attente —
|
||||||
|
* la ou un surgissement ample (0,4 puis 1,22) se lisait comme un pop. La boule ne
|
||||||
|
* doit pas jaillir : elle doit PARAITRE, et ce qu'on regarde ensuite est le tour
|
||||||
|
* du regard puis le clignement qui pose l'interface, pas l'echelle.
|
||||||
|
*
|
||||||
|
* Ecrit en `@starting-style` plutot qu'en `@keyframes` : Vue monte toute la page
|
||||||
|
* en JavaScript, donc l'element est bel et bien INSERE, et une transition part
|
||||||
|
* alors de l'etat declare ici. Meme mecanisme que le grand mot du pied de page,
|
||||||
|
* et il a l'avantage de ne rien laisser tourner apres coup.
|
||||||
|
*
|
||||||
|
* Ecrit APRES la requete de largeur, et non dedans : le `transform` doit primer
|
||||||
|
* sur celui que `.avatar` y declare, et l'apparition vaut a toutes les tailles —
|
||||||
|
* sur petit ecran il n'y a pas de colonnes a rouvrir, mais la boule parait quand
|
||||||
|
* meme.
|
||||||
|
*/
|
||||||
|
.avatar--intro {
|
||||||
|
transform: translate(0, 0) scale(1.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
@starting-style {
|
||||||
|
.avatar--intro {
|
||||||
|
opacity: 0;
|
||||||
|
/* meme suite de fonctions que l'etat d'arrivee, sinon rien a interpoler */
|
||||||
|
transform: translate(0, 0) scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* La barre laterale pendant l'arrivee. Elle reste montee — elle est `fixed`, la
|
||||||
|
* demonter ne libererait aucune place — mais `inert`, ce qui la sort de l'ordre
|
||||||
|
* de tabulation en meme temps que du clic. L'attribut sert donc de selecteur :
|
||||||
|
* une seule source de verite pour « absente », au lieu d'une classe a tenir en
|
||||||
|
* plus. Elle revient avec le meme retard que `.panneau--ouvert`, pour que tout
|
||||||
|
* le decor se pose d'un seul mouvement.
|
||||||
|
*/
|
||||||
|
.rail {
|
||||||
|
transition: opacity 0.35s ease-out 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail[inert] {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.scene,
|
||||||
|
.panneau,
|
||||||
|
.panneau--ouvert,
|
||||||
|
.avatar {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barre-export {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Les boites modales manquaient a cette liste : `.dialogue` anime un
|
||||||
|
* `translateY(8px) scale(0.98)`, donc elles glissaient encore sous « mouvement
|
||||||
|
* reduit » alors que tout le reste etait neutralise. `.rail` et `.wordmark`, eux, ne
|
||||||
|
* jouent que sur l'opacite et n'ont rien a faire ici.
|
||||||
|
*/
|
||||||
|
.dialogue,
|
||||||
|
.dialogue::backdrop {
|
||||||
|
transition: none;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family:
|
||||||
|
ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
+351
@@ -0,0 +1,351 @@
|
|||||||
|
/**
|
||||||
|
* Assemblage de l'animation exportee : un SVG dont les yeux sont animes en CSS.
|
||||||
|
*
|
||||||
|
* Tout est pur — de la chaine vers de la chaine — donc testable en `node` comme le
|
||||||
|
* reste de `src/ui/`. C'est la capture des images cles qui a besoin du DOM, et elle
|
||||||
|
* vit dans `capture.ts`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Ecrit un entier non signe sur `n` octets, petit-boutiste. */
|
||||||
|
function le(valeur: number, n: number): number[] {
|
||||||
|
const out: number[] = []
|
||||||
|
for (let i = 0; i < n; i++) out.push((valeur / 2 ** (8 * i)) & 0xff)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const ascii = (s: string) => Array.from(s, (c) => c.charCodeAt(0))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injecte l'animation des yeux dans un SVG deja rendu.
|
||||||
|
*
|
||||||
|
* On ne reconstruit PAS le dessin : on part du SVG que `BloubBot` a produit pour
|
||||||
|
* la premiere image et on remplace le `transform` de chaque oeil par une classe
|
||||||
|
* animee. Une seule source de dessin, donc aucune derive possible — c'est la
|
||||||
|
* meme regle que pour l'export fixe.
|
||||||
|
*
|
||||||
|
* Seuls les yeux bougent, et c'est mesure : au repos la silhouette ne se deplace
|
||||||
|
* que de 1,17 unite sur un rayon de 100 en trois secondes, soit environ un pixel
|
||||||
|
* et demi a la taille d'export. Le corps reste donc tel quel, ce qui rend le
|
||||||
|
* fichier minuscule — tout le poids d'une animation du bot est dans son regard.
|
||||||
|
*
|
||||||
|
* L'interpolation est faite par le NAVIGATEUR : c'est ce qui rend l'animation
|
||||||
|
* lisse a la frequence de l'ecran au lieu de sauter d'image en image comme un
|
||||||
|
* feuilletage. C'est tout l'interet par rapport a un WebP ou un GIF.
|
||||||
|
*/
|
||||||
|
export function svgAnime(base: string, matrices: string[][], duree: number): string {
|
||||||
|
if (matrices.length < 2) throw new Error('il faut au moins deux images cles')
|
||||||
|
|
||||||
|
const clip = base.match(/<g[^>]*clip-path="[^"]+"[^>]*>[\s\S]*?<\/g>/)
|
||||||
|
if (!clip) throw new Error('yeux introuvables')
|
||||||
|
|
||||||
|
let n = 0
|
||||||
|
const clipAnime = clip[0].replace(/\stransform="[^"]*"/g, () => ` class="oeil${n++}"`)
|
||||||
|
if (n === 0) throw new Error('aucun oeil a animer')
|
||||||
|
|
||||||
|
const parImage = matrices[0]!.length
|
||||||
|
if (parImage !== n) throw new Error(`${n} yeux dans le clip, ${parImage} par image cle`)
|
||||||
|
|
||||||
|
const pas = 100 / (matrices.length - 1)
|
||||||
|
const regles = Array.from({ length: n }, (_, oeil) => {
|
||||||
|
const etapes = matrices
|
||||||
|
.map((m, i) => `${+(i * pas).toFixed(3)}%{transform:${m[oeil]}}`)
|
||||||
|
.join('')
|
||||||
|
return `@keyframes oeil${oeil}{${etapes}}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const style =
|
||||||
|
'<style>' +
|
||||||
|
// `transform-box`/`transform-origin` ne sont pas decoratifs : sans eux, une
|
||||||
|
// transformation CSS sur un element SVG tourne autour du centre de sa boite
|
||||||
|
// au lieu de l'origine du repere, et l'oeil part a l'autre bout de la boule.
|
||||||
|
`.oeil0,.oeil1{transform-box:view-box;transform-origin:0 0;` +
|
||||||
|
// `alternate` donne une boucle SANS COUTURE : la derive du regard n'est pas
|
||||||
|
// periodique (ses periodes sont premieres entre elles pour ne jamais se
|
||||||
|
// repeter), donc une boucle simple montrerait un saut au raccord. Jouee puis
|
||||||
|
// rejouee a l'envers, elle reboucle exactement sur elle-meme — et un
|
||||||
|
// clignement a l'envers reste un clignement.
|
||||||
|
`animation-duration:${duree}s;animation-iteration-count:infinite;` +
|
||||||
|
`animation-timing-function:linear;animation-direction:alternate}` +
|
||||||
|
Array.from({ length: n }, (_, i) => `.oeil${i}{animation-name:oeil${i}}`).join('') +
|
||||||
|
regles.join('') +
|
||||||
|
'</style>'
|
||||||
|
|
||||||
|
return base.replace(clip[0], clipAnime).replace('</svg>', `${style}</svg>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------- gif */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compression LZW du GIF : codes de longueur VARIABLE, ecrits bit a bit en
|
||||||
|
* commencant par le bit de poids faible, et regroupes en sous-blocs de 255 octets
|
||||||
|
* au plus.
|
||||||
|
*/
|
||||||
|
function lzw(indices: Uint8Array, bitsParPixel: number): number[] {
|
||||||
|
const clear = 1 << bitsParPixel
|
||||||
|
const fin = clear + 1
|
||||||
|
let taille = bitsParPixel + 1
|
||||||
|
let suivant = fin + 1
|
||||||
|
let dico = new Map<string, number>()
|
||||||
|
|
||||||
|
const octets: number[] = []
|
||||||
|
let reserve = 0
|
||||||
|
let bits = 0
|
||||||
|
const ecris = (code: number) => {
|
||||||
|
reserve |= code << bits
|
||||||
|
bits += taille
|
||||||
|
while (bits >= 8) {
|
||||||
|
octets.push(reserve & 0xff)
|
||||||
|
reserve >>= 8
|
||||||
|
bits -= 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ecris(clear)
|
||||||
|
let prefixe = String(indices[0])
|
||||||
|
for (let i = 1; i < indices.length; i++) {
|
||||||
|
const c = indices[i]!
|
||||||
|
const cle = `${prefixe},${c}`
|
||||||
|
const connu = dico.get(cle)
|
||||||
|
if (connu !== undefined) {
|
||||||
|
prefixe = String(connu)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ecris(Number(prefixe))
|
||||||
|
if (suivant < 4096) {
|
||||||
|
dico.set(cle, suivant++)
|
||||||
|
// La largeur du code grandit un cran APRES que le dictionnaire ait
|
||||||
|
// depasse ce qu'elle adresse, et ce decalage est le piege du format :
|
||||||
|
// l'encodeur inscrit son entree juste apres avoir emis un code, alors que
|
||||||
|
// le decodeur n'inscrit la sienne qu'en lisant le code SUIVANT. Il est donc
|
||||||
|
// toujours en retard d'une entree, et l'encodeur doit elargir en retard
|
||||||
|
// aussi. Ecrire `=== (1 << taille)` desynchronise les deux et le flux
|
||||||
|
// devient illisible — verifie par un test d'aller-retour.
|
||||||
|
if (suivant > 1 << taille && taille < 12) taille++
|
||||||
|
} else {
|
||||||
|
// Dictionnaire plein : on le repart a zero, le decodeur fait pareil en
|
||||||
|
// voyant le code de remise a zero.
|
||||||
|
ecris(clear)
|
||||||
|
dico = new Map()
|
||||||
|
taille = bitsParPixel + 1
|
||||||
|
suivant = fin + 1
|
||||||
|
}
|
||||||
|
prefixe = String(c)
|
||||||
|
}
|
||||||
|
ecris(Number(prefixe))
|
||||||
|
ecris(fin)
|
||||||
|
if (bits > 0) octets.push(reserve & 0xff)
|
||||||
|
|
||||||
|
// Decoupage en sous-blocs : chacun est precede de sa longueur, et un octet nul
|
||||||
|
// termine la serie.
|
||||||
|
const sortie: number[] = [bitsParPixel]
|
||||||
|
for (let i = 0; i < octets.length; i += 255) {
|
||||||
|
const bloc = octets.slice(i, i + 255)
|
||||||
|
sortie.push(bloc.length, ...bloc)
|
||||||
|
}
|
||||||
|
sortie.push(0)
|
||||||
|
return sortie
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Palette commune a toutes les images, index 0 reserve au transparent.
|
||||||
|
*
|
||||||
|
* Elle se construit en DEUX temps — recenser les couleurs, puis indexer les
|
||||||
|
* images — parce qu'un GIF exige une palette commune : il faut avoir vu toutes
|
||||||
|
* les images avant d'en encoder une seule. Sur un cycle de trente secondes, les
|
||||||
|
* garder en pixels bruts couterait 255 Mo ; recensees puis indexees, elles ne
|
||||||
|
* pesent plus qu'un octet par pixel.
|
||||||
|
*/
|
||||||
|
export interface PaletteGif {
|
||||||
|
/** Combien de pixels par teinte, pendant le recensement. */
|
||||||
|
vues: Map<number, number>
|
||||||
|
/** RGB compacte sur 24 bits -> index, une fois la palette arretee. */
|
||||||
|
index: Map<number, number>
|
||||||
|
/** Resolution des teintes absentes vers la plus proche retenue. */
|
||||||
|
proches: Map<number, number>
|
||||||
|
/** Au moins un pixel transparent a ete vu. */
|
||||||
|
transparence: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nouvellePalette = (): PaletteGif => ({
|
||||||
|
vues: new Map(),
|
||||||
|
index: new Map(),
|
||||||
|
proches: new Map(),
|
||||||
|
transparence: false
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recense les couleurs d'une image, en COMPTANT les pixels.
|
||||||
|
*
|
||||||
|
* Le comptage n'est pas un detail : une palette remplie au premier arrive se
|
||||||
|
* serait gorgee des nuances d'anticrenelage des premieres images, et les teintes
|
||||||
|
* des anneaux — qui n'apparaissent qu'au milieu du cycle — seraient toutes tombees
|
||||||
|
* sur la meme case. Mesure : un cycle depasse les 255 couleurs, donc il FAUT
|
||||||
|
* choisir lesquelles garder.
|
||||||
|
*/
|
||||||
|
export function recense(palette: PaletteGif, px: Uint8ClampedArray) {
|
||||||
|
for (let p = 0; p < px.length; p += 4) {
|
||||||
|
if (px[p + 3]! < 128) {
|
||||||
|
palette.transparence = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const cle = (px[p]! << 16) | (px[p + 1]! << 8) | px[p + 2]!
|
||||||
|
palette.vues.set(cle, (palette.vues.get(cle) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Arrete la palette sur les 255 teintes les plus presentes. */
|
||||||
|
function arrete(palette: PaletteGif) {
|
||||||
|
if (palette.index.size) return
|
||||||
|
const tries = [...palette.vues.entries()].sort((a, b) => b[1] - a[1]).slice(0, 255)
|
||||||
|
for (const [cle] of tries) palette.index.set(cle, palette.index.size + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index de la teinte la plus proche parmi celles retenues, mis en cache. */
|
||||||
|
function plusProche(palette: PaletteGif, cle: number) {
|
||||||
|
const connu = palette.proches.get(cle)
|
||||||
|
if (connu !== undefined) return connu
|
||||||
|
const r = (cle >> 16) & 0xff
|
||||||
|
const v = (cle >> 8) & 0xff
|
||||||
|
const b = cle & 0xff
|
||||||
|
let meilleur = 1
|
||||||
|
let ecart = Infinity
|
||||||
|
for (const [autre, i] of palette.index) {
|
||||||
|
const dr = r - ((autre >> 16) & 0xff)
|
||||||
|
const dv = v - ((autre >> 8) & 0xff)
|
||||||
|
const db = b - (autre & 0xff)
|
||||||
|
const d = dr * dr + dv * dv + db * db
|
||||||
|
if (d < ecart) {
|
||||||
|
ecart = d
|
||||||
|
meilleur = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Le cache borne le cout : chaque teinte absente n'est resolue qu'une fois,
|
||||||
|
// meme si elle revient sur des centaines d'images.
|
||||||
|
palette.proches.set(cle, meilleur)
|
||||||
|
return meilleur
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Traduit une image en index de palette, un octet par pixel. */
|
||||||
|
export function indexe(palette: PaletteGif, px: Uint8ClampedArray): Uint8Array {
|
||||||
|
arrete(palette)
|
||||||
|
const out = new Uint8Array(px.length / 4)
|
||||||
|
for (let i = 0, p = 0; i < out.length; i++, p += 4) {
|
||||||
|
// 0 = transparent, et c'est deja la valeur par defaut du tableau
|
||||||
|
if (px[p + 3]! < 128) continue
|
||||||
|
const cle = (px[p]! << 16) | (px[p + 1]! << 8) | px[p + 2]!
|
||||||
|
out[i] = palette.index.get(cle) ?? plusProche(palette, cle)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assemble des images en GIF anime.
|
||||||
|
*
|
||||||
|
* La transparence du GIF est sur UN bit : un pixel est opaque ou invisible, rien
|
||||||
|
* entre les deux. Sur un fond transparent, le bord antialiase de la boule est donc
|
||||||
|
* tranche a 50 % d'opacite et ressort en marches d'escalier. Ce n'est pas un defaut
|
||||||
|
* a corriger, c'est le format — d'ou le choix laisse a l'utilisateur entre ce bord
|
||||||
|
* dur et un fond plein, ou le bord reste lisse parce qu'il a de quoi se fondre.
|
||||||
|
*
|
||||||
|
* La transparence est DEDUITE des images, pas demandee : des images deja aplaties
|
||||||
|
* sur un fond n'ont pas a declarer d'index transparent, et surtout pas a etre
|
||||||
|
* eliminees « retour au fond » entre deux — ce qui ferait clignoter le fond.
|
||||||
|
*
|
||||||
|
* Le GIF n'existe que pour les endroits qui refusent le SVG anime, comme les
|
||||||
|
* avatars animes de Discord ou de Slack. Partout ailleurs le SVG est meilleur.
|
||||||
|
*/
|
||||||
|
export function gifAnime(
|
||||||
|
images: Uint8ClampedArray[],
|
||||||
|
largeur: number,
|
||||||
|
hauteur: number,
|
||||||
|
delaiMs: number
|
||||||
|
): Uint8Array<ArrayBuffer> {
|
||||||
|
if (!images.length) throw new Error('aucune image a emballer')
|
||||||
|
const palette = nouvellePalette()
|
||||||
|
for (const px of images) recense(palette, px)
|
||||||
|
return gifIndexe(
|
||||||
|
palette,
|
||||||
|
images.map((px) => indexe(palette, px)),
|
||||||
|
largeur,
|
||||||
|
hauteur,
|
||||||
|
delaiMs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meme chose, mais sur des images DEJA indexees — la voie a memoire bornee, pour
|
||||||
|
* les longues sequences. Cf. `PaletteGif`.
|
||||||
|
*/
|
||||||
|
export function gifIndexe(
|
||||||
|
palette: PaletteGif,
|
||||||
|
images: Uint8Array[],
|
||||||
|
largeur: number,
|
||||||
|
hauteur: number,
|
||||||
|
delaiMs: number
|
||||||
|
): Uint8Array<ArrayBuffer> {
|
||||||
|
if (!images.length) throw new Error('aucune image a emballer')
|
||||||
|
|
||||||
|
arrete(palette)
|
||||||
|
const transparence = palette.transparence
|
||||||
|
const couleurs = palette.index
|
||||||
|
// Une palette GIF a une taille en puissance de deux, et il faut au moins deux
|
||||||
|
// bits par pixel pour que le code de remise a zero tienne.
|
||||||
|
const bits = Math.max(2, Math.ceil(Math.log2(couleurs.size + 1)))
|
||||||
|
const tailleTable = 1 << bits
|
||||||
|
|
||||||
|
const table: number[] = [0, 0, 0] // index 0 : le transparent, couleur sans importance
|
||||||
|
for (const cle of couleurs.keys()) table.push((cle >> 16) & 0xff, (cle >> 8) & 0xff, cle & 0xff)
|
||||||
|
while (table.length < tailleTable * 3) table.push(0)
|
||||||
|
|
||||||
|
const out: number[] = [
|
||||||
|
...ascii('GIF89a'),
|
||||||
|
...le(largeur, 2),
|
||||||
|
...le(hauteur, 2),
|
||||||
|
0x80 | (bits - 1), // table globale presente, et sa taille
|
||||||
|
0, // index de fond : le transparent
|
||||||
|
0, // pas de rapport d'aspect
|
||||||
|
...table,
|
||||||
|
// Boucle sans fin, par l'extension Netscape — rien d'autre ne sait le dire.
|
||||||
|
0x21,
|
||||||
|
0xff,
|
||||||
|
0x0b,
|
||||||
|
...ascii('NETSCAPE2.0'),
|
||||||
|
0x03,
|
||||||
|
0x01,
|
||||||
|
...le(0, 2),
|
||||||
|
0x00
|
||||||
|
]
|
||||||
|
|
||||||
|
// Le delai du GIF se compte en CENTIEMES de seconde : c'est ce qui limite sa
|
||||||
|
// cadence, un delai de 0 ou 1 etant traite differemment selon les lecteurs.
|
||||||
|
const delai = Math.max(2, Math.round(delaiMs / 10))
|
||||||
|
|
||||||
|
for (const indices of images) {
|
||||||
|
out.push(
|
||||||
|
0x21,
|
||||||
|
0xf9,
|
||||||
|
0x04,
|
||||||
|
// Sur fond transparent : elimination « retour au fond » (2) et index
|
||||||
|
// transparent declare, sinon les zones vides laissent voir l'image
|
||||||
|
// precedente et la boule traine derriere elle.
|
||||||
|
//
|
||||||
|
// Sur fond plein : « laisser en place » (1), et surtout PAS de retour au
|
||||||
|
// fond — chaque image couvre toute la toile, donc effacer entre deux ne
|
||||||
|
// sert qu'a faire clignoter le fond.
|
||||||
|
transparence ? (2 << 2) | 0x01 : 1 << 2,
|
||||||
|
...le(delai, 2),
|
||||||
|
0, // index transparent
|
||||||
|
0x00,
|
||||||
|
0x2c,
|
||||||
|
...le(0, 2),
|
||||||
|
...le(0, 2),
|
||||||
|
...le(largeur, 2),
|
||||||
|
...le(hauteur, 2),
|
||||||
|
0, // pas de table locale, pas d'entrelacement
|
||||||
|
...lzw(indices, bits)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push(0x3b)
|
||||||
|
return new Uint8Array(out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
/**
|
||||||
|
* Capture replica : serialise le SVG vivant (aplatit les var CSS) et rejoue
|
||||||
|
* hors ecran via GrokCharacter.step, sans rAF.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Block } from '@/editor/cycles'
|
||||||
|
import { mountReplica, type ReplicaInput } from '@/replica/host'
|
||||||
|
import { gifAnime, gifIndexe, indexe, nouvellePalette, recense, svgAnime } from './anime'
|
||||||
|
import { arrete, demiEcran, sansCommentaires, viewBoxExport } from './export'
|
||||||
|
|
||||||
|
export function svgAutonome(svg: SVGSVGElement, taille: number, viewBox = viewBoxExport()) {
|
||||||
|
const clone = flattenSvg(svg)
|
||||||
|
clone.removeAttribute('class')
|
||||||
|
clone.removeAttribute('style')
|
||||||
|
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
||||||
|
clone.setAttribute('viewBox', viewBox)
|
||||||
|
clone.setAttribute('width', String(taille))
|
||||||
|
clone.setAttribute('height', String(taille))
|
||||||
|
return sansCommentaires(new XMLSerializer().serializeToString(clone))
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenSvg(root: SVGSVGElement) {
|
||||||
|
const clone = root.cloneNode(true) as SVGSVGElement
|
||||||
|
const walk = (src: Element, dst: Element) => {
|
||||||
|
const cs = getComputedStyle(src)
|
||||||
|
for (const attr of ['fill', 'stroke'] as const) {
|
||||||
|
const raw = src.getAttribute(attr)
|
||||||
|
if (raw?.includes('var(')) dst.setAttribute(attr, cs[attr])
|
||||||
|
}
|
||||||
|
const style = src.getAttribute('style')
|
||||||
|
if (style?.includes('var(')) {
|
||||||
|
let next = style
|
||||||
|
if (/fill\s*:/.test(style)) next = next.replace(/fill\s*:\s*var\([^)]+\)/g, `fill:${cs.fill}`)
|
||||||
|
if (/stroke\s*:/.test(style)) {
|
||||||
|
next = next.replace(/stroke\s*:\s*var\([^)]+\)/g, `stroke:${cs.stroke}`)
|
||||||
|
}
|
||||||
|
dst.setAttribute('style', next)
|
||||||
|
}
|
||||||
|
const kids = [...src.children]
|
||||||
|
const copies = [...dst.children]
|
||||||
|
for (let i = 0; i < kids.length; i++) walk(kids[i]!, copies[i]!)
|
||||||
|
}
|
||||||
|
walk(root, clone)
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dessine(
|
||||||
|
markup: string,
|
||||||
|
taille: number,
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
fond: string | null = null
|
||||||
|
) {
|
||||||
|
const url = URL.createObjectURL(new Blob([markup], { type: 'image/svg+xml' }))
|
||||||
|
try {
|
||||||
|
const img = new Image()
|
||||||
|
img.src = url
|
||||||
|
await img.decode()
|
||||||
|
canvas.width = taille
|
||||||
|
canvas.height = taille
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) throw new Error('canvas indisponible')
|
||||||
|
ctx.clearRect(0, 0, taille, taille)
|
||||||
|
if (fond) {
|
||||||
|
ctx.fillStyle = fond
|
||||||
|
ctx.fillRect(0, 0, taille, taille)
|
||||||
|
}
|
||||||
|
ctx.drawImage(img, 0, 0, taille, taille)
|
||||||
|
return ctx
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function versPng(markup: string, taille: number): Promise<Blob> {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
await dessine(markup, taille, canvas)
|
||||||
|
return await new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob) => (blob ? resolve(blob) : reject(new Error('encodage png impossible'))),
|
||||||
|
'image/png'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function telecharge(blob: Blob, nom: string) {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
try {
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = nom
|
||||||
|
a.click()
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 10_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function copiePossible() {
|
||||||
|
return (
|
||||||
|
typeof ClipboardItem !== 'undefined' &&
|
||||||
|
!!navigator.clipboard?.write &&
|
||||||
|
(ClipboardItem.supports?.('image/png') ?? true)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copie(blob: Promise<Blob>) {
|
||||||
|
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copieTexte(texte: string) {
|
||||||
|
await navigator.clipboard.writeText(texte)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Avancement = (fait: number, total: number) => void
|
||||||
|
|
||||||
|
export interface ReglagesBot {
|
||||||
|
shape: string
|
||||||
|
color: string
|
||||||
|
expression: string
|
||||||
|
state?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputOf(reglages: ReglagesBot, taille: number, paper?: string): ReplicaInput {
|
||||||
|
return {
|
||||||
|
state: reglages.state ?? reglages.expression,
|
||||||
|
shape: reglages.shape,
|
||||||
|
color: reglages.color,
|
||||||
|
follow: false,
|
||||||
|
paper: paper ?? '#f9f9f9',
|
||||||
|
size: taille
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ouvreBot(reglages: ReglagesBot, taille: number, paper?: string) {
|
||||||
|
const hote = document.createElement('div')
|
||||||
|
hote.style.cssText = 'position:fixed;left:-99999px;top:0;width:1px;height:1px;overflow:hidden'
|
||||||
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||||
|
hote.appendChild(svg)
|
||||||
|
document.body.appendChild(hote)
|
||||||
|
const mount = await mountReplica(svg, inputOf(reglages, taille, paper), false, { driven: true })
|
||||||
|
if (!mount) {
|
||||||
|
hote.remove()
|
||||||
|
throw new Error('replica hors ecran indisponible')
|
||||||
|
}
|
||||||
|
return { hote, svg, mount }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cycleVersMp4(
|
||||||
|
reglages: ReglagesBot,
|
||||||
|
blocs: Block[],
|
||||||
|
taille: number,
|
||||||
|
images: number,
|
||||||
|
pas: number,
|
||||||
|
fond: string,
|
||||||
|
avance?: Avancement,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<Blob> {
|
||||||
|
const { versMp4 } = await import('./video')
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
const lecteur = await ouvreCycle(reglages, blocs, taille, fond)
|
||||||
|
try {
|
||||||
|
return await versMp4(
|
||||||
|
canvas,
|
||||||
|
images,
|
||||||
|
Math.round(1 / pas),
|
||||||
|
async (i) => {
|
||||||
|
const svg = await lecteur.rendre(i * pas)
|
||||||
|
await dessine(svgAutonome(svg, taille, viewBoxExport(demiEcran())), taille, canvas, fond)
|
||||||
|
},
|
||||||
|
avance,
|
||||||
|
signal
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
lecteur.ferme()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cycleVersGif(
|
||||||
|
reglages: ReglagesBot,
|
||||||
|
blocs: Block[],
|
||||||
|
taille: number,
|
||||||
|
images: number,
|
||||||
|
pas: number,
|
||||||
|
fond: string | null,
|
||||||
|
avance?: Avancement,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<Blob> {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
const vue = viewBoxExport(demiEcran())
|
||||||
|
const lecteur = await ouvreCycle(reglages, blocs, taille, fond ?? undefined)
|
||||||
|
|
||||||
|
const passe = async (lis: (index: number, pixels: Uint8ClampedArray) => void) => {
|
||||||
|
for (let i = 0; i < images; i++) {
|
||||||
|
arrete(signal)
|
||||||
|
const svg = await lecteur.rendre(i * pas)
|
||||||
|
const ctx = await dessine(svgAutonome(svg, taille, vue), taille, canvas, fond)
|
||||||
|
lis(i, ctx.getImageData(0, 0, taille, taille).data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const palette = nouvellePalette()
|
||||||
|
await passe((i, pixels) => {
|
||||||
|
recense(palette, pixels)
|
||||||
|
avance?.(i + 1, images * 2)
|
||||||
|
})
|
||||||
|
const morceaux: Uint8Array[] = []
|
||||||
|
await passe((i, pixels) => {
|
||||||
|
morceaux.push(indexe(palette, pixels))
|
||||||
|
avance?.(images + i + 1, images * 2)
|
||||||
|
})
|
||||||
|
return new Blob([gifIndexe(palette, morceaux, taille, taille, Math.round(pas * 1000))], {
|
||||||
|
type: 'image/gif'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
lecteur.ferme()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LecteurHorsEcran {
|
||||||
|
rendre: (t: number) => Promise<SVGSVGElement>
|
||||||
|
ferme: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ouvreCycle(
|
||||||
|
reglages: ReglagesBot,
|
||||||
|
blocs: Block[],
|
||||||
|
taille: number,
|
||||||
|
paper?: string
|
||||||
|
): Promise<LecteurHorsEcran> {
|
||||||
|
const { blockAt } = await import('@/editor/cycles')
|
||||||
|
let session = await ouvreBot({ ...reglages, state: blocs[0]?.state ?? 'idle' }, taille, paper)
|
||||||
|
let clock = 0
|
||||||
|
|
||||||
|
const reset = async () => {
|
||||||
|
session.mount.destroy()
|
||||||
|
session.hote.remove()
|
||||||
|
session = await ouvreBot({ ...reglages, state: blocs[0]?.state ?? 'idle' }, taille, paper)
|
||||||
|
clock = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rendre: async (t) => {
|
||||||
|
if (t < clock - 1e-6) await reset()
|
||||||
|
const pas = 1 / 30
|
||||||
|
while (clock < t - 1e-9) {
|
||||||
|
const next = Math.min(t, clock + pas)
|
||||||
|
const { index } = blockAt(blocs, next)
|
||||||
|
const want = blocs[index]?.state ?? 'idle'
|
||||||
|
session.mount.setState(want)
|
||||||
|
session.mount.step(next - clock)
|
||||||
|
clock = next
|
||||||
|
}
|
||||||
|
return session.svg
|
||||||
|
},
|
||||||
|
ferme: () => {
|
||||||
|
session.mount.destroy()
|
||||||
|
session.hote.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssTransform(svgTransform: string) {
|
||||||
|
return svgTransform.replace(/([a-z]+)\(([^)]+)\)/gi, (_m, fn: string, args: string) => {
|
||||||
|
return `${fn}(${args.trim().split(/[\s,]+/).join(', ')})`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function matricesDesYeux(svg: SVGSVGElement) {
|
||||||
|
return [...svg.querySelectorAll('g[clip-path] path')].map((e) =>
|
||||||
|
cssTransform(e.getAttribute('transform') || 'translate(0 0)')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function versSvgAnime(
|
||||||
|
reglages: ReglagesBot,
|
||||||
|
taille: number,
|
||||||
|
nombre: number,
|
||||||
|
pas: number
|
||||||
|
): Promise<Blob> {
|
||||||
|
const { hote, svg, mount } = await ouvreBot(reglages, taille)
|
||||||
|
try {
|
||||||
|
let base = ''
|
||||||
|
const matrices: string[][] = []
|
||||||
|
for (let i = 0; i < nombre; i++) {
|
||||||
|
if (i > 0) mount.step(pas)
|
||||||
|
if (i === 0) base = svgAutonome(svg, taille)
|
||||||
|
matrices.push(matricesDesYeux(svg))
|
||||||
|
}
|
||||||
|
return new Blob([svgAnime(base, matrices, +((nombre - 1) * pas).toFixed(3))], {
|
||||||
|
type: 'image/svg+xml'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
mount.destroy()
|
||||||
|
hote.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function versGifAnime(
|
||||||
|
reglages: ReglagesBot,
|
||||||
|
taille: number,
|
||||||
|
nombre: number,
|
||||||
|
pas: number,
|
||||||
|
fond: string | null
|
||||||
|
): Promise<Blob> {
|
||||||
|
const { hote, svg, mount } = await ouvreBot(reglages, taille, fond ?? undefined)
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
try {
|
||||||
|
const images: Uint8ClampedArray[] = []
|
||||||
|
for (let i = 0; i < nombre; i++) {
|
||||||
|
if (i > 0) mount.step(pas)
|
||||||
|
const ctx = await dessine(svgAutonome(svg, taille), taille, canvas, fond)
|
||||||
|
images.push(ctx.getImageData(0, 0, taille, taille).data)
|
||||||
|
}
|
||||||
|
return new Blob([gifAnime(images, taille, taille, Math.round(pas * 1000))], {
|
||||||
|
type: 'image/gif'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
mount.destroy()
|
||||||
|
hote.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Cadrage et nommage des images exportees. Pur : aucun DOM.
|
||||||
|
* Le cadre serre (avatar au repos) et le cadre ecran (cycle, anneaux) suivent
|
||||||
|
* Bloub : 1,15 × 1,08 autour du rayon, et 1,58 pour loger les overlay.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MARGE = 1.08
|
||||||
|
const RAYON_MAX = 1.15
|
||||||
|
const ECRAN = 1.58
|
||||||
|
|
||||||
|
export function rayonMoteur() {
|
||||||
|
return window.GROK_GEO?.Re ?? 114.27
|
||||||
|
}
|
||||||
|
|
||||||
|
export function demiCadre() {
|
||||||
|
return Math.ceil(rayonMoteur() * RAYON_MAX * MARGE)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function demiEcran() {
|
||||||
|
return rayonMoteur() * ECRAN
|
||||||
|
}
|
||||||
|
|
||||||
|
export function viewBoxExport(demi = demiCadre()) {
|
||||||
|
const mid = rayonMoteur()
|
||||||
|
return `${(mid - demi).toFixed(2)} ${(mid - demi).toFixed(2)} ${(demi * 2).toFixed(2)} ${(demi * 2).toFixed(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActionId = 'png' | 'svg' | 'anime' | 'gif' | 'copie' | 'copieSvg'
|
||||||
|
export type ModeExport = 'telecharge' | 'anime' | 'gif' | 'copieImage' | 'copieTexte'
|
||||||
|
|
||||||
|
export interface ActionExport {
|
||||||
|
id: ActionId
|
||||||
|
mode: ModeExport
|
||||||
|
taille: number
|
||||||
|
extension: 'png' | 'svg' | 'gif'
|
||||||
|
suffixe?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ANIM_CLES_PAR_SEC = 30
|
||||||
|
export const ANIM_SECONDES = 3
|
||||||
|
export const ANIM_IMAGES = ANIM_CLES_PAR_SEC * ANIM_SECONDES
|
||||||
|
export const ANIM_PAS = 1 / ANIM_CLES_PAR_SEC
|
||||||
|
|
||||||
|
export const GIF_FPS = 20
|
||||||
|
export const GIF_IMAGES = GIF_FPS * ANIM_SECONDES
|
||||||
|
export const GIF_PAS = 1 / GIF_FPS
|
||||||
|
export const GIF_TAILLE = 320
|
||||||
|
|
||||||
|
export type FormatCycle = 'mp4' | 'gif'
|
||||||
|
export const FORMATS_CYCLE: FormatCycle[] = ['mp4', 'gif']
|
||||||
|
export const FORMAT_CYCLE_DEFAUT: FormatCycle = 'mp4'
|
||||||
|
|
||||||
|
export const CYCLE_FPS = { gif: 20, mp4: 30 } as const
|
||||||
|
export const CYCLE_TAILLE = { gif: 320, mp4: 1024 } as const
|
||||||
|
export const cyclePas = (format: FormatCycle) => 1 / CYCLE_FPS[format]
|
||||||
|
export const cycleImages = (duree: number, format: FormatCycle) =>
|
||||||
|
Math.max(1, Math.round(duree * CYCLE_FPS[format]))
|
||||||
|
export const cycleAccepteTransparence = (format: FormatCycle) => format === 'gif'
|
||||||
|
|
||||||
|
export type FondGif = 'blanc' | 'transparent'
|
||||||
|
export const FONDS_GIF: FondGif[] = ['blanc', 'transparent']
|
||||||
|
export const FOND_GIF_DEFAUT: FondGif = 'blanc'
|
||||||
|
export const BLANC = '#ffffff'
|
||||||
|
export const couleurDeFond = (fond: FondGif) => (fond === 'blanc' ? BLANC : null)
|
||||||
|
|
||||||
|
export const ACTIONS: ActionExport[] = [
|
||||||
|
{ id: 'png', mode: 'telecharge', taille: 1024, extension: 'png' },
|
||||||
|
{ id: 'svg', mode: 'telecharge', taille: 0, extension: 'svg' },
|
||||||
|
{ id: 'anime', mode: 'anime', taille: 0, extension: 'svg', suffixe: 'anime' },
|
||||||
|
{ id: 'gif', mode: 'gif', taille: GIF_TAILLE, extension: 'gif' },
|
||||||
|
{ id: 'copie', mode: 'copieImage', taille: 1024, extension: 'png' },
|
||||||
|
{ id: 'copieSvg', mode: 'copieTexte', taille: 0, extension: 'svg' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ACTION_BY_ID = new Map<string, ActionExport>(ACTIONS.map((a) => [a.id, a]))
|
||||||
|
export const ACTION_DEFAUT: ActionId = 'png'
|
||||||
|
|
||||||
|
export function tailleAction(action: ActionExport) {
|
||||||
|
return action.taille || demiCadre() * 2
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EtatExport = 'pret' | 'occupe' | 'exporte' | 'copie' | 'erreur'
|
||||||
|
|
||||||
|
export function sansCommentaires(markup: string) {
|
||||||
|
return markup.replace(/<!--[\s\S]*?-->/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nomFichier(
|
||||||
|
forme: string,
|
||||||
|
expression: string,
|
||||||
|
couleur: string,
|
||||||
|
extension: string,
|
||||||
|
suffixe = ''
|
||||||
|
) {
|
||||||
|
const propre = (v: string) =>
|
||||||
|
v
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 40)
|
||||||
|
const morceaux = [propre(forme), propre(expression), propre(couleur), propre(suffixe)].filter(
|
||||||
|
Boolean
|
||||||
|
)
|
||||||
|
return `study${morceaux.map((m) => `-${m}`).join('')}.${extension}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function videoPossible() {
|
||||||
|
return typeof VideoEncoder !== 'undefined'
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Abandon extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('export abandonne')
|
||||||
|
this.name = 'Abandon'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function arrete(signal: AbortSignal | undefined) {
|
||||||
|
if (signal?.aborted) throw new Abandon()
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
const PREFIXE = 'study:'
|
||||||
|
const NOMS = ['cycles', 'cycle', 'forme', 'couleur', 'expression', 'langue'] as const
|
||||||
|
|
||||||
|
export type NomStocke = (typeof NOMS)[number]
|
||||||
|
|
||||||
|
export function cle(nom: NomStocke): string {
|
||||||
|
return `${PREFIXE}${nom}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lis(nom: NomStocke): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(cle(nom))
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ecris(nom: NomStocke, valeur: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(cle(nom), valeur)
|
||||||
|
} catch {
|
||||||
|
/* persist is optional */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Reglages et mises en forme de la piste de montage. Tout est pur : la piste
|
||||||
|
* n'a besoin d'aucun DOM pour savoir ou tomber, ce qui rend ses graduations
|
||||||
|
* testables — c'est la meme discipline que `src/bot/`, appliquee a l'affichage.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Largeur d'une carte, en pixels par seconde : la duree se lit donc directement
|
||||||
|
* dans la piste. Le zoom multiplie cette echelle — il ne change jamais le
|
||||||
|
* montage, seulement la loupe qu'on pose dessus.
|
||||||
|
*/
|
||||||
|
export const BASE_SCALE = 44
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bornes de la loupe : au plus petit, le montage releve sur la video tient
|
||||||
|
* entier dans la piste ; au plus grand, une carte reste manipulable sans
|
||||||
|
* devenir un mur.
|
||||||
|
*/
|
||||||
|
export const MIN_ZOOM = 0.45
|
||||||
|
export const MAX_ZOOM = 2.4
|
||||||
|
|
||||||
|
/** Ecart minimal entre deux reperes chiffres de la regle, en pixels. */
|
||||||
|
const TICK_SPACING = 52
|
||||||
|
|
||||||
|
/** Pas possibles de la regle, du plus fin au plus large. */
|
||||||
|
const STEPS = [0.5, 1, 2, 5, 10, 15, 30, 60]
|
||||||
|
|
||||||
|
export function clampZoom(v: number) {
|
||||||
|
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, v))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `0:04` — les dixiemes changeraient trop vite pour etre lisibles. */
|
||||||
|
export function mmss(t: number) {
|
||||||
|
const s = Math.max(0, Math.floor(t))
|
||||||
|
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Les durees en secondes ne sont PAS mises en forme ici : le separateur decimal
|
||||||
|
* change avec la langue (virgule en francais, point en anglais) et l'unite se
|
||||||
|
* traduit. Elles passent donc par `secondes` / `secondesCourtes` de `@/i18n`,
|
||||||
|
* qui a la langue courante — ce module, lui, reste pur.
|
||||||
|
*
|
||||||
|
* `mmss` reste : le format mm:ss n'a ni unite ni separateur decimal.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reperes de la regle pour une duree et une echelle donnees. Le pas chiffre est
|
||||||
|
* le premier de `STEPS` qui laisse `TICK_SPACING` entre deux nombres : c'est ce
|
||||||
|
* qui fait qu'en dezoomant on passe de 1 s a 5 s puis a 10 s au lieu d'empiler
|
||||||
|
* des chiffres illisibles. Les reperes intermediaires n'apparaissent que s'ils
|
||||||
|
* ne se collent pas les uns aux autres.
|
||||||
|
*/
|
||||||
|
export function ticksFor(total: number, scale: number): Array<{ t: number; major: boolean }> {
|
||||||
|
const major = STEPS.find((s) => s * scale >= TICK_SPACING) ?? STEPS[STEPS.length - 1]!
|
||||||
|
const step = (major / 5) * scale >= 7 ? major / 5 : major
|
||||||
|
const out: Array<{ t: number; major: boolean }> = []
|
||||||
|
for (let i = 0; i * step <= total + 1e-6 && out.length < MAX_TICKS; i++) {
|
||||||
|
const t = i * step
|
||||||
|
out.push({ t, major: Math.abs(t / major - Math.round(t / major)) < 1e-6 })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plafond du nombre de graduations. Garde-fou et non reglage.
|
||||||
|
*
|
||||||
|
* `parseCycles` borne deja la taille d'un montage relu, mais cette fonction ne doit pas
|
||||||
|
* dependre de ce garde-la pour rester bornee : elle rend un objet par graduation et le
|
||||||
|
* composant un `<span>` par objet, donc une duree aberrante — un montage bricole a la
|
||||||
|
* main, un zoom extreme — se paie en centaines de milliers de noeuds et l'onglet fige.
|
||||||
|
*
|
||||||
|
* Deux mille couvre trente minutes de montage au zoom le plus fin, soit bien plus que ce
|
||||||
|
* que `MAX_BLOCS` autorise a relire. La regle est de toute facon plus large que l'ecran :
|
||||||
|
* la piste defile, elle n'affiche jamais tout.
|
||||||
|
*/
|
||||||
|
const MAX_TICKS = 2000
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { onBeforeUnmount, watch, type Ref, type ShallowRef } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pilote un `<dialog>` natif ouvert en modal depuis un booleen.
|
||||||
|
*
|
||||||
|
* On passe par l'element natif plutot que par une div : le navigateur fournit
|
||||||
|
* alors le piege a focus, la fermeture par Echap, le retour du focus au
|
||||||
|
* declencheur, l'inertie du reste de la page et le fond assombri — tout ce
|
||||||
|
* qu'on reecrirait moins bien a la main.
|
||||||
|
*
|
||||||
|
* L'element vient de l'appelant (`useTemplateRef`) plutot que d'ici : c'est lui
|
||||||
|
* qui nomme la ref dans son template. Cote template, il reste a renvoyer la
|
||||||
|
* fermeture native vers le booleen :
|
||||||
|
* `@close="open = false" @cancel.prevent="open = false"`.
|
||||||
|
*/
|
||||||
|
export function useModalDialog(
|
||||||
|
open: Ref<boolean>,
|
||||||
|
el: Readonly<ShallowRef<HTMLDialogElement | null>>
|
||||||
|
) {
|
||||||
|
watch(open, (on) => {
|
||||||
|
const dialog = el.value
|
||||||
|
if (!dialog) return
|
||||||
|
if (on) dialog.showModal()
|
||||||
|
else if (dialog.open) dialog.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
// le composant peut disparaitre alors que la boite est ouverte (changement de
|
||||||
|
// vue) : un `<dialog>` laisse en modal bloquerait la page entiere
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (el.value?.open) el.value.close()
|
||||||
|
})
|
||||||
|
}
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Encodage video de l'animation, en MP4.
|
||||||
|
*
|
||||||
|
* Seul module du projet a dependre d'autre chose que Vue, et il le fait en import
|
||||||
|
* DYNAMIQUE : mediabunny pese 43 ko gzip en import statique, soit plus que les
|
||||||
|
* 34 ko qui avaient fait ecarter `vue-i18n` au profit d'une couche maison. Charge
|
||||||
|
* a la demande, il ne coute que 0,7 ko au chargement du site et n'arrive que le
|
||||||
|
* jour ou quelqu'un exporte une video.
|
||||||
|
*
|
||||||
|
* Ecrire le conteneur a la main comme pour le GIF et le WebP n'etait pas tenable
|
||||||
|
* ici : un MP4 demande tout l'arbre de boites ISO BMFF et ses tables
|
||||||
|
* d'echantillons, la ou un GIF tient en quelques centaines de lignes.
|
||||||
|
*
|
||||||
|
* La video est forcement OPAQUE. Verifie sur place : `VideoEncoder` refuse
|
||||||
|
* `alpha: 'keep'`, en H.264 comme en VP9. C'est pour ca que l'export video impose
|
||||||
|
* un fond, la ou le GIF laisse le choix.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { arrete } from './export'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QUANTISEUR explicite, et pas un niveau `QUALITY_*`.
|
||||||
|
*
|
||||||
|
* Ce n'est pas un detail de reglage, c'est la clef de la qualite ici. Les niveaux
|
||||||
|
* nommes de mediabunny ne fixent PAS de debit : quand le navigateur sait encoder
|
||||||
|
* a quantiseur fixe — Chrome 117 et au-dela pour tous ces codecs — ils se
|
||||||
|
* traduisent en `bitrateMode: 'quantizer'` et le debit calcule ne sert qu'a
|
||||||
|
* choisir le niveau AVC de la chaine de codec. `QUALITY_HIGH` posait donc un QP de
|
||||||
|
* 22, et augmenter un debit n'y aurait rien change.
|
||||||
|
*
|
||||||
|
* Mesure sur une image du bot, erreur au bord contre la source :
|
||||||
|
* QP 22 → 3,06 · QP 16 → 1,75 · QP 10 → 0,58. On prend 12, presque transparent,
|
||||||
|
* pour environ 50 % d'octets de plus qu'a 22 — un export offline n'a pas de
|
||||||
|
* contrainte de taille, et c'est le bord des formes qui fait toute l'impression
|
||||||
|
* de proprete.
|
||||||
|
*
|
||||||
|
* Le debit accompagne le quantiseur comme REPLI : si un navigateur ne sait pas
|
||||||
|
* encoder a quantiseur fixe, mediabunny bascule dessus au lieu d'echouer. Six
|
||||||
|
* megabits pour du 1024 a 30 images est genereux sur des aplats.
|
||||||
|
*/
|
||||||
|
const QP = 12
|
||||||
|
const DEBIT_REPLI = 6_000_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode une suite d'images en MP4.
|
||||||
|
*
|
||||||
|
* `rend` dessine l'image `i` DANS le canvas fourni, puis rend la main. Les images
|
||||||
|
* ne sont jamais accumulees : chacune est encodee et jetee avant la suivante,
|
||||||
|
* sans quoi un cycle de trente secondes tiendrait 255 Mo de pixels bruts en
|
||||||
|
* memoire.
|
||||||
|
*/
|
||||||
|
export async function versMp4(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
images: number,
|
||||||
|
fps: number,
|
||||||
|
rend: (index: number) => void | Promise<void>,
|
||||||
|
avance?: (fait: number, total: number) => void,
|
||||||
|
/** Abandon demande par l'utilisateur. Cf. `arrete` ci-dessous. */
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<Blob> {
|
||||||
|
const { BufferTarget, CanvasSource, Mp4OutputFormat, Output, Quality } = await import(
|
||||||
|
'mediabunny'
|
||||||
|
)
|
||||||
|
|
||||||
|
const sortie = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() })
|
||||||
|
const source = new CanvasSource(canvas, {
|
||||||
|
codec: 'avc',
|
||||||
|
quality: new Quality({ quantizer: QP, bitrate: DEBIT_REPLI })
|
||||||
|
})
|
||||||
|
sortie.addVideoTrack(source, { frameRate: fps })
|
||||||
|
await sortie.start()
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A partir d'ici l'encodeur est OUVERT, donc tout chemin de sortie doit passer par
|
||||||
|
* `cancel`. Sans ca, une image qui echoue a se rendre laissait le `VideoEncoder`
|
||||||
|
* derriere elle : Chrome plafonne le nombre d'encodeurs materiels simultanes, si bien
|
||||||
|
* qu'apres quelques echecs les exports suivants tombaient a `start()` pour une raison
|
||||||
|
* qui n'avait plus rien a voir avec la cause reelle. Le lecteur hors ecran de
|
||||||
|
* `capture.ts` est ferme dans un `finally` pour la meme raison ; l'encodeur n'avait pas
|
||||||
|
* son equivalent.
|
||||||
|
*/
|
||||||
|
try {
|
||||||
|
const duree = 1 / fps
|
||||||
|
for (let i = 0; i < images; i++) {
|
||||||
|
arrete(signal)
|
||||||
|
await rend(i)
|
||||||
|
// `await` sur chaque image et non en lot : c'est ce qui applique la
|
||||||
|
// contre-pression de l'encodeur, donc ce qui borne la memoire.
|
||||||
|
await source.add(i * duree, duree)
|
||||||
|
avance?.(i + 1, images)
|
||||||
|
}
|
||||||
|
arrete(signal)
|
||||||
|
|
||||||
|
await sortie.finalize()
|
||||||
|
const buffer = sortie.target.buffer
|
||||||
|
if (!buffer) throw new Error('encodage mp4 vide')
|
||||||
|
return new Blob([buffer], { type: 'video/mp4' })
|
||||||
|
} catch (e) {
|
||||||
|
await sortie.cancel()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<object, object, unknown>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { defineConfig, type Plugin } from 'vite'
|
||||||
|
|
||||||
|
function serveReplica(): Plugin {
|
||||||
|
const root = fileURLToPath(new URL('./replica', import.meta.url))
|
||||||
|
const types: Record<string, string> = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.js': 'application/javascript; charset=utf-8',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.md': 'text/markdown; charset=utf-8'
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: 'serve-replica',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use((req, res, next) => {
|
||||||
|
if (!req.url?.startsWith('/replica/')) {
|
||||||
|
next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const rel = decodeURIComponent(req.url.slice('/replica/'.length).split('?')[0] || '')
|
||||||
|
const file = path.resolve(root, rel)
|
||||||
|
if (!file.startsWith(root) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) {
|
||||||
|
res.statusCode = 404
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.setHeader('Content-Type', types[path.extname(file)] || 'application/octet-stream')
|
||||||
|
res.end(fs.readFileSync(file))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue(), tailwindcss(), serveReplica()],
|
||||||
|
server: { port: 5191 },
|
||||||
|
resolve: {
|
||||||
|
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user