-
- {{ currentProjectName }}
-
@@ -734,19 +771,47 @@ onBeforeUnmount(() => {
background: rgba(110, 168, 255, 0.08);
}
-.project-name {
+.project-name-btn {
display: inline-flex;
align-items: center;
- max-width: 220px;
- padding: 4px 10px;
- font-size: 11px;
- color: var(--text-dim-strong);
- background: var(--panel-alt);
- border: 1px solid var(--border);
+ gap: 6px;
+ max-width: 260px;
+ padding: 4px 8px;
+ font: inherit;
+ font-size: 12px;
+ color: var(--text);
+ background: transparent;
+ border: 1px solid transparent;
border-radius: var(--radius-sm);
- white-space: nowrap;
+ cursor: pointer;
+}
+
+.project-name-btn.is-untitled {
+ color: var(--text-dim);
+ font-style: italic;
+}
+
+.project-name-btn:hover {
+ background: var(--panel-alt);
+ border-color: var(--border);
+}
+
+.project-name-label {
overflow: hidden;
text-overflow: ellipsis;
- margin-right: 4px;
+ white-space: nowrap;
+ min-width: 0;
+}
+
+.project-name-pencil {
+ color: var(--text-dim);
+ opacity: 0;
+ transition: opacity 140ms var(--ease);
+ flex-shrink: 0;
+}
+
+.project-name-btn:hover .project-name-pencil,
+.project-name-btn:focus-visible .project-name-pencil {
+ opacity: 1;
}
diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue
index 20871f4..a0d7fd7 100644
--- a/src/components/CanvasStage.vue
+++ b/src/components/CanvasStage.vue
@@ -2414,7 +2414,12 @@ function handleDragEnd(e: Konva.KonvaEventObject) {
if (!layer) return
const newX = node.x()
const newY = node.y()
- if (selectedKfs.value.length > 0 && anySelectedKfAtPlayhead()) {
+ // Multi-keyframe shift applies only when the layer being dragged is
+ // itself part of the current selection. Otherwise a stale selection on
+ // some other layer would steal the drag delta, leaving the dragged
+ // layer to snap back while the selected one moved instead.
+ const draggedInSelection = selectedKfs.value.some((k) => k.layerId === id)
+ if (draggedInSelection && anySelectedKfAtPlayhead()) {
const samp = sampleLayer(layer, store.playhead)
const dx = newX - samp.x
const dy = newY - samp.y
diff --git a/src/components/ClipartDrawer.vue b/src/components/ClipartDrawer.vue
index 8ddc42a..fe58719 100644
--- a/src/components/ClipartDrawer.vue
+++ b/src/components/ClipartDrawer.vue
@@ -49,7 +49,7 @@ function onItemDragStart(e: DragEvent, index: number) {
>
-
+
{{ t('clipart.title') }}
@@ -106,6 +106,11 @@ function onItemDragStart(e: DragEvent, index: number) {
background: var(--border);
border-right: 1px solid var(--border);
position: relative;
+ /* The drawer-panel is always mounted at its full 400px width so the
+ thumbnail grid's columns don't reflow mid-animation. `overflow:hidden`
+ clips it to the drawer's animated width so while closed/hovering the
+ panel is rendered but invisible. */
+ overflow: hidden;
}
.clipart-drawer:hover {
@@ -157,8 +162,11 @@ function onItemDragStart(e: DragEvent, index: number) {
}
.drawer-panel {
- flex: 1;
- min-width: 0;
+ /* Rigid 400px width — never flex-shrinks. Combined with the drawer's
+ `overflow: hidden`, this lets the grid be laid out correctly the
+ moment it mounts and stay that way regardless of the drawer's
+ current animated width. */
+ flex: 0 0 400px;
width: 400px;
display: flex;
flex-direction: column;
diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue
index 896ccd7..f595927 100644
--- a/src/components/Timeline.vue
+++ b/src/components/Timeline.vue
@@ -8,6 +8,7 @@ import {
PhCode,
PhCopy,
PhDotsSixVertical,
+ PhFilmStrip,
PhFolder,
PhFolderPlus,
PhLock,
@@ -1062,12 +1063,35 @@ interface KfSegment {
toTime: number
easing: Easing
held: boolean
+ /** True when this segment is the "still-frame tail" extending past
+ * the last keyframe to the end of the timeline. Rendered dimmer so
+ * it reads as an after-image rather than an interpolation. */
+ tail?: boolean
}
-function segmentsOf(kfs: Keyframe[]): KfSegment[] {
- if (kfs.length < 2) return []
+function segmentsOf(kfs: Keyframe[], duration: number): KfSegment[] {
+ if (kfs.length === 0) return []
const sorted = [...kfs].sort((a, b) => a.time - b.time)
const out: KfSegment[] = []
+
+ // Single keyframe means the layer is always visible (no animation
+ // range), so the line should span the entire timeline as a solid
+ // dim "on-stage" indicator — same treatment as a still-frame tail.
+ if (sorted.length === 1) {
+ const only = sorted[0]
+ if (duration > 0) {
+ out.push({
+ id: `${only.id}>full`,
+ fromTime: 0,
+ toTime: duration,
+ easing: only.easing,
+ held: false,
+ tail: true,
+ })
+ }
+ return out
+ }
+
for (let i = 0; i < sorted.length - 1; i++) {
const a = sorted[i]
const b = sorted[i + 1]
@@ -1079,6 +1103,20 @@ function segmentsOf(kfs: Keyframe[]): KfSegment[] {
held: !!a.hold,
})
}
+ // Last-keyframe "still frame" tail: when the final keyframe is held,
+ // extend the on-stage indicator to the end of the timeline. Solid
+ // and dim — the value isn't interpolating, it's just idling.
+ const last = sorted[sorted.length - 1]
+ if (last.hold && duration > last.time) {
+ out.push({
+ id: `${last.id}>tail`,
+ fromTime: last.time,
+ toTime: duration,
+ easing: last.easing,
+ held: false,
+ tail: true,
+ })
+ }
return out
}
@@ -1974,6 +2012,20 @@ function onDocumentKeyDown(e: KeyboardEvent) {
store.removeMarker(id)
return
}
+ // Selected-track deletion. Only layer tracks carry a selection
+ // today (clip / script / group tracks don't set selectedLayerId).
+ // Skip the delete when the layer is locked directly or through its
+ // parent group — Backspace should respect the same locks that block
+ // mouse interaction.
+ if (store.selectedLayerId) {
+ const layerId = store.selectedLayerId
+ const layer = store.activeLayers.find((l) => l.id === layerId)
+ if (!layer) return
+ if (layer.locked || lockedGroupChildIds.value.has(layerId)) return
+ e.preventDefault()
+ store.removeLayer(layerId)
+ return
+ }
}
if (
e.key.toLowerCase() === 'm' &&
@@ -2390,10 +2442,10 @@ onBeforeUnmount(() => {
>
{
:disabled="menu.isFirst || menu.isHeld"
@click="applyEasing(opt.value)"
>
-
+
-
{{ opt.label }}