This commit is contained in:
th committed 2026-04-20 00:07:08 +02:00
1 parent 6b6157d786
commit 3ef5fb5a2d
19 files changed
+1244 -94

No files matched your search

+177
View File
@@ -0,0 +1,177 @@
<script setup lang="ts">
import { PhSparkle } from '@phosphor-icons/vue'
import { Modal } from '@/components/ui'
import { APP_NAME, APP_VERSION } from '@/version'
import { t } from '@/i18n'
defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
interface Shortcut {
labelKey: string
keysKey: string
}
const shortcuts: Shortcut[] = [
{ labelKey: 'about.shortcutLabel.playPause', keysKey: 'about.shortcut.playPause' },
{ labelKey: 'about.shortcutLabel.fullscreen', keysKey: 'about.shortcut.fullscreen' },
{ labelKey: 'about.shortcutLabel.undo', keysKey: 'about.shortcut.undo' },
{ labelKey: 'about.shortcutLabel.redo', keysKey: 'about.shortcut.redo' },
{ labelKey: 'about.shortcutLabel.panCanvas', keysKey: 'about.shortcut.panCanvas' },
{ labelKey: 'about.shortcutLabel.tests', keysKey: 'about.shortcut.tests' },
]
</script>
<template>
<Modal
:model-value="modelValue"
:title="t('about.title', { name: APP_NAME })"
size="sm"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="about">
<div class="hero">
<div class="logo">
<PhSparkle :size="28" weight="fill" />
</div>
<div class="hero-text">
<div class="app-name">{{ APP_NAME }}</div>
<div class="version">{{ t('about.versionLabel') }} {{ APP_VERSION }}</div>
</div>
</div>
<p class="tagline">{{ t('about.tagline') }}</p>
<section class="shortcuts">
<h4>{{ t('about.shortcutsHeading') }}</h4>
<ul>
<li v-for="s in shortcuts" :key="s.keysKey">
<span class="shortcut-label">{{ t(s.labelKey) }}</span>
<kbd>{{ t(s.keysKey) }}</kbd>
</li>
</ul>
</section>
</div>
<template #footer>
<button class="btn primary" @click="emit('update:modelValue', false)">
{{ t('about.close') }}
</button>
</template>
</Modal>
</template>
<style scoped>
.about {
padding: 18px 20px 14px;
display: flex;
flex-direction: column;
gap: 16px;
}
.hero {
display: flex;
align-items: center;
gap: 14px;
}
.logo {
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent-soft);
color: var(--accent);
border-radius: 12px;
flex-shrink: 0;
}
.hero-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.app-name {
font-size: 18px;
font-weight: 600;
letter-spacing: -0.01em;
}
.version {
font-size: 11px;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.tagline {
margin: 0;
font-size: 12px;
color: var(--text-dim-strong);
line-height: 1.45;
}
.shortcuts h4 {
margin: 0 0 6px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
}
.shortcuts ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.shortcuts li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 5px 8px;
background: var(--panel-alt);
border-radius: var(--radius-sm);
font-size: 12px;
}
.shortcut-label {
color: var(--text);
}
kbd {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 4px;
padding: 2px 6px;
font-family: ui-monospace, Menlo, monospace;
font-size: 10px;
color: var(--text-dim-strong);
}
.btn {
padding: 6px 14px;
border-radius: var(--radius-sm);
font: inherit;
font-size: 12px;
font-weight: 500;
border: 1px solid var(--border);
background: var(--panel-alt);
color: var(--text);
cursor: pointer;
}
.btn.primary {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.btn.primary:hover {
background: var(--accent-soft-strong);
}
</style>
+28 -4
View File
@@ -175,11 +175,35 @@ const imageAssets = computed(() =>
store.scene.assets.filter((a): a is Extract<Asset, { kind: 'image' }> => a.kind === 'image'),
)
function onAssetDragStart(e: DragEvent, assetId: string) {
function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>) {
if (!e.dataTransfer) return
e.dataTransfer.effectAllowed = 'copy'
e.dataTransfer.setData('application/x-animatr-asset', assetId)
e.dataTransfer.setData('text/plain', assetId)
e.dataTransfer.setData('application/x-animatr-asset', asset.id)
e.dataTransfer.setData('text/plain', asset.id)
// Use the actual graphic as drag ghost instead of the library entry.
const target = e.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
const thumbImg = target.querySelector('img') as HTMLImageElement | null
if (thumbImg && thumbImg.complete && thumbImg.naturalWidth > 0) {
const r = thumbImg.getBoundingClientRect()
const ox = Math.max(0, e.clientX - r.left)
const oy = Math.max(0, e.clientY - r.top)
e.dataTransfer.setDragImage(thumbImg, ox, oy)
return
}
// Fallback — create an offscreen image of the asset for use as drag ghost.
const ghost = document.createElement('img')
ghost.src = asset.src
ghost.style.cssText =
'position:fixed;top:-10000px;left:-10000px;width:72px;height:auto;pointer-events:none;'
document.body.appendChild(ghost)
const gx = Math.max(0, e.clientX - rect.left)
const gy = Math.max(0, e.clientY - rect.top)
e.dataTransfer.setDragImage(ghost, gx, gy)
setTimeout(() => {
ghost.remove()
}, 0)
}
</script>
@@ -234,7 +258,7 @@ function onAssetDragStart(e: DragEvent, assetId: string) {
v-for="asset in imageAssets"
:key="asset.id"
draggable="true"
@dragstart="onAssetDragStart($event, asset.id)"
@dragstart="onAssetDragStart($event, asset)"
>
<template v-if="showThumbs">
<div class="thumb">
+231 -29
View File
@@ -7,8 +7,10 @@ import { sampleLayer } from '@/lib/interpolate'
import { loadImage } from '@/lib/fileLoader'
import type { AnimatableProps, Asset, Layer } from '@/types/scene'
import { t } from '@/i18n'
import { useKfSelection } from '@/composables/useKfSelection'
const store = useSceneStore()
const { selectedKfs } = useKfSelection()
const container = ref<HTMLDivElement | null>(null)
const containerSize = ref({ width: 0, height: 0 })
@@ -135,6 +137,19 @@ type StackItem =
| { kind: 'layer'; item: RenderedLayer }
| { kind: 'clip'; item: RenderedClipLayer }
function applyLivePose(props: AnimatableProps, layerId: string): AnimatableProps {
const live = liveLayerPose.value
if (!live || live.layerId !== layerId) return props
return {
...props,
x: live.x,
y: live.y,
scaleX: live.scaleX,
scaleY: live.scaleY,
rotation: live.rotation,
}
}
const stackItems = computed<StackItem[]>(() => {
// Clip-edit scope: just render active clip's layers (top of timeline = top of canvas)
if (store.activeClipId) {
@@ -145,7 +160,11 @@ const stackItems = computed<StackItem[]>(() => {
if (!asset) continue
out.push({
kind: 'layer',
item: { layer, asset, props: sampleLayer(layer, store.playhead) },
item: {
layer,
asset,
props: applyLivePose(sampleLayer(layer, store.playhead), layer.id),
},
})
}
return out
@@ -173,7 +192,11 @@ const stackItems = computed<StackItem[]>(() => {
if (!asset) continue
out.push({
kind: 'layer',
item: { layer, asset, props: sampleLayer(layer, store.playhead) },
item: {
layer,
asset,
props: applyLivePose(sampleLayer(layer, store.playhead), layer.id),
},
})
continue
}
@@ -208,6 +231,23 @@ const stackItems = computed<StackItem[]>(() => {
return out
})
function shadowCfg(props: AnimatableProps): Record<string, unknown> {
const opacity = props.shadowOpacity ?? 0
if (opacity <= 0) return {}
const blur = props.shadowBlur ?? 0
const distance = props.shadowDistance ?? 0
if (blur <= 0 && distance <= 0) return {}
const angle = props.shadowAngle ?? 90
const rad = (angle * Math.PI) / 180
return {
shadowColor: props.shadowColor ?? '#000000',
shadowBlur: blur,
shadowOpacity: opacity,
shadowOffsetX: Math.cos(rad) * distance,
shadowOffsetY: Math.sin(rad) * distance,
}
}
function baseNodeConfig(layer: Layer, props: AnimatableProps) {
return {
id: layer.id,
@@ -219,18 +259,60 @@ function baseNodeConfig(layer: Layer, props: AnimatableProps) {
opacity: props.opacity,
visible: layer.visible,
draggable: true,
...shadowCfg(props),
}
}
type KonvaCtx = {
beginPath: () => void
closePath: () => void
rect: (x: number, y: number, w: number, h: number) => void
arc: (x: number, y: number, r: number, s: number, e: number, cc?: boolean) => void
fillStrokeShape: (shape: unknown) => void
save: () => void
restore: () => void
setAttr: (attr: string, value: unknown) => void
}
type KonvaShape = { width(): number; height(): number; radius(): number; attrs: Record<string, unknown> }
function makeRectSceneFunc(blur: number) {
return (ctx: KonvaCtx, shape: KonvaShape) => {
ctx.save()
if (blur > 0) ctx.setAttr('filter', `blur(${blur}px)`)
ctx.beginPath()
ctx.rect(0, 0, shape.width(), shape.height())
ctx.closePath()
ctx.fillStrokeShape(shape)
ctx.restore()
}
}
function makeCircleSceneFunc(blur: number) {
return (ctx: KonvaCtx, shape: KonvaShape) => {
ctx.save()
if (blur > 0) ctx.setAttr('filter', `blur(${blur}px)`)
ctx.beginPath()
ctx.arc(0, 0, shape.radius(), 0, Math.PI * 2, false)
ctx.closePath()
ctx.fillStrokeShape(shape)
ctx.restore()
}
}
function rectConfig(layer: Layer, asset: Extract<Asset, { kind: 'rect' }>, props: AnimatableProps) {
return {
const fill = props.fill ?? asset.fill
const blur = props.blur ?? asset.blur ?? 0
const cfg: Record<string, unknown> = {
...baseNodeConfig(layer, props),
width: asset.width,
height: asset.height,
offsetX: layer.offsetX ?? asset.width / 2,
offsetY: layer.offsetY ?? asset.height / 2,
fill: props.fill ?? asset.fill,
fill,
}
if (blur > 0) cfg.sceneFunc = makeRectSceneFunc(blur)
return cfg
}
function circleConfig(
@@ -238,13 +320,17 @@ function circleConfig(
asset: Extract<Asset, { kind: 'circle' }>,
props: AnimatableProps,
) {
return {
const fill = props.fill ?? asset.fill
const blur = props.blur ?? asset.blur ?? 0
const cfg: Record<string, unknown> = {
...baseNodeConfig(layer, props),
radius: asset.radius,
offsetX: layer.offsetX ?? 0,
offsetY: layer.offsetY ?? 0,
fill: props.fill ?? asset.fill,
fill,
}
if (blur > 0) cfg.sceneFunc = makeCircleSceneFunc(blur)
return cfg
}
function textConfig(layer: Layer, asset: Extract<Asset, { kind: 'text' }>, props: AnimatableProps) {
@@ -287,30 +373,39 @@ function clipBaseConfig(instanceId: string, layer: Layer, props: AnimatableProps
visible: layer.visible,
draggable: false,
listening: false,
...shadowCfg(props),
}
}
function clipRectConfig(item: RenderedClipLayer) {
const asset = item.asset as Extract<Asset, { kind: 'rect' }>
return {
const fill = item.props.fill ?? asset.fill
const blur = item.props.blur ?? asset.blur ?? 0
const cfg: Record<string, unknown> = {
...clipBaseConfig(item.instanceId, item.layer, item.props),
width: asset.width,
height: asset.height,
offsetX: item.layer.offsetX ?? asset.width / 2,
offsetY: item.layer.offsetY ?? asset.height / 2,
fill: item.props.fill ?? asset.fill,
fill,
}
if (blur > 0) cfg.sceneFunc = makeRectSceneFunc(blur)
return cfg
}
function clipCircleConfig(item: RenderedClipLayer) {
const asset = item.asset as Extract<Asset, { kind: 'circle' }>
return {
const fill = item.props.fill ?? asset.fill
const blur = item.props.blur ?? asset.blur ?? 0
const cfg: Record<string, unknown> = {
...clipBaseConfig(item.instanceId, item.layer, item.props),
radius: asset.radius,
offsetX: item.layer.offsetX ?? 0,
offsetY: item.layer.offsetY ?? 0,
fill: item.props.fill ?? asset.fill,
fill,
}
if (blur > 0) cfg.sceneFunc = makeCircleSceneFunc(blur)
return cfg
}
function clipTextConfig(item: RenderedClipLayer) {
@@ -344,6 +439,17 @@ interface PivotInfo {
sampled: AnimatableProps
}
interface LiveLayerPose {
layerId: string
x: number
y: number
scaleX: number
scaleY: number
rotation: number
}
const liveLayerPose = ref<LiveLayerPose | null>(null)
const pivotInfo = computed<PivotInfo | null>(() => {
const id = store.selectedLayerId
if (!id) return null
@@ -351,7 +457,22 @@ const pivotInfo = computed<PivotInfo | null>(() => {
if (!layer) return null
const asset = assetMap.value.get(layer.assetId)
if (!asset) return null
return { layerId: layer.id, sampled: sampleLayer(layer, store.playhead) }
const sampled = sampleLayer(layer, store.playhead)
const live = liveLayerPose.value
if (live && live.layerId === id) {
return {
layerId: layer.id,
sampled: {
...sampled,
x: live.x,
y: live.y,
scaleX: live.scaleX,
scaleY: live.scaleY,
rotation: live.rotation,
},
}
}
return { layerId: layer.id, sampled }
})
const pivotHandleConfig = computed(() => {
@@ -441,6 +562,31 @@ watch(
{ immediate: true },
)
function updateLivePose(node: Konva.Node, layerId: string) {
liveLayerPose.value = {
layerId,
x: node.x(),
y: node.y(),
scaleX: node.scaleX(),
scaleY: node.scaleY(),
rotation: node.rotation(),
}
}
function handleDragMove(e: Konva.KonvaEventObject<DragEvent>) {
const node = e.target
const id = node.id()
if (!store.activeLayers.some((l) => l.id === id)) return
updateLivePose(node, id)
}
function handleTransform(e: Konva.KonvaEventObject<Event>) {
const node = e.target
const id = node.id()
if (!store.activeLayers.some((l) => l.id === id)) return
updateLivePose(node, id)
}
function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
const target = e.target
const tr = transformerRef.value?.getNode()
@@ -469,11 +615,21 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
function handleDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
const node = e.target
const id = node.id()
if (!store.activeLayers.some((l) => l.id === id)) return
store.upsertKeyframe(id, store.playhead, {
x: node.x(),
y: node.y(),
})
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer) return
const newX = node.x()
const newY = node.y()
if (selectedKfs.value.length > 0) {
const samp = sampleLayer(layer, store.playhead)
const dx = newX - samp.x
const dy = newY - samp.y
if (dx !== 0 || dy !== 0) {
store.shiftKeyframesPosition(selectedKfs.value, dx, dy)
}
} else {
store.upsertKeyframe(id, store.playhead, { x: newX, y: newY })
}
liveLayerPose.value = null
}
function handleTransformEnd(e: Konva.KonvaEventObject<Event>) {
@@ -487,13 +643,24 @@ function handleTransformEnd(e: Konva.KonvaEventObject<Event>) {
scaleY: node.scaleY(),
rotation: node.rotation(),
})
liveLayerPose.value = null
}
const ROT_ARM_PX = 44
const rotationHandlePos = ref<{ x: number; y: number } | null>(null)
const rotationHandleConfig = computed(() => {
const info = pivotInfo.value
if (!info) return null
if (rotationHandlePos.value) {
return {
x: rotationHandlePos.value.x,
y: rotationHandlePos.value.y,
draggable: true,
name: 'rotation-handle',
}
}
const arm = ROT_ARM_PX / zoom.value
const rad = (info.sampled.rotation * Math.PI) / 180
return {
@@ -507,15 +674,19 @@ const rotationHandleConfig = computed(() => {
const rotationArmConfig = computed(() => {
const info = pivotInfo.value
if (!info) return null
const arm = ROT_ARM_PX / zoom.value
const rad = (info.sampled.rotation * Math.PI) / 180
let endX: number
let endY: number
if (rotationHandlePos.value) {
endX = rotationHandlePos.value.x
endY = rotationHandlePos.value.y
} else {
const arm = ROT_ARM_PX / zoom.value
const rad = (info.sampled.rotation * Math.PI) / 180
endX = info.sampled.x + arm * Math.sin(rad)
endY = info.sampled.y - arm * Math.cos(rad)
}
return {
points: [
info.sampled.x,
info.sampled.y,
info.sampled.x + arm * Math.sin(rad),
info.sampled.y - arm * Math.cos(rad),
],
points: [info.sampled.x, info.sampled.y, endX, endY],
stroke: '#6ea8ff',
strokeWidth: 1.25 / zoom.value,
dash: [3 / zoom.value, 3 / zoom.value],
@@ -537,11 +708,33 @@ function onRotationHandleDragMove(e: Konva.KonvaEventObject<DragEvent>) {
const dx = node.x() - info.sampled.x
const dy = node.y() - info.sampled.y
if (dx === 0 && dy === 0) return
const rad = Math.atan2(dx, -dy)
let deg = (rad * 180) / Math.PI
if (deg > 180) deg -= 360
if (deg < -180) deg += 360
store.upsertKeyframe(info.layerId, store.playhead, { rotation: deg })
const raw = (Math.atan2(dx, -dy) * 180) / Math.PI
// Snap to the 360°-equivalent nearest to the previous rotation so small
// motions don't flip a full turn across the ±180° boundary.
const prev = liveLayerPose.value?.rotation ?? info.sampled.rotation
let deg = raw
while (deg - prev > 180) deg -= 360
while (deg - prev < -180) deg += 360
const base = liveLayerPose.value ?? {
layerId: info.layerId,
x: info.sampled.x,
y: info.sampled.y,
scaleX: info.sampled.scaleX,
scaleY: info.sampled.scaleY,
rotation: info.sampled.rotation,
}
liveLayerPose.value = { ...base, layerId: info.layerId, rotation: deg }
rotationHandlePos.value = { x: node.x(), y: node.y() }
}
function onRotationHandleDragEnd() {
const info = pivotInfo.value
const live = liveLayerPose.value
if (info && live && live.layerId === info.layerId) {
store.upsertKeyframe(info.layerId, store.playhead, { rotation: live.rotation })
}
rotationHandlePos.value = null
liveLayerPose.value = null
}
let ro: ResizeObserver | null = null
@@ -699,25 +892,33 @@ onBeforeUnmount(() => {
<v-rect
v-if="entry.item.asset.kind === 'rect'"
:config="rectConfig(entry.item.layer, entry.item.asset, entry.item.props)"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@transformend="handleTransformEnd"
/>
<v-circle
v-else-if="entry.item.asset.kind === 'circle'"
:config="circleConfig(entry.item.layer, entry.item.asset, entry.item.props)"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@transformend="handleTransformEnd"
/>
<v-text
v-else-if="entry.item.asset.kind === 'text'"
:config="textConfig(entry.item.layer, entry.item.asset, entry.item.props)"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@transformend="handleTransformEnd"
/>
<v-image
v-else-if="entry.item.asset.kind === 'image' && imageConfig(entry.item.layer, entry.item.asset, entry.item.props)"
:config="imageConfig(entry.item.layer, entry.item.asset, entry.item.props)!"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@transformend="handleTransformEnd"
/>
</template>
@@ -755,6 +956,7 @@ onBeforeUnmount(() => {
v-if="rotationHandleConfig"
:config="rotationHandleConfig"
@dragmove="onRotationHandleDragMove"
@dragend="onRotationHandleDragEnd"
>
<v-circle :config="rotationKnobConfig" />
</v-group>
+152
View File
@@ -34,6 +34,76 @@ const textAsset = computed(() => {
return a && a.kind === 'text' ? a : null
})
const blurAsset = computed(() => {
const a = selectedAsset.value
return a && (a.kind === 'rect' || a.kind === 'circle') ? a : null
})
const blurValue = computed<number>({
get: () => {
const a = blurAsset.value
if (!a) return 0
return sampled.value?.blur ?? a.blur ?? 0
},
set: (v: number) => {
if (!selected.value) return
store.upsertKeyframe(selected.value.id, store.playhead, { blur: v })
},
})
type ShadowNumKey = 'shadowBlur' | 'shadowOpacity' | 'shadowAngle' | 'shadowDistance'
interface ShadowAll {
shadowBlur: number
shadowOpacity: number
shadowAngle: number
shadowDistance: number
shadowColor: string
}
const SHADOW_DEFAULTS: ShadowAll = {
shadowBlur: 8,
shadowOpacity: 0,
shadowAngle: 90,
shadowDistance: 6,
shadowColor: '#000000',
}
function currentShadow(): ShadowAll {
const samp = sampled.value
return {
shadowBlur: samp?.shadowBlur ?? SHADOW_DEFAULTS.shadowBlur,
shadowOpacity: samp?.shadowOpacity ?? SHADOW_DEFAULTS.shadowOpacity,
shadowAngle: samp?.shadowAngle ?? SHADOW_DEFAULTS.shadowAngle,
shadowDistance: samp?.shadowDistance ?? SHADOW_DEFAULTS.shadowDistance,
shadowColor: samp?.shadowColor ?? SHADOW_DEFAULTS.shadowColor,
}
}
function writeShadow(patch: Partial<ShadowAll>) {
if (!selected.value) return
const next = { ...currentShadow(), ...patch }
store.upsertKeyframe(selected.value.id, store.playhead, next)
}
function shadowBinding(key: ShadowNumKey, decimals = 0) {
const factor = 10 ** decimals
return computed({
get: () => Math.round(currentShadow()[key] * factor) / factor,
set: (v: number) => writeShadow({ [key]: v } as Partial<ShadowAll>),
})
}
const shadowBlur = shadowBinding('shadowBlur')
const shadowOpacity = shadowBinding('shadowOpacity', 2)
const shadowAngle = shadowBinding('shadowAngle')
const shadowDistance = shadowBinding('shadowDistance')
const shadowColorValue = computed<string>({
get: () => currentShadow().shadowColor,
set: (v: string) => writeShadow({ shadowColor: v }),
})
const canFill = computed(() => {
const a = selectedAsset.value
return !!a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'text')
@@ -108,6 +178,71 @@ function removeKeyframeHere() {
<label>Text<input type="text" v-model="textAsset.text" /></label>
<label>Font Size<input type="number" min="1" v-model.number="textAsset.fontSize" /></label>
</template>
<label v-if="blurAsset" class="slider-label">
{{ t('properties.edgeBlur') }}
<RangeSlider
v-model="blurValue"
:min="0"
:max="64"
:step="1"
:format="(v: number) => `${Math.round(v)}px`"
/>
</label>
<section class="shadow-group">
<h4>{{ t('properties.shadow') }}</h4>
<label class="color-label">
{{ t('properties.shadowColor') }}
<div class="color-row">
<input
type="color"
class="color-swatch"
:value="shadowColorValue"
@input="shadowColorValue = ($event.target as HTMLInputElement).value"
/>
<span class="color-hex">{{ shadowColorValue.toUpperCase() }}</span>
</div>
</label>
<label class="slider-label">
{{ t('properties.shadowOpacity') }}
<RangeSlider
v-model="shadowOpacity"
:min="0"
:max="1"
:step="0.01"
:format="formatPercent"
/>
</label>
<label class="slider-label">
{{ t('properties.shadowBlur') }}
<RangeSlider
v-model="shadowBlur"
:min="0"
:max="64"
:step="1"
:format="(v: number) => `${Math.round(v)}px`"
/>
</label>
<label class="slider-label">
{{ t('properties.shadowDistance') }}
<RangeSlider
v-model="shadowDistance"
:min="0"
:max="100"
:step="1"
:format="(v: number) => `${Math.round(v)}px`"
/>
</label>
<label class="slider-label">
{{ t('properties.shadowAngle') }}
<RangeSlider
v-model="shadowAngle"
:min="0"
:max="360"
:step="1"
:format="(v: number) => `${Math.round(v)}°`"
/>
</label>
</section>
<label v-if="canFill" class="color-label">
Color
<div class="color-row">
@@ -180,6 +315,23 @@ input {
padding: 8px 0;
}
.shadow-group {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
.shadow-group h4 {
margin: 0;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
font-weight: 500;
}
.kf-row {
display: flex;
align-items: center;
+149 -50
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
PhArrowsSplit,
PhCheck,
@@ -16,6 +16,7 @@ import { sampleLayer } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap'
import { confirmDialog } from '@/components/ui'
import { t } from '@/i18n'
import { kfSelectionKey, useKfSelection, type KfSelectionKey } from '@/composables/useKfSelection'
import type {
AnimatableProps,
Clip,
@@ -318,23 +319,9 @@ function isDropAfter(track: TimelineTrack, nextTrack: TimelineTrack | undefined)
return true
}
interface SelectionKey {
layerId: string
kind: 'main' | 'position' | 'rotation'
keyframeId: string
}
const selectedKfs = ref<SelectionKey[]>([])
function selectionKey(k: SelectionKey): string {
return `${k.layerId}:${k.kind}:${k.keyframeId}`
}
const selectedKfSet = computed(() => {
const s = new Set<string>()
for (const k of selectedKfs.value) s.add(selectionKey(k))
return s
})
type SelectionKey = KfSelectionKey
const { selectedKfs, selectedKfSet } = useKfSelection()
const selectionKey = kfSelectionKey
function isKfSelected(layerId: string, kind: 'main' | 'position' | 'rotation', keyframeId: string): boolean {
return selectedKfSet.value.has(`${layerId}:${kind}:${keyframeId}`)
@@ -687,6 +674,7 @@ interface BoxSelectState {
currentClientY: number
tracksEl: HTMLElement
moved: boolean
additive: boolean
}
const boxSelect = ref<BoxSelectState | null>(null)
@@ -728,6 +716,7 @@ function onTracksPointerDown(e: PointerEvent) {
currentClientY: e.clientY,
tracksEl,
moved: false,
additive: e.shiftKey,
}
}
@@ -749,8 +738,8 @@ function onTracksPointerUp(e: PointerEvent) {
if (b.tracksEl.hasPointerCapture(e.pointerId)) b.tracksEl.releasePointerCapture(e.pointerId)
if (b.moved) {
commitBoxSelection(b)
} else {
// bare click on empty space → clear selection
} else if (!b.additive) {
// bare click on empty space without Shift → clear selection
clearKfSelection()
}
boxSelect.value = null
@@ -763,7 +752,7 @@ function commitBoxSelection(b: BoxSelectState) {
const bottom = Math.max(b.originClientY, b.currentClientY)
const nodes = b.tracksEl.querySelectorAll<HTMLElement>('.kf[data-kf-id]')
const next: SelectionKey[] = []
const hits: SelectionKey[] = []
for (const node of Array.from(nodes)) {
const r = node.getBoundingClientRect()
const cx = r.left + r.width / 2
@@ -773,9 +762,22 @@ function commitBoxSelection(b: BoxSelectState) {
const kind = node.dataset.kind as 'main' | 'position' | 'rotation' | undefined
const keyframeId = node.dataset.kfId
if (!layerId || !kind || !keyframeId) continue
next.push({ layerId, kind, keyframeId })
hits.push({ layerId, kind, keyframeId })
}
if (b.additive) {
const seen = new Set(selectedKfs.value.map(selectionKey))
const merged = [...selectedKfs.value]
for (const h of hits) {
const k = selectionKey(h)
if (!seen.has(k)) {
seen.add(k)
merged.push(h)
}
}
selectedKfs.value = merged
} else {
selectedKfs.value = hits
}
selectedKfs.value = next
}
function pixelTimeFromEvent(e: DragEvent | PointerEvent, el: HTMLElement): number {
@@ -858,8 +860,46 @@ function onClipBlockPointerUp(e: PointerEvent) {
if (wasMoved) e.preventDefault()
}
function addClipTrack() {
store.addClipTrack()
const clipDragOverDropzone = ref(false)
function onNewTrackDragEnter(e: DragEvent) {
if (!e.dataTransfer?.types.includes('application/x-animatr-clip')) return
e.preventDefault()
clipDragOverDropzone.value = true
}
function onNewTrackDragOver(e: DragEvent) {
if (!e.dataTransfer?.types.includes('application/x-animatr-clip')) return
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
clipDragOverDropzone.value = true
}
function onNewTrackDragLeave() {
clipDragOverDropzone.value = false
}
function onNewTrackDrop(e: DragEvent) {
clipDragOverDropzone.value = false
const clipId = e.dataTransfer?.getData('application/x-animatr-clip')
if (!clipId) return
e.preventDefault()
const el = e.currentTarget as HTMLElement
const time = pixelTimeFromLane(e, el)
const track = store.addClipTrack()
store.addClipInstance(clipId, time, track.id)
}
function pixelTimeFromLane(e: DragEvent, el: HTMLElement): number {
// Account for sticky track-name column offset: compute based on the full tl-content lane.
const rect = el.getBoundingClientRect()
if (rect.width <= 0) return 0
const NAME_COL = 140
const laneLeft = rect.left + NAME_COL
const laneWidth = rect.width - NAME_COL
if (laneWidth <= 0) return 0
const ratio = Math.max(0, Math.min(1, (e.clientX - laneLeft) / laneWidth))
return ratio * duration.value
}
function clipDurationOf(ci: ClipInstance): number {
@@ -1076,6 +1116,36 @@ watch([menu, trackMenu], ([m, tm]) => {
}
})
async function clampMenuIntoViewport(
getEl: () => HTMLElement | null,
x: number,
y: number,
) {
await nextTick()
const el = getEl()
if (!el) return
const rect = el.getBoundingClientRect()
const vw = window.innerWidth
const vh = window.innerHeight
const margin = 8
let left = x
let top = y
if (left + rect.width + margin > vw) left = Math.max(margin, vw - rect.width - margin)
if (top + rect.height + margin > vh) top = Math.max(margin, vh - rect.height - margin)
el.style.left = `${left}px`
el.style.top = `${top}px`
}
watch(menu, (m) => {
if (!m) return
clampMenuIntoViewport(() => menuRef.value, m.x, m.y)
})
watch(trackMenu, (tm) => {
if (!tm) return
clampMenuIntoViewport(() => trackMenuRef.value, tm.x, tm.y)
})
function onWheel(e: WheelEvent) {
if (!scrollEl.value) return
if (e.ctrlKey || e.metaKey) {
@@ -1308,6 +1378,7 @@ onBeforeUnmount(() => {
@dragover.stop="onClipLaneDragOver($event, track.clipTrackId)"
@drop.stop="onClipLaneDrop($event, track.clipTrackId)"
>
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
<span
v-for="ci in clipInstancesByTrack[track.clipTrackId] ?? []"
:key="ci.id"
@@ -1357,11 +1428,16 @@ onBeforeUnmount(() => {
</div>
</template>
<p v-if="!tracks.length" class="empty">{{ t('timeline.noTracks') }}</p>
<div v-if="!inClipEdit" class="track clip-track add-clip-track">
<button class="add-clip-btn" :title="t('timeline.addClipTrackTooltip')" @click="addClipTrack">
<PhPlus :size="11" weight="bold" />
<span>{{ t('timeline.addClipTrack') }}</span>
</button>
<div
v-if="!inClipEdit"
class="track clip-track-dropzone"
:class="{ hot: clipDragOverDropzone }"
@dragenter="onNewTrackDragEnter"
@dragover="onNewTrackDragOver"
@dragleave="onNewTrackDragLeave"
@drop="onNewTrackDrop"
>
<span class="dropzone-hint">{{ t('timeline.dropNewTrackHint') }}</span>
</div>
</div>
</div>
@@ -1391,6 +1467,7 @@ onBeforeUnmount(() => {
weight="bold"
/>
</span>
<span class="kf-menu-icon-spacer"></span>
{{ opt.label }}
</button>
<div v-if="menu.isFirst" class="kf-menu-note">{{ t('timeline.firstKfHint') }}</div>
@@ -1407,16 +1484,18 @@ onBeforeUnmount(() => {
<span class="check">
<PhCheck v-if="menu.isHeld" :size="12" weight="bold" />
</span>
<PhPauseCircle :size="12" />
<PhPauseCircle :size="12" class="kf-menu-icon" />
{{ t('timeline.stillFrame') }}
</button>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item" @click="duplicateFromMenu">
<PhCopy :size="12" />
<span class="check"></span>
<PhCopy :size="12" class="kf-menu-icon" />
{{ t('timeline.duplicate') }}
</button>
<button class="kf-menu-item danger" @click="deleteFromMenu">
<PhTrash :size="12" />
<span class="check"></span>
<PhTrash :size="12" class="kf-menu-icon" />
{{ t('timeline.delete') }}
</button>
</div>
@@ -1428,7 +1507,6 @@ onBeforeUnmount(() => {
:style="{ left: trackMenu.x + 'px', top: trackMenu.y + 'px' }"
>
<div class="kf-menu-section">
<div class="kf-menu-header">{{ t('timeline.color') }}</div>
<div class="color-tiles">
<button
v-for="c in TRACK_COLORS"
@@ -1452,11 +1530,10 @@ onBeforeUnmount(() => {
<span class="check">
<PhCheck v-if="trackMenu.locked" :size="12" weight="bold" />
</span>
<component :is="trackMenu.locked ? PhLockOpen : PhLock" :size="12" />
<component :is="trackMenu.locked ? PhLockOpen : PhLock" :size="12" class="kf-menu-icon" />
{{ trackMenu.locked ? t('timeline.unlock') : t('timeline.lock') }}
</button>
<template v-if="trackMenu.kind === 'layer'">
<div class="kf-menu-divider"></div>
<button
class="kf-menu-item"
:disabled="trackMenu.locked"
@@ -1465,12 +1542,14 @@ onBeforeUnmount(() => {
<span class="check">
<PhCheck v-if="trackMenu.separated" :size="12" weight="bold" />
</span>
<PhArrowsSplit :size="12" class="kf-menu-icon" />
{{ t('timeline.separate') }}
</button>
</template>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item danger" @click="deleteTrack">
<PhTrash :size="12" />
<span class="check"></span>
<PhTrash :size="12" class="kf-menu-icon" />
{{ trackMenu.kind === 'layer' ? t('timeline.deleteLayer') : t('timeline.deleteClipTrack') }}
</button>
</div>
@@ -1822,7 +1901,7 @@ onBeforeUnmount(() => {
.track-name {
position: sticky;
left: 0;
z-index: 3;
z-index: 5;
width: var(--track-name-width);
padding: 0 12px;
color: var(--text-dim);
@@ -1931,6 +2010,7 @@ onBeforeUnmount(() => {
background: var(--accent);
pointer-events: none;
transform: translateX(-0.5px);
z-index: 4;
}
.kf-seg {
@@ -2193,28 +2273,34 @@ onBeforeUnmount(() => {
opacity: 1 !important;
}
.add-clip-track {
.clip-track-dropzone {
height: 26px;
background: transparent;
border-bottom: 1px dashed var(--border);
}
.add-clip-btn {
display: inline-flex;
display: flex;
align-items: center;
gap: 6px;
height: 100%;
padding: 0 14px;
justify-content: center;
background: transparent;
border: none;
border: 1px dashed var(--border);
border-left: none;
border-right: none;
transition: background 140ms var(--ease), border-color 140ms var(--ease);
color: var(--text-dim);
font-size: 11px;
}
.add-clip-btn:hover {
.clip-track-dropzone.hot {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.dropzone-hint {
position: sticky;
left: 50%;
transform: translateX(-50%);
pointer-events: none;
white-space: nowrap;
}
.kf-menu {
position: fixed;
@@ -2280,9 +2366,22 @@ onBeforeUnmount(() => {
.check {
width: 14px;
height: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--accent);
flex-shrink: 0;
}
.kf-menu-icon,
.kf-menu-icon-spacer {
width: 12px;
height: 12px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.kf-menu-note {