Files
grok-icon-study/src/components/BlockPicker.vue
T
Edom 1ba126c92b feat:加上 Vue 学习站与可嵌入机芯
站点按 bloub 三栏做自定义、动画时间线和设置,支持序列导出。
形体只留常见几种并配中文名;动画页右栏拉满时间线旁空位,滚动条隐藏。
机芯补 GROK.create / <grok-bot>,以及暂停冻结。
2026-08-19 01:29:13 +08:00

111 lines
3.7 KiB
Vue

<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>