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:
parent
a862cc6ca6
commit
daa949e9e3
1
components.d.ts
vendored
1
components.d.ts
vendored
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
194
src/components/properties/EffectsSection.vue
Normal file
194
src/components/properties/EffectsSection.vue
Normal 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>
|
||||
Loading…
Reference in a new issue