init
This commit is contained in:
commit
c6863f2bfc
34 files changed
+6909
No files matched your search
@@ -0,0 +1,288 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { PhDiamond, PhTrash, PhX } from '@phosphor-icons/vue'
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { sampleLayer } from '@/lib/interpolate'
|
||||
import type { AnimatableProps } from '@/types/scene'
|
||||
import RangeSlider from './RangeSlider.vue'
|
||||
|
||||
const formatPercent = (v: number) => `${Math.round(v * 100)}%`
|
||||
|
||||
const store = useSceneStore()
|
||||
|
||||
const selected = computed(() =>
|
||||
store.scene.layers.find((l) => l.id === store.selectedLayerId) ?? null,
|
||||
)
|
||||
|
||||
const sampled = computed<AnimatableProps | null>(() =>
|
||||
selected.value ? sampleLayer(selected.value, store.playhead) : null,
|
||||
)
|
||||
|
||||
const hasKeyframeHere = computed(() => {
|
||||
if (!selected.value) return false
|
||||
return selected.value.keyframes.some((k) => Math.abs(k.time - store.playhead) < 1e-3)
|
||||
})
|
||||
|
||||
const selectedAsset = computed(() => {
|
||||
if (!selected.value) return null
|
||||
return store.scene.assets.find((a) => a.id === selected.value!.assetId) ?? null
|
||||
})
|
||||
|
||||
const canFill = computed(() => {
|
||||
const a = selectedAsset.value
|
||||
return !!a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'text')
|
||||
})
|
||||
|
||||
const fillColor = computed<string>({
|
||||
get: () => {
|
||||
if (sampled.value?.fill) return sampled.value.fill
|
||||
const a = selectedAsset.value
|
||||
if (a && 'fill' in a) return a.fill
|
||||
return '#000000'
|
||||
},
|
||||
set: (v: string) => {
|
||||
if (!selected.value) return
|
||||
store.upsertKeyframe(selected.value.id, store.playhead, { fill: v })
|
||||
},
|
||||
})
|
||||
|
||||
type NumericKey = 'x' | 'y' | 'scaleX' | 'scaleY' | 'rotation' | 'opacity'
|
||||
|
||||
function bind(key: NumericKey, decimals = 0) {
|
||||
const factor = 10 ** decimals
|
||||
return computed({
|
||||
get: () => (sampled.value ? Math.round(sampled.value[key] * factor) / factor : 0),
|
||||
set: (v: number) => {
|
||||
if (!selected.value) return
|
||||
store.upsertKeyframe(selected.value.id, store.playhead, { [key]: v })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const x = bind('x')
|
||||
const y = bind('y')
|
||||
const scaleX = bind('scaleX', 2)
|
||||
const scaleY = bind('scaleY', 2)
|
||||
const rotation = bind('rotation')
|
||||
const opacity = bind('opacity', 2)
|
||||
|
||||
function removeKeyframeHere() {
|
||||
if (!selected.value) return
|
||||
store.removeKeyframeAt(selected.value.id, store.playhead)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<h3>Properties</h3>
|
||||
<template v-if="selected && sampled">
|
||||
<label>Name<input v-model="selected.name" /></label>
|
||||
|
||||
<div class="kf-row" :class="{ on: hasKeyframeHere }">
|
||||
<PhDiamond :size="11" :weight="hasKeyframeHere ? 'fill' : 'regular'" class="kf-dot" />
|
||||
<span class="kf-label">
|
||||
{{ hasKeyframeHere ? 'Keyframe' : 'Interpoliert' }}
|
||||
<span class="kf-time">@ {{ store.playhead.toFixed(2) }}s</span>
|
||||
</span>
|
||||
<button v-if="hasKeyframeHere" class="kf-btn" title="Keyframe hier löschen" @click="removeKeyframeHere">
|
||||
<PhX :size="12" weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>X<input type="number" v-model.number="x" /></label>
|
||||
<label>Y<input type="number" v-model.number="y" /></label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Scale X<input type="number" step="0.1" v-model.number="scaleX" /></label>
|
||||
<label>Scale Y<input type="number" step="0.1" v-model.number="scaleY" /></label>
|
||||
</div>
|
||||
<label>Rotation<input type="number" v-model.number="rotation" /></label>
|
||||
<label v-if="canFill" class="color-label">
|
||||
Color
|
||||
<div class="color-row">
|
||||
<input type="color" class="color-swatch" :value="fillColor" @input="fillColor = ($event.target as HTMLInputElement).value" />
|
||||
<span class="color-hex">{{ fillColor.toUpperCase() }}</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="slider-label">
|
||||
Opacity
|
||||
<RangeSlider v-model="opacity" :min="0" :max="1" :step="0.01" :format="formatPercent" />
|
||||
</label>
|
||||
|
||||
<button class="danger-btn" @click="store.removeLayer(selected.id)">
|
||||
<PhTrash :size="13" />
|
||||
Layer löschen
|
||||
</button>
|
||||
</template>
|
||||
<p v-else class="hint">Kein Layer ausgewählt.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 2px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.kf-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
height: 30px;
|
||||
background: var(--panel-alt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
transition: background 140ms var(--ease), border-color 140ms var(--ease);
|
||||
}
|
||||
|
||||
.kf-row.on {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.kf-dot {
|
||||
color: inherit;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kf-label {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: baseline;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.kf-time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-dim);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.kf-row.on .kf-time {
|
||||
color: var(--accent);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.kf-btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kf-btn:hover:not(:disabled) {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.color-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
width: 36px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: border-color 140ms var(--ease);
|
||||
}
|
||||
|
||||
.color-swatch:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.color-swatch::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.color-swatch::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.color-swatch::-moz-color-swatch {
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.color-hex {
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-dim-strong);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.danger-btn {
|
||||
justify-content: center;
|
||||
margin-top: 6px;
|
||||
background: var(--danger-soft);
|
||||
border-color: transparent;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.danger-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 122, 122, 0.18);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user