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:
1 parent
a75e9151ed
commit
373a835576
2 files changed
+192
-8
No files matched your search
+187
-8
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user