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
This commit is contained in:
Danila Poyarkov 2026-07-17 22:10:27 +03:00
parent 23645d5a2d
commit 0bd5afe784
10 changed files with 271 additions and 87 deletions

View file

@ -19,6 +19,7 @@
- Add a mixed-selection corner-smoothing percentage control with live preview, per-node undo restoration, and `.fig` roundtrip coverage.
- Model imported fill, stroke, text, effect, and grid styles with reusable SDK/app selectors, automatic detach-on-edit, undo, and `.fig` definition roundtrips.
- Add variant, text, boolean, and nested instance-swap component property controls with mixed-selection undo and typed `.fig` metadata roundtrips.
- Add fill and effect blend-mode controls with shared-style detachment and mixed-selection undo.
- Standardize Pages, Layers/Assets navigation, and document tab states across light and dark themes.
- Standardize Vue SDK and app override type names on the `UI` acronym, including `FontPickerUI`.
- Add a headless Vue SDK NumberField with pointer scrubbing, keyboard stepping, safe arithmetic expressions, and mixed/bound states; remove the superseded ScrubInput API.

View file

@ -138,7 +138,7 @@ Figma's design documentation groups features into these areas:
| Image fills | ✅ | ✅ | ◐ | ✅ | ✅ | Fill/fit/crop/tile support exists; imported crop/tile affine transforms are applied, but exact Figma parity is still partial. |
| Pattern / noise / custom fills | ✅ | ◐ | — | ✅ | — | Schema metadata imports/exports; Figma pattern fills with a referenced source node render as repeated source tiles with scale, spacing, alignment, and basic hex offsets. Noise/custom paints still render with a solid fallback pending real paint payload samples; Figma-authored noise/texture/glass effect payloads are captured separately. |
| Video/GIF/media fills | ↩ | — | — | ↩ | — | Kiwi schema includes media paint/export enums, but OpenPencil has no video/GIF playback or media layer support. |
| Layer/fill/effect blend modes | ✅ | ◐ | — | ✅ | ✅ | Canvas applies node, fill, and common shadow effect blend modes; Figma isolation edge cases remain partial. |
| Layer/fill/effect blend modes | ✅ | ◐ | ✅ | ✅ | ✅ | Appearance, fill, and effect controls are exposed; Canvas applies common modes, while Figma isolation edge cases remain partial. |
| Opacity | ✅ | ✅ | ✅ | ✅ | ✅ | Node opacity uses save layers in the renderer. |
| Strokes | ✅ | ✅ | ✅ | ✅ | ✅ | Weight, alignment, dashes, and side weights are supported. |
| Stroke caps / joins / miter limit | ✅ | ✅ | ✅ | ✅ | ✅ | Inspector controls support mixed cap/join/miter editing; CanvasKit rendering and `.fig` roundtrips preserve miter limits. |

View file

@ -1,10 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import { AppearanceControlsRoot, MIXED, useI18n } from '@open-pencil/vue'
import NumberField from '@/components/inputs/NumberField.vue'
import VariableNumberField from '@/components/properties/VariableNumberField.vue'
import { useBlendModeOptions } from '@/components/properties/blend-mode/use'
import AppSelect from '@/components/ui/AppSelect.vue'
import IconButton from '@/components/ui/IconButton.vue'
import PanelFieldGroup from '@/components/ui/panel/PanelFieldGroup.vue'
@ -17,25 +16,7 @@ import type { BlendMode } from '@open-pencil/scene-graph'
const { panels } = useI18n()
type BlendModeSelectValue = BlendMode | 'MIXED'
const baseBlendModeOptions = computed<Array<{ value: BlendModeSelectValue; label: string }>>(() => [
{ value: 'PASS_THROUGH', label: panels.value.blendModePassThrough },
{ value: 'NORMAL', label: panels.value.blendModeNormal },
{ value: 'DARKEN', label: panels.value.blendModeDarken },
{ value: 'MULTIPLY', label: panels.value.blendModeMultiply },
{ value: 'COLOR_BURN', label: panels.value.blendModeColorBurn },
{ value: 'LIGHTEN', label: panels.value.blendModeLighten },
{ value: 'SCREEN', label: panels.value.blendModeScreen },
{ value: 'COLOR_DODGE', label: panels.value.blendModeColorDodge },
{ value: 'OVERLAY', label: panels.value.blendModeOverlay },
{ value: 'SOFT_LIGHT', label: panels.value.blendModeSoftLight },
{ value: 'HARD_LIGHT', label: panels.value.blendModeHardLight },
{ value: 'DIFFERENCE', label: panels.value.blendModeDifference },
{ value: 'EXCLUSION', label: panels.value.blendModeExclusion },
{ value: 'HUE', label: panels.value.blendModeHue },
{ value: 'SATURATION', label: panels.value.blendModeSaturation },
{ value: 'COLOR', label: panels.value.blendModeColor },
{ value: 'LUMINOSITY', label: panels.value.blendModeLuminosity }
])
const baseBlendModeOptions = useBlendModeOptions(true)
function blendModeOptions(value: BlendMode | typeof MIXED) {
return value === MIXED

View file

@ -5,10 +5,15 @@ 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'
@ -16,6 +21,7 @@ import type { Effect, Fill } from '@open-pencil/scene-graph'
const effectsCtx = useEffectsControls()
const { panels } = useI18n()
const blendModeOptions = useBlendModeOptions()
function effectPreview(effect: Effect): Fill {
return {
@ -29,7 +35,7 @@ function effectPreview(effect: Effect): Fill {
<template>
<PropertyListRoot
v-slot="{ items, isMixed, activeNode, actions }"
v-slot="{ items, isMixed, activeNode, flush, actions }"
prop-key="effects"
:label="panels.effects"
>
@ -105,6 +111,19 @@ function effectPreview(effect: Effect): Fill {
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">

View file

@ -19,10 +19,16 @@ import {
} from '@/components/properties/paint/binding'
import { fillLabel } from '@/components/properties/fill-label'
import { createFillOkhclAdapter } from '@/components/properties/paint/okhcl'
import {
commitDiscretePropertyListChange,
useBlendModeOptions
} from '@/components/properties/blend-mode/use'
import PropertyListRoot from '@/components/properties/PropertyListRoot.vue'
import SharedStyleField from '@/components/properties/shared-style/SharedStyleField.vue'
import VariableBindingPicker from '@/components/properties/binding/VariableBindingPicker.vue'
import AppSelect from '@/components/ui/AppSelect.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 { colorToHexRaw } from '@open-pencil/core/color'
@ -34,6 +40,7 @@ const fillCtx = useFillControls()
const okhcl = useOkHCL()
const colorProvider = useColorBindingProvider()
const { panels, dialogs } = useI18n()
const blendModeOptions = useBlendModeOptions()
function displayFill(fill: Fill, resolvedColor: Color | undefined): Fill {
return fill.type === 'SOLID' && resolvedColor ? { ...fill, color: resolvedColor } : fill
@ -78,74 +85,87 @@ function updateSolidColor(
<p v-if="isMixed" class="text-[11px] text-muted">{{ panels.mixedFillsHelp }}</p>
<PropertyItemRow
v-for="(fill, index) in items"
:key="`${index}:${fill.visible ? 'visible' : 'hidden'}`"
prop-key="fills"
:index="index"
:visibility-label="panels.toggleVisibility"
:remove-label="panels.removeFill"
>
<BindableValueRoot
v-slot="binding"
:provider="colorProvider"
:targets="paintBindingTargets(selectedNodeIds, 'fills', index)"
:value="fill.color"
batch-label="Change fill color"
<div v-for="(fill, index) in items" :key="`${index}:${fill.visible ? 'visible' : 'hidden'}`">
<PropertyItemRow
prop-key="fills"
:index="index"
:visibility-label="panels.toggleVisibility"
:remove-label="panels.removeFill"
>
<PaintField
:opacity="fill.opacity"
:opacity-label="panels.opacity"
@update:opacity="actions.patch(index, { opacity: $event })"
<BindableValueRoot
v-slot="binding"
:provider="colorProvider"
:targets="paintBindingTargets(selectedNodeIds, 'fills', index)"
:value="fill.color"
batch-label="Change fill color"
>
<template #preview>
<FillPicker
:fill="displayFill(fill, binding.resolvedValue)"
:okhcl="createFillOkhclAdapter(okhcl, activeNode, index)"
@update="
updatePickerFill(binding.actions, flush, $event, (next) =>
actions.update(index, next)
)
"
@open-change="!$event && commitPaintMutation(binding.actions)"
@cancel="cancelPaintMutation(binding.actions)"
/>
</template>
<PaintField
:opacity="fill.opacity"
:opacity-label="panels.opacity"
@update:opacity="actions.patch(index, { opacity: $event })"
>
<template #preview>
<FillPicker
:fill="displayFill(fill, binding.resolvedValue)"
:okhcl="createFillOkhclAdapter(okhcl, activeNode, index)"
@update="
updatePickerFill(binding.actions, flush, $event, (next) =>
actions.update(index, next)
)
"
@open-change="!$event && commitPaintMutation(binding.actions)"
@cancel="cancelPaintMutation(binding.actions)"
/>
</template>
<template #value>
<PaintValue
v-if="fill.type === 'SOLID'"
:color="fill.color"
:resolved-color="binding.resolvedValue"
:variable-name="binding.variable?.name"
:label="panels.fill"
@update="
updateSolidColor(binding.actions, flush, fill, $event, (next) =>
actions.update(index, next)
)
"
/>
<span v-else class="min-w-0 flex-1 truncate font-mono text-xs text-surface">
{{ fillLabel(fill) }}
</span>
</template>
<template #value>
<PaintValue
v-if="fill.type === 'SOLID'"
:color="fill.color"
:resolved-color="binding.resolvedValue"
:variable-name="binding.variable?.name"
:label="panels.fill"
@update="
updateSolidColor(binding.actions, flush, fill, $event, (next) =>
actions.update(index, next)
)
"
/>
<span v-else class="min-w-0 flex-1 truncate font-mono text-xs text-surface">
{{ fillLabel(fill) }}
</span>
</template>
<template v-if="fill.type === 'SOLID'" #binding>
<VariableBindingPicker
:trigger-label="panels.applyVariable"
:search-placeholder="dialogs.search"
:empty-label="panels.noVariablesFound"
:detach-label="panels.detachVariable"
:create-label="
panels.createColorVariable({ value: `#${colorToHexRaw(fill.color)}` })
"
:create-name-placeholder="panels.variableName"
:create-submit-label="panels.create"
/>
</template>
</PaintField>
</BindableValueRoot>
</PropertyItemRow>
<template v-if="fill.type === 'SOLID'" #binding>
<VariableBindingPicker
:trigger-label="panels.applyVariable"
:search-placeholder="dialogs.search"
:empty-label="panels.noVariablesFound"
:detach-label="panels.detachVariable"
:create-label="
panels.createColorVariable({ value: `#${colorToHexRaw(fill.color)}` })
"
:create-name-placeholder="panels.variableName"
:create-submit-label="panels.create"
/>
</template>
</PaintField>
</BindableValueRoot>
</PropertyItemRow>
<PanelFieldGroup :label="panels.blendMode" class="ml-[26px] mt-1.5">
<AppSelect
:model-value="fill.blendMode ?? 'NORMAL'"
:options="blendModeOptions"
:label="panels.blendMode"
data-property="fill-blend-mode"
@update:model-value="
commitDiscretePropertyListChange(flush, () =>
actions.patch(index, { blendMode: $event as Fill['blendMode'] })
)
"
/>
</PanelFieldGroup>
</div>
</PanelSection>
</PropertyListRoot>
</template>

View file

@ -0,0 +1,39 @@
import { computed } from 'vue'
import type { BlendMode } from '@open-pencil/scene-graph'
import { useI18n } from '@open-pencil/vue'
export function commitDiscretePropertyListChange(flush: () => void, update: () => void): void {
update()
flush()
}
export interface BlendModeOption {
value: BlendMode
label: string
}
export function useBlendModeOptions(includePassThrough = false) {
const { panels } = useI18n()
return computed<BlendModeOption[]>(() => [
...(includePassThrough
? [{ value: 'PASS_THROUGH' as const, label: panels.value.blendModePassThrough }]
: []),
{ value: 'NORMAL', label: panels.value.blendModeNormal },
{ value: 'DARKEN', label: panels.value.blendModeDarken },
{ value: 'MULTIPLY', label: panels.value.blendModeMultiply },
{ value: 'COLOR_BURN', label: panels.value.blendModeColorBurn },
{ value: 'LIGHTEN', label: panels.value.blendModeLighten },
{ value: 'SCREEN', label: panels.value.blendModeScreen },
{ value: 'COLOR_DODGE', label: panels.value.blendModeColorDodge },
{ value: 'OVERLAY', label: panels.value.blendModeOverlay },
{ value: 'SOFT_LIGHT', label: panels.value.blendModeSoftLight },
{ value: 'HARD_LIGHT', label: panels.value.blendModeHardLight },
{ value: 'DIFFERENCE', label: panels.value.blendModeDifference },
{ value: 'EXCLUSION', label: panels.value.blendModeExclusion },
{ value: 'HUE', label: panels.value.blendModeHue },
{ value: 'SATURATION', label: panels.value.blendModeSaturation },
{ value: 'COLOR', label: panels.value.blendModeColor },
{ value: 'LUMINOSITY', label: panels.value.blendModeLuminosity }
])
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View file

@ -0,0 +1,124 @@
import { expect, test, type Page } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
import { propertySection } from '#tests/helpers/properties'
let page: Page
let canvas: CanvasHelper
let firstId = ''
let secondId = ''
async function chooseBlend(sectionName: string, option: string) {
const section = propertySection(page, sectionName)
await section.getByRole('combobox', { name: 'Blend mode' }).click()
await page.getByRole('option', { name: option, exact: true }).click()
await canvas.waitForRender()
}
async function nodeState(id: string) {
return page.evaluate((nodeId) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const node = store.graph.getNode(nodeId)
return node
? {
fillBlend: node.fills[0]?.blendMode ?? 'NORMAL',
effectBlend: node.effects[0]?.blendMode ?? 'NORMAL',
fillStyleId: node.fillStyleId,
effectStyleId: node.effectStyleId
}
: null
}, id)
}
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ browser }) => {
page = await browser.newPage()
await page.goto('/')
canvas = new CanvasHelper(page)
await canvas.waitForInit()
;[firstId, secondId] = await page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const props = {
fills: [
{
type: 'SOLID' as const,
color: { r: 0.85, g: 0.2, b: 0.15, a: 1 },
opacity: 1,
visible: true,
blendMode: 'NORMAL' as const
}
],
effects: [
{
type: 'DROP_SHADOW' as const,
color: { r: 0, g: 0, b: 0, a: 0.25 },
offset: { x: 0, y: 4 },
radius: 8,
spread: 0,
visible: true,
blendMode: 'NORMAL' as const
}
],
fillStyleId: '1:10',
effectStyleId: '1:11',
width: 160,
height: 100
}
const first = store.graph.createNode('RECTANGLE', store.state.currentPageId, {
...structuredClone(props),
name: 'Blend first',
x: 160,
y: 160
})
const second = store.graph.createNode('RECTANGLE', store.state.currentPageId, {
...structuredClone(props),
name: 'Blend second',
x: 360,
y: 160
})
store.select([first.id])
return [first.id, second.id]
})
await canvas.waitForRender()
})
test.afterAll(async () => {
await page.close()
})
test('edits fill and effect blend modes with style detachment and undo', async () => {
await chooseBlend('Fill', 'Multiply')
expect(await nodeState(firstId)).toMatchObject({ fillBlend: 'MULTIPLY', fillStyleId: null })
await canvas.pressKey('Meta+z')
await canvas.waitForRender()
expect(await nodeState(firstId)).toMatchObject({ fillBlend: 'NORMAL', fillStyleId: '1:10' })
const effects = propertySection(page, 'Effects')
await effects.getByRole('button', { name: 'Expand effect settings' }).click()
await chooseBlend('Effects', 'Screen')
expect(await nodeState(firstId)).toMatchObject({ effectBlend: 'SCREEN', effectStyleId: null })
await expect(effects).toHaveScreenshot('effect-blend-mode.png')
})
test('batches fill blend modes across a compatible selection', async () => {
await page.evaluate(
([first, second]) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
store.select([first, second])
},
[firstId, secondId]
)
await canvas.waitForRender()
await chooseBlend('Fill', 'Overlay')
expect(await nodeState(firstId)).toMatchObject({ fillBlend: 'OVERLAY' })
expect(await nodeState(secondId)).toMatchObject({ fillBlend: 'OVERLAY' })
await canvas.pressKey('Meta+z')
await canvas.waitForRender()
expect(await nodeState(firstId)).toMatchObject({ fillBlend: 'NORMAL' })
expect(await nodeState(secondId)).toMatchObject({ fillBlend: 'NORMAL' })
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB