|
| 1 | +# `usePanzoomSync` — Design |
| 2 | + |
| 3 | +**Date** : 2026-05-14 |
| 4 | +**Branche** : `refactoring-review` |
| 5 | +**Scope** : extraire la logique de tracking de la transform panzoom partagée |
| 6 | +entre `SharedPlaylistPlayer` et (à terme) `PreviewPlayer`, et préparer le |
| 7 | +terrain pour l'objectif final : annoter en zoomant/panant dans |
| 8 | +`PreviewPlayer`. |
| 9 | + |
| 10 | +## Contexte |
| 11 | + |
| 12 | +Aujourd'hui le tracking de la transform panzoom existe sous deux formes |
| 13 | +incompatibles dans la base de code : |
| 14 | + |
| 15 | +- **`SharedPlaylistPlayer`** : `panzoomTransform: Ref<{x,y,scale}>` mis à |
| 16 | + jour via `@panzoom-changed`, passé en prop à `SharedAnnotationOverlay` |
| 17 | + qui applique `fabric.setViewportTransform(...)` sur le canvas |
| 18 | + d'annotation. Les annotations suivent le zoom en temps réel. |
| 19 | +- **`PreviewPlayer`** : pas de tracking. Le toggle `isZoomPan` est modal : |
| 20 | + zoom ON cache le fabric canvas (`v-show="!isZoomPan && …"`), zoom OFF |
| 21 | + reset à scale 1. Annotations et zoom sont mutuellement exclusifs. |
| 22 | + |
| 23 | +Cap visé : faire converger `PreviewPlayer` vers le pattern overlay-live |
| 24 | +(annotations toujours visibles, suivent la transform). Mais on y va |
| 25 | +progressivement. |
| 26 | + |
| 27 | +## Approche choisie |
| 28 | + |
| 29 | +Composable minimal, agnostique du viewer : `state + handlers`. |
| 30 | + |
| 31 | +```js |
| 32 | +// src/composables/panzoom.js |
| 33 | +import { ref } from 'vue' |
| 34 | + |
| 35 | +export function usePanzoomSync() { |
| 36 | + const transform = ref({ x: 0, y: 0, scale: 1 }) |
| 37 | + |
| 38 | + const onPanzoomChanged = ({ x, y, scale }) => { |
| 39 | + transform.value = { x, y, scale } |
| 40 | + } |
| 41 | + |
| 42 | + const reset = () => { |
| 43 | + transform.value = { x: 0, y: 0, scale: 1 } |
| 44 | + } |
| 45 | + |
| 46 | + const applyTo = fabricCanvas => { |
| 47 | + if (!fabricCanvas) return |
| 48 | + const { x, y, scale } = transform.value |
| 49 | + fabricCanvas.setViewportTransform([scale, 0, 0, scale, x, y]) |
| 50 | + fabricCanvas.requestRenderAll() |
| 51 | + } |
| 52 | + |
| 53 | + return { transform, onPanzoomChanged, reset, applyTo } |
| 54 | +} |
| 55 | +``` |
| 56 | + |
| 57 | +**Pourquoi pas un composable couplé au viewer (qui encapsulerait aussi |
| 58 | +`resumeZoom`/`pauseZoom`)** : la gestion impérative du panzoom underlying |
| 59 | +(via `PreviewViewer.resumeZoom`/`pauseZoom`/`resetZoom`) est corrélée à |
| 60 | +plusieurs états dans `PreviewPlayer` (`isAnnotationsDisplayed`, |
| 61 | +`isDrawing`, `isTyping`) qui n'ont rien à voir avec le tracking de la |
| 62 | +transform. Mélanger les deux responsabilités élargirait l'API du |
| 63 | +composable sans bénéfice. |
| 64 | + |
| 65 | +## Mode comparison (PreviewPlayer) |
| 66 | + |
| 67 | +Décision : zoom synchronisé sur les deux viewers. Une seule instance de |
| 68 | +`usePanzoomSync` par `PreviewPlayer`. Le viewer comparison adoptera la |
| 69 | +même transform que le viewer principal en phase 3 (en propageant |
| 70 | +impérativement via `PictureViewer.setPanZoom` qui existe déjà). |
| 71 | + |
| 72 | +## Découpage en phases |
| 73 | + |
| 74 | +### Phase 1 — Extraction + adoption dans `SharedPlaylistPlayer` |
| 75 | + |
| 76 | +Remplace exactement le code dupliqué actuel |
| 77 | +(`SharedPlaylistPlayer.vue:265, 608-610, 744`) : |
| 78 | + |
| 79 | +```js |
| 80 | +import { usePanzoomSync } from '@/composables/panzoom' |
| 81 | + |
| 82 | +const { transform: panzoomTransform, onPanzoomChanged, reset: resetPanzoomTransform } = |
| 83 | + usePanzoomSync() |
| 84 | + |
| 85 | +// dans watch(isZoomEnabled, …) |
| 86 | +resetPanzoomTransform() |
| 87 | +``` |
| 88 | + |
| 89 | +- L'alias `transform: panzoomTransform` préserve le nom utilisé dans le |
| 90 | + template (`:panzoom-transform="panzoomTransform"`). |
| 91 | +- `applyTo` n'est **pas** consommé par `SharedPlaylistPlayer` : la cible |
| 92 | + (fabric canvas dans `SharedAnnotationOverlay`) est dans un autre |
| 93 | + composant qui reçoit `panzoomTransform` en prop et applique lui-même |
| 94 | + (`SharedAnnotationOverlay.vue:217-223`). Ce flux prop-down reste tel |
| 95 | + quel pour cette PR. |
| 96 | +- Comportement strictement identique. Zéro changement visible. |
| 97 | + |
| 98 | +### Phase 2 — Adoption dans `PreviewPlayer` (modal préservé) + fix bug comparison |
| 99 | + |
| 100 | +**2a. Fix du bug "comparisonViewer figé en zoom-pan"** |
| 101 | + |
| 102 | +`PreviewPlayer.vue:2095-2101` aujourd'hui : |
| 103 | + |
| 104 | +```js |
| 105 | +watch(isZoomPan, () => { |
| 106 | + if (isZoomPan.value) { |
| 107 | + previewViewer.value.resumeZoom() |
| 108 | + } else { |
| 109 | + previewViewer.value.pauseZoom() |
| 110 | + } |
| 111 | +}) |
| 112 | +``` |
| 113 | + |
| 114 | +→ Symétriser sur les deux viewers + reset : |
| 115 | + |
| 116 | +```js |
| 117 | +watch(isZoomPan, enabled => { |
| 118 | + const viewers = [previewViewer.value, comparisonViewer.value] |
| 119 | + if (enabled) { |
| 120 | + viewers.forEach(v => v?.resumeZoom()) |
| 121 | + } else { |
| 122 | + viewers.forEach(v => { |
| 123 | + v?.pauseZoom() |
| 124 | + v?.resetZoom() |
| 125 | + }) |
| 126 | + resetPanzoomTransform() |
| 127 | + } |
| 128 | +}) |
| 129 | +``` |
| 130 | +
|
| 131 | +**2b. Brancher `usePanzoomSync` (uniquement `reset` pour l'instant)** |
| 132 | +
|
| 133 | +```js |
| 134 | +const { transform: panzoomTransform, onPanzoomChanged, reset: resetPanzoomTransform, applyTo: applyPanzoomTo } = |
| 135 | + usePanzoomSync() |
| 136 | +``` |
| 137 | +
|
| 138 | +Phase 2 = on n'ajoute que ce qu'on utilise : `resetPanzoomTransform()` |
| 139 | +aux endroits où les viewers sont reset (`watch(isZoomPan)` ci-dessus, |
| 140 | +`onAnnotationDisplayedClicked` au `PreviewPlayer.vue:1450-1456`, |
| 141 | +`clearPreview` au `:2052`). `onPanzoomChanged`, `applyTo` et la ref |
| 142 | +`transform`/`panzoomTransform` ne sont pas branchés en phase 2 — ils |
| 143 | +arrivent en phase 3. |
| 144 | +
|
| 145 | +### Phase 3 — Annoter en zoomant dans `PreviewPlayer` (esquisse, hors de ce design doc) |
| 146 | +
|
| 147 | +Pour vérifier que l'API phase 1 est suffisante : |
| 148 | +
|
| 149 | +1. Retirer `v-show="!isZoomPan && …"` sur `canvas-wrapper` et |
| 150 | + `canvas-comparison-wrapper` → les fabric canvases sont toujours dans |
| 151 | + le DOM. |
| 152 | +2. Retirer le `resetZoom` automatique dans |
| 153 | + `onAnnotationDisplayedClicked` et `watch(isAnnotationsDisplayed)`. |
| 154 | +3. Dans `useAnnotation`, ajouter `applyPanzoomToCanvas(transform)` qui |
| 155 | + appelle `fabricCanvas.setViewportTransform(...)` sur les deux canvases |
| 156 | + (main + comparison). Soit injecté en paramètre du composable, soit |
| 157 | + exposé pour que le composant le wire avec `watch(transform, |
| 158 | + applyPanzoomToCanvas)`. |
| 159 | +4. Brancher `@panzoom-changed="onPanzoomChanged"` sur le main viewer. |
| 160 | +5. Synchroniser le panzoom underlying du comparison viewer sur celui du |
| 161 | + main via `PictureViewer.setPanZoom` (existe déjà, |
| 162 | + `PictureViewer.vue:333-351`). Ajouter l'équivalent côté video si |
| 163 | + besoin. |
| 164 | +
|
| 165 | +**Risque connu** : le canvas comparison du `PreviewPlayer` est shifté de |
| 166 | +`getDimensions().width / 2` dans `fixCanvasComparisonSize`. Suivre la |
| 167 | +transform live avec `setViewportTransform` va probablement nécessiter |
| 168 | +d'ajuster ce positionnement. À traiter en phase 3. |
| 169 | +
|
| 170 | +## Tests |
| 171 | +
|
| 172 | +### Unitaires (composable) — `tests/unit/composables/panzoom.spec.js` |
| 173 | +
|
| 174 | +Le folder `tests/unit/composables/` n'existe pas encore, à créer. |
| 175 | +
|
| 176 | +Cas : |
| 177 | +
|
| 178 | +- État initial : `transform.value` égal à `{x:0, y:0, scale:1}`. |
| 179 | +- `onPanzoomChanged({x:10, y:20, scale:2})` → `transform.value` reflète |
| 180 | + exactement. |
| 181 | +- `reset()` après modification → revient à `{0,0,1}`. |
| 182 | +- `applyTo(null)` / `applyTo(undefined)` → no-op, ne lève pas. |
| 183 | +- `applyTo(fakeCanvas)` avec transform non-identité → `setViewportTransform` |
| 184 | + appelé avec `[scale, 0, 0, scale, x, y]` + `requestRenderAll` appelé. |
| 185 | + Fake canvas = objet avec deux spies (pas de stub fabric global). |
| 186 | +- Réactivité : muter via `onPanzoomChanged` puis lire — la valeur |
| 187 | + retournée est la même ref que celle exposée. |
| 188 | +
|
| 189 | +### Consommateurs |
| 190 | +
|
| 191 | +Pas de nouveaux specs. Ni `SharedPlaylistPlayer` ni `PreviewPlayer` n'ont |
| 192 | +de couverture aujourd'hui — c'est un refactor sans changement observable |
| 193 | +côté UX en phase 1, et un fix isolé en phase 2. |
| 194 | +
|
| 195 | +### Vérif manuelle (phase 2) |
| 196 | +
|
| 197 | +- Task avec preview vidéo + une révision précédente → comparison mode → |
| 198 | + activer zoom-pan → **les deux** viewers zooment (2a). |
| 199 | +- Désactiver zoom-pan → les deux reviennent à scale 1, transform interne |
| 200 | + reset. |
| 201 | +- SharedPlaylistPlayer : lancer un playlist partagé, vérifier que les |
| 202 | + annotations suivent toujours le zoom (régression possible si l'alias |
| 203 | + `transform: panzoomTransform` est mal câblé). |
| 204 | +
|
| 205 | +## Risques et hors-scope |
| 206 | +
|
| 207 | +- **Hors-scope phase 1/2** : refonte du positionnement du canvas |
| 208 | + comparison dans `PreviewPlayer`. |
| 209 | +- **Risque transverse** : le bug fix 2a change un comportement observable |
| 210 | + (les deux viewers zooment au lieu d'un seul). Si un utilisateur s'en |
| 211 | + était inconsciemment accommodé, c'est un changement de feel — assumé |
| 212 | + car le comportement actuel est clairement un oubli, pas un design. |
| 213 | +- **Aucun risque côté SharedPlaylistPlayer** : refactor pur, même |
| 214 | + comportement, mêmes événements, mêmes consommateurs. |
0 commit comments