openpencil/src/components/properties/EffectsSection.vue
Danila Poyarkov 0bd5afe784 feat(editor): add paint blend mode controls
- Expose localized blend mode selects for fills and expanded effects

- Reuse PropertyList batching for shared-style detachment and mixed-selection undo

- Share Appearance, Fill, and Effects blend mode options

- Add focused E2E and panel visual coverage
2026-07-17 22:10:27 +03:00

236 lines
8.4 KiB
Vue

<script setup lang="ts">
import { useEffectsControls, useI18n } from '@open-pencil/vue'
import ColorInput from '@/components/ColorPicker/ColorInput.vue'
import NumberField from '@/components/inputs/NumberField.vue'
import PropertyItemRow from '@/components/properties/item-list/PropertyItemRow.vue'
import PropertyListRoot from '@/components/properties/PropertyListRoot.vue'
import {
commitDiscretePropertyListChange,
useBlendModeOptions
} from '@/components/properties/blend-mode/use'
import SharedStyleField from '@/components/properties/shared-style/SharedStyleField.vue'
import AppSelect from '@/components/ui/AppSelect.vue'
import FillSwatch from '@/components/ui/FillSwatch.vue'
import IconButton from '@/components/ui/IconButton.vue'
import PanelFieldGroup from '@/components/ui/panel/PanelFieldGroup.vue'
import PanelSection from '@/components/ui/panel/PanelSection.vue'
import Tip from '@/components/ui/Tip.vue'
import type { Effect, Fill } from '@open-pencil/scene-graph'
const effectsCtx = useEffectsControls()
const { panels } = useI18n()
const blendModeOptions = useBlendModeOptions()
function effectPreview(effect: Effect): Fill {
return {
type: 'SOLID',
color: effect.color,
opacity: 1,
visible: effect.visible
}
}
</script>
<template>
<PropertyListRoot
v-slot="{ items, isMixed, activeNode, flush, actions }"
prop-key="effects"
:label="panels.effects"
>
<PanelSection :label="panels.effects" :empty="!isMixed && items.length === 0">
<template #actions>
<IconButton
:label="panels.addEffect"
@click="actions.add(effectsCtx.createDefaultEffect())"
>
<icon-lucide-plus class="size-3.5" />
</IconButton>
</template>
<SharedStyleField kind="effect" :label="panels.effectStyle" />
<p v-if="isMixed" class="text-[11px] text-muted">{{ panels.mixedEffectsHelp }}</p>
<div
v-for="(effect, index) in items"
:key="`${index}:${effect.visible ? 'visible' : 'hidden'}`"
:data-effect-index="index"
data-effect-group
>
<PropertyItemRow
prop-key="effects"
:index="index"
:visibility-label="panels.toggleVisibility"
:remove-label="panels.removeEffect"
@remove="effectsCtx.adjustExpandedAfterRemove(index)"
>
<Tip
:label="
effectsCtx.expandedIndex.value === index
? panels.collapseEffectSettings
: panels.expandEffectSettings
"
>
<button
type="button"
:aria-expanded="effectsCtx.expandedIndex.value === index"
:aria-label="
effectsCtx.expandedIndex.value === index
? panels.collapseEffectSettings
: panels.expandEffectSettings
"
data-property="effect-expand"
class="flex size-5 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded border border-border bg-input p-0"
@click="effectsCtx.toggleExpand(index)"
>
<FillSwatch
v-if="effectsCtx.isShadow(effect.type)"
:fill="effectPreview(effect)"
class="size-full border-0"
/>
<icon-lucide-blend v-else class="size-3 text-muted" />
</button>
</Tip>
<AppSelect
class="min-w-0 flex-1"
:model-value="effect.type"
:options="effectsCtx.effectOptions"
:label="panels.effects"
data-property="effect-type"
@update:model-value="
effectsCtx.updateType(actions.patch, activeNode, index, $event as Effect['type'])
"
/>
</PropertyItemRow>
<div
v-if="effectsCtx.expandedIndex.value === index"
class="ml-[26px] flex flex-col gap-1.5 py-1.5"
data-slot="effect-settings"
>
<PanelFieldGroup :label="panels.blendMode">
<AppSelect
:model-value="effect.blendMode ?? 'NORMAL'"
:options="blendModeOptions"
:label="panels.blendMode"
data-property="effect-blend-mode"
@update:model-value="
commitDiscretePropertyListChange(flush, () =>
actions.patch(index, { blendMode: $event as Effect['blendMode'] })
)
"
/>
</PanelFieldGroup>
<template v-if="effectsCtx.isShadow(effect.type)">
<div class="flex items-center gap-1.5">
<Tip :label="panels.xAxis">
<NumberField
icon="X"
:model-value="effect.offset.x"
data-property="effect-offset-x"
@update:model-value="
effectsCtx.scrubEffect(activeNode, index, {
offset: { ...effect.offset, x: $event }
})
"
@commit="
effectsCtx.commitEffect(activeNode, index, {
offset: { ...effect.offset, x: $event }
})
"
/>
</Tip>
<Tip :label="panels.yAxis">
<NumberField
icon="Y"
:model-value="effect.offset.y"
data-property="effect-offset-y"
@update:model-value="
effectsCtx.scrubEffect(activeNode, index, {
offset: { ...effect.offset, y: $event }
})
"
@commit="
effectsCtx.commitEffect(activeNode, index, {
offset: { ...effect.offset, y: $event }
})
"
/>
</Tip>
</div>
<div class="flex items-center gap-1.5">
<Tip :label="panels.radius">
<NumberField
icon="B"
:model-value="effect.radius"
:min="0"
data-property="effect-radius"
@update:model-value="
effectsCtx.scrubEffect(activeNode, index, { radius: $event })
"
@commit="effectsCtx.commitEffect(activeNode, index, { radius: $event })"
/>
</Tip>
<Tip :label="panels.spread">
<NumberField
icon="S"
:model-value="effect.spread"
data-property="effect-spread"
@update:model-value="
effectsCtx.scrubEffect(activeNode, index, { spread: $event })
"
@commit="effectsCtx.commitEffect(activeNode, index, { spread: $event })"
/>
</Tip>
</div>
<div class="flex items-center gap-1.5">
<ColorInput
class="min-w-0 flex-1"
:color="effect.color"
editable
@update="effectsCtx.updateColor(actions.patch, index, $event)"
/>
<Tip :label="panels.opacity">
<NumberField
class="w-14"
suffix="%"
:model-value="Math.round(effect.color.a * 100)"
:min="0"
:max="100"
data-property="effect-opacity"
@update:model-value="
effectsCtx.scrubEffect(activeNode, index, {
color: { ...effect.color, a: Math.max(0, Math.min(1, $event / 100)) }
})
"
@commit="
effectsCtx.commitEffect(activeNode, index, {
color: { ...effect.color, a: Math.max(0, Math.min(1, $event / 100)) }
})
"
/>
</Tip>
</div>
</template>
<NumberField
v-else
class="w-24 flex-none"
icon="B"
:model-value="effect.radius"
:min="0"
data-property="effect-radius"
@update:model-value="effectsCtx.scrubEffect(activeNode, index, { radius: $event })"
@commit="effectsCtx.commitEffect(activeNode, index, { radius: $event })"
/>
</div>
</div>
</PanelSection>
</PropertyListRoot>
</template>