feat(web): keep the playhead centered when navigating the timeline
The wheel alone now scrolls the page, to reach the lower tracks. Ctrl+wheel moves the playhead with the timeline following it in the middle of the screen, and Alt+wheel zooms on the playhead, which stays centered, as in Filmora. A jump off-screen while paused recenters it.
42bca16 parent: fdce48a modified
web/src/i18n/locales/en/editing.ts +2 -2 | @@ -15,14 +15,14 @@ export default { | ||
| 15 | 15 | zoomOut: "Zoom out (−)", |
| 16 | 16 | fit: "Fit", |
| 17 | 17 | fitTitle: "Fit to window (Shift+Z)", |
| 18 | - zoomIn: "Zoom in (+) · Ctrl+scroll", | |
| 18 | + zoomIn: "Zoom in (+) · Alt+scroll", | |
| 19 | 19 | busy: "Daryl is working on a copy: the changes land at the end of the turn", |
| 20 | 20 | undo: "Undo", |
| 21 | 21 | export: "Export for Resolve", |
| 22 | 22 | exportTitle: "Writes the .fcpxml to the project folder", |
| 23 | 23 | revealTitle: "Open the edit folder", |
| 24 | 24 | }, |
| 25 | - keys: "Ctrl+scroll zoom · Shift+Z fit · Space play · J K L · ←→ frame · ↑↓ cuts · A/B select/blade · Ctrl+B cut · Del · M beat · I/O range · Ctrl+Z undo · drag on empty space = range", | |
| 25 | + keys: "Ctrl+scroll move playhead · Alt+scroll zoom · Shift+Z fit · Space play · J K L · ←→ frame · ↑↓ cuts · A/B select/blade · Ctrl+B cut · Del · M beat · I/O range · Ctrl+Z undo · drag on empty space = range", | |
| 26 | 26 | tracks: { |
| 27 | 27 | aroll: "Video", |
| 28 | 28 | zooms: "Zoom", |
| @@ -15,14 +15,14 @@ export default { | |||
| 15 | zoomOut: "Zoom out (−)", | 15 | zoomOut: "Zoom out (−)", |
| 16 | fit: "Fit", | 16 | fit: "Fit", |
| 17 | fitTitle: "Fit to window (Shift+Z)", | 17 | fitTitle: "Fit to window (Shift+Z)", |
| 18 | - zoomIn: "Zoom in (+) · Ctrl+scroll", | 18 | + zoomIn: "Zoom in (+) · Alt+scroll", |
| 19 | busy: "Daryl is working on a copy: the changes land at the end of the turn", | 19 | busy: "Daryl is working on a copy: the changes land at the end of the turn", |
| 20 | undo: "Undo", | 20 | undo: "Undo", |
| 21 | export: "Export for Resolve", | 21 | export: "Export for Resolve", |
| 22 | exportTitle: "Writes the .fcpxml to the project folder", | 22 | exportTitle: "Writes the .fcpxml to the project folder", |
| 23 | revealTitle: "Open the edit folder", | 23 | revealTitle: "Open the edit folder", |
| 24 | }, | 24 | }, |
| 25 | - keys: "Ctrl+scroll zoom · Shift+Z fit · Space play · J K L · ←→ frame · ↑↓ cuts · A/B select/blade · Ctrl+B cut · Del · M beat · I/O range · Ctrl+Z undo · drag on empty space = range", | 25 | + keys: "Ctrl+scroll move playhead · Alt+scroll zoom · Shift+Z fit · Space play · J K L · ←→ frame · ↑↓ cuts · A/B select/blade · Ctrl+B cut · Del · M beat · I/O range · Ctrl+Z undo · drag on empty space = range", |
| 26 | tracks: { | 26 | tracks: { |
| 27 | aroll: "Video", | 27 | aroll: "Video", |
| 28 | zooms: "Zoom", | 28 | zooms: "Zoom", |
modified
web/src/i18n/locales/fr/editing.ts +2 -2 | @@ -15,14 +15,14 @@ export default { | ||
| 15 | 15 | zoomOut: "Dézoomer (−)", |
| 16 | 16 | fit: "Ajuster", |
| 17 | 17 | fitTitle: "Ajuster à la fenêtre (Maj+Z)", |
| 18 | - zoomIn: "Zoomer (+) · Ctrl+molette", | |
| 18 | + zoomIn: "Zoomer (+) · Alt+molette", | |
| 19 | 19 | busy: "Daryl travaille sur sa copie : ses changements arrivent à la fin de son tour", |
| 20 | 20 | undo: "Annuler", |
| 21 | 21 | export: "Exporter pour Resolve", |
| 22 | 22 | exportTitle: "Écrit le .fcpxml dans le dossier du projet", |
| 23 | 23 | revealTitle: "Ouvrir le dossier du montage", |
| 24 | 24 | }, |
| 25 | - keys: "Ctrl+molette zoom · Maj+Z ajuster · Espace lecture · J K L · ←→ image · ↑↓ coupes · A/B sélection/lame · Ctrl+B couper · Suppr · M beat · I/O plage · Ctrl+Z annuler · glisser sur une zone vide = plage", | |
| 25 | + keys: "Ctrl+molette déplacer la tête · Alt+molette zoom · Maj+Z ajuster · Espace lecture · J K L · ←→ image · ↑↓ coupes · A/B sélection/lame · Ctrl+B couper · Suppr · M beat · I/O plage · Ctrl+Z annuler · glisser sur une zone vide = plage", | |
| 26 | 26 | tracks: { |
| 27 | 27 | aroll: "Vidéo", |
| 28 | 28 | zooms: "Zoom", |
| @@ -15,14 +15,14 @@ export default { | |||
| 15 | zoomOut: "Dézoomer (−)", | 15 | zoomOut: "Dézoomer (−)", |
| 16 | fit: "Ajuster", | 16 | fit: "Ajuster", |
| 17 | fitTitle: "Ajuster à la fenêtre (Maj+Z)", | 17 | fitTitle: "Ajuster à la fenêtre (Maj+Z)", |
| 18 | - zoomIn: "Zoomer (+) · Ctrl+molette", | 18 | + zoomIn: "Zoomer (+) · Alt+molette", |
| 19 | busy: "Daryl travaille sur sa copie : ses changements arrivent à la fin de son tour", | 19 | busy: "Daryl travaille sur sa copie : ses changements arrivent à la fin de son tour", |
| 20 | undo: "Annuler", | 20 | undo: "Annuler", |
| 21 | export: "Exporter pour Resolve", | 21 | export: "Exporter pour Resolve", |
| 22 | exportTitle: "Écrit le .fcpxml dans le dossier du projet", | 22 | exportTitle: "Écrit le .fcpxml dans le dossier du projet", |
| 23 | revealTitle: "Ouvrir le dossier du montage", | 23 | revealTitle: "Ouvrir le dossier du montage", |
| 24 | }, | 24 | }, |
| 25 | - keys: "Ctrl+molette zoom · Maj+Z ajuster · Espace lecture · J K L · ←→ image · ↑↓ coupes · A/B sélection/lame · Ctrl+B couper · Suppr · M beat · I/O plage · Ctrl+Z annuler · glisser sur une zone vide = plage", | 25 | + keys: "Ctrl+molette déplacer la tête · Alt+molette zoom · Maj+Z ajuster · Espace lecture · J K L · ←→ image · ↑↓ coupes · A/B sélection/lame · Ctrl+B couper · Suppr · M beat · I/O plage · Ctrl+Z annuler · glisser sur une zone vide = plage", |
| 26 | tracks: { | 26 | tracks: { |
| 27 | aroll: "Vidéo", | 27 | aroll: "Vidéo", |
| 28 | zooms: "Zoom", | 28 | zooms: "Zoom", |
modified
web/src/project/steps/MontageStep.vue +1 -1 | @@ -7,7 +7,7 @@ | ||
| 7 | 7 | * |
| 8 | 8 | * Raccourcis (conventions de Resolve) : Espace lecture, J/K/L, ←/→ image par image (Maj : 1 s), |
| 9 | 9 | * ↑/↓ coupe précédente / suivante, A sélection, B lame, Ctrl+B couper à la tête, Suppr supprimer, |
| 10 | - * M beat (posé à la tête de lecture, à tirer sur son passage), I/O plage, Alt+X effacer la plage, Ctrl+molette ou +/− zoom, Maj+Z ajuster, Ctrl+Z annuler. | |
| 10 | + * M beat (posé à la tête de lecture, à tirer sur son passage), I/O plage, Alt+X effacer la plage, Alt+molette ou +/− zoom, Ctrl+molette déplacer la tête, Maj+Z ajuster, Ctrl+Z annuler. | |
| 11 | 11 | */ |
| 12 | 12 | import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue"; |
| 13 | 13 | import { useI18n } from "vue-i18n"; |
| @@ -7,7 +7,7 @@ | |||
| 7 | * | 7 | * |
| 8 | * Raccourcis (conventions de Resolve) : Espace lecture, J/K/L, ←/→ image par image (Maj : 1 s), | 8 | * Raccourcis (conventions de Resolve) : Espace lecture, J/K/L, ←/→ image par image (Maj : 1 s), |
| 9 | * ↑/↓ coupe précédente / suivante, A sélection, B lame, Ctrl+B couper à la tête, Suppr supprimer, | 9 | * ↑/↓ coupe précédente / suivante, A sélection, B lame, Ctrl+B couper à la tête, Suppr supprimer, |
| 10 | - * M beat (posé à la tête de lecture, à tirer sur son passage), I/O plage, Alt+X effacer la plage, Ctrl+molette ou +/− zoom, Maj+Z ajuster, Ctrl+Z annuler. | 10 | + * M beat (posé à la tête de lecture, à tirer sur son passage), I/O plage, Alt+X effacer la plage, Alt+molette ou +/− zoom, Ctrl+molette déplacer la tête, Maj+Z ajuster, Ctrl+Z annuler. |
| 11 | */ | 11 | */ |
| 12 | import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue"; | 12 | import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue"; |
| 13 | import { useI18n } from "vue-i18n"; | 13 | import { useI18n } from "vue-i18n"; |
modified
web/src/project/steps/montage/Timeline.vue +19 -12 | @@ -1,8 +1,9 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | 2 | /** |
| 3 | - * La timeline du montage, façon DaVinci Resolve : Ctrl + molette zoome autour de la tête de lecture, la molette | |
| 4 | - * fait défiler, Maj + Z ajuste à la fenêtre. On glisse les éléments, on tire leurs bords pour les | |
| 5 | - * rogner (avec aimantation), la lame coupe ; glisser sur une zone vide sélectionne une plage. | |
| 3 | + * La timeline du montage, façon DaVinci Resolve : Alt + molette zoome sur la tête de lecture, Ctrl + molette la | |
| 4 | + * déplace (elle reste au milieu de l'écran, comme dans Filmora ; la molette seule fait défiler la page), | |
| 5 | + * Maj + Z ajuste à la fenêtre. On glisse les éléments, on tire leurs bords pour les rogner (avec aimantation), | |
| 6 | + * la lame coupe ; glisser sur une zone vide sélectionne une plage. | |
| 6 | 7 | * Seule la partie visible est dessinée : vignettes et forme d'onde suivent le zoom. |
| 7 | 8 | * Un média glissé depuis l'inspecteur s'insère dans l'A-roll s'il est lâché sur sa piste, sinon il se pose au-dessus. |
| 8 | 9 | * Les beats du storyboard pas encore faits, en pointillés sur leur piste à eux (au-dessus du motion : un beat peut |
| @@ -95,10 +96,10 @@ function setZoom(next: number, anchor: number, anchorPx: number) { | ||
| 95 | 96 | if (scroller.value) scroller.value.scrollLeft = Math.max(0, anchor * pps.value - anchorPx); |
| 96 | 97 | }); |
| 97 | 98 | } |
| 98 | -// la tête de lecture garde sa place à l'écran ; hors champ, on la ramène au centre | |
| 99 | +// comme dans Filmora : on zoome sur la tête de lecture, qui reste au milieu de l'écran (près d'un bout de la | |
| 100 | +// timeline, elle s'en écarte le temps qu'il faut) | |
| 99 | 101 | function zoomBy(factor: number, anchor = props.t) { |
| 100 | - const px = x(anchor) - scrollX.value; | |
| 101 | - setZoom(pps.value * factor, anchor, px >= 0 && px <= viewW.value ? px : viewW.value / 2); | |
| 102 | + setZoom(pps.value * factor, anchor, viewW.value / 2); | |
| 102 | 103 | } |
| 103 | 104 | function fit() { |
| 104 | 105 | setZoom(minPps.value, 0, 0); |
| @@ -106,12 +107,16 @@ function fit() { | ||
| 106 | 107 | defineExpose({ zoomBy, fit }); |
| 107 | 108 | |
| 108 | 109 | function onWheel(e: WheelEvent) { |
| 109 | - if (e.ctrlKey || e.metaKey) { | |
| 110 | + // la molette seule fait défiler la page (pour atteindre les pistes du bas) | |
| 111 | + if (e.altKey) { | |
| 110 | 112 | e.preventDefault(); |
| 111 | 113 | zoomBy(Math.pow(1.0015, -e.deltaY)); |
| 112 | - } else if (!e.shiftKey && Math.abs(e.deltaY) > Math.abs(e.deltaX)) { | |
| 114 | + } else if ((e.ctrlKey || e.metaKey) && Math.abs(e.deltaY) > Math.abs(e.deltaX)) { | |
| 115 | + // on se déplace avec la tête de lecture, qui reste au milieu de l'écran | |
| 113 | 116 | e.preventDefault(); |
| 114 | - scroller.value!.scrollLeft += e.deltaY; | |
| 117 | + const t = Math.max(0, Math.min(props.t + e.deltaY / pps.value, props.total)); | |
| 118 | + emit("seek", t); | |
| 119 | + scroller.value!.scrollLeft = Math.max(0, x(t) - viewW.value / 2); | |
| 115 | 120 | } |
| 116 | 121 | } |
| 117 | 122 | |
| @@ -124,12 +129,14 @@ onMounted(() => { | ||
| 124 | 129 | onUnmounted(() => ro.disconnect()); |
| 125 | 130 | watch(() => props.total, (n, old) => { if (!old && n) nextTick(fit); else if (pps.value < minPps.value) pps.value = minPps.value; }); |
| 126 | 131 | |
| 127 | -// la lecture reste visible : on tourne la page quand la tête sort de l'écran | |
| 132 | +// la tête reste visible : en lecture on tourne la page quand elle sort de l'écran ; un saut (flèches, coupe | |
| 133 | +// suivante, ±5 s…) hors de l'écran la ramène au centre | |
| 128 | 134 | watch(() => props.t, (t) => { |
| 129 | 135 | const el = scroller.value; |
| 130 | - if (!el || !props.playing) return; | |
| 136 | + if (!el || scrub) return; | |
| 131 | 137 | const px = x(t); |
| 132 | - if (px > scrollX.value + viewW.value - 30 || px < scrollX.value) el.scrollLeft = Math.max(0, px - 60); | |
| 138 | + if (px <= scrollX.value + viewW.value - 30 && px >= scrollX.value) return; | |
| 139 | + el.scrollLeft = Math.max(0, props.playing ? px - 60 : px - viewW.value / 2); | |
| 133 | 140 | }); |
| 134 | 141 | |
| 135 | 142 | // --- règle |
| @@ -1,8 +1,9 @@ | |||
| 1 | <script setup lang="ts"> | 1 | <script setup lang="ts"> |
| 2 | /** | 2 | /** |
| 3 | - * La timeline du montage, façon DaVinci Resolve : Ctrl + molette zoome autour de la tête de lecture, la molette | 3 | + * La timeline du montage, façon DaVinci Resolve : Alt + molette zoome sur la tête de lecture, Ctrl + molette la |
| 4 | - * fait défiler, Maj + Z ajuste à la fenêtre. On glisse les éléments, on tire leurs bords pour les | 4 | + * déplace (elle reste au milieu de l'écran, comme dans Filmora ; la molette seule fait défiler la page), |
| 5 | - * rogner (avec aimantation), la lame coupe ; glisser sur une zone vide sélectionne une plage. | 5 | + * Maj + Z ajuste à la fenêtre. On glisse les éléments, on tire leurs bords pour les rogner (avec aimantation), |
| 6 | + * la lame coupe ; glisser sur une zone vide sélectionne une plage. | ||
| 6 | * Seule la partie visible est dessinée : vignettes et forme d'onde suivent le zoom. | 7 | * Seule la partie visible est dessinée : vignettes et forme d'onde suivent le zoom. |
| 7 | * Un média glissé depuis l'inspecteur s'insère dans l'A-roll s'il est lâché sur sa piste, sinon il se pose au-dessus. | 8 | * Un média glissé depuis l'inspecteur s'insère dans l'A-roll s'il est lâché sur sa piste, sinon il se pose au-dessus. |
| 8 | * Les beats du storyboard pas encore faits, en pointillés sur leur piste à eux (au-dessus du motion : un beat peut | 9 | * Les beats du storyboard pas encore faits, en pointillés sur leur piste à eux (au-dessus du motion : un beat peut |
| @@ -95,10 +96,10 @@ function setZoom(next: number, anchor: number, anchorPx: number) { | |||
| 95 | if (scroller.value) scroller.value.scrollLeft = Math.max(0, anchor * pps.value - anchorPx); | 96 | if (scroller.value) scroller.value.scrollLeft = Math.max(0, anchor * pps.value - anchorPx); |
| 96 | }); | 97 | }); |
| 97 | } | 98 | } |
| 98 | -// la tête de lecture garde sa place à l'écran ; hors champ, on la ramène au centre | 99 | +// comme dans Filmora : on zoome sur la tête de lecture, qui reste au milieu de l'écran (près d'un bout de la |
| 100 | +// timeline, elle s'en écarte le temps qu'il faut) | ||
| 99 | function zoomBy(factor: number, anchor = props.t) { | 101 | function zoomBy(factor: number, anchor = props.t) { |
| 100 | - const px = x(anchor) - scrollX.value; | 102 | + setZoom(pps.value * factor, anchor, viewW.value / 2); |
| 101 | - setZoom(pps.value * factor, anchor, px >= 0 && px <= viewW.value ? px : viewW.value / 2); | ||
| 102 | } | 103 | } |
| 103 | function fit() { | 104 | function fit() { |
| 104 | setZoom(minPps.value, 0, 0); | 105 | setZoom(minPps.value, 0, 0); |
| @@ -106,12 +107,16 @@ function fit() { | |||
| 106 | defineExpose({ zoomBy, fit }); | 107 | defineExpose({ zoomBy, fit }); |
| 107 | 108 | ||
| 108 | function onWheel(e: WheelEvent) { | 109 | function onWheel(e: WheelEvent) { |
| 109 | - if (e.ctrlKey || e.metaKey) { | 110 | + // la molette seule fait défiler la page (pour atteindre les pistes du bas) |
| 111 | + if (e.altKey) { | ||
| 110 | e.preventDefault(); | 112 | e.preventDefault(); |
| 111 | zoomBy(Math.pow(1.0015, -e.deltaY)); | 113 | zoomBy(Math.pow(1.0015, -e.deltaY)); |
| 112 | - } else if (!e.shiftKey && Math.abs(e.deltaY) > Math.abs(e.deltaX)) { | 114 | + } else if ((e.ctrlKey || e.metaKey) && Math.abs(e.deltaY) > Math.abs(e.deltaX)) { |
| 115 | + // on se déplace avec la tête de lecture, qui reste au milieu de l'écran | ||
| 113 | e.preventDefault(); | 116 | e.preventDefault(); |
| 114 | - scroller.value!.scrollLeft += e.deltaY; | 117 | + const t = Math.max(0, Math.min(props.t + e.deltaY / pps.value, props.total)); |
| 118 | + emit("seek", t); | ||
| 119 | + scroller.value!.scrollLeft = Math.max(0, x(t) - viewW.value / 2); | ||
| 115 | } | 120 | } |
| 116 | } | 121 | } |
| 117 | 122 | ||
| @@ -124,12 +129,14 @@ onMounted(() => { | |||
| 124 | onUnmounted(() => ro.disconnect()); | 129 | onUnmounted(() => ro.disconnect()); |
| 125 | watch(() => props.total, (n, old) => { if (!old && n) nextTick(fit); else if (pps.value < minPps.value) pps.value = minPps.value; }); | 130 | watch(() => props.total, (n, old) => { if (!old && n) nextTick(fit); else if (pps.value < minPps.value) pps.value = minPps.value; }); |
| 126 | 131 | ||
| 127 | -// la lecture reste visible : on tourne la page quand la tête sort de l'écran | 132 | +// la tête reste visible : en lecture on tourne la page quand elle sort de l'écran ; un saut (flèches, coupe |
| 133 | +// suivante, ±5 s…) hors de l'écran la ramène au centre | ||
| 128 | watch(() => props.t, (t) => { | 134 | watch(() => props.t, (t) => { |
| 129 | const el = scroller.value; | 135 | const el = scroller.value; |
| 130 | - if (!el || !props.playing) return; | 136 | + if (!el || scrub) return; |
| 131 | const px = x(t); | 137 | const px = x(t); |
| 132 | - if (px > scrollX.value + viewW.value - 30 || px < scrollX.value) el.scrollLeft = Math.max(0, px - 60); | 138 | + if (px <= scrollX.value + viewW.value - 30 && px >= scrollX.value) return; |
| 139 | + el.scrollLeft = Math.max(0, props.playing ? px - 60 : px - viewW.value / 2); | ||
| 133 | }); | 140 | }); |
| 134 | 141 | ||
| 135 | // --- règle | 142 | // --- règle |