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)
|
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) {
|
function onLaneClick(e: MouseEvent) {
|
||||||
if (kfDrag.value) return
|
if (kfDrag.value) return
|
||||||
const el = e.currentTarget as HTMLElement
|
const el = e.currentTarget as HTMLElement
|
||||||
@@ -1560,16 +1638,42 @@ function isEditableTarget(target: EventTarget | null): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onDocumentKeyDown(e: KeyboardEvent) {
|
function onDocumentKeyDown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape' && selectedKfs.value.length) {
|
if (e.key === 'Escape') {
|
||||||
selectedKfs.value = []
|
if (selectedKfs.value.length) {
|
||||||
return
|
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()
|
e.preventDefault()
|
||||||
const targets = [...selectedKfs.value]
|
addMarkerAtPlayhead()
|
||||||
selectedKfs.value = []
|
|
||||||
for (const k of targets) store.removeKeyframe(k.layerId, k.keyframeId)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1763,6 +1867,25 @@ onBeforeUnmount(() => {
|
|||||||
@pointerup="onOutBracketPointerUp"
|
@pointerup="onOutBracketPointerUp"
|
||||||
@pointercancel="onOutBracketPointerUp"
|
@pointercancel="onOutBracketPointerUp"
|
||||||
></span>
|
></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" :style="{ left: pxFromTime(store.playhead) + 'px' }">
|
||||||
<span class="scrub-handle-head"></span>
|
<span class="scrub-handle-head"></span>
|
||||||
<span class="scrub-handle-line"></span>
|
<span class="scrub-handle-line"></span>
|
||||||
@@ -2540,6 +2663,62 @@ onBeforeUnmount(() => {
|
|||||||
background: var(--range-hover);
|
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 {
|
.scrub-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
@@ -178,6 +178,11 @@ export const en = {
|
|||||||
showLocked: 'Show locked tracks',
|
showLocked: 'Show locked tracks',
|
||||||
inPoint: 'In point — drag to set where playback and export start',
|
inPoint: 'In point — drag to set where playback and export start',
|
||||||
outPoint: 'Out point — drag to set where playback and export end',
|
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',
|
stillFrameTooltip: 'Still frame {time} — holds until the next keyframe',
|
||||||
keyframeTooltip: 'Keyframe {time} — drag to move, right-click for options',
|
keyframeTooltip: 'Keyframe {time} — drag to move, right-click for options',
|
||||||
trimTooltip: '{name} · {duration}s{trims} · Drag edges to trim, double-click to open',
|
trimTooltip: '{name} · {duration}s{trims} · Drag edges to trim, double-click to open',
|
||||||
|
|||||||
Reference in new issue
Block a user