Add Effects section to properties panel

Support adding, removing, and editing drop shadows, inner shadows,
layer blurs, background blurs, and foreground blurs. Each effect row
has visibility toggle, type dropdown, and remove button. Expanded
view shows offset, blur, spread, and color controls for shadows,
or just blur radius for blur effects.
This commit is contained in:
Danila Poyarkov 2026-02-28 20:12:37 +03:00
parent a862cc6ca6
commit daa949e9e3
3 changed files with 197 additions and 5 deletions

1
components.d.ts vendored
View file

@ -15,6 +15,7 @@ declare module 'vue' {
CanvasContextMenu: typeof import('./src/components/CanvasContextMenu.vue')['default']
ColorPicker: typeof import('./src/components/ColorPicker.vue')['default']
EditorCanvas: typeof import('./src/components/EditorCanvas.vue')['default']
EffectsSection: typeof import('./src/components/properties/EffectsSection.vue')['default']
FillPicker: typeof import('./src/components/FillPicker.vue')['default']
FillSection: typeof import('./src/components/properties/FillSection.vue')['default']
IconLucideALargeSmall: typeof import('~icons/lucide/a-large-small')['default']

View file

@ -9,6 +9,7 @@ import LayoutSection from './properties/LayoutSection.vue'
import PageSection from './properties/PageSection.vue'
import PositionSection from './properties/PositionSection.vue'
import StrokeSection from './properties/StrokeSection.vue'
import EffectsSection from './properties/EffectsSection.vue'
import TypographySection from './properties/TypographySection.vue'
const store = useEditorStore()
@ -74,11 +75,7 @@ const isComponentType = computed(() => {
<TypographySection v-if="node.type === 'TEXT'" />
<FillSection />
<StrokeSection />
<!-- Effects -->
<div class="border-b border-border px-3 py-2">
<label class="mb-1.5 block text-[11px] text-muted">Effects</label>
</div>
<EffectsSection />
<!-- Export -->
<div class="border-b border-border px-3 py-2">

View file

@ -0,0 +1,194 @@
<script setup lang="ts">
import { ref } from 'vue'
import ColorPicker from '../ColorPicker.vue'
import ScrubInput from '../ScrubInput.vue'
import { useNodeProps } from '../../composables/use-node-props'
import { colorToHexRaw, parseColor } from '../../engine/color'
import type { Color, Effect } from '../../engine/scene-graph'
const { store, node } = useNodeProps()
const expandedIndex = ref<number | null>(null)
type EffectType = Effect['type']
const EFFECT_LABELS: Record<EffectType, string> = {
DROP_SHADOW: 'Drop shadow',
INNER_SHADOW: 'Inner shadow',
LAYER_BLUR: 'Layer blur',
BACKGROUND_BLUR: 'Background blur',
FOREGROUND_BLUR: 'Foreground blur',
}
const EFFECT_TYPES = Object.keys(EFFECT_LABELS) as EffectType[]
function isShadow(type: EffectType) {
return type === 'DROP_SHADOW' || type === 'INNER_SHADOW'
}
function defaultEffect(): Effect {
return {
type: 'DROP_SHADOW',
color: { r: 0, g: 0, b: 0, a: 0.25 },
offset: { x: 0, y: 4 },
radius: 4,
spread: 0,
visible: true,
}
}
function updateEffect(index: number, changes: Partial<Effect>) {
const effects = [...node.value!.effects]
effects[index] = { ...effects[index], ...changes }
store.updateNodeWithUndo(node.value!.id, { effects }, 'Change effect')
}
function updateColor(index: number, color: Color) {
updateEffect(index, { color })
}
function updateHex(index: number, hex: string) {
const color = parseColor(hex.startsWith('#') ? hex : `#${hex}`)
if (!color) return
const existing = node.value!.effects[index]
updateColor(index, { ...color, a: existing.color.a })
}
function updateColorOpacity(index: number, opacity: number) {
const existing = node.value!.effects[index]
updateColor(index, { ...existing.color, a: Math.max(0, Math.min(1, opacity / 100)) })
}
function toggleVisibility(index: number) {
updateEffect(index, { visible: !node.value!.effects[index].visible })
}
function updateType(index: number, type: EffectType) {
const changes: Partial<Effect> = { type }
if (!isShadow(type)) {
changes.offset = { x: 0, y: 0 }
changes.spread = 0
}
updateEffect(index, changes)
}
function add() {
const effects = [...node.value!.effects, defaultEffect()]
store.updateNodeWithUndo(node.value!.id, { effects }, 'Add effect')
expandedIndex.value = effects.length - 1
}
function remove(index: number) {
store.updateNodeWithUndo(
node.value!.id,
{ effects: node.value!.effects.filter((_, i) => i !== index) },
'Remove effect',
)
if (expandedIndex.value === index) expandedIndex.value = null
else if (expandedIndex.value !== null && expandedIndex.value > index) expandedIndex.value--
}
function toggleExpand(index: number) {
expandedIndex.value = expandedIndex.value === index ? null : index
}
</script>
<template>
<div v-if="node" class="border-b border-border px-3 py-2">
<div class="flex items-center justify-between">
<label class="mb-1 block text-[11px] text-muted">Effects</label>
<button
class="cursor-pointer rounded border-none bg-transparent px-1 text-base leading-none text-muted hover:bg-hover hover:text-surface"
@click="add"
>+</button>
</div>
<div v-for="(effect, i) in node.effects" :key="i">
<div class="group flex items-center gap-1.5 py-0.5">
<button
class="cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
@click="toggleVisibility(i)"
>
<icon-lucide-eye v-if="effect.visible" class="size-3.5" />
<icon-lucide-eye-off v-else class="size-3.5" />
</button>
<button
class="min-w-0 flex-1 cursor-pointer truncate border-none bg-transparent p-0 text-left text-xs text-surface hover:text-accent"
@click="toggleExpand(i)"
>{{ EFFECT_LABELS[effect.type] }}</button>
<button
class="cursor-pointer border-none bg-transparent p-0 text-sm leading-none text-muted hover:text-surface"
@click="remove(i)"
></button>
</div>
<div v-if="expandedIndex === i" class="mt-1 mb-1 flex flex-col gap-1.5 pl-5">
<select
class="h-[26px] rounded border border-border bg-input px-1.5 text-xs text-surface outline-none"
:value="effect.type"
@change="updateType(i, ($event.target as HTMLSelectElement).value as EffectType)"
>
<option v-for="t in EFFECT_TYPES" :key="t" :value="t">{{ EFFECT_LABELS[t] }}</option>
</select>
<template v-if="isShadow(effect.type)">
<div class="flex items-center gap-1.5">
<ScrubInput
icon="X"
:model-value="effect.offset.x"
@update:model-value="updateEffect(i, { offset: { ...effect.offset, x: $event } })"
/>
<ScrubInput
icon="Y"
:model-value="effect.offset.y"
@update:model-value="updateEffect(i, { offset: { ...effect.offset, y: $event } })"
/>
</div>
<div class="flex items-center gap-1.5">
<ScrubInput
icon="B"
:model-value="effect.radius"
:min="0"
@update:model-value="updateEffect(i, { radius: $event })"
/>
<ScrubInput
icon="S"
:model-value="effect.spread"
@update:model-value="updateEffect(i, { spread: $event })"
/>
</div>
<div class="flex items-center gap-1.5">
<ColorPicker :color="effect.color" @update="updateColor(i, $event)" />
<input
class="min-w-0 flex-1 border-none bg-transparent font-mono text-xs text-surface outline-none"
:value="colorToHexRaw(effect.color)"
@change="updateHex(i, ($event.target as HTMLInputElement).value)"
/>
<ScrubInput
class="w-12"
suffix="%"
:model-value="Math.round(effect.color.a * 100)"
:min="0"
:max="100"
@update:model-value="updateColorOpacity(i, $event)"
/>
</div>
</template>
<template v-else>
<ScrubInput
icon="B"
:model-value="effect.radius"
:min="0"
@update:model-value="updateEffect(i, { radius: $event })"
/>
</template>
</div>
</div>
</div>
</template>