bak
This commit is contained in:
1 parent
6b6157d786
commit
3ef5fb5a2d
19 files changed
+1244
-94
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "animatr",
|
"name": "animatr",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -95,17 +95,34 @@
|
|||||||
ctx.translate(p.x,p.y);
|
ctx.translate(p.x,p.y);
|
||||||
ctx.rotate(p.rotation*Math.PI/180);
|
ctx.rotate(p.rotation*Math.PI/180);
|
||||||
ctx.scale(p.scaleX,p.scaleY);
|
ctx.scale(p.scaleX,p.scaleY);
|
||||||
|
var sOp=p.shadowOpacity||0, sBl=p.shadowBlur||0, sDi=p.shadowDistance||0;
|
||||||
|
if(sOp>0 && (sBl>0 || sDi>0)){
|
||||||
|
var sAn=(p.shadowAngle==null?90:p.shadowAngle)*Math.PI/180;
|
||||||
|
var sHex=(p.shadowColor||'#000000').replace('#','');
|
||||||
|
if(sHex.length===3){sHex=sHex[0]+sHex[0]+sHex[1]+sHex[1]+sHex[2]+sHex[2];}
|
||||||
|
var sNum=parseInt(sHex.slice(0,6),16); if(isNaN(sNum))sNum=0;
|
||||||
|
ctx.shadowColor='rgba('+((sNum>>16)&255)+','+((sNum>>8)&255)+','+(sNum&255)+','+sOp+')';
|
||||||
|
ctx.shadowBlur=sBl;
|
||||||
|
ctx.shadowOffsetX=Math.cos(sAn)*sDi;
|
||||||
|
ctx.shadowOffsetY=Math.sin(sAn)*sDi;
|
||||||
|
}
|
||||||
var fill = p.fill || asset.fill;
|
var fill = p.fill || asset.fill;
|
||||||
if(asset.kind==='rect'){
|
if(asset.kind==='rect'){
|
||||||
var rOffX=layer.offsetX!=null?layer.offsetX:asset.width/2;
|
var rOffX=layer.offsetX!=null?layer.offsetX:asset.width/2;
|
||||||
var rOffY=layer.offsetY!=null?layer.offsetY:asset.height/2;
|
var rOffY=layer.offsetY!=null?layer.offsetY:asset.height/2;
|
||||||
|
var rBlur=(p.blur!=null?p.blur:(asset.blur||0));
|
||||||
ctx.fillStyle=fill;
|
ctx.fillStyle=fill;
|
||||||
|
if(rBlur>0) ctx.filter='blur('+rBlur+'px)';
|
||||||
ctx.fillRect(-rOffX,-rOffY,asset.width,asset.height);
|
ctx.fillRect(-rOffX,-rOffY,asset.width,asset.height);
|
||||||
|
ctx.filter='none';
|
||||||
} else if(asset.kind==='circle'){
|
} else if(asset.kind==='circle'){
|
||||||
var cOffX=layer.offsetX!=null?layer.offsetX:0;
|
var cOffX=layer.offsetX!=null?layer.offsetX:0;
|
||||||
var cOffY=layer.offsetY!=null?layer.offsetY:0;
|
var cOffY=layer.offsetY!=null?layer.offsetY:0;
|
||||||
|
var cBlur=(p.blur!=null?p.blur:(asset.blur||0));
|
||||||
ctx.fillStyle=fill;
|
ctx.fillStyle=fill;
|
||||||
|
if(cBlur>0) ctx.filter='blur('+cBlur+'px)';
|
||||||
ctx.beginPath(); ctx.arc(-cOffX,-cOffY,asset.radius,0,Math.PI*2); ctx.fill();
|
ctx.beginPath(); ctx.arc(-cOffX,-cOffY,asset.radius,0,Math.PI*2); ctx.fill();
|
||||||
|
ctx.filter='none';
|
||||||
} else if(asset.kind==='text'){
|
} else if(asset.kind==='text'){
|
||||||
var tOffX=layer.offsetX!=null?layer.offsetX:0;
|
var tOffX=layer.offsetX!=null?layer.offsetX:0;
|
||||||
var tOffY=layer.offsetY!=null?layer.offsetY:0;
|
var tOffY=layer.offsetY!=null?layer.offsetY:0;
|
||||||
@@ -121,6 +138,10 @@
|
|||||||
ctx.drawImage(img,-iOffX,-iOffY,asset.width,asset.height);
|
ctx.drawImage(img,-iOffX,-iOffY,asset.width,asset.height);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ctx.shadowColor='transparent';
|
||||||
|
ctx.shadowBlur=0;
|
||||||
|
ctx.shadowOffsetX=0;
|
||||||
|
ctx.shadowOffsetY=0;
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
function drawFrame(time){
|
function drawFrame(time){
|
||||||
|
|||||||
+25
-2
@@ -15,6 +15,7 @@ import {
|
|||||||
PhSparkle,
|
PhSparkle,
|
||||||
PhStop,
|
PhStop,
|
||||||
} from '@phosphor-icons/vue'
|
} from '@phosphor-icons/vue'
|
||||||
|
import AboutDialog from './components/AboutDialog.vue'
|
||||||
import AssetSidebar from './components/AssetSidebar.vue'
|
import AssetSidebar from './components/AssetSidebar.vue'
|
||||||
import CanvasStage from './components/CanvasStage.vue'
|
import CanvasStage from './components/CanvasStage.vue'
|
||||||
import DocumentSettings from './components/DocumentSettings.vue'
|
import DocumentSettings from './components/DocumentSettings.vue'
|
||||||
@@ -48,6 +49,7 @@ const exportOpen = ref(false)
|
|||||||
const testsOpen = ref(false)
|
const testsOpen = ref(false)
|
||||||
const welcomeOpen = ref(false)
|
const welcomeOpen = ref(false)
|
||||||
const fullscreenOpen = ref(false)
|
const fullscreenOpen = ref(false)
|
||||||
|
const aboutOpen = ref(false)
|
||||||
const TIMELINE_HEIGHT_KEY = 'animatr:timelineHeight'
|
const TIMELINE_HEIGHT_KEY = 'animatr:timelineHeight'
|
||||||
const DEFAULT_TIMELINE_HEIGHT = 220
|
const DEFAULT_TIMELINE_HEIGHT = 220
|
||||||
const MIN_TIMELINE_HEIGHT = 120
|
const MIN_TIMELINE_HEIGHT = 120
|
||||||
@@ -237,6 +239,12 @@ function isEditable(el: EventTarget | null): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onDocumentContextMenu(e: MouseEvent) {
|
||||||
|
// Allow native context menu only on editable inputs (for paste, etc.).
|
||||||
|
if (isEditable(e.target)) return
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
if (e.ctrlKey && e.shiftKey && e.altKey && (e.code === 'KeyT' || e.key.toLowerCase() === 't')) {
|
if (e.ctrlKey && e.shiftKey && e.altKey && (e.code === 'KeyT' || e.key.toLowerCase() === 't')) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -270,11 +278,13 @@ function onKeyDown(e: KeyboardEvent) {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('keydown', onKeyDown)
|
document.addEventListener('keydown', onKeyDown)
|
||||||
|
document.addEventListener('contextmenu', onDocumentContextMenu)
|
||||||
window.addEventListener('resize', onWindowResize)
|
window.addEventListener('resize', onWindowResize)
|
||||||
bootWelcome()
|
bootWelcome()
|
||||||
})
|
})
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('keydown', onKeyDown)
|
document.removeEventListener('keydown', onKeyDown)
|
||||||
|
document.removeEventListener('contextmenu', onDocumentContextMenu)
|
||||||
window.removeEventListener('resize', onWindowResize)
|
window.removeEventListener('resize', onWindowResize)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -283,10 +293,10 @@ onBeforeUnmount(() => {
|
|||||||
<div class="app" :style="appStyle">
|
<div class="app" :style="appStyle">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand-area">
|
<div class="brand-area">
|
||||||
<span class="brand">
|
<button class="brand" :title="t('about.openTooltip')" @click="aboutOpen = true">
|
||||||
<PhSparkle :size="16" weight="fill" class="brand-icon" />
|
<PhSparkle :size="16" weight="fill" class="brand-icon" />
|
||||||
Animatr
|
Animatr
|
||||||
</span>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="transport">
|
<div class="transport">
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -380,6 +390,7 @@ onBeforeUnmount(() => {
|
|||||||
<ExportDialog v-model="exportOpen" />
|
<ExportDialog v-model="exportOpen" />
|
||||||
<TestOverlay v-model="testsOpen" />
|
<TestOverlay v-model="testsOpen" />
|
||||||
<FullscreenPreview v-model="fullscreenOpen" />
|
<FullscreenPreview v-model="fullscreenOpen" />
|
||||||
|
<AboutDialog v-model="aboutOpen" />
|
||||||
<DialogsHost />
|
<DialogsHost />
|
||||||
<WelcomeDialog
|
<WelcomeDialog
|
||||||
v-model="welcomeOpen"
|
v-model="welcomeOpen"
|
||||||
@@ -434,6 +445,18 @@ onBeforeUnmount(() => {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin-left: -8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand:hover {
|
||||||
|
background: var(--panel-alt);
|
||||||
|
border-color: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-icon {
|
.brand-icon {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -175,11 +175,35 @@ const imageAssets = computed(() =>
|
|||||||
store.scene.assets.filter((a): a is Extract<Asset, { kind: 'image' }> => a.kind === 'image'),
|
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
|
if (!e.dataTransfer) return
|
||||||
e.dataTransfer.effectAllowed = 'copy'
|
e.dataTransfer.effectAllowed = 'copy'
|
||||||
e.dataTransfer.setData('application/x-animatr-asset', assetId)
|
e.dataTransfer.setData('application/x-animatr-asset', asset.id)
|
||||||
e.dataTransfer.setData('text/plain', assetId)
|
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>
|
</script>
|
||||||
@@ -234,7 +258,7 @@ function onAssetDragStart(e: DragEvent, assetId: string) {
|
|||||||
v-for="asset in imageAssets"
|
v-for="asset in imageAssets"
|
||||||
:key="asset.id"
|
:key="asset.id"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
@dragstart="onAssetDragStart($event, asset.id)"
|
@dragstart="onAssetDragStart($event, asset)"
|
||||||
>
|
>
|
||||||
<template v-if="showThumbs">
|
<template v-if="showThumbs">
|
||||||
<div class="thumb">
|
<div class="thumb">
|
||||||
|
|||||||
+231
-29
@@ -7,8 +7,10 @@ import { sampleLayer } from '@/lib/interpolate'
|
|||||||
import { loadImage } from '@/lib/fileLoader'
|
import { loadImage } from '@/lib/fileLoader'
|
||||||
import type { AnimatableProps, Asset, Layer } from '@/types/scene'
|
import type { AnimatableProps, Asset, Layer } from '@/types/scene'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
import { useKfSelection } from '@/composables/useKfSelection'
|
||||||
|
|
||||||
const store = useSceneStore()
|
const store = useSceneStore()
|
||||||
|
const { selectedKfs } = useKfSelection()
|
||||||
|
|
||||||
const container = ref<HTMLDivElement | null>(null)
|
const container = ref<HTMLDivElement | null>(null)
|
||||||
const containerSize = ref({ width: 0, height: 0 })
|
const containerSize = ref({ width: 0, height: 0 })
|
||||||
@@ -135,6 +137,19 @@ type StackItem =
|
|||||||
| { kind: 'layer'; item: RenderedLayer }
|
| { kind: 'layer'; item: RenderedLayer }
|
||||||
| { kind: 'clip'; item: RenderedClipLayer }
|
| { 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[]>(() => {
|
const stackItems = computed<StackItem[]>(() => {
|
||||||
// Clip-edit scope: just render active clip's layers (top of timeline = top of canvas)
|
// Clip-edit scope: just render active clip's layers (top of timeline = top of canvas)
|
||||||
if (store.activeClipId) {
|
if (store.activeClipId) {
|
||||||
@@ -145,7 +160,11 @@ const stackItems = computed<StackItem[]>(() => {
|
|||||||
if (!asset) continue
|
if (!asset) continue
|
||||||
out.push({
|
out.push({
|
||||||
kind: 'layer',
|
kind: 'layer',
|
||||||
item: { layer, asset, props: sampleLayer(layer, store.playhead) },
|
item: {
|
||||||
|
layer,
|
||||||
|
asset,
|
||||||
|
props: applyLivePose(sampleLayer(layer, store.playhead), layer.id),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -173,7 +192,11 @@ const stackItems = computed<StackItem[]>(() => {
|
|||||||
if (!asset) continue
|
if (!asset) continue
|
||||||
out.push({
|
out.push({
|
||||||
kind: 'layer',
|
kind: 'layer',
|
||||||
item: { layer, asset, props: sampleLayer(layer, store.playhead) },
|
item: {
|
||||||
|
layer,
|
||||||
|
asset,
|
||||||
|
props: applyLivePose(sampleLayer(layer, store.playhead), layer.id),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -208,6 +231,23 @@ const stackItems = computed<StackItem[]>(() => {
|
|||||||
return out
|
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) {
|
function baseNodeConfig(layer: Layer, props: AnimatableProps) {
|
||||||
return {
|
return {
|
||||||
id: layer.id,
|
id: layer.id,
|
||||||
@@ -219,18 +259,60 @@ function baseNodeConfig(layer: Layer, props: AnimatableProps) {
|
|||||||
opacity: props.opacity,
|
opacity: props.opacity,
|
||||||
visible: layer.visible,
|
visible: layer.visible,
|
||||||
draggable: true,
|
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) {
|
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),
|
...baseNodeConfig(layer, props),
|
||||||
width: asset.width,
|
width: asset.width,
|
||||||
height: asset.height,
|
height: asset.height,
|
||||||
offsetX: layer.offsetX ?? asset.width / 2,
|
offsetX: layer.offsetX ?? asset.width / 2,
|
||||||
offsetY: layer.offsetY ?? asset.height / 2,
|
offsetY: layer.offsetY ?? asset.height / 2,
|
||||||
fill: props.fill ?? asset.fill,
|
fill,
|
||||||
}
|
}
|
||||||
|
if (blur > 0) cfg.sceneFunc = makeRectSceneFunc(blur)
|
||||||
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
function circleConfig(
|
function circleConfig(
|
||||||
@@ -238,13 +320,17 @@ function circleConfig(
|
|||||||
asset: Extract<Asset, { kind: 'circle' }>,
|
asset: Extract<Asset, { kind: 'circle' }>,
|
||||||
props: AnimatableProps,
|
props: AnimatableProps,
|
||||||
) {
|
) {
|
||||||
return {
|
const fill = props.fill ?? asset.fill
|
||||||
|
const blur = props.blur ?? asset.blur ?? 0
|
||||||
|
const cfg: Record<string, unknown> = {
|
||||||
...baseNodeConfig(layer, props),
|
...baseNodeConfig(layer, props),
|
||||||
radius: asset.radius,
|
radius: asset.radius,
|
||||||
offsetX: layer.offsetX ?? 0,
|
offsetX: layer.offsetX ?? 0,
|
||||||
offsetY: layer.offsetY ?? 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) {
|
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,
|
visible: layer.visible,
|
||||||
draggable: false,
|
draggable: false,
|
||||||
listening: false,
|
listening: false,
|
||||||
|
...shadowCfg(props),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clipRectConfig(item: RenderedClipLayer) {
|
function clipRectConfig(item: RenderedClipLayer) {
|
||||||
const asset = item.asset as Extract<Asset, { kind: 'rect' }>
|
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),
|
...clipBaseConfig(item.instanceId, item.layer, item.props),
|
||||||
width: asset.width,
|
width: asset.width,
|
||||||
height: asset.height,
|
height: asset.height,
|
||||||
offsetX: item.layer.offsetX ?? asset.width / 2,
|
offsetX: item.layer.offsetX ?? asset.width / 2,
|
||||||
offsetY: item.layer.offsetY ?? asset.height / 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) {
|
function clipCircleConfig(item: RenderedClipLayer) {
|
||||||
const asset = item.asset as Extract<Asset, { kind: 'circle' }>
|
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),
|
...clipBaseConfig(item.instanceId, item.layer, item.props),
|
||||||
radius: asset.radius,
|
radius: asset.radius,
|
||||||
offsetX: item.layer.offsetX ?? 0,
|
offsetX: item.layer.offsetX ?? 0,
|
||||||
offsetY: item.layer.offsetY ?? 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) {
|
function clipTextConfig(item: RenderedClipLayer) {
|
||||||
@@ -344,6 +439,17 @@ interface PivotInfo {
|
|||||||
sampled: AnimatableProps
|
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 pivotInfo = computed<PivotInfo | null>(() => {
|
||||||
const id = store.selectedLayerId
|
const id = store.selectedLayerId
|
||||||
if (!id) return null
|
if (!id) return null
|
||||||
@@ -351,7 +457,22 @@ const pivotInfo = computed<PivotInfo | null>(() => {
|
|||||||
if (!layer) return null
|
if (!layer) return null
|
||||||
const asset = assetMap.value.get(layer.assetId)
|
const asset = assetMap.value.get(layer.assetId)
|
||||||
if (!asset) return null
|
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(() => {
|
const pivotHandleConfig = computed(() => {
|
||||||
@@ -441,6 +562,31 @@ watch(
|
|||||||
{ immediate: true },
|
{ 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>) {
|
function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
|
||||||
const target = e.target
|
const target = e.target
|
||||||
const tr = transformerRef.value?.getNode()
|
const tr = transformerRef.value?.getNode()
|
||||||
@@ -469,11 +615,21 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
|
|||||||
function handleDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
|
function handleDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
|
||||||
const node = e.target
|
const node = e.target
|
||||||
const id = node.id()
|
const id = node.id()
|
||||||
if (!store.activeLayers.some((l) => l.id === id)) return
|
const layer = store.activeLayers.find((l) => l.id === id)
|
||||||
store.upsertKeyframe(id, store.playhead, {
|
if (!layer) return
|
||||||
x: node.x(),
|
const newX = node.x()
|
||||||
y: node.y(),
|
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>) {
|
function handleTransformEnd(e: Konva.KonvaEventObject<Event>) {
|
||||||
@@ -487,13 +643,24 @@ function handleTransformEnd(e: Konva.KonvaEventObject<Event>) {
|
|||||||
scaleY: node.scaleY(),
|
scaleY: node.scaleY(),
|
||||||
rotation: node.rotation(),
|
rotation: node.rotation(),
|
||||||
})
|
})
|
||||||
|
liveLayerPose.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROT_ARM_PX = 44
|
const ROT_ARM_PX = 44
|
||||||
|
|
||||||
|
const rotationHandlePos = ref<{ x: number; y: number } | null>(null)
|
||||||
|
|
||||||
const rotationHandleConfig = computed(() => {
|
const rotationHandleConfig = computed(() => {
|
||||||
const info = pivotInfo.value
|
const info = pivotInfo.value
|
||||||
if (!info) return null
|
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 arm = ROT_ARM_PX / zoom.value
|
||||||
const rad = (info.sampled.rotation * Math.PI) / 180
|
const rad = (info.sampled.rotation * Math.PI) / 180
|
||||||
return {
|
return {
|
||||||
@@ -507,15 +674,19 @@ const rotationHandleConfig = computed(() => {
|
|||||||
const rotationArmConfig = computed(() => {
|
const rotationArmConfig = computed(() => {
|
||||||
const info = pivotInfo.value
|
const info = pivotInfo.value
|
||||||
if (!info) return null
|
if (!info) return null
|
||||||
const arm = ROT_ARM_PX / zoom.value
|
let endX: number
|
||||||
const rad = (info.sampled.rotation * Math.PI) / 180
|
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 {
|
return {
|
||||||
points: [
|
points: [info.sampled.x, info.sampled.y, endX, endY],
|
||||||
info.sampled.x,
|
|
||||||
info.sampled.y,
|
|
||||||
info.sampled.x + arm * Math.sin(rad),
|
|
||||||
info.sampled.y - arm * Math.cos(rad),
|
|
||||||
],
|
|
||||||
stroke: '#6ea8ff',
|
stroke: '#6ea8ff',
|
||||||
strokeWidth: 1.25 / zoom.value,
|
strokeWidth: 1.25 / zoom.value,
|
||||||
dash: [3 / zoom.value, 3 / 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 dx = node.x() - info.sampled.x
|
||||||
const dy = node.y() - info.sampled.y
|
const dy = node.y() - info.sampled.y
|
||||||
if (dx === 0 && dy === 0) return
|
if (dx === 0 && dy === 0) return
|
||||||
const rad = Math.atan2(dx, -dy)
|
const raw = (Math.atan2(dx, -dy) * 180) / Math.PI
|
||||||
let deg = (rad * 180) / Math.PI
|
// Snap to the 360°-equivalent nearest to the previous rotation so small
|
||||||
if (deg > 180) deg -= 360
|
// motions don't flip a full turn across the ±180° boundary.
|
||||||
if (deg < -180) deg += 360
|
const prev = liveLayerPose.value?.rotation ?? info.sampled.rotation
|
||||||
store.upsertKeyframe(info.layerId, store.playhead, { rotation: deg })
|
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
|
let ro: ResizeObserver | null = null
|
||||||
@@ -699,25 +892,33 @@ onBeforeUnmount(() => {
|
|||||||
<v-rect
|
<v-rect
|
||||||
v-if="entry.item.asset.kind === 'rect'"
|
v-if="entry.item.asset.kind === 'rect'"
|
||||||
:config="rectConfig(entry.item.layer, entry.item.asset, entry.item.props)"
|
:config="rectConfig(entry.item.layer, entry.item.asset, entry.item.props)"
|
||||||
|
@dragmove="handleDragMove"
|
||||||
@dragend="handleDragEnd"
|
@dragend="handleDragEnd"
|
||||||
|
@transform="handleTransform"
|
||||||
@transformend="handleTransformEnd"
|
@transformend="handleTransformEnd"
|
||||||
/>
|
/>
|
||||||
<v-circle
|
<v-circle
|
||||||
v-else-if="entry.item.asset.kind === 'circle'"
|
v-else-if="entry.item.asset.kind === 'circle'"
|
||||||
:config="circleConfig(entry.item.layer, entry.item.asset, entry.item.props)"
|
:config="circleConfig(entry.item.layer, entry.item.asset, entry.item.props)"
|
||||||
|
@dragmove="handleDragMove"
|
||||||
@dragend="handleDragEnd"
|
@dragend="handleDragEnd"
|
||||||
|
@transform="handleTransform"
|
||||||
@transformend="handleTransformEnd"
|
@transformend="handleTransformEnd"
|
||||||
/>
|
/>
|
||||||
<v-text
|
<v-text
|
||||||
v-else-if="entry.item.asset.kind === 'text'"
|
v-else-if="entry.item.asset.kind === 'text'"
|
||||||
:config="textConfig(entry.item.layer, entry.item.asset, entry.item.props)"
|
:config="textConfig(entry.item.layer, entry.item.asset, entry.item.props)"
|
||||||
|
@dragmove="handleDragMove"
|
||||||
@dragend="handleDragEnd"
|
@dragend="handleDragEnd"
|
||||||
|
@transform="handleTransform"
|
||||||
@transformend="handleTransformEnd"
|
@transformend="handleTransformEnd"
|
||||||
/>
|
/>
|
||||||
<v-image
|
<v-image
|
||||||
v-else-if="entry.item.asset.kind === 'image' && imageConfig(entry.item.layer, entry.item.asset, entry.item.props)"
|
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)!"
|
:config="imageConfig(entry.item.layer, entry.item.asset, entry.item.props)!"
|
||||||
|
@dragmove="handleDragMove"
|
||||||
@dragend="handleDragEnd"
|
@dragend="handleDragEnd"
|
||||||
|
@transform="handleTransform"
|
||||||
@transformend="handleTransformEnd"
|
@transformend="handleTransformEnd"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -755,6 +956,7 @@ onBeforeUnmount(() => {
|
|||||||
v-if="rotationHandleConfig"
|
v-if="rotationHandleConfig"
|
||||||
:config="rotationHandleConfig"
|
:config="rotationHandleConfig"
|
||||||
@dragmove="onRotationHandleDragMove"
|
@dragmove="onRotationHandleDragMove"
|
||||||
|
@dragend="onRotationHandleDragEnd"
|
||||||
>
|
>
|
||||||
<v-circle :config="rotationKnobConfig" />
|
<v-circle :config="rotationKnobConfig" />
|
||||||
</v-group>
|
</v-group>
|
||||||
|
|||||||
@@ -34,6 +34,76 @@ const textAsset = computed(() => {
|
|||||||
return a && a.kind === 'text' ? a : null
|
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 canFill = computed(() => {
|
||||||
const a = selectedAsset.value
|
const a = selectedAsset.value
|
||||||
return !!a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'text')
|
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>Text<input type="text" v-model="textAsset.text" /></label>
|
||||||
<label>Font Size<input type="number" min="1" v-model.number="textAsset.fontSize" /></label>
|
<label>Font Size<input type="number" min="1" v-model.number="textAsset.fontSize" /></label>
|
||||||
</template>
|
</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">
|
<label v-if="canFill" class="color-label">
|
||||||
Color
|
Color
|
||||||
<div class="color-row">
|
<div class="color-row">
|
||||||
@@ -180,6 +315,23 @@ input {
|
|||||||
padding: 8px 0;
|
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 {
|
.kf-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+149
-50
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import {
|
import {
|
||||||
PhArrowsSplit,
|
PhArrowsSplit,
|
||||||
PhCheck,
|
PhCheck,
|
||||||
@@ -16,6 +16,7 @@ import { sampleLayer } from '@/lib/interpolate'
|
|||||||
import { snapToCandidates } from '@/lib/timelineSnap'
|
import { snapToCandidates } from '@/lib/timelineSnap'
|
||||||
import { confirmDialog } from '@/components/ui'
|
import { confirmDialog } from '@/components/ui'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
import { kfSelectionKey, useKfSelection, type KfSelectionKey } from '@/composables/useKfSelection'
|
||||||
import type {
|
import type {
|
||||||
AnimatableProps,
|
AnimatableProps,
|
||||||
Clip,
|
Clip,
|
||||||
@@ -318,23 +319,9 @@ function isDropAfter(track: TimelineTrack, nextTrack: TimelineTrack | undefined)
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SelectionKey {
|
type SelectionKey = KfSelectionKey
|
||||||
layerId: string
|
const { selectedKfs, selectedKfSet } = useKfSelection()
|
||||||
kind: 'main' | 'position' | 'rotation'
|
const selectionKey = kfSelectionKey
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
function isKfSelected(layerId: string, kind: 'main' | 'position' | 'rotation', keyframeId: string): boolean {
|
function isKfSelected(layerId: string, kind: 'main' | 'position' | 'rotation', keyframeId: string): boolean {
|
||||||
return selectedKfSet.value.has(`${layerId}:${kind}:${keyframeId}`)
|
return selectedKfSet.value.has(`${layerId}:${kind}:${keyframeId}`)
|
||||||
@@ -687,6 +674,7 @@ interface BoxSelectState {
|
|||||||
currentClientY: number
|
currentClientY: number
|
||||||
tracksEl: HTMLElement
|
tracksEl: HTMLElement
|
||||||
moved: boolean
|
moved: boolean
|
||||||
|
additive: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const boxSelect = ref<BoxSelectState | null>(null)
|
const boxSelect = ref<BoxSelectState | null>(null)
|
||||||
@@ -728,6 +716,7 @@ function onTracksPointerDown(e: PointerEvent) {
|
|||||||
currentClientY: e.clientY,
|
currentClientY: e.clientY,
|
||||||
tracksEl,
|
tracksEl,
|
||||||
moved: false,
|
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.tracksEl.hasPointerCapture(e.pointerId)) b.tracksEl.releasePointerCapture(e.pointerId)
|
||||||
if (b.moved) {
|
if (b.moved) {
|
||||||
commitBoxSelection(b)
|
commitBoxSelection(b)
|
||||||
} else {
|
} else if (!b.additive) {
|
||||||
// bare click on empty space → clear selection
|
// bare click on empty space without Shift → clear selection
|
||||||
clearKfSelection()
|
clearKfSelection()
|
||||||
}
|
}
|
||||||
boxSelect.value = null
|
boxSelect.value = null
|
||||||
@@ -763,7 +752,7 @@ function commitBoxSelection(b: BoxSelectState) {
|
|||||||
const bottom = Math.max(b.originClientY, b.currentClientY)
|
const bottom = Math.max(b.originClientY, b.currentClientY)
|
||||||
|
|
||||||
const nodes = b.tracksEl.querySelectorAll<HTMLElement>('.kf[data-kf-id]')
|
const nodes = b.tracksEl.querySelectorAll<HTMLElement>('.kf[data-kf-id]')
|
||||||
const next: SelectionKey[] = []
|
const hits: SelectionKey[] = []
|
||||||
for (const node of Array.from(nodes)) {
|
for (const node of Array.from(nodes)) {
|
||||||
const r = node.getBoundingClientRect()
|
const r = node.getBoundingClientRect()
|
||||||
const cx = r.left + r.width / 2
|
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 kind = node.dataset.kind as 'main' | 'position' | 'rotation' | undefined
|
||||||
const keyframeId = node.dataset.kfId
|
const keyframeId = node.dataset.kfId
|
||||||
if (!layerId || !kind || !keyframeId) continue
|
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 {
|
function pixelTimeFromEvent(e: DragEvent | PointerEvent, el: HTMLElement): number {
|
||||||
@@ -858,8 +860,46 @@ function onClipBlockPointerUp(e: PointerEvent) {
|
|||||||
if (wasMoved) e.preventDefault()
|
if (wasMoved) e.preventDefault()
|
||||||
}
|
}
|
||||||
|
|
||||||
function addClipTrack() {
|
const clipDragOverDropzone = ref(false)
|
||||||
store.addClipTrack()
|
|
||||||
|
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 {
|
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) {
|
function onWheel(e: WheelEvent) {
|
||||||
if (!scrollEl.value) return
|
if (!scrollEl.value) return
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
@@ -1308,6 +1378,7 @@ onBeforeUnmount(() => {
|
|||||||
@dragover.stop="onClipLaneDragOver($event, track.clipTrackId)"
|
@dragover.stop="onClipLaneDragOver($event, track.clipTrackId)"
|
||||||
@drop.stop="onClipLaneDrop($event, track.clipTrackId)"
|
@drop.stop="onClipLaneDrop($event, track.clipTrackId)"
|
||||||
>
|
>
|
||||||
|
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
|
||||||
<span
|
<span
|
||||||
v-for="ci in clipInstancesByTrack[track.clipTrackId] ?? []"
|
v-for="ci in clipInstancesByTrack[track.clipTrackId] ?? []"
|
||||||
:key="ci.id"
|
:key="ci.id"
|
||||||
@@ -1357,11 +1428,16 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<p v-if="!tracks.length" class="empty">{{ t('timeline.noTracks') }}</p>
|
<p v-if="!tracks.length" class="empty">{{ t('timeline.noTracks') }}</p>
|
||||||
<div v-if="!inClipEdit" class="track clip-track add-clip-track">
|
<div
|
||||||
<button class="add-clip-btn" :title="t('timeline.addClipTrackTooltip')" @click="addClipTrack">
|
v-if="!inClipEdit"
|
||||||
<PhPlus :size="11" weight="bold" />
|
class="track clip-track-dropzone"
|
||||||
<span>{{ t('timeline.addClipTrack') }}</span>
|
:class="{ hot: clipDragOverDropzone }"
|
||||||
</button>
|
@dragenter="onNewTrackDragEnter"
|
||||||
|
@dragover="onNewTrackDragOver"
|
||||||
|
@dragleave="onNewTrackDragLeave"
|
||||||
|
@drop="onNewTrackDrop"
|
||||||
|
>
|
||||||
|
<span class="dropzone-hint">{{ t('timeline.dropNewTrackHint') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1391,6 +1467,7 @@ onBeforeUnmount(() => {
|
|||||||
weight="bold"
|
weight="bold"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
<span class="kf-menu-icon-spacer"></span>
|
||||||
{{ opt.label }}
|
{{ opt.label }}
|
||||||
</button>
|
</button>
|
||||||
<div v-if="menu.isFirst" class="kf-menu-note">{{ t('timeline.firstKfHint') }}</div>
|
<div v-if="menu.isFirst" class="kf-menu-note">{{ t('timeline.firstKfHint') }}</div>
|
||||||
@@ -1407,16 +1484,18 @@ onBeforeUnmount(() => {
|
|||||||
<span class="check">
|
<span class="check">
|
||||||
<PhCheck v-if="menu.isHeld" :size="12" weight="bold" />
|
<PhCheck v-if="menu.isHeld" :size="12" weight="bold" />
|
||||||
</span>
|
</span>
|
||||||
<PhPauseCircle :size="12" />
|
<PhPauseCircle :size="12" class="kf-menu-icon" />
|
||||||
{{ t('timeline.stillFrame') }}
|
{{ t('timeline.stillFrame') }}
|
||||||
</button>
|
</button>
|
||||||
<div class="kf-menu-divider"></div>
|
<div class="kf-menu-divider"></div>
|
||||||
<button class="kf-menu-item" @click="duplicateFromMenu">
|
<button class="kf-menu-item" @click="duplicateFromMenu">
|
||||||
<PhCopy :size="12" />
|
<span class="check"></span>
|
||||||
|
<PhCopy :size="12" class="kf-menu-icon" />
|
||||||
{{ t('timeline.duplicate') }}
|
{{ t('timeline.duplicate') }}
|
||||||
</button>
|
</button>
|
||||||
<button class="kf-menu-item danger" @click="deleteFromMenu">
|
<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') }}
|
{{ t('timeline.delete') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1428,7 +1507,6 @@ onBeforeUnmount(() => {
|
|||||||
:style="{ left: trackMenu.x + 'px', top: trackMenu.y + 'px' }"
|
:style="{ left: trackMenu.x + 'px', top: trackMenu.y + 'px' }"
|
||||||
>
|
>
|
||||||
<div class="kf-menu-section">
|
<div class="kf-menu-section">
|
||||||
<div class="kf-menu-header">{{ t('timeline.color') }}</div>
|
|
||||||
<div class="color-tiles">
|
<div class="color-tiles">
|
||||||
<button
|
<button
|
||||||
v-for="c in TRACK_COLORS"
|
v-for="c in TRACK_COLORS"
|
||||||
@@ -1452,11 +1530,10 @@ onBeforeUnmount(() => {
|
|||||||
<span class="check">
|
<span class="check">
|
||||||
<PhCheck v-if="trackMenu.locked" :size="12" weight="bold" />
|
<PhCheck v-if="trackMenu.locked" :size="12" weight="bold" />
|
||||||
</span>
|
</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') }}
|
{{ trackMenu.locked ? t('timeline.unlock') : t('timeline.lock') }}
|
||||||
</button>
|
</button>
|
||||||
<template v-if="trackMenu.kind === 'layer'">
|
<template v-if="trackMenu.kind === 'layer'">
|
||||||
<div class="kf-menu-divider"></div>
|
|
||||||
<button
|
<button
|
||||||
class="kf-menu-item"
|
class="kf-menu-item"
|
||||||
:disabled="trackMenu.locked"
|
:disabled="trackMenu.locked"
|
||||||
@@ -1465,12 +1542,14 @@ onBeforeUnmount(() => {
|
|||||||
<span class="check">
|
<span class="check">
|
||||||
<PhCheck v-if="trackMenu.separated" :size="12" weight="bold" />
|
<PhCheck v-if="trackMenu.separated" :size="12" weight="bold" />
|
||||||
</span>
|
</span>
|
||||||
|
<PhArrowsSplit :size="12" class="kf-menu-icon" />
|
||||||
{{ t('timeline.separate') }}
|
{{ t('timeline.separate') }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<div class="kf-menu-divider"></div>
|
<div class="kf-menu-divider"></div>
|
||||||
<button class="kf-menu-item danger" @click="deleteTrack">
|
<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') }}
|
{{ trackMenu.kind === 'layer' ? t('timeline.deleteLayer') : t('timeline.deleteClipTrack') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1822,7 +1901,7 @@ onBeforeUnmount(() => {
|
|||||||
.track-name {
|
.track-name {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 3;
|
z-index: 5;
|
||||||
width: var(--track-name-width);
|
width: var(--track-name-width);
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
@@ -1931,6 +2010,7 @@ onBeforeUnmount(() => {
|
|||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transform: translateX(-0.5px);
|
transform: translateX(-0.5px);
|
||||||
|
z-index: 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kf-seg {
|
.kf-seg {
|
||||||
@@ -2193,28 +2273,34 @@ onBeforeUnmount(() => {
|
|||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-clip-track {
|
.clip-track-dropzone {
|
||||||
height: 26px;
|
height: 26px;
|
||||||
background: transparent;
|
display: flex;
|
||||||
border-bottom: 1px dashed var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-clip-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
justify-content: center;
|
||||||
height: 100%;
|
|
||||||
padding: 0 14px;
|
|
||||||
background: transparent;
|
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);
|
color: var(--text-dim);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-clip-btn:hover {
|
.clip-track-dropzone.hot {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-color: var(--accent);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropzone-hint {
|
||||||
|
position: sticky;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.kf-menu {
|
.kf-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -2280,9 +2366,22 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
.check {
|
.check {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
|
height: 12px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: var(--accent);
|
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 {
|
.kf-menu-note {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
export interface KfSelectionKey {
|
||||||
|
layerId: string
|
||||||
|
kind: 'main' | 'position' | 'rotation'
|
||||||
|
keyframeId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedKfs = ref<KfSelectionKey[]>([])
|
||||||
|
|
||||||
|
export function kfSelectionKey(k: KfSelectionKey): string {
|
||||||
|
return `${k.layerId}:${k.kind}:${k.keyframeId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedKfSet = computed(() => {
|
||||||
|
const s = new Set<string>()
|
||||||
|
for (const k of selectedKfs.value) s.add(kfSelectionKey(k))
|
||||||
|
return s
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useKfSelection() {
|
||||||
|
return { selectedKfs, selectedKfSet }
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
} from '@/lib/exportPlayer'
|
} from '@/lib/exportPlayer'
|
||||||
import { ProjectFileError, parseProject, serializeProject } from '@/lib/projectFile'
|
import { ProjectFileError, parseProject, serializeProject } from '@/lib/projectFile'
|
||||||
import { createHistory } from '@/composables/useHistory'
|
import { createHistory } from '@/composables/useHistory'
|
||||||
|
import { useKfSelection, kfSelectionKey } from '@/composables/useKfSelection'
|
||||||
|
import { APP_NAME, APP_VERSION } from '@/version'
|
||||||
|
import { drawSceneFrame } from '@/lib/render'
|
||||||
import {
|
import {
|
||||||
deleteStoredProject,
|
deleteStoredProject,
|
||||||
getStoredProject,
|
getStoredProject,
|
||||||
@@ -26,6 +29,34 @@ interface MountedHandle {
|
|||||||
unmount: () => void
|
unmount: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RecordingCtx {
|
||||||
|
ctx: CanvasRenderingContext2D
|
||||||
|
filterSets: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRecordingCtx(): RecordingCtx {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = 200
|
||||||
|
canvas.height = 200
|
||||||
|
const real = canvas.getContext('2d')!
|
||||||
|
const filterSets: string[] = []
|
||||||
|
const proxy = new Proxy(real, {
|
||||||
|
set(target, prop, value) {
|
||||||
|
if (prop === 'filter') filterSets.push(value as string)
|
||||||
|
;(target as unknown as Record<string | symbol, unknown>)[prop as string | symbol] = value
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
get(target, prop) {
|
||||||
|
const v = (target as unknown as Record<string | symbol, unknown>)[
|
||||||
|
prop as string | symbol
|
||||||
|
]
|
||||||
|
if (typeof v === 'function') return v.bind(target)
|
||||||
|
return v
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { ctx: proxy as CanvasRenderingContext2D, filterSets }
|
||||||
|
}
|
||||||
|
|
||||||
function mountComponent(
|
function mountComponent(
|
||||||
component: Component,
|
component: Component,
|
||||||
props: Record<string, unknown>,
|
props: Record<string, unknown>,
|
||||||
@@ -1213,5 +1244,228 @@ export function registerAllTests(): void {
|
|||||||
const cropBtn = document.querySelector('.crop-btn')
|
const cropBtn = document.querySelector('.crop-btn')
|
||||||
expect(cropBtn).toBeTruthy()
|
expect(cropBtn).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('brand button opens about dialog', () => {
|
||||||
|
const brand = document.querySelector('button.brand')
|
||||||
|
expect(brand).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('timeline has new-track dropzone', () => {
|
||||||
|
const dz = document.querySelector('.clip-track-dropzone')
|
||||||
|
expect(dz).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
suite('version — app metadata', () => {
|
||||||
|
test('APP_VERSION is a semver-like string', () => {
|
||||||
|
expect(typeof APP_VERSION === 'string').toBe(true)
|
||||||
|
expect(/^\d+\.\d+\.\d+/.test(APP_VERSION)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('APP_NAME is non-empty', () => {
|
||||||
|
expect(APP_NAME.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
suite('kf selection — shared composable', () => {
|
||||||
|
test('setting selection reflects across handles', () => {
|
||||||
|
const { selectedKfs } = useKfSelection()
|
||||||
|
const snapshot = selectedKfs.value
|
||||||
|
try {
|
||||||
|
selectedKfs.value = [
|
||||||
|
{ layerId: 'L1', kind: 'main', keyframeId: 'K1' },
|
||||||
|
{ layerId: 'L2', kind: 'position', keyframeId: 'K2' },
|
||||||
|
]
|
||||||
|
const second = useKfSelection()
|
||||||
|
expect(second.selectedKfs.value).toHaveLength(2)
|
||||||
|
expect(second.selectedKfSet.value.has('L1:main:K1')).toBe(true)
|
||||||
|
expect(second.selectedKfSet.value.has('L2:position:K2')).toBe(true)
|
||||||
|
} finally {
|
||||||
|
selectedKfs.value = snapshot
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('kfSelectionKey builds stable id', () => {
|
||||||
|
expect(kfSelectionKey({ layerId: 'a', kind: 'main', keyframeId: 'b' })).toBe('a:main:b')
|
||||||
|
expect(kfSelectionKey({ layerId: 'x', kind: 'position', keyframeId: 'y' })).toBe(
|
||||||
|
'x:position:y',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
suite('assets — edge blur', () => {
|
||||||
|
test('rect asset stores blur value', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const a = s.addAsset({
|
||||||
|
kind: 'rect',
|
||||||
|
name: 'R',
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
fill: '#ff0000',
|
||||||
|
blur: 12,
|
||||||
|
})
|
||||||
|
const stored = s.scene.assets.find((x) => x.id === a.id)!
|
||||||
|
expect(stored.kind).toBe('rect')
|
||||||
|
expect((stored as Extract<typeof stored, { kind: 'rect' }>).blur).toBe(12)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('circle asset stores blur value', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const a = s.addAsset({
|
||||||
|
kind: 'circle',
|
||||||
|
name: 'C',
|
||||||
|
radius: 30,
|
||||||
|
fill: '#0000ff',
|
||||||
|
blur: 6,
|
||||||
|
})
|
||||||
|
const stored = s.scene.assets.find((x) => x.id === a.id)!
|
||||||
|
expect(stored.kind).toBe('circle')
|
||||||
|
expect((stored as Extract<typeof stored, { kind: 'circle' }>).blur).toBe(6)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('drawSceneFrame sets ctx.filter to blur() for blurred rect', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const a = s.addAsset({
|
||||||
|
kind: 'rect',
|
||||||
|
name: 'Blurred',
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
fill: '#ff4488',
|
||||||
|
blur: 18,
|
||||||
|
})
|
||||||
|
s.addLayer(a.id)
|
||||||
|
const { ctx, filterSets } = makeRecordingCtx()
|
||||||
|
drawSceneFrame(ctx, s.scene, 0, {})
|
||||||
|
expect(filterSets.some((v) => v === 'blur(18px)')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('drawSceneFrame does not apply blur when blur is unset or zero', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const a = s.addAsset({
|
||||||
|
kind: 'rect',
|
||||||
|
name: 'Sharp',
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
fill: '#123456',
|
||||||
|
})
|
||||||
|
s.addLayer(a.id)
|
||||||
|
const { ctx, filterSets } = makeRecordingCtx()
|
||||||
|
drawSceneFrame(ctx, s.scene, 0, {})
|
||||||
|
// The only filter writes should be the post-draw reset to 'none'
|
||||||
|
expect(filterSets.every((v) => v === 'none')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('drawSceneFrame applies blur for circle asset', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const a = s.addAsset({
|
||||||
|
kind: 'circle',
|
||||||
|
name: 'CB',
|
||||||
|
radius: 20,
|
||||||
|
fill: '#22aa22',
|
||||||
|
blur: 9,
|
||||||
|
})
|
||||||
|
s.addLayer(a.id)
|
||||||
|
const { ctx, filterSets } = makeRecordingCtx()
|
||||||
|
drawSceneFrame(ctx, s.scene, 0, {})
|
||||||
|
expect(filterSets.some((v) => v === 'blur(9px)')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
suite('scene store — shiftKeyframesPosition', () => {
|
||||||
|
test('shifts x/y of a single keyframe by delta', async () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const a = s.addAsset({ kind: 'rect', name: 'Shift', width: 40, height: 40, fill: '#a55' })
|
||||||
|
const l = s.addLayer(a.id)
|
||||||
|
s.upsertKeyframe(l.id, 1, { x: 100, y: 50 })
|
||||||
|
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||||||
|
s.shiftKeyframesPosition(
|
||||||
|
[{ layerId: l.id, kind: 'main', keyframeId: kf.id }],
|
||||||
|
25,
|
||||||
|
-10,
|
||||||
|
)
|
||||||
|
await tick()
|
||||||
|
expect(kf.props.x).toBe(125)
|
||||||
|
expect(kf.props.y).toBe(40)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shifts multiple keyframes across layers by the same delta', async () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const a1 = s.addAsset({ kind: 'rect', name: 'A', width: 10, height: 10, fill: '#111' })
|
||||||
|
const a2 = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#222' })
|
||||||
|
const l1 = s.addLayer(a1.id)
|
||||||
|
const l2 = s.addLayer(a2.id)
|
||||||
|
s.upsertKeyframe(l1.id, 1, { x: 100, y: 100 })
|
||||||
|
s.upsertKeyframe(l2.id, 2, { x: 200, y: 300 })
|
||||||
|
const k1 = l1.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||||||
|
const k2 = l2.keyframes.find((k) => Math.abs(k.time - 2) < 1e-3)!
|
||||||
|
s.shiftKeyframesPosition(
|
||||||
|
[
|
||||||
|
{ layerId: l1.id, kind: 'main', keyframeId: k1.id },
|
||||||
|
{ layerId: l2.id, kind: 'main', keyframeId: k2.id },
|
||||||
|
],
|
||||||
|
10,
|
||||||
|
5,
|
||||||
|
)
|
||||||
|
await tick()
|
||||||
|
expect(k1.props.x).toBe(110)
|
||||||
|
expect(k1.props.y).toBe(105)
|
||||||
|
expect(k2.props.x).toBe(210)
|
||||||
|
expect(k2.props.y).toBe(305)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('skips rotation keyframes (no x/y)', async () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#333' })
|
||||||
|
const l = s.addLayer(a.id)
|
||||||
|
s.setLayerSeparated(l.id, true)
|
||||||
|
s.upsertKeyframe(l.id, 1, { rotation: 45 })
|
||||||
|
const rotKf = (l.rotationKeyframes ?? []).find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||||||
|
s.shiftKeyframesPosition(
|
||||||
|
[{ layerId: l.id, kind: 'rotation', keyframeId: rotKf.id }],
|
||||||
|
100,
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
await tick()
|
||||||
|
expect((rotKf.props as Record<string, unknown>).x).toBe(undefined)
|
||||||
|
expect((rotKf.props as Record<string, unknown>).y).toBe(undefined)
|
||||||
|
expect(rotKf.props.rotation).toBe(45)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('uses sampled fallback when keyframe has no explicit x/y', async () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const a = s.addAsset({ kind: 'rect', name: 'Fb', width: 10, height: 10, fill: '#444' })
|
||||||
|
const l = s.addLayer(a.id)
|
||||||
|
// initial keyframe at t=0 has full base props (including x=640 from canvas center)
|
||||||
|
// Create a later keyframe that only sets scaleX
|
||||||
|
s.upsertKeyframe(l.id, 2, { scaleX: 2 })
|
||||||
|
const kf = l.keyframes.find((k) => Math.abs(k.time - 2) < 1e-3)!
|
||||||
|
// ensure x is not explicitly set
|
||||||
|
delete (kf.props as Record<string, unknown>).x
|
||||||
|
delete (kf.props as Record<string, unknown>).y
|
||||||
|
const sampledBefore = sampleLayer(l, 2)
|
||||||
|
s.shiftKeyframesPosition([{ layerId: l.id, kind: 'main', keyframeId: kf.id }], 7, 11)
|
||||||
|
await tick()
|
||||||
|
expect(kf.props.x).toBe(sampledBefore.x + 7)
|
||||||
|
expect(kf.props.y).toBe(sampledBefore.y + 11)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('non-existing layer/keyframe refs are safely ignored', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#555' })
|
||||||
|
const l = s.addLayer(a.id)
|
||||||
|
s.upsertKeyframe(l.id, 1, { x: 50 })
|
||||||
|
// Call with mix of valid and invalid refs — should not throw
|
||||||
|
s.shiftKeyframesPosition(
|
||||||
|
[
|
||||||
|
{ layerId: 'missing-layer', kind: 'main', keyframeId: 'missing-kf' },
|
||||||
|
{ layerId: l.id, kind: 'main', keyframeId: 'also-missing' },
|
||||||
|
],
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
// Valid keyframe at t=1 remained unshifted (we didn't target it)
|
||||||
|
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||||||
|
expect(kf.props.x).toBe(50)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -95,6 +95,13 @@ export const en = {
|
|||||||
removeKeyframe: 'Remove keyframe here',
|
removeKeyframe: 'Remove keyframe here',
|
||||||
deleteLayer: 'Delete layer',
|
deleteLayer: 'Delete layer',
|
||||||
noLayer: 'No layer selected.',
|
noLayer: 'No layer selected.',
|
||||||
|
edgeBlur: 'Edge blur',
|
||||||
|
shadow: 'Shadow',
|
||||||
|
shadowBlur: 'Shadow blur',
|
||||||
|
shadowOpacity: 'Shadow opacity',
|
||||||
|
shadowAngle: 'Shadow angle',
|
||||||
|
shadowDistance: 'Shadow distance',
|
||||||
|
shadowColor: 'Shadow color',
|
||||||
},
|
},
|
||||||
|
|
||||||
fullscreen: {
|
fullscreen: {
|
||||||
@@ -120,6 +127,7 @@ export const en = {
|
|||||||
noTracks: 'No tracks yet.',
|
noTracks: 'No tracks yet.',
|
||||||
addClipTrack: 'Add clip track',
|
addClipTrack: 'Add clip track',
|
||||||
addClipTrackTooltip: 'Add another clip track',
|
addClipTrackTooltip: 'Add another clip track',
|
||||||
|
dropNewTrackHint: 'Drop a clip here to add a new track',
|
||||||
clipTrackName: 'Clip track',
|
clipTrackName: 'Clip track',
|
||||||
toggleTimeUnit: 'Toggle time unit',
|
toggleTimeUnit: 'Toggle time unit',
|
||||||
snapOn: 'Snapping on (playhead & keyframes snap together)',
|
snapOn: 'Snapping on (playhead & keyframes snap together)',
|
||||||
@@ -227,6 +235,32 @@ export const en = {
|
|||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
close: 'Close',
|
close: 'Close',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
about: {
|
||||||
|
openTooltip: 'About Animatr',
|
||||||
|
title: 'About {name}',
|
||||||
|
tagline: 'A timeline-based animation editor that runs in your browser.',
|
||||||
|
versionLabel: 'Version',
|
||||||
|
buildLabel: 'Build',
|
||||||
|
close: 'Close',
|
||||||
|
shortcutsHeading: 'Shortcuts',
|
||||||
|
shortcut: {
|
||||||
|
undo: 'Ctrl+Z',
|
||||||
|
redo: 'Ctrl+Shift+Z',
|
||||||
|
playPause: 'Space',
|
||||||
|
fullscreen: 'Shift+Space',
|
||||||
|
tests: 'Ctrl+Shift+Alt+T',
|
||||||
|
panCanvas: 'Middle-click / Ctrl+Drag',
|
||||||
|
},
|
||||||
|
shortcutLabel: {
|
||||||
|
undo: 'Undo',
|
||||||
|
redo: 'Redo',
|
||||||
|
playPause: 'Play / Pause',
|
||||||
|
fullscreen: 'Fullscreen preview',
|
||||||
|
tests: 'Open test runner',
|
||||||
|
panCanvas: 'Pan canvas',
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type Messages = typeof en
|
export type Messages = typeof en
|
||||||
@@ -149,18 +149,38 @@ const ENGINE_SOURCE = `(function(){
|
|||||||
ctx.translate(p.x,p.y);
|
ctx.translate(p.x,p.y);
|
||||||
ctx.rotate(p.rotation*Math.PI/180);
|
ctx.rotate(p.rotation*Math.PI/180);
|
||||||
ctx.scale(p.scaleX,p.scaleY);
|
ctx.scale(p.scaleX,p.scaleY);
|
||||||
|
var sOp=p.shadowOpacity||0;
|
||||||
|
var sBl=p.shadowBlur||0;
|
||||||
|
var sDi=p.shadowDistance||0;
|
||||||
|
if(sOp>0 && (sBl>0 || sDi>0)){
|
||||||
|
var sAn=(p.shadowAngle==null?90:p.shadowAngle)*Math.PI/180;
|
||||||
|
var sHex=(p.shadowColor||'#000000').replace('#','');
|
||||||
|
if(sHex.length===3){sHex=sHex[0]+sHex[0]+sHex[1]+sHex[1]+sHex[2]+sHex[2];}
|
||||||
|
var sNum=parseInt(sHex.slice(0,6),16);
|
||||||
|
if(isNaN(sNum))sNum=0;
|
||||||
|
ctx.shadowColor='rgba('+((sNum>>16)&255)+','+((sNum>>8)&255)+','+(sNum&255)+','+sOp+')';
|
||||||
|
ctx.shadowBlur=sBl;
|
||||||
|
ctx.shadowOffsetX=Math.cos(sAn)*sDi;
|
||||||
|
ctx.shadowOffsetY=Math.sin(sAn)*sDi;
|
||||||
|
}
|
||||||
if(asset.kind==='rect'){
|
if(asset.kind==='rect'){
|
||||||
var rOffX=layer.offsetX!=null?layer.offsetX:asset.width/2;
|
var rOffX=layer.offsetX!=null?layer.offsetX:asset.width/2;
|
||||||
var rOffY=layer.offsetY!=null?layer.offsetY:asset.height/2;
|
var rOffY=layer.offsetY!=null?layer.offsetY:asset.height/2;
|
||||||
|
var rBlur=(p.blur!=null?p.blur:(asset.blur||0));
|
||||||
ctx.fillStyle=p.fill||asset.fill;
|
ctx.fillStyle=p.fill||asset.fill;
|
||||||
|
if(rBlur>0) ctx.filter='blur('+rBlur+'px)';
|
||||||
ctx.fillRect(-rOffX,-rOffY,asset.width,asset.height);
|
ctx.fillRect(-rOffX,-rOffY,asset.width,asset.height);
|
||||||
|
ctx.filter='none';
|
||||||
}else if(asset.kind==='circle'){
|
}else if(asset.kind==='circle'){
|
||||||
var cOffX=layer.offsetX!=null?layer.offsetX:0;
|
var cOffX=layer.offsetX!=null?layer.offsetX:0;
|
||||||
var cOffY=layer.offsetY!=null?layer.offsetY:0;
|
var cOffY=layer.offsetY!=null?layer.offsetY:0;
|
||||||
|
var cBlur=(p.blur!=null?p.blur:(asset.blur||0));
|
||||||
ctx.fillStyle=p.fill||asset.fill;
|
ctx.fillStyle=p.fill||asset.fill;
|
||||||
|
if(cBlur>0) ctx.filter='blur('+cBlur+'px)';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(-cOffX,-cOffY,asset.radius,0,Math.PI*2);
|
ctx.arc(-cOffX,-cOffY,asset.radius,0,Math.PI*2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
ctx.filter='none';
|
||||||
}else if(asset.kind==='text'){
|
}else if(asset.kind==='text'){
|
||||||
var tOffX=layer.offsetX!=null?layer.offsetX:0;
|
var tOffX=layer.offsetX!=null?layer.offsetX:0;
|
||||||
var tOffY=layer.offsetY!=null?layer.offsetY:0;
|
var tOffY=layer.offsetY!=null?layer.offsetY:0;
|
||||||
@@ -176,6 +196,10 @@ const ENGINE_SOURCE = `(function(){
|
|||||||
ctx.drawImage(img,-iOffX,-iOffY,asset.width,asset.height);
|
ctx.drawImage(img,-iOffX,-iOffY,asset.width,asset.height);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ctx.shadowColor='transparent';
|
||||||
|
ctx.shadowBlur=0;
|
||||||
|
ctx.shadowOffsetX=0;
|
||||||
|
ctx.shadowOffsetY=0;
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
function draw(time){
|
function draw(time){
|
||||||
|
|||||||
@@ -49,6 +49,19 @@ function lerpProps(a: AnimatableProps, b: AnimatableProps, t: number): Animatabl
|
|||||||
if (a.fill && b.fill) out.fill = lerpColor(a.fill, b.fill, t)
|
if (a.fill && b.fill) out.fill = lerpColor(a.fill, b.fill, t)
|
||||||
else if (b.fill) out.fill = b.fill
|
else if (b.fill) out.fill = b.fill
|
||||||
else if (a.fill) out.fill = a.fill
|
else if (a.fill) out.fill = a.fill
|
||||||
|
if (a.blur !== undefined && b.blur !== undefined) out.blur = lerp(a.blur, b.blur, t)
|
||||||
|
else if (b.blur !== undefined) out.blur = b.blur
|
||||||
|
else if (a.blur !== undefined) out.blur = a.blur
|
||||||
|
for (const key of ['shadowBlur', 'shadowOpacity', 'shadowAngle', 'shadowDistance'] as const) {
|
||||||
|
const av = a[key]
|
||||||
|
const bv = b[key]
|
||||||
|
if (av !== undefined && bv !== undefined) out[key] = lerp(av, bv, t)
|
||||||
|
else if (bv !== undefined) out[key] = bv
|
||||||
|
else if (av !== undefined) out[key] = av
|
||||||
|
}
|
||||||
|
if (a.shadowColor && b.shadowColor) out.shadowColor = lerpColor(a.shadowColor, b.shadowColor, t)
|
||||||
|
else if (b.shadowColor) out.shadowColor = b.shadowColor
|
||||||
|
else if (a.shadowColor) out.shadowColor = a.shadowColor
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+71
-3
@@ -1,4 +1,4 @@
|
|||||||
import type { Asset, Layer, Scene } from '@/types/scene'
|
import type { AnimatableProps, Asset, Layer, Scene } from '@/types/scene'
|
||||||
import { sampleLayer } from './interpolate'
|
import { sampleLayer } from './interpolate'
|
||||||
|
|
||||||
export async function loadSceneImages(scene: Scene): Promise<Record<string, HTMLImageElement>> {
|
export async function loadSceneImages(scene: Scene): Promise<Record<string, HTMLImageElement>> {
|
||||||
@@ -24,6 +24,62 @@ export async function loadSceneImages(scene: Scene): Promise<Record<string, HTML
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ShadowState {
|
||||||
|
sx: number
|
||||||
|
sy: number
|
||||||
|
blur: number
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgba(hex: string, alpha: number): string {
|
||||||
|
const h = hex.trim().replace('#', '')
|
||||||
|
const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h
|
||||||
|
if (full.length < 6) return `rgba(0, 0, 0, ${alpha})`
|
||||||
|
const n = parseInt(full.slice(0, 6), 16)
|
||||||
|
if (Number.isNaN(n)) return `rgba(0, 0, 0, ${alpha})`
|
||||||
|
const r = (n >> 16) & 255
|
||||||
|
const g = (n >> 8) & 255
|
||||||
|
const b = n & 255
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeShadow(p: AnimatableProps): ShadowState | null {
|
||||||
|
const opacity = p.shadowOpacity ?? 0
|
||||||
|
if (opacity <= 0) return null
|
||||||
|
const blur = p.shadowBlur ?? 0
|
||||||
|
const distance = p.shadowDistance ?? 0
|
||||||
|
if (blur <= 0 && distance <= 0) return null
|
||||||
|
const angle = p.shadowAngle ?? 90
|
||||||
|
const rad = (angle * Math.PI) / 180
|
||||||
|
return {
|
||||||
|
sx: Math.cos(rad) * distance,
|
||||||
|
sy: Math.sin(rad) * distance,
|
||||||
|
blur,
|
||||||
|
color: hexToRgba(p.shadowColor ?? '#000000', opacity),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyShadow(ctx: CanvasRenderingContext2D, s: ShadowState | null) {
|
||||||
|
if (!s) {
|
||||||
|
ctx.shadowColor = 'transparent'
|
||||||
|
ctx.shadowBlur = 0
|
||||||
|
ctx.shadowOffsetX = 0
|
||||||
|
ctx.shadowOffsetY = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.shadowColor = s.color
|
||||||
|
ctx.shadowBlur = s.blur
|
||||||
|
ctx.shadowOffsetX = s.sx
|
||||||
|
ctx.shadowOffsetY = s.sy
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearShadow(ctx: CanvasRenderingContext2D) {
|
||||||
|
ctx.shadowColor = 'transparent'
|
||||||
|
ctx.shadowBlur = 0
|
||||||
|
ctx.shadowOffsetX = 0
|
||||||
|
ctx.shadowOffsetY = 0
|
||||||
|
}
|
||||||
|
|
||||||
function drawLayers(
|
function drawLayers(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
layers: Layer[],
|
layers: Layer[],
|
||||||
@@ -44,18 +100,29 @@ function drawLayers(
|
|||||||
ctx.rotate((p.rotation * Math.PI) / 180)
|
ctx.rotate((p.rotation * Math.PI) / 180)
|
||||||
ctx.scale(p.scaleX, p.scaleY)
|
ctx.scale(p.scaleX, p.scaleY)
|
||||||
|
|
||||||
|
const shadow = computeShadow(p)
|
||||||
|
applyShadow(ctx, shadow)
|
||||||
|
|
||||||
if (asset.kind === 'rect') {
|
if (asset.kind === 'rect') {
|
||||||
const offX = layer.offsetX ?? asset.width / 2
|
const offX = layer.offsetX ?? asset.width / 2
|
||||||
const offY = layer.offsetY ?? asset.height / 2
|
const offY = layer.offsetY ?? asset.height / 2
|
||||||
ctx.fillStyle = p.fill ?? asset.fill
|
const fill = p.fill ?? asset.fill
|
||||||
|
const blur = p.blur ?? asset.blur ?? 0
|
||||||
|
ctx.fillStyle = fill
|
||||||
|
if (blur > 0) ctx.filter = `blur(${blur}px)`
|
||||||
ctx.fillRect(-offX, -offY, asset.width, asset.height)
|
ctx.fillRect(-offX, -offY, asset.width, asset.height)
|
||||||
|
ctx.filter = 'none'
|
||||||
} else if (asset.kind === 'circle') {
|
} else if (asset.kind === 'circle') {
|
||||||
const offX = layer.offsetX ?? 0
|
const offX = layer.offsetX ?? 0
|
||||||
const offY = layer.offsetY ?? 0
|
const offY = layer.offsetY ?? 0
|
||||||
ctx.fillStyle = p.fill ?? asset.fill
|
const fill = p.fill ?? asset.fill
|
||||||
|
const blur = p.blur ?? asset.blur ?? 0
|
||||||
|
ctx.fillStyle = fill
|
||||||
|
if (blur > 0) ctx.filter = `blur(${blur}px)`
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.arc(-offX, -offY, asset.radius, 0, Math.PI * 2)
|
ctx.arc(-offX, -offY, asset.radius, 0, Math.PI * 2)
|
||||||
ctx.fill()
|
ctx.fill()
|
||||||
|
ctx.filter = 'none'
|
||||||
} else if (asset.kind === 'text') {
|
} else if (asset.kind === 'text') {
|
||||||
const offX = layer.offsetX ?? 0
|
const offX = layer.offsetX ?? 0
|
||||||
const offY = layer.offsetY ?? 0
|
const offY = layer.offsetY ?? 0
|
||||||
@@ -72,6 +139,7 @@ function drawLayers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearShadow(ctx)
|
||||||
ctx.restore()
|
ctx.restore()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -409,6 +409,25 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
else clipTrack.color = color
|
else clipTrack.color = color
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
shiftKeyframesPosition(
|
||||||
|
refs: Array<{ layerId: string; kind: 'main' | 'position' | 'rotation'; keyframeId: string }>,
|
||||||
|
dx: number,
|
||||||
|
dy: number,
|
||||||
|
) {
|
||||||
|
for (const ref of refs) {
|
||||||
|
if (ref.kind === 'rotation') continue
|
||||||
|
const layer = this.activeLayers.find((l) => l.id === ref.layerId)
|
||||||
|
if (!layer) continue
|
||||||
|
const arr =
|
||||||
|
ref.kind === 'position' ? layer.positionKeyframes : layer.keyframes
|
||||||
|
const kf = arr?.find((k) => k.id === ref.keyframeId)
|
||||||
|
if (!kf) continue
|
||||||
|
const sampled = sampleLayer(layer, kf.time)
|
||||||
|
const curX = kf.props.x ?? sampled.x
|
||||||
|
const curY = kf.props.y ?? sampled.y
|
||||||
|
kf.props = { ...kf.props, x: curX + dx, y: curY + dy }
|
||||||
|
}
|
||||||
|
},
|
||||||
setTrackLocked(trackId: string, locked: boolean) {
|
setTrackLocked(trackId: string, locked: boolean) {
|
||||||
const layer = this.activeLayers.find((l) => l.id === trackId)
|
const layer = this.activeLayers.find((l) => l.id === trackId)
|
||||||
if (layer) {
|
if (layer) {
|
||||||
|
|||||||
@@ -88,6 +88,14 @@ button {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:focus,
|
||||||
|
button:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
button::-moz-focus-inner {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
button:hover:not(:disabled) {
|
button:hover:not(:disabled) {
|
||||||
background: var(--panel-hover);
|
background: var(--panel-hover);
|
||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ export interface AnimatableProps {
|
|||||||
rotation: number
|
rotation: number
|
||||||
opacity: number
|
opacity: number
|
||||||
fill?: string
|
fill?: string
|
||||||
|
blur?: number
|
||||||
|
shadowBlur?: number
|
||||||
|
shadowOpacity?: number
|
||||||
|
shadowAngle?: number
|
||||||
|
shadowDistance?: number
|
||||||
|
shadowColor?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Keyframe {
|
export interface Keyframe {
|
||||||
@@ -36,6 +42,7 @@ export interface RectAsset {
|
|||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
fill: string
|
fill: string
|
||||||
|
blur?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CircleAsset {
|
export interface CircleAsset {
|
||||||
@@ -44,6 +51,7 @@ export interface CircleAsset {
|
|||||||
name: string
|
name: string
|
||||||
radius: number
|
radius: number
|
||||||
fill: string
|
fill: string
|
||||||
|
blur?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TextAsset {
|
export interface TextAsset {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const APP_VERSION = '0.1.0'
|
||||||
|
export const APP_NAME = 'Animatr'
|
||||||
@@ -55,12 +55,11 @@ rect circle und text brauchen wir nicht als instanzen in der lib. da tauchen nur
|
|||||||
ALLE texte der gui bitte in englisch
|
ALLE texte der gui bitte in englisch
|
||||||
|
|
||||||
|
|
||||||
|
wir machen mal ein about popup und verwalten ab sofort eine version. wir starten mit 0.1.0
|
||||||
|
about öffnet bei click auf den app namen oben links
|
||||||
|
|
||||||
ein element rein draggen muss das element (grafik) als draggable anzeigen, nicht den bibliothekseintrag
|
ein element rein draggen muss das element (grafik) als draggable anzeigen, nicht den bibliothekseintrag
|
||||||
|
|
||||||
context menüs dürfen nicht unten aus dem viewport rausgehen
|
context menüs ragen unten immernoch aus dem viewport raus
|
||||||
|
|
||||||
wir machen mal ein about popup und verwalten ab sofort eine version. wir starten mit 0.1.0
|
rechts click menu muss überall unterdrückt werden wo wir noch keins haben. das browser context menu darf nirgends angezeigt werden
|
||||||
about öffnet bei click auf den app namen oben links
|
|
||||||
Reference in new issue
Block a user