518 lines
19 KiB
Vue
518 lines
19 KiB
Vue
<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,
|
||
followMove,
|
||
moveBlock,
|
||
offsetOf,
|
||
STEP,
|
||
totalDuration,
|
||
type Block,
|
||
type StateId
|
||
} from '@/editor/cycles'
|
||
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
|
||
* 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
|
||
emit('update:blocks', moveBlock(props.blocks, d.from, d.to))
|
||
block.value = followMove(block.value, d.from, d.to)
|
||
}
|
||
|
||
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 = followMove(block.value, index, 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.5"
|
||
: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.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.
|
||
`right-px` + gouttiere de 6px : la ligne garde 1px d'air de chaque
|
||
cote, elle ne touche plus le ring de la carte selectionnee -->
|
||
<button
|
||
type="button"
|
||
class="absolute inset-y-2 right-px 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>
|