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) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-20 20:42:45 +02:00
1 parent a75e9151ed
commit 373a835576
2 files changed
+192 -8

No files matched your search

+187 -8
View File
@@ -364,6 +364,84 @@ function onOutBracketPointerUp(e: PointerEvent) {
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
}
// -------- Markers -----------------------------------------------------
const selectedMarkerId = ref<string | null>(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"
></span>
<span
v-for="marker in store.scene.markers ?? []"
:key="marker.id"
class="timeline-marker"
:class="{ selected: selectedMarkerId === marker.id }"
:style="{
left: pxFromTime(marker.time) + 'px',
'--marker-color': marker.color || 'var(--accent)',
}"
:title="marker.name"
@pointerdown="onMarkerPointerDown($event, marker.id)"
@pointermove="onMarkerPointerMove($event, marker.id)"
@pointerup="onMarkerPointerUp($event, marker.id)"
@pointercancel="onMarkerPointerUp($event, marker.id)"
@dblclick.stop="renameMarkerPrompt(marker.id)"
>
<span class="timeline-marker-flag"></span>
<span class="timeline-marker-label">{{ marker.name }}</span>
</span>
<span class="scrub-handle" :style="{ left: pxFromTime(store.playhead) + 'px' }">
<span class="scrub-handle-head"></span>
<span class="scrub-handle-line"></span>
@@ -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;
+5
View File
@@ -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',