From 373a83557617811b18284e53c562ee4beda0ca50 Mon Sep 17 00:00:00 2001
From: th
Date: Mon, 20 Apr 2026 20:42:45 +0200
Subject: [PATCH] macro-clips slice 2: marker UI on the scrub ruler
- marker flags render on the scrub ruler with color, label, and a dimmed
vertical guideline; z-index sits below brackets and playhead
- click marker to jump the playhead, drag to retime (snaps), double-click
to rename via prompt dialog, 'm' adds a marker at the playhead and
opens the rename prompt
- rename collision shows a dialog and loops back into the prompt
- selectedMarkerId tracks the active marker; Delete/Backspace removes it,
Escape clears the selection
- document keydown now gates m/Delete/Backspace on editable targets but
keeps Escape universal
Co-Authored-By: Claude Opus 4.7 (1M context)
---
src/components/Timeline.vue | 195 ++++++++++++++++++++++++++++++++++--
src/i18n/messages.ts | 5 +
2 files changed, 192 insertions(+), 8 deletions(-)
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',
|