fix(properties): simplify Effect styles and equivalent corners

This commit is contained in:
Danila Poyarkov 2026-09-11 03:27:27 +03:00
parent 510b953777
commit 0dd1014c2a
7 changed files with 70 additions and 5 deletions

View file

@ -36,6 +36,7 @@
### Changed
- Keep applied and available Effect styles concise, and collapse equal independent corner fields when all four use the same variable.
- Keep pixel-grid rounding invisible while showing alignment guides only for real geometry, objects, and canvas/layout guides.
- Copy selections as Figma-compatible clipboard data while preserving typed geometry, text sizing, images, components, variables, modes, and shared styles for lossless in-app paste.
- Choose whether interface animations follow the system motion preference or stay off, with live updates and a persistent override.

View file

@ -26,6 +26,16 @@ type AppearanceActionOptions = AppearanceStateOptions & {
editor: Editor
}
function cornersHaveEquivalentBindings(node: SceneNode): boolean {
const first = node.boundVariables.topLeftRadius
if (!first) return false
return [
node.boundVariables.topRightRadius,
node.boundVariables.bottomRightRadius,
node.boundVariables.bottomLeftRadius
].every((id) => id === first)
}
function hasUnequalCorners(node: SceneNode) {
return !(
node.topLeftRadius === node.topRightRadius &&
@ -48,7 +58,10 @@ export function createAppearanceState({ node, nodes, isMulti, merged }: Appearan
const showIndependentCorners = computed(() => {
if (isMulti.value) return false
const selected = node.value
return selected ? selected.independentCorners || hasUnequalCorners(selected) : false
return selected
? hasUnequalCorners(selected) ||
(selected.independentCorners && !cornersHaveEquivalentBindings(selected))
: false
})
const cornerRadiusValue = computed(() => {

View file

@ -10,7 +10,7 @@ import {
} from '@/components/properties/blend-mode/use'
import PropertyItemRow from '@/components/properties/item-list/PropertyItemRow.vue'
import PropertyListRoot from '@/components/properties/PropertyListRoot.vue'
import SharedStyleField from '@/components/properties/shared-style/SharedStyleField.vue'
import { useSharedStylePicker } from '@/components/properties/shared-style/useSharedStylePicker'
import IconButton from '@/components/ui/button/IconButton.vue'
import Tip from '@/components/ui/overlay/Tip.vue'
import FillSwatch from '@/components/ui/paint/FillSwatch.vue'
@ -21,6 +21,13 @@ import AppSelect from '@/components/ui/select/AppSelect.vue'
const effectsCtx = useEffectsControls()
const { panels } = useI18n()
const blendModeOptions = useBlendModeOptions()
const {
visible: stylesVisible,
hasStyle,
value: styleValue,
options: styleOptions,
update: updateStyle
} = useSharedStylePicker('effect')
function effectPreview(effect: Effect): Fill {
return {
@ -40,6 +47,17 @@ function effectPreview(effect: Effect): Fill {
>
<PanelSection :label="panels.effects" :empty="!isMixed && items.length === 0">
<template #actions>
<AppSelect
v-if="stylesVisible && !hasStyle"
:model-value="styleValue"
:options="styleOptions"
@update:model-value="updateStyle"
>
<template #trigger
><IconButton :label="panels.effectStyle" data-property="effect-style"
><icon-lucide-layout-grid class="size-3.5" /></IconButton
></template>
</AppSelect>
<IconButton
:label="panels.addEffect"
@click="actions.add(effectsCtx.createDefaultEffect())"
@ -48,7 +66,15 @@ function effectPreview(effect: Effect): Fill {
</IconButton>
</template>
<SharedStyleField kind="effect" :label="panels.effectStyle" />
<AppSelect
v-if="stylesVisible && hasStyle"
:model-value="styleValue"
:options="styleOptions"
:label="panels.effectStyle"
data-property="effect-style"
class="mb-1.5"
@update:model-value="updateStyle"
/>
<p v-if="isMixed" class="text-[11px] text-muted">{{ panels.mixedEffectsHelp }}</p>

View file

@ -113,7 +113,8 @@ async function targetStyles(id = targetId) {
test('unbound paint styles use one header action without redundant field labels', async () => {
for (const [section, label] of [
['Fill', 'Fill style'],
['Stroke', 'Stroke style']
['Stroke', 'Stroke style'],
['Effects', 'Effect style']
]) {
const panel = propertySection(page, section)
await expect(panel.getByRole('combobox', { name: label })).toHaveCount(1)
@ -129,7 +130,7 @@ test('applies fill, stroke, effect, and grid styles from local definitions', asy
await chooseStyle('Effects', 'Effect style', 'Effects/Card')
await chooseStyle('Layout guide', 'Grid style', 'Grid/12 columns')
await expect(propertySection(page, 'Fill')).toHaveScreenshot('fill-applied-style.png')
await expect(propertySection(page, 'Stroke')).toHaveScreenshot('stroke-applied-style.png')
await expect(propertySection(page, 'Effects')).toHaveScreenshot('effects-applied-style.png')
expect(await targetStyles()).toMatchObject({
fillStyleId: '1:100',

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -47,6 +47,30 @@ describe('appearance control state', () => {
expect(state.showIndependentCorners.value).toBe(true)
})
test('collapses equal independent corners when they share one binding', () => {
const node = rectangle()
node.independentCorners = true
node.boundVariables = {
topLeftRadius: 'radius',
topRightRadius: 'radius',
bottomRightRadius: 'radius',
bottomLeftRadius: 'radius'
}
expect(appearanceState(node).showIndependentCorners.value).toBe(false)
})
test('keeps equal independent corners expanded for distinct bindings', () => {
const node = rectangle()
node.independentCorners = true
node.boundVariables = {
topLeftRadius: 'radius-a',
topRightRadius: 'radius-b',
bottomRightRadius: 'radius-a',
bottomLeftRadius: 'radius-a'
}
expect(appearanceState(node).showIndependentCorners.value).toBe(true)
})
test('expands imported unequal corners when the explicit flag is stale', () => {
const node = rectangle()
node.independentCorners = false