fix:修导出时钟与切页卡顿

导出与播放共用模拟时钟;换循环、重排播放头、<grok-bot> 重挂和构建产物对齐。
自定义/动画面板留下不重画,角色高度对齐,表情走弹簧。
This commit is contained in:
Edom
2026-08-19 02:01:29 +08:00
parent 1ba126c92b
commit 851f2b8c6c
17 changed files with 179 additions and 84 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ Do not commit extracted geometry, icons, or a third-party app bundle.
## What's on the site ## What's on the site
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 rail switches three views. **Customise** is 8 body shapes, 11 inks and the rest-face playlist; stills export as PNG / SVG / GIF. **Animations** is bloub’s timeline editor on our 39 states: drag to reorder, pull a card to set duration, save named cycles in `localStorage`, export the sequence as MP4 or GIF. The right-hand tiles append a block. **Settings** is language (French, English, Chinese) and credits.
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. 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.
+22
View File
@@ -72,6 +72,17 @@
place-items: center; place-items: center;
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);
} }
.missing {
width: min(100%, 280px);
margin: 12px auto 0;
padding: 22px 18px;
border-radius: 16px;
background: #2a2823;
color: #d8d0c0;
font-size: 13px;
}
.missing strong { display: block; margin-bottom: 8px; color: var(--accent); font-weight: 560; }
.missing a { color: var(--accent); }
.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; }
.disk-login span { .disk-login span {
display: block; display: block;
@@ -181,6 +192,12 @@
<span style="display:block;margin-top:8px;font-size:10px;color:#8d877b;text-align:center">登录 sizePx: 64</span> <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 class="modes"> <div class="modes">
<button id="mode-onboard" aria-pressed="true">登录轮换</button> <button id="mode-onboard" aria-pressed="true">登录轮换</button>
<button id="mode-hold" aria-pressed="false">锁定状态</button> <button id="mode-hold" aria-pressed="false">锁定状态</button>
@@ -296,6 +313,10 @@
document.getElementById("mode-hold").setAttribute("aria-pressed", mode === "hold"); document.getElementById("mode-hold").setAttribute("aria-pressed", mode === "hold");
} }
if (!window.GROK_GEO?.shapes || typeof window.GROK?.create !== "function") {
document.getElementById("missing-geo").hidden = false;
} else {
GROK_META.groups.forEach((group) => { GROK_META.groups.forEach((group) => {
const h = document.createElement("h2"); const h = document.createElement("h2");
h.textContent = group.label; h.textContent = group.label;
@@ -387,6 +408,7 @@
document.getElementById("act-spin").onclick = () => bots.forEach((el) => el.spinOnce(1)); document.getElementById("act-spin").onclick = () => bots.forEach((el) => el.spinOnce(1));
document.getElementById("act-bounce").onclick = () => bots.forEach((el) => el.bounceOnce()); document.getElementById("act-bounce").onclick = () => bots.forEach((el) => el.bounceOnce());
document.getElementById("act-burst").onclick = () => bots.forEach((el) => el.burstOnce()); document.getElementById("act-burst").onclick = () => bots.forEach((el) => el.burstOnce());
}
</script> </script>
</body> </body>
</html> </html>
+2 -1
View File
@@ -56,7 +56,8 @@
connectedCallback() { connectedCallback() {
if (this._handle) return; if (this._handle) return;
const root = this.attachShadow({ mode: "open" }); const root = this.shadowRoot || this.attachShadow({ mode: "open" });
root.replaceChildren();
const box = document.createElement("div"); const box = document.createElement("div");
box.setAttribute("part", "disk"); box.setAttribute("part", "disk");
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+18 -3
View File
@@ -276,10 +276,20 @@
this.svg.style.setProperty("--bg", this.eyeColor || EYE_BG); this.svg.style.setProperty("--bg", this.eyeColor || EYE_BG);
} }
_clock() {
return this.driven ? this.last : performance.now();
}
setState(name, { resetEyes = false } = {}) { setState(name, { resetEyes = false } = {}) {
if (!this.playlists[name]) return; if (!this.playlists[name]) return;
this.state = name; this.state = name;
this.stateAt = performance.now(); this.stateAt = this._clock();
if (this.driven) {
this.trick = null;
this.spinTurn = null;
this.hopAt = -1;
this.wildWide = false;
}
const list = this.playlists[name]; const list = this.playlists[name];
this.eyeIdx = 0; this.eyeIdx = 0;
if (resetEyes) { if (resetEyes) {
@@ -407,6 +417,11 @@
return this.snapshot(); return this.snapshot();
} }
setSize(px) {
this.sizePx = px > 0 ? px : null;
this._applyPoseScale();
}
_applyPoseScale() { _applyPoseScale() {
const sc = this.loginWrap ? poseScale(this.shapeName) : (this.pose.scale || 1); const sc = this.loginWrap ? poseScale(this.shapeName) : (this.pose.scale || 1);
this.pose.scale = sc; this.pose.scale = sc;
@@ -529,7 +544,7 @@
} }
_cycleShapeTrick() { _cycleShapeTrick() {
if (this.reduceMotion || this.paused) return; if (this.reduceMotion || this.paused || this.driven) return;
this.trickCycle = (this.trickCycle + 1) % 5; this.trickCycle = (this.trickCycle + 1) % 5;
this.wildWide = false; this.wildWide = false;
if (this.trickCycle === 0) this._pn(1); if (this.trickCycle === 0) this._pn(1);
@@ -686,7 +701,7 @@
this.celebrateAt = now + 6200; this.celebrateAt = now + 6200;
} }
if (now >= this.trickAt) { if (!this.driven && now >= this.trickAt) {
if ((V_T.has(this.state) || B_T.has(this.state)) && !this.spinTurn && this.hopAt < 0 && !this.trick) { if ((V_T.has(this.state) || B_T.has(this.state)) && !this.spinTurn && this.hopAt < 0 && !this.trick) {
const z = Math.random(); const z = Math.random();
if (V_T.has(this.state)) { if (V_T.has(this.state)) {
+19 -3
View File
@@ -112,6 +112,7 @@ watch(activeId, (v) => {
ecris('cycle', v) ecris('cycle', v)
block.value = 0 block.value = 0
elapsed.value = 0 elapsed.value = 0
state.value = cycle.value.blocks[0]?.state ?? 'idle'
}) })
watch(block, (i) => { watch(block, (i) => {
@@ -144,9 +145,12 @@ function onSeek(t: number) {
state.value = cycle.value.blocks[index]?.state ?? 'idle' state.value = cycle.value.blocks[index]?.state ?? 'idle'
} }
const gardeAnim = ref(false)
watch(view, (now) => { watch(view, (now) => {
intro.value = false intro.value = false
if (now === 'animations') { if (now === 'animations') {
gardeAnim.value = true
playing.value = true playing.value = true
state.value = cycle.value.blocks[block.value]?.state ?? 'idle' state.value = cycle.value.blocks[block.value]?.state ?? 'idle'
return return
@@ -248,6 +252,11 @@ function annuleCycle() {
watch(dialogueCycle, (ouverte) => { watch(dialogueCycle, (ouverte) => {
if (ouverte) erreurCycle.value = false if (ouverte) erreurCycle.value = false
else annuleCycle()
})
watch([view, preview], () => {
if (view.value !== 'animations' || preview.value) annuleCycle()
}) })
const CONFIRMATION_MS = 1800 const CONFIRMATION_MS = 1800
@@ -330,6 +339,7 @@ onUnmounted(() => {
clearTimeout(pending) clearTimeout(pending)
clearTimeout(minuteurBarre) clearTimeout(minuteurBarre)
clearTimeout(confirmation) clearTimeout(confirmation)
annuleCycle()
}) })
</script> </script>
@@ -350,7 +360,8 @@ onUnmounted(() => {
<div <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="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="[ :class="[
!preview && view === 'animations' && 'scene--timeline pb-[calc(var(--timeline)_+_1rem)]', !preview && 'scene--timeline lg:pb-[calc(var(--timeline)_+_1rem)]',
!preview && view === 'animations' && 'pb-[calc(var(--timeline)_+_1rem)]',
!preview && 'max-lg:pt-20', !preview && 'max-lg:pt-20',
nue || preview ? 'scene--seule' : view === 'reglages' && 'scene--gauche' nue || preview ? 'scene--seule' : view === 'reglages' && 'scene--gauche'
]" ]"
@@ -427,23 +438,27 @@ onUnmounted(() => {
:class="droite ? 'panneau--ouvert max-lg:order-2' : 'max-lg:hidden'" :class="droite ? 'panneau--ouvert max-lg:order-2' : 'max-lg:hidden'"
> >
<Customizer <Customizer
v-if="view === 'personnaliser'" v-show="view === 'personnaliser'"
v-model:shape="shape" v-model:shape="shape"
v-model:color="color" v-model:color="color"
v-model:expression="expression" v-model:expression="expression"
:ready="ready" :ready="ready"
:active="view === 'personnaliser'"
/> />
<AnimPanel <AnimPanel
v-else-if="view === 'animations'" v-if="gardeAnim"
v-show="view === 'animations'"
v-model="state" v-model="state"
:shape="shape" :shape="shape"
:color="color" :color="color"
:ready="ready" :ready="ready"
:active="view === 'animations'"
@pick="addBlock" @pick="addBlock"
/> />
</aside> </aside>
</div> </div>
<Transition name="fondu">
<Timeline <Timeline
v-if="view === 'animations' && !preview" v-if="view === 'animations' && !preview"
v-model:cycles="cycles" v-model:cycles="cycles"
@@ -458,6 +473,7 @@ onUnmounted(() => {
@preview="preview = true" @preview="preview = true"
@exporter="dialogueCycle = true" @exporter="dialogueCycle = true"
/> />
</Transition>
<p v-if="view === 'reglages' && !preview" class="wordmark" aria-hidden="true">STUDY</p> <p v-if="view === 'reglages' && !preview" class="wordmark" aria-hidden="true">STUDY</p>
</template> </template>
+2
View File
@@ -11,6 +11,7 @@ const props = defineProps<{
shape: string shape: string
color: string color: string
ready?: boolean ready?: boolean
active?: boolean
}>() }>()
const groups = computed(() => { const groups = computed(() => {
@@ -43,6 +44,7 @@ const groups = computed(() => {
:state="id" :state="id"
:shape="shape" :shape="shape"
:color="color" :color="color"
:active="active"
@click="emit('pick', id)" @click="emit('pick', id)"
/> />
</div> </div>
+17 -2
View File
@@ -12,8 +12,9 @@ const props = withDefaults(
size?: number size?: number
interactive?: boolean interactive?: boolean
showLabel?: boolean showLabel?: boolean
active?: boolean
}>(), }>(),
{ interactive: true, showLabel: true } { interactive: true, showLabel: true, active: true }
) )
const svg = ref<SVGSVGElement | null>(null) const svg = ref<SVGSVGElement | null>(null)
@@ -22,7 +23,6 @@ const painted = ref(false)
async function paint() { async function paint() {
const node = svg.value const node = svg.value
if (!node) return if (!node) return
node.innerHTML = ''
const mount = await paintTile(node, { const mount = await paintTile(node, {
state: props.state ?? 'idle', state: props.state ?? 'idle',
shape: props.shape ?? 'blob', shape: props.shape ?? 'blob',
@@ -34,12 +34,27 @@ async function paint() {
painted.value = !!mount painted.value = !!mount
} }
const dirty = ref(false)
onMounted(() => { onMounted(() => {
void paint() void paint()
}) })
watch(() => [props.state, props.shape, props.color], () => { watch(() => [props.state, props.shape, props.color], () => {
if (!props.active) {
dirty.value = true
return
}
void paint() void paint()
}) })
watch(
() => props.active,
(on) => {
if (on && dirty.value) {
dirty.value = false
void paint()
}
}
)
</script> </script>
<template> <template>
+3 -1
View File
@@ -4,7 +4,7 @@ import BotTile from '@/components/BotTile.vue'
import { shapeName, stateName, t } from '@/i18n' import { shapeName, stateName, t } from '@/i18n'
import { COLOR_IDS, EXPRESSION_IDS, colorFill, liveShapes } from '@/replica/catalog' import { COLOR_IDS, EXPRESSION_IDS, colorFill, liveShapes } from '@/replica/catalog'
const props = defineProps<{ ready?: boolean }>() const props = defineProps<{ ready?: boolean; active?: boolean }>()
const shape = defineModel<string>('shape', { required: true }) const shape = defineModel<string>('shape', { required: true })
const color = defineModel<string>('color', { required: true }) const color = defineModel<string>('color', { required: true })
const expression = defineModel<string>('expression', { required: true }) const expression = defineModel<string>('expression', { required: true })
@@ -26,6 +26,7 @@ const shapes = computed(() => {
:shape="id" :shape="id"
:color="color" :color="color"
:state="expression" :state="expression"
:active="active"
@click="shape = id" @click="shape = id"
/> />
</div> </div>
@@ -40,6 +41,7 @@ const shapes = computed(() => {
:shape="shape" :shape="shape"
:color="color" :color="color"
:state="id" :state="id"
:active="active"
@click="expression = id" @click="expression = id"
/> />
</div> </div>
+5 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue' import { onBeforeUnmount, ref, watch } from 'vue'
import type { Cycle } from '@/editor/cycles' import { MAX_CYCLES, type Cycle } from '@/editor/cycles'
import { nomDeCycle, t } from '@/i18n' import { nomDeCycle, t } from '@/i18n'
/** /**
@@ -8,7 +8,7 @@ import { nomDeCycle, t } from '@/i18n'
* ni la suppression d'une entree, d'ou ce menu — et il s'ouvre vers le haut, * 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. * la barre etant en bas de l'ecran.
*/ */
defineProps<{ cycles: Cycle[]; current: Cycle }>() const props = defineProps<{ cycles: Cycle[]; current: Cycle }>()
const activeId = defineModel<string>('activeId', { required: true }) const activeId = defineModel<string>('activeId', { required: true })
const emit = defineEmits<{ create: []; remove: [id: string]; rename: [id: string] }>() const emit = defineEmits<{ create: []; remove: [id: string]; rename: [id: string] }>()
@@ -21,6 +21,7 @@ function choose(id: string) {
} }
function create() { function create() {
if (props.cycles.length >= MAX_CYCLES) return
open.value = false open.value = false
emit('create') emit('create')
} }
@@ -135,7 +136,8 @@ onBeforeUnmount(() => window.removeEventListener('pointerdown', onOutside))
<button <button
type="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" 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 disabled:cursor-not-allowed disabled:opacity-40"
:disabled="cycles.length >= MAX_CYCLES"
@click="create" @click="create"
> >
<span class="w-3 shrink-0 text-[var(--muted)]">+</span> <span class="w-3 shrink-0 text-[var(--muted)]">+</span>
+2
View File
@@ -8,6 +8,7 @@ import ZoomSlider from '@/components/ZoomSlider.vue'
import { import {
blocksWith, blocksWith,
makeBlock, makeBlock,
MAX_CYCLES,
nextCycleId, nextCycleId,
offsetOf, offsetOf,
totalDuration, totalDuration,
@@ -77,6 +78,7 @@ function select(id: string) {
} }
function askCreate() { function askCreate() {
if (cycles.value.length >= MAX_CYCLES) return
naming.value = { mode: 'create' } naming.value = { mode: 'create' }
nameDraft.value = uniqueName(t('cycles.newName'), cycles.value) nameDraft.value = uniqueName(t('cycles.newName'), cycles.value)
nameOpen.value = true nameOpen.value = true
-39
View File
@@ -1,39 +0,0 @@
<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>
+4 -5
View File
@@ -4,6 +4,7 @@ import BlockPicker from '@/components/BlockPicker.vue'
import BotTile from '@/components/BotTile.vue' import BotTile from '@/components/BotTile.vue'
import { import {
clampDuration, clampDuration,
followMove,
moveBlock, moveBlock,
offsetOf, offsetOf,
STEP, STEP,
@@ -11,8 +12,8 @@ import {
type Block, type Block,
type StateId type StateId
} from '@/editor/cycles' } from '@/editor/cycles'
import { BASE_SCALE, clampZoom, ticksFor } from '@/ui/timeline'
import { secondes, secondesCourtes, stateName, t } from '@/i18n' import { secondes, secondesCourtes, stateName, t } from '@/i18n'
import { BASE_SCALE, clampZoom, ticksFor } from '@/ui/timeline'
/** /**
* La piste : une regle graduee, les cartes du montage, et les gestes qui vont * La piste : une regle graduee, les cartes du montage, et les gestes qui vont
@@ -240,10 +241,8 @@ function onBlockUp(index: number) {
return return
} }
if (d.to === d.from) 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)) emit('update:blocks', moveBlock(props.blocks, d.from, d.to))
block.value = suivi block.value = followMove(block.value, d.from, d.to)
} }
function onResizeDown(index: number, e: PointerEvent) { function onResizeDown(index: number, e: PointerEvent) {
@@ -298,7 +297,7 @@ async function onCardKey(index: number, e: KeyboardEvent) {
const cible = index + sens const cible = index + sens
if (cible < 0 || cible >= props.blocks.length) return if (cible < 0 || cible >= props.blocks.length) return
emit('update:blocks', moveBlock(props.blocks, index, cible)) emit('update:blocks', moveBlock(props.blocks, index, cible))
block.value = cible block.value = followMove(block.value, index, cible)
await nextTick() await nextTick()
const liste = track.value?.querySelectorAll<HTMLButtonElement>('[data-carte]') const liste = track.value?.querySelectorAll<HTMLButtonElement>('[data-carte]')
liste?.[cible]?.focus() liste?.[cible]?.focus()
+8
View File
@@ -93,6 +93,14 @@ export function moveBlock(blocks: Block[], from: number, to: number): Block[] {
return next return next
} }
/** Index of the clip that was current, after `moveBlock(from, to)`. */
export function followMove(current: number, from: number, to: number): number {
if (current === from) return to
if (from < to && current > from && current <= to) return current - 1
if (from > to && current >= to && current < from) return current + 1
return current
}
export function uniqueName(base: string, cycles: Cycle[]): string { export function uniqueName(base: string, cycles: Cycle[]): string {
const taken = new Set(cycles.map((c) => c.name)) const taken = new Set(cycles.map((c) => c.name))
if (!taken.has(base)) return base if (!taken.has(base)) return base
+1
View File
@@ -13,6 +13,7 @@ export type ReplicaBot = {
orbitGaze: (ms?: number) => void orbitGaze: (ms?: number) => void
holdFrame: (at?: number) => void holdFrame: (at?: number) => void
step: (dt: number) => void step: (dt: number) => void
setSize: (px: number) => void
destroy: () => void destroy: () => void
} }
+34 -10
View File
@@ -18,16 +18,39 @@ export type ReplicaInput = {
size: number size: number
} }
let tileQueue: Promise<unknown> = Promise.resolve() const TILE_MAX = 6
let tileActive = 0
const tileWait: Array<() => void> = []
/** One-frame tiles share a queue so first paint does not spawn 30 engines at once. */ function lockTile(): Promise<void> {
export function paintTile(svg: SVGSVGElement, input: ReplicaInput): Promise<ReplicaMount | null> { if (tileActive < TILE_MAX) {
const job = tileQueue.then(() => mountReplica(svg, input, false)) tileActive++
tileQueue = job.then( return Promise.resolve()
() => undefined, }
() => undefined return new Promise((resolve) => {
) tileWait.push(() => {
return job tileActive++
resolve()
})
})
}
function unlockTile() {
tileActive--
tileWait.shift()?.()
}
export async function paintTile(
svg: SVGSVGElement,
input: ReplicaInput
): Promise<ReplicaMount | null> {
await lockTile()
try {
svg.innerHTML = ''
return await mountReplica(svg, input, false)
} finally {
unlockTile()
}
} }
export async function mountReplica( export async function mountReplica(
@@ -70,11 +93,12 @@ export async function mountReplica(
let last = { ...input } let last = { ...input }
return { return {
apply(next) { apply(next) {
if (next.state !== last.state) bot.setState(next.state, { resetEyes: true }) if (next.state !== last.state) bot.setState(next.state, { resetEyes: false })
if (next.shape !== last.shape) bot.setShape(next.shape) if (next.shape !== last.shape) bot.setShape(next.shape)
if (next.color !== last.color) bot.setColor(next.color) if (next.color !== last.color) bot.setColor(next.color)
if (next.follow !== last.follow) bot.setFollowPointer(next.follow) if (next.follow !== last.follow) bot.setFollowPointer(next.follow)
if (next.paper !== last.paper) bot.setEyeColor(next.paper) if (next.paper !== last.paper) bot.setEyeColor(next.paper)
if (next.size !== last.size) bot.setSize(next.size)
last = { ...next } last = { ...next }
}, },
spin: () => bot.spinOnce(1), spin: () => bot.spinOnce(1),
+13 -1
View File
@@ -593,11 +593,23 @@ html::-webkit-scrollbar {
opacity: 0; opacity: 0;
} }
.fondu-enter-active,
.fondu-leave-active {
transition: opacity 0.35s ease-out;
}
.fondu-enter-from,
.fondu-leave-to {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.scene, .scene,
.panneau, .panneau,
.panneau--ouvert, .panneau--ouvert,
.avatar { .avatar,
.fondu-enter-active,
.fondu-leave-active {
transition: none; transition: none;
} }
+28 -15
View File
@@ -13,24 +13,37 @@ function serveReplica(): Plugin {
'.svg': 'image/svg+xml', '.svg': 'image/svg+xml',
'.md': 'text/markdown; charset=utf-8' '.md': 'text/markdown; charset=utf-8'
} }
function middleware(
req: { url?: string },
res: { statusCode: number; setHeader: (k: string, v: string) => void; end: (b?: Buffer) => void },
next: () => void
) {
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))
}
return { return {
name: 'serve-replica', name: 'serve-replica',
configureServer(server) { configureServer(server) {
server.middlewares.use((req, res, next) => { server.middlewares.use(middleware)
if (!req.url?.startsWith('/replica/')) { },
next() configurePreviewServer(server) {
return server.middlewares.use(middleware)
} },
const rel = decodeURIComponent(req.url.slice('/replica/'.length).split('?')[0] || '') writeBundle(options) {
const file = path.resolve(root, rel) const dest = path.resolve(options.dir ?? 'dist', 'replica')
if (!file.startsWith(root) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) { fs.cpSync(root, dest, { recursive: true })
res.statusCode = 404
res.end()
return
}
res.setHeader('Content-Type', types[path.extname(file)] || 'application/octet-stream')
res.end(fs.readFileSync(file))
})
} }
} }
} }