1570 lines
49 KiB
Vue
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>
|