Files
RUWIO/src/components/PropertiesPanel.vue
T
2026-06-08 10:57:00 +02:00

1570 lines
49 KiB
Vue

<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import {
PhCaretDown,
PhCaretRight,
PhDiamond,
PhEye,
PhEyeSlash,
PhEyedropper,
PhFlower,
PhMaskHappy,
PhWaveSine,
PhMinus,
PhPolygon,
PhPlus,
PhTrash,
PhX,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { sampleCamera } from '@/lib/camera'
import { sampleLayer } from '@/lib/interpolate'
import type { Action, AnimatableProps, CameraProps, Layer } from '@/types/scene'
import RangeSlider from './RangeSlider.vue'
import ActionEditor from './ActionEditor.vue'
import FontPicker from './FontPicker.vue'
import { ToggleSwitch } from '@/components/ui'
import { t } from '@/i18n'
import { useTimeFormat } from '@/composables/useTimeFormat'
import { useEyeDropper } from '@/composables/useEyeDropper'
const { formatTime, timeToInputValue, inputValueToSeconds, inputStep, unitLabel } = useTimeFormat()
const { supported: eyeDropperSupported, pick: pickColor } = useEyeDropper()
async function pickFillColor() {
const c = await pickColor()
if (c) fillColor.value = c
}
async function pickStrokeColor() {
const c = await pickColor()
if (c) strokeColor.value = c
}
async function pickShadowColor() {
const c = await pickColor()
if (c) shadowColorValue.value = c
}
const SECTION_STORAGE_KEY = 'animatr:propSections'
type SectionKey = 'transform' | 'text' | 'polygon' | 'appearance' | 'shadow' | 'mask' | 'button' | 'effects'
const defaultOpen: Record<SectionKey, boolean> = {
transform: true,
text: true,
polygon: true,
appearance: true,
shadow: false,
mask: false,
button: true,
effects: false,
}
function readStoredOpen(): Record<SectionKey, boolean> {
try {
const raw = localStorage.getItem(SECTION_STORAGE_KEY)
if (!raw) return { ...defaultOpen }
const parsed = JSON.parse(raw) as Partial<Record<SectionKey, boolean>>
return { ...defaultOpen, ...parsed }
} catch {
return { ...defaultOpen }
}
}
const sectionOpen = ref<Record<SectionKey, boolean>>(readStoredOpen())
watch(
sectionOpen,
(val) => {
try {
localStorage.setItem(SECTION_STORAGE_KEY, JSON.stringify(val))
} catch {
/* ignore */
}
},
{ deep: true },
)
function toggleSection(key: SectionKey) {
sectionOpen.value[key] = !sectionOpen.value[key]
}
const formatPercent = (v: number) => `${Math.round(v * 100)}%`
const store = useSceneStore()
const selected = computed(() =>
store.activeLayers.find((l) => l.id === store.selectedLayerId) ?? null,
)
const sampled = computed<AnimatableProps | null>(() =>
selected.value ? sampleLayer(selected.value, store.playhead) : null,
)
const selectedCameraTrack = computed(() =>
store.selectedCamera ? (store.scene.camera ?? null) : null,
)
const sampledCamera = computed<CameraProps | null>(() =>
selectedCameraTrack.value ? sampleCamera(store.scene, store.playhead) : null,
)
const cameraHasKeyframeHere = computed(() => {
const camera = selectedCameraTrack.value
if (!camera) return false
return camera.keyframes.some((kf) => Math.abs(kf.time - store.playhead) < 1e-3)
})
const cameraEnabled = computed<boolean>({
get: () => selectedCameraTrack.value?.enabled ?? false,
set: (v) => {
if (v) store.enableCamera()
else store.disableCamera()
},
})
const cameraOverlayVisible = computed<boolean>({
get: () => selectedCameraTrack.value?.overlayVisible !== false,
set: (v) => store.setCameraOverlayVisible(v),
})
const cameraLocked = computed<boolean>({
get: () => selectedCameraTrack.value?.locked === true,
set: (v) => store.setCameraLocked(v),
})
function setCameraProp(patch: Partial<CameraProps>) {
const camera = selectedCameraTrack.value
if (!camera || camera.locked) return
store.upsertCameraKeyframe(store.playhead, patch)
}
function cameraOutputAspect(): number {
const width = Number.isFinite(store.scene.canvas.width) && store.scene.canvas.width > 0
? store.scene.canvas.width
: 1
const height = Number.isFinite(store.scene.canvas.height) && store.scene.canvas.height > 0
? store.scene.canvas.height
: 1
return width / height
}
const cameraX = computed<number>({
get: () => Math.round(sampledCamera.value?.x ?? 0),
set: (v) => setCameraProp({ x: v }),
})
const cameraY = computed<number>({
get: () => Math.round(sampledCamera.value?.y ?? 0),
set: (v) => setCameraProp({ y: v }),
})
const cameraWidth = computed<number>({
get: () => Math.round(sampledCamera.value?.width ?? store.scene.canvas.width),
set: (v) => {
const width = Math.max(1, v)
setCameraProp({ width, height: Math.max(1, Math.round(width / cameraOutputAspect())) })
},
})
const cameraHeight = computed<number>({
get: () => Math.round(sampledCamera.value?.height ?? store.scene.canvas.height),
set: (v) => {
const height = Math.max(1, v)
setCameraProp({ width: Math.max(1, Math.round(height * cameraOutputAspect())), height })
},
})
function removeCameraKeyframeHere() {
store.removeCameraKeyframeAt(store.playhead)
}
/** True when the user has more than one layer in the selection.
* Drives the multi-edit flow: writers fan out across every
* editable selected layer, kind-specific sections collapse, and
* the shared-common values come from the anchor (selected). */
const multiSelected = computed(() => store.selectedLayerIds.length > 1)
const lockedGroupChildIds = computed(() => {
const out = new Set<string>()
for (const g of store.scene.groups ?? []) {
if (g.locked) for (const c of g.childIds) out.add(c)
}
return out
})
/** Layers that should receive multi-edit writes — every selection
* member that isn't directly locked nor inside a locked group.
* Falls back to the single anchor for non-multi selections so the
* bind helpers below have a uniform code path. */
const editableLayers = computed<Layer[]>(() => {
const ids = store.selectedLayerIds.length
? store.selectedLayerIds
: store.selectedLayerId
? [store.selectedLayerId]
: []
const out: Layer[] = []
for (const id of ids) {
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer) continue
if (layer.locked) continue
if (lockedGroupChildIds.value.has(layer.id)) continue
out.push(layer)
}
return out
})
/** True when the selected layer is locked directly or reached through
* a locked parent group. Suppresses the edit UI — the user must
* unlock the layer (or the group) before changing properties. */
const selectedIsLocked = computed(() => {
const l = selected.value
if (!l) return false
if (l.locked) return true
for (const g of store.scene.groups ?? []) {
if (g.locked && g.childIds.includes(l.id)) return true
}
return false
})
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 textAsset = computed(() => {
const a = selectedAsset.value
return a && a.kind === 'text' ? a : null
})
// Empty string represents the system-sans default. Stored as
// `undefined` on the asset so the model field round-trips cleanly
// through JSON without leaking a bogus 'system-sans' id.
const textFontModel = computed({
get: () => textAsset.value?.fontFamily ?? '',
set: (id: string) => {
if (!textAsset.value) return
textAsset.value.fontFamily = id || undefined
},
})
const polygonAsset = computed(() => {
const a = selectedAsset.value
return a && a.kind === 'polygon' ? a : null
})
const buttonAsset = computed(() => {
const a = selectedAsset.value
return a && a.kind === 'button' ? a : null
})
// Kaleido is allowed on any non-button layer. Buttons are excluded
// because radial copies multiply hit areas and break click routing.
const canKaleido = computed(() => {
const a = selectedAsset.value
if (!a) return false
return a.kind !== 'button'
})
const selectedClipInstance = computed(() => {
const id = store.selectedClipInstanceId
if (!id) return null
return store.scene.clipInstances?.find((c) => c.id === id) ?? null
})
const ciKaleidoOn = computed(() => !!selectedClipInstance.value?.kaleido)
const ciKaleidoCount = computed(() => selectedClipInstance.value?.kaleidoCount ?? 6)
// Offset is stored as seconds (the renderer's unit) but presented in
// whichever unit the user picked globally (Timeline ruler toggle) —
// frames or seconds. Conversion at the UI boundary via useTimeFormat.
const ciKaleidoOffsetInput = computed(() =>
timeToInputValue(selectedClipInstance.value?.kaleidoOffset ?? 0),
)
const ciPivotX = computed(() =>
Math.round(selectedClipInstance.value?.kaleidoPivotX ?? selectedClipInstance.value?.x ?? 0),
)
const ciPivotY = computed(() =>
Math.round(selectedClipInstance.value?.kaleidoPivotY ?? selectedClipInstance.value?.y ?? 0),
)
const ciRotateCopies = computed(() => selectedClipInstance.value?.kaleidoRotateCopies !== false)
function setCiKaleido(patch: Parameters<typeof store.setClipInstanceKaleido>[1]) {
const inst = selectedClipInstance.value
if (!inst) return
store.setClipInstanceKaleido(inst.id, patch)
}
function setCiKaleidoOffset(value: number) {
setCiKaleido({ kaleidoOffset: inputValueToSeconds(value) })
}
const kaleidoOn = computed(() => !!selected.value?.kaleido)
const kaleidoCountValue = computed(() => selected.value?.kaleidoCount ?? 6)
/** Display value for the layer's kaleido pivot inputs. Reads the
* sampled keyframe value first (so a moving pivot animates the
* number too), then the layer's static legacy override, then the
* layer's own (x, y). Rounded to int for compact display. */
const kaleidoPivotX = computed(() => {
const l = selected.value
if (!l) return 0
const s = sampled.value
if (s?.kaleidoPivotX !== undefined) return Math.round(s.kaleidoPivotX)
if (l.kaleidoPivotX !== undefined) return Math.round(l.kaleidoPivotX)
return Math.round(s?.x ?? 0)
})
const kaleidoPivotY = computed(() => {
const l = selected.value
if (!l) return 0
const s = sampled.value
if (s?.kaleidoPivotY !== undefined) return Math.round(s.kaleidoPivotY)
if (l.kaleidoPivotY !== undefined) return Math.round(l.kaleidoPivotY)
return Math.round(s?.y ?? 0)
})
function setKaleidoOn(on: boolean) {
if (!selected.value) return
store.setLayerKaleido(selected.value.id, on)
}
/** Pivot writes go through the keyframe pipeline — same flow as
* Transform X/Y. Animators can drop multiple kfs and the orbit
* centre interpolates between them. */
function setKaleidoPivot(patch: { x?: number; y?: number }) {
if (!selected.value) return
const props: Partial<import('@/types/scene').AnimatableProps> = {}
if (patch.x !== undefined) props.kaleidoPivotX = patch.x
if (patch.y !== undefined) props.kaleidoPivotY = patch.y
store.upsertKeyframe(selected.value.id, store.playhead, props)
}
const wiggleOn = computed(() => !!selected.value?.wiggle)
const wiggleAmplitude = computed<number>({
get: () => selected.value?.wiggle?.amplitude ?? 8,
set: (v: number) => {
if (!selected.value) return
store.setLayerWiggle(selected.value.id, { amplitude: v })
},
})
const wiggleRotation = computed<number>({
get: () => selected.value?.wiggle?.rotation ?? 2,
set: (v: number) => {
if (!selected.value) return
store.setLayerWiggle(selected.value.id, { rotation: v })
},
})
const wiggleFrequency = computed<number>({
get: () => selected.value?.wiggle?.frequency ?? 2,
set: (v: number) => {
if (!selected.value) return
store.setLayerWiggle(selected.value.id, { frequency: v })
},
})
function setWiggleOn(on: boolean) {
if (!selected.value) return
if (on) {
// Seed with the pleasant defaults animators tend to start with;
// they can dial it down from there.
store.setLayerWiggle(selected.value.id, { amplitude: 8, rotation: 2, frequency: 2 })
} else {
store.setLayerWiggle(selected.value.id, null)
}
}
function onKaleidoCountInput(e: Event) {
if (!selected.value) return
const v = Number((e.target as HTMLInputElement).value)
if (!Number.isFinite(v)) return
store.setLayerKaleidoCount(selected.value.id, v)
}
const polygonPointCount = computed<number>({
get: () => polygonAsset.value?.points.length ?? 0,
set: (v: number) => {
const a = polygonAsset.value
if (!a) return
store.setPolygonPointCount(a.id, v)
},
})
const polygonClosed = computed<boolean>({
get: () => polygonAsset.value?.closed !== false,
set: (v: boolean) => {
const a = polygonAsset.value
if (!a) return
store.setPolygonClosed(a.id, v)
},
})
const canStroke = computed(() => {
const k = selectedAsset.value?.kind
return k === 'polygon' || k === 'rect' || k === 'circle' || k === 'text'
})
// Remember the last non-transparent stroke color so we can restore it when
// the user flips "Transparent" back off. Updates whenever the sampled stroke
// has a real color, regardless of whether the user set it via picker, keyframe
// interpolation or layer selection.
const lastStrokeColor = ref<string>('#000000')
watch(
() => [sampled.value?.stroke, selectedAsset.value?.id] as const,
([s]) => {
if (s && s !== 'transparent') lastStrokeColor.value = s
},
{ immediate: true },
)
const strokeColor = computed<string>({
get: () => {
const s = sampled.value?.stroke
if (s && s !== 'transparent') return s
return lastStrokeColor.value
},
set: (v: string) => {
if (!selected.value) return
if (v !== 'transparent') lastStrokeColor.value = v
store.upsertKeyframe(selected.value.id, store.playhead, { stroke: v })
},
})
const strokeWidth = computed<number>({
get: () => {
if (sampled.value?.strokeWidth !== undefined) return sampled.value.strokeWidth
const a = selectedAsset.value
if (a && 'strokeWidth' in a && a.strokeWidth !== undefined) return a.strokeWidth
return 0
},
set: (v: number) => {
if (!selected.value) return
const patch: Partial<AnimatableProps> = { strokeWidth: v }
// Always co-write a stroke color alongside a non-zero width so the
// outline stays visible regardless of prior width=0 states.
if (v > 0) {
const a = selectedAsset.value
const assetStroke = a && 'stroke' in a ? a.stroke : undefined
patch.stroke = sampled.value?.stroke ?? assetStroke ?? '#000000'
}
store.upsertKeyframe(selected.value.id, store.playhead, patch)
},
})
const blurAsset = computed(() => {
const a = selectedAsset.value
return a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'polygon') ? a : null
})
const canBeMask = computed(() => {
const a = selectedAsset.value
return !!a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'text' || a.kind === 'polygon')
})
const maskInGroup = computed(() => {
if (!selected.value) return false
const id = selected.value.id
return (store.scene.groups ?? []).some((g) => g.childIds.includes(id))
})
const blurValue = computed<number>({
get: () => {
const a = blurAsset.value
if (!a) return 0
return sampled.value?.blur ?? a.blur ?? 0
},
set: (v: number) => {
if (!selected.value) return
store.upsertKeyframe(selected.value.id, store.playhead, { blur: v })
},
})
type ShadowNumKey = 'shadowBlur' | 'shadowOpacity' | 'shadowAngle' | 'shadowDistance'
interface ShadowAll {
shadowBlur: number
shadowOpacity: number
shadowAngle: number
shadowDistance: number
shadowColor: string
}
const SHADOW_DEFAULTS: ShadowAll = {
shadowBlur: 8,
shadowOpacity: 0,
shadowAngle: 90,
shadowDistance: 6,
shadowColor: '#000000',
}
function currentShadow(): ShadowAll {
const samp = sampled.value
return {
shadowBlur: samp?.shadowBlur ?? SHADOW_DEFAULTS.shadowBlur,
shadowOpacity: samp?.shadowOpacity ?? SHADOW_DEFAULTS.shadowOpacity,
shadowAngle: samp?.shadowAngle ?? SHADOW_DEFAULTS.shadowAngle,
shadowDistance: samp?.shadowDistance ?? SHADOW_DEFAULTS.shadowDistance,
shadowColor: samp?.shadowColor ?? SHADOW_DEFAULTS.shadowColor,
}
}
function writeShadow(patch: Partial<ShadowAll>) {
if (!selected.value) return
const next = { ...currentShadow(), ...patch }
store.upsertKeyframe(selected.value.id, store.playhead, next)
}
function shadowBinding(key: ShadowNumKey, decimals = 0) {
const factor = 10 ** decimals
return computed({
get: () => Math.round(currentShadow()[key] * factor) / factor,
set: (v: number) => writeShadow({ [key]: v } as Partial<ShadowAll>),
})
}
const shadowBlur = shadowBinding('shadowBlur')
const shadowOpacity = shadowBinding('shadowOpacity', 2)
const shadowAngle = shadowBinding('shadowAngle')
const shadowDistance = shadowBinding('shadowDistance')
const shadowColorValue = computed<string>({
get: () => currentShadow().shadowColor,
set: (v: string) => writeShadow({ shadowColor: v }),
})
const canFill = computed(() => {
const a = selectedAsset.value
return !!a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'text' || a.kind === 'polygon')
})
const lastFillColor = ref<string>('#000000')
watch(
() => [sampled.value?.fill, selectedAsset.value?.id] as const,
([f]) => {
if (f && f !== 'transparent') lastFillColor.value = f
},
{ immediate: true },
)
const fillColor = computed<string>({
get: () => {
const samp = sampled.value?.fill
if (samp && samp !== 'transparent') return samp
return lastFillColor.value
},
set: (v: string) => {
if (!selected.value) return
if (v !== 'transparent') lastFillColor.value = v
store.upsertKeyframe(selected.value.id, store.playhead, { fill: v })
},
})
function resolveRestoreFill(): string {
// Prefer the cached last non-transparent color, but fall back to the layer
// base or asset fill so that the restore is visibly different from
// 'transparent' even if the user never picked a color via the picker.
if (lastFillColor.value && lastFillColor.value !== 'transparent') {
return lastFillColor.value
}
const layer = selected.value
const base = layer?.base?.fill
if (base && base !== 'transparent') return base
const a = selectedAsset.value
if (a && 'fill' in a && a.fill && a.fill !== 'transparent') return a.fill
return '#6ea8ff'
}
const fillTransparent = computed<boolean>({
get: () => {
const samp = sampled.value?.fill
if (samp !== undefined) return samp === 'transparent'
const a = selectedAsset.value
if (a && 'fill' in a) return a.fill === 'transparent'
return false
},
set: (v: boolean) => {
if (!selected.value) return
if (v) {
store.upsertKeyframe(selected.value.id, store.playhead, { fill: 'transparent' })
} else {
const color = resolveRestoreFill()
lastFillColor.value = color
store.upsertKeyframe(selected.value.id, store.playhead, { fill: color })
}
},
})
function resolveRestoreStroke(): string {
if (lastStrokeColor.value && lastStrokeColor.value !== 'transparent') {
return lastStrokeColor.value
}
const layer = selected.value
const base = layer?.base?.stroke
if (base && base !== 'transparent') return base
const a = selectedAsset.value
if (a && 'stroke' in a && a.stroke && a.stroke !== 'transparent') return a.stroke
return '#000000'
}
const strokeTransparent = computed<boolean>({
get: () => {
const s = sampled.value?.stroke
if (s !== undefined) return s === 'transparent'
const a = selectedAsset.value
if (a && 'stroke' in a) return a.stroke === 'transparent'
return false
},
set: (v: boolean) => {
if (!selected.value) return
if (v) {
store.upsertKeyframe(selected.value.id, store.playhead, { stroke: 'transparent' })
} else {
const color = resolveRestoreStroke()
lastStrokeColor.value = color
store.upsertKeyframe(selected.value.id, store.playhead, { stroke: color })
}
},
})
type NumericKey = 'x' | 'y' | 'scaleX' | 'scaleY' | 'rotation' | 'opacity'
/** Patch one key on every editable selected layer, snapshotting
* each layer's other props at the playhead so the new keyframe
* doesn't accidentally collapse to defaults. Used by the bind
* helpers + the shadow / fill / stroke writers below. */
function writeKeyToSelection(patch: Partial<AnimatableProps>) {
const layers = editableLayers.value
if (!layers.length) return
for (const l of layers) {
store.upsertKeyframe(l.id, store.playhead, patch)
}
}
function bind(key: NumericKey, decimals = 0) {
const factor = 10 ** decimals
return computed({
// Reading sticks with the anchor (sampled) so the input shows a
// single concrete value. Writes fan out to every editable layer.
get: () => (sampled.value ? Math.round(sampled.value[key] * factor) / factor : 0),
set: (v: number) => writeKeyToSelection({ [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() {
for (const l of editableLayers.value) {
store.removeKeyframeAt(l.id, store.playhead)
}
}
/** Trash button — removes the selection. Single-select drops one
* layer; multi-select fans out across every editable id. */
function deleteSelection() {
for (const l of editableLayers.value) {
store.removeLayer(l.id)
}
}
</script>
<template>
<div class="panel">
<h3>Properties</h3>
<template v-if="selectedCameraTrack && sampledCamera">
<div class="multi-banner">{{ t('properties.camera.banner') }}</div>
<section class="prop-group">
<button class="group-header" @click="toggleSection('transform')">
<component :is="sectionOpen.transform ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.camera.frame') }}</span>
</button>
<div v-if="sectionOpen.transform" class="group-body">
<div class="toggle-row">
<span class="toggle-label">{{ t('properties.camera.enabled') }}</span>
<ToggleSwitch
v-model="cameraEnabled"
:aria-label="t('properties.camera.enabled')"
/>
</div>
<div class="toggle-row">
<span class="toggle-label">{{ t('properties.camera.overlay') }}</span>
<ToggleSwitch
v-model="cameraOverlayVisible"
:aria-label="t('properties.camera.overlay')"
/>
</div>
<div class="toggle-row">
<span class="toggle-label">{{ t('properties.camera.locked') }}</span>
<ToggleSwitch
v-model="cameraLocked"
:aria-label="t('properties.camera.locked')"
/>
</div>
<div class="row">
<label>X<input type="number" v-model.number="cameraX" :disabled="cameraLocked" /></label>
<label>Y<input type="number" v-model.number="cameraY" :disabled="cameraLocked" /></label>
</div>
<div class="row">
<label>{{ t('properties.camera.width') }}<input type="number" min="1" v-model.number="cameraWidth" :disabled="cameraLocked" /></label>
<label>{{ t('properties.camera.height') }}<input type="number" min="1" v-model.number="cameraHeight" :disabled="cameraLocked" /></label>
</div>
<div class="kf-state">
<span>
{{ cameraHasKeyframeHere ? t('properties.keyframeLabel') : t('properties.interpolated') }}
<span class="kf-time">@ {{ formatTime(store.playhead) }}</span>
</span>
<button
v-if="cameraHasKeyframeHere"
class="kf-btn"
v-tooltip="t('properties.removeKeyframe')"
:disabled="cameraLocked"
@click="removeCameraKeyframeHere"
>
<PhMinus :size="12" />
</button>
</div>
</div>
</section>
</template>
<template v-else-if="selected && sampled && editableLayers.length > 0">
<div v-if="multiSelected" class="multi-banner">
{{ t('properties.multiSelected', { n: editableLayers.length }) }}
</div>
<label v-if="!multiSelected">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 ? t('properties.keyframeLabel') : t('properties.interpolated') }}
<span class="kf-time">@ {{ formatTime(store.playhead) }}</span>
</span>
<button v-if="hasKeyframeHere" class="kf-btn" v-tooltip="t('properties.removeKeyframe')" @click="removeKeyframeHere">
<PhX :size="12" weight="bold" />
</button>
</div>
<section class="prop-group">
<button class="group-header" @click="toggleSection('transform')">
<component :is="sectionOpen.transform ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.sections.transform') }}</span>
</button>
<div v-if="sectionOpen.transform" class="group-body">
<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>
</div>
</section>
<section v-if="textAsset && !multiSelected" class="prop-group">
<button class="group-header" @click="toggleSection('text')">
<component :is="sectionOpen.text ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.sections.text') }}</span>
</button>
<div v-if="sectionOpen.text" class="group-body">
<label>{{ t('properties.text.text') }}<input type="text" v-model="textAsset.text" /></label>
<label>{{ t('properties.text.fontSize') }}<input type="number" min="1" v-model.number="textAsset.fontSize" /></label>
<label>
{{ t('properties.text.font') }}
<FontPicker v-model="textFontModel" />
</label>
</div>
</section>
<section v-if="polygonAsset && !multiSelected" class="prop-group">
<button class="group-header" @click="toggleSection('polygon')">
<component :is="sectionOpen.polygon ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.sections.polygon') }}</span>
</button>
<div v-if="sectionOpen.polygon" class="group-body">
<div class="toggle-row">
<span class="toggle-label">{{ t('properties.polygonClosed') }}</span>
<button
type="button"
class="icon-toggle"
:class="{ active: polygonClosed }"
v-tooltip="t('properties.polygonClosed')"
@click="polygonClosed = !polygonClosed"
>
<PhPolygon :size="14" :weight="polygonClosed ? 'fill' : 'regular'" />
</button>
</div>
<label>
{{ t('properties.polygonPoints') }}
<input type="number" min="3" max="64" v-model.number="polygonPointCount" />
</label>
<p class="hint">{{ t('properties.polygonPointsHint') }}</p>
</div>
</section>
<section class="prop-group">
<button class="group-header" @click="toggleSection('appearance')">
<component :is="sectionOpen.appearance ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.sections.appearance') }}</span>
</button>
<div v-if="sectionOpen.appearance" class="group-body">
<label v-if="canFill && !multiSelected" class="color-label">
{{ t('properties.fill') }}
<div class="color-row">
<input
type="color"
class="color-swatch"
:value="fillColor"
:disabled="fillTransparent"
@input="fillColor = ($event.target as HTMLInputElement).value"
/>
<span class="color-hex">{{ fillTransparent ? '—' : fillColor.toUpperCase() }}</span>
<button
v-if="eyeDropperSupported"
type="button"
class="icon-toggle eyedropper-btn"
:disabled="fillTransparent"
v-tooltip="t('properties.eyedropper')"
@click="pickFillColor"
>
<PhEyedropper :size="13" />
</button>
<button
type="button"
class="icon-toggle alpha-toggle-btn"
:class="{ active: fillTransparent }"
v-tooltip="t('properties.transparent')"
@click="fillTransparent = !fillTransparent"
>
<component :is="fillTransparent ? PhEyeSlash : PhEye" :size="13" />
</button>
</div>
</label>
<label v-if="canStroke && !multiSelected" class="color-label">
{{ t('properties.stroke') }}
<div class="color-row">
<input
type="color"
class="color-swatch"
:value="strokeColor"
:disabled="strokeTransparent"
@input="strokeColor = ($event.target as HTMLInputElement).value"
/>
<span class="color-hex">{{ strokeTransparent ? '—' : strokeColor.toUpperCase() }}</span>
<button
v-if="eyeDropperSupported"
type="button"
class="icon-toggle eyedropper-btn"
:disabled="strokeTransparent"
v-tooltip="t('properties.eyedropper')"
@click="pickStrokeColor"
>
<PhEyedropper :size="13" />
</button>
<button
type="button"
class="icon-toggle alpha-toggle-btn"
:class="{ active: strokeTransparent }"
v-tooltip="t('properties.transparent')"
@click="strokeTransparent = !strokeTransparent"
>
<component :is="strokeTransparent ? PhEyeSlash : PhEye" :size="13" />
</button>
</div>
</label>
<label v-if="canStroke && !multiSelected" class="slider-label">
{{ t('properties.strokeWidth') }}
<RangeSlider
v-model="strokeWidth"
:min="0"
:max="32"
:step="0.5"
:format="(v: number) => `${Math.round(v * 2) / 2}px`"
/>
</label>
<label class="slider-label">
{{ t('properties.opacity') }}
<RangeSlider v-model="opacity" :min="0" :max="1" :step="0.01" :format="formatPercent" />
</label>
<label v-if="blurAsset && !multiSelected" class="slider-label">
{{ t('properties.edgeBlur') }}
<RangeSlider
v-model="blurValue"
:min="0"
:max="64"
:step="1"
:format="(v: number) => `${Math.round(v)}px`"
/>
</label>
</div>
</section>
<section v-if="!multiSelected" class="prop-group">
<button class="group-header" @click="toggleSection('shadow')">
<component :is="sectionOpen.shadow ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.shadow') }}</span>
</button>
<div v-if="sectionOpen.shadow" class="group-body">
<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>
<button
v-if="eyeDropperSupported"
type="button"
class="icon-toggle eyedropper-btn"
v-tooltip="t('properties.eyedropper')"
@click="pickShadowColor"
>
<PhEyedropper :size="13" />
</button>
</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>
</div>
</section>
<section v-if="canBeMask && !multiSelected" class="prop-group">
<button class="group-header" @click="toggleSection('mask')">
<component :is="sectionOpen.mask ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.mask') }}</span>
</button>
<div v-if="sectionOpen.mask" class="group-body">
<div class="toggle-row">
<span class="toggle-label">{{ t('properties.maskUse') }}</span>
<button
type="button"
class="icon-toggle"
:class="{ active: !!selected.isMask }"
v-tooltip="t('properties.maskUse')"
@click="store.setLayerMask(selected.id, !selected.isMask)"
>
<PhMaskHappy :size="14" />
</button>
</div>
<div v-if="selected.isMask" class="toggle-row">
<span class="toggle-label">{{ t('properties.maskMode') }}</span>
<div class="segmented">
<button
type="button"
class="icon-toggle"
:class="{ active: (selected.maskMode ?? 'negative') === 'negative', danger: (selected.maskMode ?? 'negative') === 'negative' }"
v-tooltip="t('properties.maskNegative')"
@click="store.setLayerMaskMode(selected.id, 'negative')"
>
<PhMinus :size="13" weight="bold" />
</button>
<button
type="button"
class="icon-toggle"
:class="{ active: selected.maskMode === 'positive' }"
v-tooltip="t('properties.maskPositive')"
@click="store.setLayerMaskMode(selected.id, 'positive')"
>
<PhPlus :size="13" weight="bold" />
</button>
</div>
</div>
<p v-if="selected.isMask && maskInGroup" class="status-active">
{{ t('properties.maskActive') }}
</p>
<p v-if="selected.isMask && !maskInGroup" class="status-warn">
{{ t('properties.maskNeedsGroup') }}
</p>
<p v-if="selected.isMask" class="hint">{{ t('properties.maskHint') }}</p>
</div>
</section>
<section v-if="canKaleido && !multiSelected" class="prop-group">
<button class="group-header" @click="toggleSection('effects')">
<component :is="sectionOpen.effects ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.sections.effects') }}</span>
</button>
<div v-if="sectionOpen.effects" class="group-body">
<!-- Wiggle -->
<div class="effect-block">
<div class="toggle-row">
<span class="toggle-label">{{ t('properties.wiggle.toggle') }}</span>
<button
type="button"
class="icon-toggle"
:class="{ active: wiggleOn }"
v-tooltip="t('properties.wiggle.toggle')"
@click="setWiggleOn(!wiggleOn)"
>
<PhWaveSine :size="14" :weight="wiggleOn ? 'fill' : 'regular'" />
</button>
</div>
<label v-if="wiggleOn" class="slider-label">
{{ t('properties.wiggle.amplitude') }}
<RangeSlider
v-model="wiggleAmplitude"
:min="0"
:max="64"
:step="1"
:format="(v: number) => `${Math.round(v)}px`"
/>
</label>
<label v-if="wiggleOn" class="slider-label">
{{ t('properties.wiggle.rotation') }}
<RangeSlider
v-model="wiggleRotation"
:min="0"
:max="45"
:step="0.5"
:format="(v: number) => `${Math.round(v * 2) / 2}°`"
/>
</label>
<label v-if="wiggleOn" class="slider-label">
{{ t('properties.wiggle.frequency') }}
<RangeSlider
v-model="wiggleFrequency"
:min="0"
:max="10"
:step="0.1"
:format="(v: number) => `${Math.round(v * 10) / 10} Hz`"
/>
</label>
<p v-if="wiggleOn" class="hint">{{ t('properties.wiggle.hint') }}</p>
</div>
<!-- Kaleidoscope -->
<div class="effect-block">
<div class="toggle-row">
<span class="toggle-label">{{ t('properties.kaleido.toggle') }}</span>
<button
type="button"
class="icon-toggle"
:class="{ active: kaleidoOn }"
v-tooltip="t('properties.kaleido.toggle')"
@click="setKaleidoOn(!kaleidoOn)"
>
<PhFlower :size="14" :weight="kaleidoOn ? 'fill' : 'regular'" />
</button>
</div>
<label v-if="kaleidoOn">
{{ t('properties.kaleido.repeat') }}
<input
type="number"
min="2"
max="64"
step="1"
:value="kaleidoCountValue"
@input="onKaleidoCountInput($event)"
/>
</label>
<div v-if="kaleidoOn" class="row">
<label>
{{ t('properties.kaleido.pivotX') }}
<input
type="number"
:value="kaleidoPivotX"
@input="setKaleidoPivot({ x: Number(($event.target as HTMLInputElement).value) })"
/>
</label>
<label>
{{ t('properties.kaleido.pivotY') }}
<input
type="number"
:value="kaleidoPivotY"
@input="setKaleidoPivot({ y: Number(($event.target as HTMLInputElement).value) })"
/>
</label>
</div>
<p v-if="kaleidoOn" class="hint">{{ t('properties.kaleido.hint') }}</p>
</div>
</div>
</section>
<section v-if="buttonAsset && !multiSelected" class="prop-group">
<button class="group-header" @click="toggleSection('button')">
<component :is="sectionOpen.button ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.sections.button') }}</span>
</button>
<div v-if="sectionOpen.button" class="group-body">
<ActionEditor
:actions="selected.onClick ?? []"
@update:actions="(v: Action[]) => store.setLayerOnClick(selected!.id, v)"
/>
</div>
</section>
<button class="danger-btn" @click="deleteSelection">
<PhTrash :size="13" />
{{
multiSelected
? t('properties.deleteLayers', { n: editableLayers.length })
: t('properties.deleteLayer')
}}
</button>
</template>
<template v-else-if="selectedClipInstance">
<div class="multi-banner">{{ t('properties.clipInstance.banner') }}</div>
<section class="prop-group">
<button class="group-header" @click="toggleSection('effects')">
<component :is="sectionOpen.effects ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.sections.effects') }}</span>
</button>
<div v-if="sectionOpen.effects" class="group-body">
<div class="toggle-row">
<span class="toggle-label">{{ t('properties.kaleido.toggle') }}</span>
<button
type="button"
class="icon-toggle"
:class="{ active: ciKaleidoOn }"
v-tooltip="t('properties.kaleido.toggle')"
@click="setCiKaleido({ kaleido: !ciKaleidoOn })"
>
<PhFlower :size="14" :weight="ciKaleidoOn ? 'fill' : 'regular'" />
</button>
</div>
<label v-if="ciKaleidoOn">
{{ t('properties.kaleido.repeat') }}
<input
type="number"
min="2"
max="64"
step="1"
:value="ciKaleidoCount"
@input="setCiKaleido({ kaleidoCount: Number(($event.target as HTMLInputElement).value) })"
/>
</label>
<label v-if="ciKaleidoOn">
{{ t('properties.kaleido.offset') }} ({{ unitLabel }})
<input
type="number"
:step="inputStep"
:value="ciKaleidoOffsetInput"
@input="setCiKaleidoOffset(Number(($event.target as HTMLInputElement).value))"
/>
</label>
<div v-if="ciKaleidoOn" class="row">
<label>
{{ t('properties.kaleido.pivotX') }}
<input
type="number"
:value="ciPivotX"
@input="setCiKaleido({ kaleidoPivotX: Number(($event.target as HTMLInputElement).value) })"
/>
</label>
<label>
{{ t('properties.kaleido.pivotY') }}
<input
type="number"
:value="ciPivotY"
@input="setCiKaleido({ kaleidoPivotY: Number(($event.target as HTMLInputElement).value) })"
/>
</label>
</div>
<div v-if="ciKaleidoOn" class="toggle-row">
<span class="toggle-label">{{ t('properties.kaleido.rotateCopies') }}</span>
<button
type="button"
class="icon-toggle"
:class="{ active: ciRotateCopies }"
@click="setCiKaleido({ kaleidoRotateCopies: !ciRotateCopies })"
>
<PhFlower :size="14" :weight="ciRotateCopies ? 'fill' : 'regular'" />
</button>
</div>
<p v-if="ciKaleidoOn" class="hint">{{ t('properties.kaleido.hintClip') }}</p>
</div>
</section>
</template>
<p v-else-if="selected && editableLayers.length === 0" class="hint">
{{ t('properties.lockedLayer') }}
</p>
<p v-else class="hint">{{ t('properties.noLayer') }}</p>
</div>
</template>
<style scoped>
.panel {
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
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;
}
/* Number inputs hold short numeric values. Capping the width keeps
them from stretching to the panel's full ~260px and aligns the
panel visually with the more compact slider controls. Inputs
inside a `.row` (two side-by-side labels) still split that row
evenly because their label parent is `flex: 1 1 0`. */
input[type='number'] {
max-width: 96px;
font-variant-numeric: tabular-nums;
}
.row input[type='number'] {
max-width: none;
}
.row {
display: flex;
gap: 8px;
}
.hint {
color: var(--text-dim);
font-size: 12px;
font-style: italic;
padding: 8px 0;
}
.prop-group {
display: flex;
flex-direction: column;
border-top: 1px solid var(--border);
margin: 0;
padding: 0;
}
.group-header {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 8px 4px;
background: transparent;
border: none;
color: var(--text-dim);
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
cursor: pointer;
text-align: left;
}
.group-header:hover {
color: var(--text);
}
.group-header svg {
flex-shrink: 0;
}
.group-body {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 2px 10px;
}
/* Sub-block inside the Effects section. The thin top border visually
separates Wiggle from Kaleidoscope without forcing a second
collapsible header — they share one section but each toggles
independently. */
.effect-block {
display: flex;
flex-direction: column;
gap: 8px;
}
.effect-block + .effect-block {
padding-top: 10px;
border-top: 1px solid var(--border);
}
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.toggle-label {
font-size: 12px;
color: var(--text);
text-transform: none;
letter-spacing: 0;
flex: 1 1 auto;
}
.segmented {
display: inline-flex;
gap: 2px;
background: var(--panel-alt);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 2px;
}
.icon-toggle {
width: 26px;
height: 26px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--panel-alt);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-dim);
cursor: pointer;
transition: background 140ms var(--ease), border-color 140ms var(--ease),
color 140ms var(--ease);
flex-shrink: 0;
}
.icon-toggle:hover {
border-color: var(--border-strong);
color: var(--text);
}
.icon-toggle.active {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.icon-toggle.active.danger {
background: color-mix(in srgb, #ff6b6b 16%, transparent);
border-color: #ff6b6b;
color: #ff6b6b;
}
.segmented .icon-toggle {
border: none;
background: transparent;
}
.segmented .icon-toggle.active {
background: var(--accent-soft);
color: var(--accent);
}
.segmented .icon-toggle.active.danger {
background: color-mix(in srgb, #ff6b6b 16%, transparent);
color: #ff6b6b;
}
.status-active,
.status-warn {
margin: 0;
font-size: 11px;
padding: 4px 8px;
border-radius: var(--radius-sm);
}
.status-active {
color: #6ea8ff;
background: color-mix(in srgb, #6ea8ff 14%, transparent);
}
.status-warn {
color: #ffb84d;
background: color-mix(in srgb, #ffb84d 16%, transparent);
}
.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;
}
.alpha-toggle-btn {
margin-left: auto;
}
.color-swatch:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.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);
}
.multi-banner {
padding: 6px 8px;
font-size: 11px;
background: var(--accent-soft);
color: var(--accent);
border-radius: var(--radius-sm);
border: 1px solid var(--accent);
}
</style>