feat(derush): navigate the timeline like the editing one
Alt+wheel zooms on the playhead, which stays centered; Ctrl+wheel moves the playhead with the timeline following it; the wheel alone scrolls the page. A jump off-screen while paused recenters the playhead, scrubbing the ruler snaps to cuts (Alt: free), and trim edges show [ and ] cursors.
22449ed parent: 8566bea modified
web/src/i18n/locales/en/derush.ts +2 -2 | @@ -138,7 +138,7 @@ export default { | ||
| 138 | 138 | splitKey: "Split the piece under the playhead (Ctrl + B or S)", |
| 139 | 139 | undo: "Undo (Ctrl + Z)", |
| 140 | 140 | redo: "Redo (Ctrl + Y)", |
| 141 | - zoomIn: "Zoom in (+)", | |
| 141 | + zoomIn: "Zoom in (+) · Alt+scroll", | |
| 142 | 142 | zoomOut: "Zoom out (−)", |
| 143 | 143 | fit: "Fit", |
| 144 | 144 | }, |
| @@ -146,5 +146,5 @@ export default { | ||
| 146 | 146 | trim: "Drag to trim or extend (Alt: no snapping)", |
| 147 | 147 | nested: "Nested timeline “{name}” · double-click to open it", |
| 148 | 148 | }, |
| 149 | - shortcuts: "Drag a piece to move it · drag its edges to trim · B blade, A select · Delete remove · S split here · ← → frame (Shift: second) · ↑ ↓ prev. / next piece · I / O range · Ctrl + Z undo · Ctrl + wheel zoom", | |
| 149 | + shortcuts: "Drag a piece to move it · drag its edges to trim · B blade, A select · Delete remove · S split here · ← → frame (Shift: second) · ↑ ↓ prev. / next piece · I / O range · Ctrl + Z undo · Ctrl + wheel move playhead · Alt + wheel zoom", | |
| 150 | 150 | }; |
| @@ -138,7 +138,7 @@ export default { | |||
| 138 | splitKey: "Split the piece under the playhead (Ctrl + B or S)", | 138 | splitKey: "Split the piece under the playhead (Ctrl + B or S)", |
| 139 | undo: "Undo (Ctrl + Z)", | 139 | undo: "Undo (Ctrl + Z)", |
| 140 | redo: "Redo (Ctrl + Y)", | 140 | redo: "Redo (Ctrl + Y)", |
| 141 | - zoomIn: "Zoom in (+)", | 141 | + zoomIn: "Zoom in (+) · Alt+scroll", |
| 142 | zoomOut: "Zoom out (−)", | 142 | zoomOut: "Zoom out (−)", |
| 143 | fit: "Fit", | 143 | fit: "Fit", |
| 144 | }, | 144 | }, |
| @@ -146,5 +146,5 @@ export default { | |||
| 146 | trim: "Drag to trim or extend (Alt: no snapping)", | 146 | trim: "Drag to trim or extend (Alt: no snapping)", |
| 147 | nested: "Nested timeline “{name}” · double-click to open it", | 147 | nested: "Nested timeline “{name}” · double-click to open it", |
| 148 | }, | 148 | }, |
| 149 | - shortcuts: "Drag a piece to move it · drag its edges to trim · B blade, A select · Delete remove · S split here · ← → frame (Shift: second) · ↑ ↓ prev. / next piece · I / O range · Ctrl + Z undo · Ctrl + wheel zoom", | 149 | + shortcuts: "Drag a piece to move it · drag its edges to trim · B blade, A select · Delete remove · S split here · ← → frame (Shift: second) · ↑ ↓ prev. / next piece · I / O range · Ctrl + Z undo · Ctrl + wheel move playhead · Alt + wheel zoom", |
| 150 | }; | 150 | }; |
modified
web/src/i18n/locales/fr/derush.ts +2 -2 | @@ -138,7 +138,7 @@ export default { | ||
| 138 | 138 | splitKey: "Couper le bout sous la tête de lecture (Ctrl + B ou S)", |
| 139 | 139 | undo: "Annuler (Ctrl + Z)", |
| 140 | 140 | redo: "Refaire (Ctrl + Y)", |
| 141 | - zoomIn: "Zoomer (+)", | |
| 141 | + zoomIn: "Zoomer (+) · Alt+molette", | |
| 142 | 142 | zoomOut: "Dézoomer (−)", |
| 143 | 143 | fit: "Ajuster", |
| 144 | 144 | }, |
| @@ -146,5 +146,5 @@ export default { | ||
| 146 | 146 | trim: "Tire pour rogner ou rallonger (Alt : sans aimantation)", |
| 147 | 147 | nested: "Timeline imbriquée « {name} » · double-clic pour l’ouvrir", |
| 148 | 148 | }, |
| 149 | - shortcuts: "Glisse un bout pour le déplacer · tire ses bords pour le rogner · B lame, A sélection · Suppr supprimer · S couper ici · ← → image (Maj : seconde) · ↑ ↓ bout préc. / suiv. · I / O plage · Ctrl + Z annuler · Ctrl + molette zoom", | |
| 149 | + shortcuts: "Glisse un bout pour le déplacer · tire ses bords pour le rogner · B lame, A sélection · Suppr supprimer · S couper ici · ← → image (Maj : seconde) · ↑ ↓ bout préc. / suiv. · I / O plage · Ctrl + Z annuler · Ctrl + molette déplacer la tête · Alt + molette zoom", | |
| 150 | 150 | }; |
| @@ -138,7 +138,7 @@ export default { | |||
| 138 | splitKey: "Couper le bout sous la tête de lecture (Ctrl + B ou S)", | 138 | splitKey: "Couper le bout sous la tête de lecture (Ctrl + B ou S)", |
| 139 | undo: "Annuler (Ctrl + Z)", | 139 | undo: "Annuler (Ctrl + Z)", |
| 140 | redo: "Refaire (Ctrl + Y)", | 140 | redo: "Refaire (Ctrl + Y)", |
| 141 | - zoomIn: "Zoomer (+)", | 141 | + zoomIn: "Zoomer (+) · Alt+molette", |
| 142 | zoomOut: "Dézoomer (−)", | 142 | zoomOut: "Dézoomer (−)", |
| 143 | fit: "Ajuster", | 143 | fit: "Ajuster", |
| 144 | }, | 144 | }, |
| @@ -146,5 +146,5 @@ export default { | |||
| 146 | trim: "Tire pour rogner ou rallonger (Alt : sans aimantation)", | 146 | trim: "Tire pour rogner ou rallonger (Alt : sans aimantation)", |
| 147 | nested: "Timeline imbriquée « {name} » · double-clic pour l’ouvrir", | 147 | nested: "Timeline imbriquée « {name} » · double-clic pour l’ouvrir", |
| 148 | }, | 148 | }, |
| 149 | - shortcuts: "Glisse un bout pour le déplacer · tire ses bords pour le rogner · B lame, A sélection · Suppr supprimer · S couper ici · ← → image (Maj : seconde) · ↑ ↓ bout préc. / suiv. · I / O plage · Ctrl + Z annuler · Ctrl + molette zoom", | 149 | + shortcuts: "Glisse un bout pour le déplacer · tire ses bords pour le rogner · B lame, A sélection · Suppr supprimer · S couper ici · ← → image (Maj : seconde) · ↑ ↓ bout préc. / suiv. · I / O plage · Ctrl + Z annuler · Ctrl + molette déplacer la tête · Alt + molette zoom", |
| 150 | }; | 150 | }; |
modified
web/src/project/steps/derush/Timeline.vue +32 -19 | @@ -3,7 +3,9 @@ | ||
| 3 | 3 | * La timeline du dérush, façon logiciel de montage : des bouts de rushes bout à bout (V1) et leur son (A1). |
| 4 | 4 | * On attrape un bout pour le déplacer, on tire ses bords pour le rogner ou le rallonger (aimanté aux |
| 5 | 5 | * phrases et aux mots, Alt pour s'en passer), la lame le coupe en deux. Glisser sur A1 sélectionne une plage. |
| 6 | - * Ctrl + molette zoome autour de la souris, la molette fait défiler. Dans chaque bout, une bande marque | |
| 6 | + * Alt + molette zoome sur la tête de lecture, Ctrl + molette la déplace (elle reste au milieu de l'écran, comme dans | |
| 7 | + * Filmora ; la molette seule fait défiler la page). Tirée sur la règle, la tête s'aimante aux coupes (Alt : sans aimant). | |
| 8 | + * Dans chaque bout, une bande marque | |
| 7 | 9 | * la parole (les trous sont des silences, les pointillés du son non transcrit) ; zoomé, le texte des phrases s'affiche. |
| 8 | 10 | * Une timeline imbriquée est un seul bloc (double-clic : on l'ouvre). On y dépose rushes et timelines de la médiathèque. |
| 9 | 11 | */ |
| @@ -59,9 +61,10 @@ function setZoom(next: number, anchor: number, anchorPx: number) { | ||
| 59 | 61 | if (scroller.value) scroller.value.scrollLeft = Math.max(0, anchor * pps.value - anchorPx); |
| 60 | 62 | }); |
| 61 | 63 | } |
| 64 | +// 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 | |
| 65 | +// timeline, elle s'en écarte le temps qu'il faut) | |
| 62 | 66 | function zoomBy(factor: number) { |
| 63 | - const at = props.player.t.value; | |
| 64 | - setZoom(pps.value * factor, at, Math.max(0, Math.min(viewW.value, x(at) - scrollX.value))); | |
| 67 | + setZoom(pps.value * factor, props.player.t.value, viewW.value / 2); | |
| 65 | 68 | } |
| 66 | 69 | function fit() { |
| 67 | 70 | setZoom(minPps.value, 0, 0); |
| @@ -69,14 +72,16 @@ function fit() { | ||
| 69 | 72 | defineExpose({ zoomBy, fit }); |
| 70 | 73 | |
| 71 | 74 | function onWheel(e: WheelEvent) { |
| 72 | - if (e.ctrlKey || e.metaKey) { | |
| 75 | + // la molette seule fait défiler la page | |
| 76 | + if (e.altKey) { | |
| 73 | 77 | e.preventDefault(); |
| 74 | - const rect = scroller.value!.getBoundingClientRect(); | |
| 75 | - const px = e.clientX - rect.left; | |
| 76 | - setZoom(pps.value * Math.pow(1.0015, -e.deltaY), (scrollX.value + px) / pps.value, px); | |
| 77 | - } else if (!e.shiftKey && Math.abs(e.deltaY) > Math.abs(e.deltaX)) { | |
| 78 | + zoomBy(Math.pow(1.0015, -e.deltaY)); | |
| 79 | + } else if ((e.ctrlKey || e.metaKey) && Math.abs(e.deltaY) > Math.abs(e.deltaX)) { | |
| 80 | + // on se déplace avec la tête de lecture, qui reste au milieu de l'écran | |
| 78 | 81 | e.preventDefault(); |
| 79 | - scroller.value!.scrollLeft += e.deltaY; | |
| 82 | + const t = Math.max(0, Math.min(props.player.t.value + e.deltaY / pps.value, total.value)); | |
| 83 | + props.player.seek(t); | |
| 84 | + scroller.value!.scrollLeft = Math.max(0, x(t) - viewW.value / 2); | |
| 80 | 85 | } |
| 81 | 86 | } |
| 82 | 87 | |
| @@ -89,13 +94,13 @@ onMounted(() => { | ||
| 89 | 94 | onUnmounted(() => ro.disconnect()); |
| 90 | 95 | watch(total, (n, old) => { if (!old && n) nextTick(fit); else if (pps.value < minPps.value) pps.value = minPps.value; }); |
| 91 | 96 | |
| 92 | -// la tête de lecture reste visible : pendant la lecture on tourne la page, après un saut on la ramène | |
| 97 | +// la tête reste visible : en lecture on tourne la page quand elle sort de l'écran ; un saut (flèches, bout | |
| 98 | +// suivant, ±1 s…) hors de l'écran la ramène au centre | |
| 93 | 99 | watch(headX, (px) => { |
| 94 | 100 | const el = scroller.value; |
| 95 | 101 | if (!el || drag) return; |
| 96 | - if (px > scrollX.value + viewW.value - 30 || px < scrollX.value) { | |
| 97 | - el.scrollLeft = Math.max(0, props.player.playing.value ? px - 60 : px - viewW.value / 3); | |
| 98 | - } | |
| 102 | + if (px <= scrollX.value + viewW.value - 30 && px >= scrollX.value) return; | |
| 103 | + el.scrollLeft = Math.max(0, props.player.playing.value ? px - 60 : px - viewW.value / 2); | |
| 99 | 104 | }); |
| 100 | 105 | |
| 101 | 106 | // --- règle |
| @@ -178,7 +183,7 @@ const timeAt = (e: PointerEvent | MouseEvent) => { | ||
| 178 | 183 | }; |
| 179 | 184 | |
| 180 | 185 | type Drag = |
| 181 | - | { kind: "scrub" } | |
| 186 | + | { kind: "scrub"; pts: number[] } | |
| 182 | 187 | | { kind: "range"; a: number; moved: boolean } |
| 183 | 188 | | { kind: "clip"; id: string; d0: number; moved: boolean; additive: boolean } |
| 184 | 189 | | { kind: "trim"; i: number; side: "in" | "out"; edge: number; d0: number; to: number; pts: number[]; min: number; max: number }; |
| @@ -193,8 +198,9 @@ function capture(e: PointerEvent) { | ||
| 193 | 198 | function downRuler(e: PointerEvent) { |
| 194 | 199 | if (e.button !== 0) return; |
| 195 | 200 | e.stopPropagation(); |
| 196 | - drag = { kind: "scrub" }; | |
| 197 | - props.player.seek(timeAt(e)); | |
| 201 | + // la tête tirée sur la règle s'aimante aux coupes (sans sa propre position, où elle resterait collée) | |
| 202 | + drag = { kind: "scrub", pts: [0, total.value, ...starts.value].filter((p) => p !== props.player.t.value) }; | |
| 203 | + props.player.seek(e.altKey ? timeAt(e) : snap(timeAt(e), drag.pts)); | |
| 198 | 204 | capture(e); |
| 199 | 205 | } |
| 200 | 206 | |
| @@ -249,7 +255,7 @@ function onMove(e: PointerEvent) { | ||
| 249 | 255 | if (!drag) return; |
| 250 | 256 | const d = timeAt(e); |
| 251 | 257 | if (drag.kind === "scrub") { |
| 252 | - props.player.seek(d); | |
| 258 | + props.player.seek(e.altKey ? d : snap(d, drag.pts)); | |
| 253 | 259 | } else if (drag.kind === "range") { |
| 254 | 260 | if (!drag.moved && Math.abs(d - drag.a) * pps.value < 4) return; |
| 255 | 261 | drag.moved = true; |
| @@ -419,8 +425,15 @@ const shortName = (n: string) => n.replace(/\.[^.]+$/, ""); | ||
| 419 | 425 | .mark.voice { background: transparent; border-bottom: 4px dotted rgba(255, 255, 255, .7); } |
| 420 | 426 | .mark em { display: block; font-style: normal; font-size: 10px; line-height: 1.25; color: #fff; padding: 2px 4px; white-space: normal; max-height: 26px; overflow: hidden; } |
| 421 | 427 | .edge { position: absolute; top: 0; bottom: 0; width: 8px; cursor: ew-resize; z-index: 4; } |
| 422 | -.edge.l { left: 0; } | |
| 423 | -.edge.r { right: 0; } | |
| 428 | +/* comme dans Filmora : [ rogne le début du bout sous la souris, ] sa fin (entre deux bouts collés, on voit lequel) */ | |
| 429 | +.edge.l { | |
| 430 | + left: 0; | |
| 431 | + cursor: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke-linecap='round' stroke-linejoin='round'><path d='M16 4H12V20H16M8 12H2M4 10L2 12L4 14' stroke='white' stroke-width='4'/><path d='M16 4H12V20H16M8 12H2M4 10L2 12L4 14' stroke='black' stroke-width='2'/></svg>") 12 12, w-resize; | |
| 432 | +} | |
| 433 | +.edge.r { | |
| 434 | + right: 0; | |
| 435 | + cursor: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke-linecap='round' stroke-linejoin='round'><path d='M8 4H12V20H8M16 12H22M20 10L22 12L20 14' stroke='white' stroke-width='4'/><path d='M8 4H12V20H8M16 12H22M20 10L22 12L20 14' stroke='black' stroke-width='2'/></svg>") 12 12, e-resize; | |
| 436 | +} | |
| 424 | 437 | .edge:hover { background: rgba(229, 72, 77, .55); } |
| 425 | 438 | .blade .edge { display: none; } |
| 426 | 439 | .drop-at { position: absolute; width: 3px; margin-left: -1.5px; background: #3B82C4; border-radius: 2px; z-index: 7; pointer-events: none; } |
| @@ -3,7 +3,9 @@ | |||
| 3 | * La timeline du dérush, façon logiciel de montage : des bouts de rushes bout à bout (V1) et leur son (A1). | 3 | * La timeline du dérush, façon logiciel de montage : des bouts de rushes bout à bout (V1) et leur son (A1). |
| 4 | * On attrape un bout pour le déplacer, on tire ses bords pour le rogner ou le rallonger (aimanté aux | 4 | * On attrape un bout pour le déplacer, on tire ses bords pour le rogner ou le rallonger (aimanté aux |
| 5 | * phrases et aux mots, Alt pour s'en passer), la lame le coupe en deux. Glisser sur A1 sélectionne une plage. | 5 | * phrases et aux mots, Alt pour s'en passer), la lame le coupe en deux. Glisser sur A1 sélectionne une plage. |
| 6 | - * Ctrl + molette zoome autour de la souris, la molette fait défiler. Dans chaque bout, une bande marque | 6 | + * Alt + molette zoome sur la tête de lecture, Ctrl + molette la déplace (elle reste au milieu de l'écran, comme dans |
| 7 | + * Filmora ; la molette seule fait défiler la page). Tirée sur la règle, la tête s'aimante aux coupes (Alt : sans aimant). | ||
| 8 | + * Dans chaque bout, une bande marque | ||
| 7 | * la parole (les trous sont des silences, les pointillés du son non transcrit) ; zoomé, le texte des phrases s'affiche. | 9 | * la parole (les trous sont des silences, les pointillés du son non transcrit) ; zoomé, le texte des phrases s'affiche. |
| 8 | * Une timeline imbriquée est un seul bloc (double-clic : on l'ouvre). On y dépose rushes et timelines de la médiathèque. | 10 | * Une timeline imbriquée est un seul bloc (double-clic : on l'ouvre). On y dépose rushes et timelines de la médiathèque. |
| 9 | */ | 11 | */ |
| @@ -59,9 +61,10 @@ function setZoom(next: number, anchor: number, anchorPx: number) { | |||
| 59 | if (scroller.value) scroller.value.scrollLeft = Math.max(0, anchor * pps.value - anchorPx); | 61 | if (scroller.value) scroller.value.scrollLeft = Math.max(0, anchor * pps.value - anchorPx); |
| 60 | }); | 62 | }); |
| 61 | } | 63 | } |
| 64 | +// 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 | ||
| 65 | +// timeline, elle s'en écarte le temps qu'il faut) | ||
| 62 | function zoomBy(factor: number) { | 66 | function zoomBy(factor: number) { |
| 63 | - const at = props.player.t.value; | 67 | + setZoom(pps.value * factor, props.player.t.value, viewW.value / 2); |
| 64 | - setZoom(pps.value * factor, at, Math.max(0, Math.min(viewW.value, x(at) - scrollX.value))); | ||
| 65 | } | 68 | } |
| 66 | function fit() { | 69 | function fit() { |
| 67 | setZoom(minPps.value, 0, 0); | 70 | setZoom(minPps.value, 0, 0); |
| @@ -69,14 +72,16 @@ function fit() { | |||
| 69 | defineExpose({ zoomBy, fit }); | 72 | defineExpose({ zoomBy, fit }); |
| 70 | 73 | ||
| 71 | function onWheel(e: WheelEvent) { | 74 | function onWheel(e: WheelEvent) { |
| 72 | - if (e.ctrlKey || e.metaKey) { | 75 | + // la molette seule fait défiler la page |
| 76 | + if (e.altKey) { | ||
| 73 | e.preventDefault(); | 77 | e.preventDefault(); |
| 74 | - const rect = scroller.value!.getBoundingClientRect(); | 78 | + zoomBy(Math.pow(1.0015, -e.deltaY)); |
| 75 | - const px = e.clientX - rect.left; | 79 | + } else if ((e.ctrlKey || e.metaKey) && Math.abs(e.deltaY) > Math.abs(e.deltaX)) { |
| 76 | - setZoom(pps.value * Math.pow(1.0015, -e.deltaY), (scrollX.value + px) / pps.value, px); | 80 | + // on se déplace avec la tête de lecture, qui reste au milieu de l'écran |
| 77 | - } else if (!e.shiftKey && Math.abs(e.deltaY) > Math.abs(e.deltaX)) { | ||
| 78 | e.preventDefault(); | 81 | e.preventDefault(); |
| 79 | - scroller.value!.scrollLeft += e.deltaY; | 82 | + const t = Math.max(0, Math.min(props.player.t.value + e.deltaY / pps.value, total.value)); |
| 83 | + props.player.seek(t); | ||
| 84 | + scroller.value!.scrollLeft = Math.max(0, x(t) - viewW.value / 2); | ||
| 80 | } | 85 | } |
| 81 | } | 86 | } |
| 82 | 87 | ||
| @@ -89,13 +94,13 @@ onMounted(() => { | |||
| 89 | onUnmounted(() => ro.disconnect()); | 94 | onUnmounted(() => ro.disconnect()); |
| 90 | watch(total, (n, old) => { if (!old && n) nextTick(fit); else if (pps.value < minPps.value) pps.value = minPps.value; }); | 95 | watch(total, (n, old) => { if (!old && n) nextTick(fit); else if (pps.value < minPps.value) pps.value = minPps.value; }); |
| 91 | 96 | ||
| 92 | -// la tête de lecture reste visible : pendant la lecture on tourne la page, après un saut on la ramène | 97 | +// la tête reste visible : en lecture on tourne la page quand elle sort de l'écran ; un saut (flèches, bout |
| 98 | +// suivant, ±1 s…) hors de l'écran la ramène au centre | ||
| 93 | watch(headX, (px) => { | 99 | watch(headX, (px) => { |
| 94 | const el = scroller.value; | 100 | const el = scroller.value; |
| 95 | if (!el || drag) return; | 101 | if (!el || drag) return; |
| 96 | - if (px > scrollX.value + viewW.value - 30 || px < scrollX.value) { | 102 | + if (px <= scrollX.value + viewW.value - 30 && px >= scrollX.value) return; |
| 97 | - el.scrollLeft = Math.max(0, props.player.playing.value ? px - 60 : px - viewW.value / 3); | 103 | + el.scrollLeft = Math.max(0, props.player.playing.value ? px - 60 : px - viewW.value / 2); |
| 98 | - } | ||
| 99 | }); | 104 | }); |
| 100 | 105 | ||
| 101 | // --- règle | 106 | // --- règle |
| @@ -178,7 +183,7 @@ const timeAt = (e: PointerEvent | MouseEvent) => { | |||
| 178 | }; | 183 | }; |
| 179 | 184 | ||
| 180 | type Drag = | 185 | type Drag = |
| 181 | - | { kind: "scrub" } | 186 | + | { kind: "scrub"; pts: number[] } |
| 182 | | { kind: "range"; a: number; moved: boolean } | 187 | | { kind: "range"; a: number; moved: boolean } |
| 183 | | { kind: "clip"; id: string; d0: number; moved: boolean; additive: boolean } | 188 | | { kind: "clip"; id: string; d0: number; moved: boolean; additive: boolean } |
| 184 | | { kind: "trim"; i: number; side: "in" | "out"; edge: number; d0: number; to: number; pts: number[]; min: number; max: number }; | 189 | | { kind: "trim"; i: number; side: "in" | "out"; edge: number; d0: number; to: number; pts: number[]; min: number; max: number }; |
| @@ -193,8 +198,9 @@ function capture(e: PointerEvent) { | |||
| 193 | function downRuler(e: PointerEvent) { | 198 | function downRuler(e: PointerEvent) { |
| 194 | if (e.button !== 0) return; | 199 | if (e.button !== 0) return; |
| 195 | e.stopPropagation(); | 200 | e.stopPropagation(); |
| 196 | - drag = { kind: "scrub" }; | 201 | + // la tête tirée sur la règle s'aimante aux coupes (sans sa propre position, où elle resterait collée) |
| 197 | - props.player.seek(timeAt(e)); | 202 | + drag = { kind: "scrub", pts: [0, total.value, ...starts.value].filter((p) => p !== props.player.t.value) }; |
| 203 | + props.player.seek(e.altKey ? timeAt(e) : snap(timeAt(e), drag.pts)); | ||
| 198 | capture(e); | 204 | capture(e); |
| 199 | } | 205 | } |
| 200 | 206 | ||
| @@ -249,7 +255,7 @@ function onMove(e: PointerEvent) { | |||
| 249 | if (!drag) return; | 255 | if (!drag) return; |
| 250 | const d = timeAt(e); | 256 | const d = timeAt(e); |
| 251 | if (drag.kind === "scrub") { | 257 | if (drag.kind === "scrub") { |
| 252 | - props.player.seek(d); | 258 | + props.player.seek(e.altKey ? d : snap(d, drag.pts)); |
| 253 | } else if (drag.kind === "range") { | 259 | } else if (drag.kind === "range") { |
| 254 | if (!drag.moved && Math.abs(d - drag.a) * pps.value < 4) return; | 260 | if (!drag.moved && Math.abs(d - drag.a) * pps.value < 4) return; |
| 255 | drag.moved = true; | 261 | drag.moved = true; |
| @@ -419,8 +425,15 @@ const shortName = (n: string) => n.replace(/\.[^.]+$/, ""); | |||
| 419 | .mark.voice { background: transparent; border-bottom: 4px dotted rgba(255, 255, 255, .7); } | 425 | .mark.voice { background: transparent; border-bottom: 4px dotted rgba(255, 255, 255, .7); } |
| 420 | .mark em { display: block; font-style: normal; font-size: 10px; line-height: 1.25; color: #fff; padding: 2px 4px; white-space: normal; max-height: 26px; overflow: hidden; } | 426 | .mark em { display: block; font-style: normal; font-size: 10px; line-height: 1.25; color: #fff; padding: 2px 4px; white-space: normal; max-height: 26px; overflow: hidden; } |
| 421 | .edge { position: absolute; top: 0; bottom: 0; width: 8px; cursor: ew-resize; z-index: 4; } | 427 | .edge { position: absolute; top: 0; bottom: 0; width: 8px; cursor: ew-resize; z-index: 4; } |
| 422 | -.edge.l { left: 0; } | 428 | +/* comme dans Filmora : [ rogne le début du bout sous la souris, ] sa fin (entre deux bouts collés, on voit lequel) */ |
| 423 | -.edge.r { right: 0; } | 429 | +.edge.l { |
| 430 | + left: 0; | ||
| 431 | + cursor: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke-linecap='round' stroke-linejoin='round'><path d='M16 4H12V20H16M8 12H2M4 10L2 12L4 14' stroke='white' stroke-width='4'/><path d='M16 4H12V20H16M8 12H2M4 10L2 12L4 14' stroke='black' stroke-width='2'/></svg>") 12 12, w-resize; | ||
| 432 | +} | ||
| 433 | +.edge.r { | ||
| 434 | + right: 0; | ||
| 435 | + cursor: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke-linecap='round' stroke-linejoin='round'><path d='M8 4H12V20H8M16 12H22M20 10L22 12L20 14' stroke='white' stroke-width='4'/><path d='M8 4H12V20H8M16 12H22M20 10L22 12L20 14' stroke='black' stroke-width='2'/></svg>") 12 12, e-resize; | ||
| 436 | +} | ||
| 424 | .edge:hover { background: rgba(229, 72, 77, .55); } | 437 | .edge:hover { background: rgba(229, 72, 77, .55); } |
| 425 | .blade .edge { display: none; } | 438 | .blade .edge { display: none; } |
| 426 | .drop-at { position: absolute; width: 3px; margin-left: -1.5px; background: #3B82C4; border-radius: 2px; z-index: 7; pointer-events: none; } | 439 | .drop-at { position: absolute; width: 3px; margin-left: -1.5px; background: #3B82C4; border-radius: 2px; z-index: 7; pointer-events: none; } |