diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index 9176e41..a096ac2 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -364,6 +364,84 @@ function onOutBracketPointerUp(e: PointerEvent) { if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId) } +// -------- Markers ----------------------------------------------------- + +const selectedMarkerId = ref(null) +const markerDrag = ref<{ id: string; startX: number; moved: boolean } | null>(null) + +function markerTimeFromEvent(e: PointerEvent, markerEl: HTMLElement): number { + const ruler = markerEl.parentElement as HTMLElement | null + if (!ruler) return 0 + const rect = ruler.getBoundingClientRect() + if (rect.width <= 0) return 0 + const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) + return snapTime(ratio * duration.value) +} + +function onMarkerPointerDown(e: PointerEvent, markerId: string) { + e.stopPropagation() + selectedMarkerId.value = markerId + markerDrag.value = { id: markerId, startX: e.clientX, moved: false } + ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId) +} + +function onMarkerPointerMove(e: PointerEvent, markerId: string) { + const el = e.currentTarget as HTMLElement + if (!el.hasPointerCapture(e.pointerId)) return + const drag = markerDrag.value + if (!drag || drag.id !== markerId) return + e.stopPropagation() + if (!drag.moved && Math.abs(e.clientX - drag.startX) > 3) drag.moved = true + if (drag.moved) { + store.setMarkerTime(markerId, markerTimeFromEvent(e, el)) + } +} + +function onMarkerPointerUp(e: PointerEvent, markerId: string) { + const el = e.currentTarget as HTMLElement + if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId) + const drag = markerDrag.value + markerDrag.value = null + if (!drag || drag.id !== markerId) return + // Click (no movement) → jump playhead to the marker. + if (!drag.moved) { + const m = store.scene.markers?.find((x) => x.id === markerId) + if (m) store.setPlayhead(m.time) + } +} + +async function renameMarkerPrompt(markerId: string) { + const marker = store.scene.markers?.find((m) => m.id === markerId) + if (!marker) return + const next = await promptDialog({ + title: t('timeline.renameMarkerTitle'), + message: t('timeline.renameMarkerMessage'), + defaultValue: marker.name, + confirmLabel: t('timeline.renameMarkerConfirm'), + }) + if (next === null) return + const trimmed = next.trim() + if (!trimmed) return + const ok = store.renameMarker(markerId, trimmed) + if (!ok) { + await confirmDialog({ + title: t('timeline.renameMarkerTitle'), + message: t('timeline.renameMarkerCollision', { name: trimmed }), + confirmLabel: t('timeline.renameMarkerRetry'), + }) + // Let the user pick again. + renameMarkerPrompt(markerId) + } +} + +async function addMarkerAtPlayhead() { + const marker = store.addMarker(store.playhead) + selectedMarkerId.value = marker.id + await nextTick() + // Open rename prompt immediately so the user can name the marker. + renameMarkerPrompt(marker.id) +} + function onLaneClick(e: MouseEvent) { if (kfDrag.value) return const el = e.currentTarget as HTMLElement @@ -1560,16 +1638,42 @@ function isEditableTarget(target: EventTarget | null): boolean { } function onDocumentKeyDown(e: KeyboardEvent) { - if (e.key === 'Escape' && selectedKfs.value.length) { - selectedKfs.value = [] - return + if (e.key === 'Escape') { + if (selectedKfs.value.length) { + selectedKfs.value = [] + return + } + if (selectedMarkerId.value) { + selectedMarkerId.value = null + return + } } - if ((e.key === 'Backspace' || e.key === 'Delete') && selectedKfs.value.length) { - if (isEditableTarget(e.target)) return + if (isEditableTarget(e.target)) return + if (e.key === 'Backspace' || e.key === 'Delete') { + if (selectedKfs.value.length) { + e.preventDefault() + const targets = [...selectedKfs.value] + selectedKfs.value = [] + for (const k of targets) store.removeKeyframe(k.layerId, k.keyframeId) + return + } + if (selectedMarkerId.value) { + e.preventDefault() + const id = selectedMarkerId.value + selectedMarkerId.value = null + store.removeMarker(id) + return + } + } + if ( + e.key.toLowerCase() === 'm' && + !e.ctrlKey && + !e.metaKey && + !e.altKey && + !e.shiftKey + ) { e.preventDefault() - const targets = [...selectedKfs.value] - selectedKfs.value = [] - for (const k of targets) store.removeKeyframe(k.layerId, k.keyframeId) + addMarkerAtPlayhead() } } @@ -1763,6 +1867,25 @@ onBeforeUnmount(() => { @pointerup="onOutBracketPointerUp" @pointercancel="onOutBracketPointerUp" > + + + {{ marker.name }} + @@ -2540,6 +2663,62 @@ onBeforeUnmount(() => { background: var(--range-hover); } +.timeline-marker { + position: absolute; + top: 0; + bottom: 0; + z-index: 18; + pointer-events: auto; + cursor: ew-resize; + touch-action: none; + --marker-color: var(--accent); +} + +.timeline-marker::before { + content: ''; + position: absolute; + top: 8px; + bottom: 0; + left: 0; + width: 1px; + background: var(--marker-color); + opacity: 0.45; + pointer-events: none; +} + +.timeline-marker-flag { + position: absolute; + top: 0; + left: 0; + width: 8px; + height: 10px; + background: var(--marker-color); + clip-path: polygon(0 0, 100% 0, 100% 70%, 0 100%); +} + +.timeline-marker-label { + position: absolute; + top: -1px; + left: 10px; + font-size: 10px; + font-weight: 500; + color: var(--marker-color); + white-space: nowrap; + pointer-events: none; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; +} + +.timeline-marker:hover .timeline-marker-flag, +.timeline-marker.selected .timeline-marker-flag { + box-shadow: 0 0 0 1.5px var(--range-hover); +} + +.timeline-marker.selected .timeline-marker-label { + text-decoration: underline; +} + .scrub-handle { position: absolute; top: 0; diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index e29e947..b8b15c7 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -178,6 +178,11 @@ export const en = { showLocked: 'Show locked tracks', inPoint: 'In point — drag to set where playback and export start', outPoint: 'Out point — drag to set where playback and export end', + renameMarkerTitle: 'Rename marker', + renameMarkerMessage: 'Pick a unique name — used as a jump target in scripts.', + renameMarkerConfirm: 'Rename', + renameMarkerCollision: 'A marker named "{name}" already exists.', + renameMarkerRetry: 'Try again', stillFrameTooltip: 'Still frame {time} — holds until the next keyframe', keyframeTooltip: 'Keyframe {time} — drag to move, right-click for options', trimTooltip: '{name} · {duration}s{trims} · Drag edges to trim, double-click to open',