Tooltips - New v-tooltip directive + TooltipHost floating panel replace the native title attribute across 95+ sites (topbar, timeline, sidebar, properties, stage overlays, fullscreen preview, export dialog, clipart drawer, modals). Styled dark card with a configurable label + optional help line for inline documentation; screen readers still get an aria-label when the panel is disabled. - Settings → Interface toggle (`ruwio:tooltipsEnabled`) hides the visual panel for users who know the app. IconButton now routes its title prop through v-tooltip so every existing call site is tooltip-aware without churn. Editor-hide + export filter - New `Layer.editorHidden` flag, eye toggle per layer row, skipped by the Konva stage and the mask-preview canvas path but honoured by exports and fullscreen preview (runtime untouched). - Export dialog gets a "Skip hidden tracks" switch that strips editor-hidden layers from every format (scene JS, runtime, example HTML, MP4, WebM) on opt-in. Disabled when the scene has no hidden layers. Button editor - `+` buttons appear on empty state slots in button-timeline rows and duplicate the nearest keyframe (prev by preference, next otherwise) onto that slot. New `duplicateKeyframeAt` store action carries props, easing, hold, and bezier handles across. Stage / interaction - Plain left-click on the empty content view pans the viewport, but only when the pointer isn't over a Konva shape — transformer handles, vertex dots, and selection boxes that extend past the canvas edge keep their drag. Settings reshuffle - Timeline tab merged into Document (Duration + FPS sit under a new "Timing" sub-heading). New Interface tab gathers three global prefs: Show tooltips, Show locked tracks, Asset thumbnails — driven by shared composables so the existing toolbar toggles stay in sync. Interface-tab CSS defensively resets the scoped label flex-column + uppercase rule that was making the toggle row look like an accident. Project identity + UX polish - Favicon wired up (moved to public/favicon.ico, linked from index.html); scripts/pack.sh packs the minimum Vue project (src, index.html, configs, public) into a tarball with the right version in the filename, and `npm run pack` drives it. - About dialog now credits Torsten Hoeltge. Test infra - Progress bar turns red mid-run on the first failure: runAll gets an onResult callback that TestOverlay uses to push each result into the reactive array incrementally. - mountComponent in the test harness registers v-tooltip on the isolated app instance so IconButton's aria-label contract actually fires under test. Regression tests - duplicateKeyframeAt: copy fidelity (props, easing, hold), collision refusal, unknown-id guards, separated-track targeting. - Shared composables: useTooltipPref and useAssetThumbsPref both return the same ref across calls and persist to localStorage. - Tooltip contract: IconButton ships `aria-label` (not title); live-DOM smoke tests match on aria-label selectors now. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
959 lines
27 KiB
Vue
959 lines
27 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref, watch } from 'vue'
|
|
import {
|
|
PhCaretDown,
|
|
PhCaretRight,
|
|
PhDiamond,
|
|
PhEye,
|
|
PhEyeSlash,
|
|
PhMaskHappy,
|
|
PhMinus,
|
|
PhPolygon,
|
|
PhPlus,
|
|
PhTrash,
|
|
PhX,
|
|
} from '@phosphor-icons/vue'
|
|
import { useSceneStore } from '@/stores/scene'
|
|
import { sampleLayer } from '@/lib/interpolate'
|
|
import type { Action, AnimatableProps } from '@/types/scene'
|
|
import RangeSlider from './RangeSlider.vue'
|
|
import ActionEditor from './ActionEditor.vue'
|
|
import { t } from '@/i18n'
|
|
|
|
const SECTION_STORAGE_KEY = 'animatr:propSections'
|
|
|
|
type SectionKey = 'transform' | 'text' | 'polygon' | 'appearance' | 'shadow' | 'mask' | 'button'
|
|
|
|
const defaultOpen: Record<SectionKey, boolean> = {
|
|
transform: true,
|
|
text: true,
|
|
polygon: true,
|
|
appearance: true,
|
|
shadow: false,
|
|
mask: false,
|
|
button: true,
|
|
}
|
|
|
|
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 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
|
|
})
|
|
|
|
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
|
|
})
|
|
|
|
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'
|
|
|
|
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 ? t('properties.keyframeLabel') : t('properties.interpolated') }}
|
|
<span class="kf-time">@ {{ store.playhead.toFixed(2) }}s</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" 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>Text<input type="text" v-model="textAsset.text" /></label>
|
|
<label>Font Size<input type="number" min="1" v-model.number="textAsset.fontSize" /></label>
|
|
</div>
|
|
</section>
|
|
|
|
<section v-if="polygonAsset" 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" 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
|
|
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" 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
|
|
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" class="slider-label">
|
|
{{ t('properties.strokeWidth') }}
|
|
<RangeSlider
|
|
v-model="strokeWidth"
|
|
:min="0"
|
|
:max="32"
|
|
:step="0.5"
|
|
:format="(v: number) => `${v}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" 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 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>
|
|
</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" 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="buttonAsset" 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="store.removeLayer(selected.id)">
|
|
<PhTrash :size="13" />
|
|
{{ t('properties.deleteLayer') }}
|
|
</button>
|
|
</template>
|
|
<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;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.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);
|
|
}
|
|
</style>
|