feat(editor): add corner smoothing control
- Expose mixed smoothing percentages through Appearance controls - Preserve per-node corner preview values in one-step undo - Cover CanvasKit profiles and .fig import/export with engine and visual tests
This commit is contained in:
parent
2f8d799a5f
commit
1d8ddd951a
|
|
@ -16,6 +16,7 @@
|
|||
- Scale the Layers panel to 5,000-node documents with virtualized rows, indexed updates, scroll-to-selection, range selection, and focus-aware themed states.
|
||||
- Add Figma-style horizontal and vertical constraint controls with pin interactions, mixed-selection editing, undo, and responsive frame resizing.
|
||||
- Add mixed-selection stroke cap, join, and miter-limit controls with CanvasKit rendering and `.fig` roundtrip support.
|
||||
- Add a mixed-selection corner-smoothing percentage control with live preview, per-node undo restoration, and `.fig` roundtrip coverage.
|
||||
- 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.
|
||||
- Add provider-driven BindableValue primitives for variable and token binding, including detach-on-edit, read-only, edit-variable, mixed-value, and undo-batched interactions.
|
||||
|
|
|
|||
|
|
@ -254,7 +254,7 @@ function serializeCornerRadii(node: SceneNode, nc: KiwiNodeChange): void {
|
|||
nc.rectangleBottomLeftCornerRadius = node.bottomLeftRadius
|
||||
nc.rectangleBottomRightCornerRadius = node.bottomRightRadius
|
||||
}
|
||||
if (node.cornerSmoothing > 0) {
|
||||
if (node.cornerSmoothing > 0 || 'cornerSmoothing' in node.source.fig.rawNodeFields) {
|
||||
nc.cornerSmoothing = node.cornerSmoothing
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ Figma's design documentation groups features into these areas:
|
|||
| Effects: shadows and blurs | ✅ | ✅ | ✅ | ✅ | ✅ | `showShadowBehindNode` is rendered but not exposed in UI. |
|
||||
| Effect styles | ↩ | — | — | ↩ | — | Style IDs round-trip; no style manager. |
|
||||
| Corner radius | ✅ | ✅ | ✅ | ✅ | ✅ | Uniform and independent radii supported. |
|
||||
| Corner smoothing | ✅ | ✅ | — | ✅ | ✅ | Figma-style smoothed corners render for common uniform and independent-radius rectangles; exact parity still needs broader fixture tuning. |
|
||||
| Corner smoothing | ✅ | ✅ | ✅ | ✅ | ✅ | The inspector supports mixed smoothing percentages with undo; uniform and independent-radius corners render, while exact Figma parity still needs broader fixture tuning. |
|
||||
| Masks | ✅ | ◐ | — | ✅ | ✅ | Figma schema `mask`, `maskType`, and `maskIsOutline` fields import and export; common sibling alpha/vector/luminance mask stacks render, including consecutive mask layers. UI controls and deeper Figma edge cases remain incomplete. |
|
||||
| Auto layout: vertical/horizontal | ✅ | ✅ | ✅ | ✅ | ✅ | Yoga-backed layout. |
|
||||
| Auto layout: wrap | ✅ | ✅ | ✅ | ✅ | ✅ | UI toggle exists. |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: AppearanceControlsRoot
|
||||
description: Headless root primitive for opacity, visibility, blend mode, and corner-radius controls.
|
||||
description: Headless root primitive for opacity, visibility, blend mode, corner radius, and smoothing controls.
|
||||
---
|
||||
|
||||
<script setup lang="ts">
|
||||
|
|
@ -17,7 +17,11 @@ That state becomes active when the selected node explicitly uses independent cor
|
|||
imported node contains unequal corner values with a stale uniform flag. Consumers should render
|
||||
from this state rather than maintaining a parallel local expansion ref.
|
||||
|
||||
Multi-node independent-corner toggles and per-corner commits are grouped into one undo entry.
|
||||
`cornerSmoothingPercent` exposes the normalized scene value as `0…100` or `MIXED`. Update it
|
||||
through the corner actions using normalized `0…1` values.
|
||||
|
||||
Multi-node independent-corner toggles, smoothing edits, and per-corner commits are grouped into one
|
||||
undo entry while preserving each node's original value.
|
||||
|
||||
## Generated API reference
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: useAppearance
|
||||
description: Control visibility, opacity, and corner radius state for the current selection.
|
||||
description: Control visibility, opacity, corner radius, and smoothing for the current selection.
|
||||
---
|
||||
|
||||
# useAppearance
|
||||
|
|
@ -12,6 +12,7 @@ It exposes selection-derived UI state for:
|
|||
- visibility
|
||||
- opacity
|
||||
- corner radius
|
||||
- corner smoothing as a normalized percentage
|
||||
- independent corner radii, including imported unequal-corner state
|
||||
- blend mode
|
||||
|
||||
|
|
@ -30,6 +31,7 @@ const {
|
|||
visibilityState,
|
||||
opacityPercent,
|
||||
cornerRadiusValue,
|
||||
cornerSmoothingPercent,
|
||||
showIndependentCorners,
|
||||
toggleVisibility,
|
||||
toggleIndependentCorners,
|
||||
|
|
@ -51,6 +53,13 @@ appearance.updateCornerProp('topLeftRadius', 12)
|
|||
appearance.commitCornerProp('topLeftRadius', 12, 8)
|
||||
```
|
||||
|
||||
### Edit corner smoothing
|
||||
|
||||
```ts
|
||||
appearance.updateCornerProp('cornerSmoothing', 0.75)
|
||||
appearance.commitCornerProp('cornerSmoothing', 0.75, 0)
|
||||
```
|
||||
|
||||
Render the per-corner editor from `showIndependentCorners`. It accounts for both the explicit
|
||||
scene-node flag and imported nodes whose corner values differ. Multi-selection toggles and commits
|
||||
are grouped into one undo entry.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { ComputedRef } from 'vue'
|
|||
import type { Editor } from '@open-pencil/core/editor'
|
||||
import type { BlendMode, SceneNode } from '@open-pencil/scene-graph'
|
||||
|
||||
import type { CornerRadiusKey } from '#vue/controls/appearance/types'
|
||||
import type { CornerGeometryKey } from '#vue/controls/appearance/types'
|
||||
import { MIXED, type MixedValue } from '#vue/controls/node-props/use'
|
||||
|
||||
const CORNER_RADIUS_TYPES = new Set([
|
||||
|
|
@ -56,6 +56,11 @@ export function createAppearanceState({ node, nodes, isMulti, merged }: Appearan
|
|||
return node.value?.cornerRadius ?? 0
|
||||
})
|
||||
|
||||
const cornerSmoothingPercent = computed(() => {
|
||||
const value = merged('cornerSmoothing')
|
||||
return value === MIXED ? MIXED : Math.round(Math.max(0, Math.min(value, 1)) * 100)
|
||||
})
|
||||
|
||||
const opacityPercent = computed(() => {
|
||||
const v = merged('opacity')
|
||||
return v === MIXED ? MIXED : Math.round(v * 100)
|
||||
|
|
@ -77,6 +82,7 @@ export function createAppearanceState({ node, nodes, isMulti, merged }: Appearan
|
|||
independentCorners,
|
||||
showIndependentCorners,
|
||||
cornerRadiusValue,
|
||||
cornerSmoothingPercent,
|
||||
opacityPercent,
|
||||
blendModeValue,
|
||||
visibilityState
|
||||
|
|
@ -84,6 +90,8 @@ export function createAppearanceState({ node, nodes, isMulti, merged }: Appearan
|
|||
}
|
||||
|
||||
export function createAppearanceActions({ editor, node, nodes, isMulti }: AppearanceActionOptions) {
|
||||
const previousCornerValues = new Map<CornerGeometryKey, Map<string, number>>()
|
||||
|
||||
function setBlendMode(value: BlendMode) {
|
||||
const selected = node.value
|
||||
const targets = isMulti.value ? nodes.value : []
|
||||
|
|
@ -166,28 +174,40 @@ export function createAppearanceActions({ editor, node, nodes, isMulti }: Appear
|
|||
)
|
||||
}
|
||||
|
||||
function updateCornerProp(key: CornerRadiusKey, value: number) {
|
||||
if (isMulti.value) {
|
||||
for (const n of nodes.value) editor.updateNode(n.id, { [key]: value })
|
||||
} else {
|
||||
const n = node.value
|
||||
if (n) editor.updateNode(n.id, { [key]: value })
|
||||
function cornerTargets() {
|
||||
if (isMulti.value) return nodes.value
|
||||
const selected = node.value
|
||||
return selected ? [selected] : []
|
||||
}
|
||||
|
||||
function updateCornerProp(key: CornerGeometryKey, value: number) {
|
||||
let snapshots = previousCornerValues.get(key)
|
||||
if (!snapshots) {
|
||||
snapshots = new Map()
|
||||
previousCornerValues.set(key, snapshots)
|
||||
}
|
||||
const normalized = key === 'cornerSmoothing' ? Math.max(0, Math.min(value, 1)) : value
|
||||
for (const target of cornerTargets()) {
|
||||
if (!snapshots.has(target.id)) snapshots.set(target.id, target[key])
|
||||
editor.updateNode(target.id, { [key]: normalized })
|
||||
}
|
||||
}
|
||||
|
||||
function commitCornerProp(key: CornerRadiusKey, _value: number, previous: number) {
|
||||
if (isMulti.value) {
|
||||
editor.undo.runBatch(`Change ${key}`, () => {
|
||||
for (const n of nodes.value) {
|
||||
editor.commitNodeUpdate(n.id, { [key]: previous } as Partial<SceneNode>, `Change ${key}`)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const n = node.value
|
||||
if (n) {
|
||||
editor.commitNodeUpdate(n.id, { [key]: previous } as Partial<SceneNode>, `Change ${key}`)
|
||||
function commitCornerProp(key: CornerGeometryKey, _value: number, previous: number) {
|
||||
const targets = cornerTargets()
|
||||
const snapshots = previousCornerValues.get(key)
|
||||
const commit = () => {
|
||||
for (const target of targets) {
|
||||
editor.commitNodeUpdate(
|
||||
target.id,
|
||||
{ [key]: snapshots?.get(target.id) ?? previous } as Partial<SceneNode>,
|
||||
`Change ${key}`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (targets.length > 1) editor.undo.runBatch(`Change ${key}`, commit)
|
||||
else commit()
|
||||
previousCornerValues.delete(key)
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -3,3 +3,5 @@ export type CornerRadiusKey =
|
|||
| 'topRightRadius'
|
||||
| 'bottomRightRadius'
|
||||
| 'bottomLeftRadius'
|
||||
|
||||
export type CornerGeometryKey = CornerRadiusKey | 'cornerSmoothing'
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@
|
|||
"height": "Höhe",
|
||||
"opacity": "Deckkraft",
|
||||
"radius": "Radius",
|
||||
"cornerSmoothing": "Eckenglättung",
|
||||
"spread": "Ausbreitung",
|
||||
"foregroundBlur": "Vordergrund-Weichzeichnung",
|
||||
"strokeType": "Linientyp",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
"height": "Altura",
|
||||
"opacity": "Opacidad",
|
||||
"radius": "Redondeado",
|
||||
"cornerSmoothing": "Suavizado de esquinas",
|
||||
"spread": "Esparcimiento",
|
||||
"fill": "Relleno",
|
||||
"stroke": "Trazo",
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@
|
|||
"height": "Hauteur",
|
||||
"opacity": "Opacité",
|
||||
"radius": "Rayon",
|
||||
"cornerSmoothing": "Lissage des coins",
|
||||
"spread": "Diffusion",
|
||||
"foregroundBlur": "Flou de premier plan",
|
||||
"strokeType": "Type de contour",
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@
|
|||
"height": "Altezza",
|
||||
"opacity": "Opacità",
|
||||
"radius": "Raggio",
|
||||
"cornerSmoothing": "Smussatura degli angoli",
|
||||
"spread": "Diffusione",
|
||||
"foregroundBlur": "Sfocatura primo piano",
|
||||
"strokeType": "Tipo tratto",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
"height": "高さ",
|
||||
"opacity": "不透明度",
|
||||
"radius": "角丸",
|
||||
"cornerSmoothing": "コーナーの滑らかさ",
|
||||
"spread": "スプレッド",
|
||||
"fill": "塗り",
|
||||
"stroke": "線",
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@
|
|||
"height": "Wysokość",
|
||||
"opacity": "Krycie",
|
||||
"radius": "Promień",
|
||||
"cornerSmoothing": "Wygładzanie narożników",
|
||||
"spread": "Rozproszenie",
|
||||
"foregroundBlur": "Rozmycie pierwszego planu",
|
||||
"strokeType": "Typ obrysu",
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@
|
|||
"height": "Высота",
|
||||
"opacity": "Непрозрачность",
|
||||
"radius": "Радиус",
|
||||
"cornerSmoothing": "Сглаживание углов",
|
||||
"spread": "Размах",
|
||||
"foregroundBlur": "Размытие переднего плана",
|
||||
"strokeType": "Тип обводки",
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@
|
|||
"height": "高度",
|
||||
"opacity": "不透明度",
|
||||
"radius": "圆角",
|
||||
"cornerSmoothing": "圆角平滑度",
|
||||
"spread": "扩展",
|
||||
"foregroundBlur": "前景模糊",
|
||||
"strokeType": "描边类型",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export const panelMessageDefaults = {
|
|||
opacity: 'Opacity',
|
||||
blendMode: 'Blend mode',
|
||||
radius: 'Radius',
|
||||
cornerSmoothing: 'Corner smoothing',
|
||||
spread: 'Spread',
|
||||
|
||||
page: 'Page',
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ export {
|
|||
useConstraints
|
||||
} from '#vue/controls/constraints'
|
||||
export type { ConstraintAxis, ConstraintEdge, ConstraintValue } from '#vue/controls/constraints'
|
||||
export type { CornerRadiusKey } from '#vue/controls/appearance/types'
|
||||
export type { CornerGeometryKey, CornerRadiusKey } from '#vue/controls/appearance/types'
|
||||
export { PageListRoot } from '#vue/primitives/PageList'
|
||||
export { PositionControlsRoot } from '#vue/primitives/PositionControls'
|
||||
export { useEditorPropertyList } from '#vue/controls/property-list'
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ const actions = {
|
|||
:independent-corners="ctx.independentCorners.value"
|
||||
:show-independent-corners="ctx.showIndependentCorners.value"
|
||||
:corner-radius-value="ctx.cornerRadiusValue.value"
|
||||
:corner-smoothing-percent="ctx.cornerSmoothingPercent.value"
|
||||
:opacity-percent="ctx.opacityPercent.value"
|
||||
:blend-mode-value="ctx.blendModeValue.value"
|
||||
:visibility-state="ctx.visibilityState.value"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { VNode } from 'vue'
|
|||
|
||||
import type { BlendMode, SceneNode } from '@open-pencil/scene-graph'
|
||||
|
||||
import type { CornerRadiusKey } from '#vue/controls/appearance/types'
|
||||
import type { CornerGeometryKey } from '#vue/controls/appearance/types'
|
||||
import type { MixedValue } from '#vue/controls/node-props/use'
|
||||
|
||||
export interface AppearanceControlsActions {
|
||||
|
|
@ -11,8 +11,8 @@ export interface AppearanceControlsActions {
|
|||
setBlendMode(value: BlendMode): void
|
||||
toggleVisibility(): void
|
||||
toggleIndependentCorners(): void
|
||||
updateCornerProp(key: CornerRadiusKey, value: number): void
|
||||
commitCornerProp(key: CornerRadiusKey, value: number, previous: number): void
|
||||
updateCornerProp(key: CornerGeometryKey, value: number): void
|
||||
commitCornerProp(key: CornerGeometryKey, value: number, previous: number): void
|
||||
}
|
||||
|
||||
export interface AppearanceControlsRootSlotProps {
|
||||
|
|
@ -23,6 +23,7 @@ export interface AppearanceControlsRootSlotProps {
|
|||
independentCorners: MixedValue<boolean>
|
||||
showIndependentCorners: boolean
|
||||
cornerRadiusValue: MixedValue<number>
|
||||
cornerSmoothingPercent: MixedValue<number>
|
||||
opacityPercent: MixedValue<number>
|
||||
blendModeValue: MixedValue<BlendMode>
|
||||
visibilityState: 'visible' | 'hidden' | 'mixed'
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ function blendModeOptions(value: BlendMode | typeof MIXED) {
|
|||
independentCorners,
|
||||
showIndependentCorners,
|
||||
cornerRadiusValue,
|
||||
cornerSmoothingPercent,
|
||||
opacityPercent,
|
||||
blendModeValue,
|
||||
visibilityState,
|
||||
|
|
@ -221,6 +222,28 @@ function blendModeOptions(value: BlendMode | typeof MIXED) {
|
|||
/>
|
||||
<PanelRail />
|
||||
</PanelGrid>
|
||||
|
||||
<PanelGrid v-if="hasCornerRadius" columns="fill" class="mt-panel">
|
||||
<PanelFieldGroup :label="panels.cornerSmoothing">
|
||||
<NumberField
|
||||
suffix="%"
|
||||
:model-value="cornerSmoothingPercent"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:aria-label="panels.cornerSmoothing"
|
||||
data-property="corner-smoothing"
|
||||
@update:model-value="actions.updateCornerProp('cornerSmoothing', $event / 100)"
|
||||
@commit="
|
||||
(v: number, p: number) =>
|
||||
actions.commitCornerProp('cornerSmoothing', v / 100, p / 100)
|
||||
"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-lucide-squircle class="size-3" />
|
||||
</template>
|
||||
</NumberField>
|
||||
</PanelFieldGroup>
|
||||
</PanelGrid>
|
||||
</PanelSection>
|
||||
</AppearanceControlsRoot>
|
||||
</template>
|
||||
|
|
|
|||
60
tests/e2e/canvas/corner-smoothing-visual.spec.ts
Normal file
60
tests/e2e/canvas/corner-smoothing-visual.spec.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures'
|
||||
|
||||
const editor = useEditorSetupWithClear('/?test&no-chrome&no-rulers')
|
||||
|
||||
test('regular partial full and independent corner smoothing', async () => {
|
||||
await editor.page.evaluate(() => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('OpenPencil store not initialized')
|
||||
const pageId = store.state.currentPageId
|
||||
const smoothingValues = [0, 0.5, 1]
|
||||
for (const [index, cornerSmoothing] of smoothingValues.entries()) {
|
||||
store.graph.createNode('RECTANGLE', pageId, {
|
||||
name: `Corner smoothing ${cornerSmoothing}`,
|
||||
x: 70 + index * 180,
|
||||
y: 90,
|
||||
width: 140,
|
||||
height: 120,
|
||||
cornerRadius: 42,
|
||||
cornerSmoothing,
|
||||
fills: [
|
||||
{
|
||||
type: 'SOLID',
|
||||
color: { r: 0.23, g: 0.51, b: 0.96, a: 1 },
|
||||
visible: true,
|
||||
opacity: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
store.graph.createNode('RECTANGLE', pageId, {
|
||||
name: 'Independent smoothed corners',
|
||||
x: 610,
|
||||
y: 90,
|
||||
width: 160,
|
||||
height: 120,
|
||||
independentCorners: true,
|
||||
topLeftRadius: 50,
|
||||
topRightRadius: 20,
|
||||
bottomRightRadius: 50,
|
||||
bottomLeftRadius: 8,
|
||||
cornerSmoothing: 1,
|
||||
fills: [
|
||||
{
|
||||
type: 'SOLID',
|
||||
color: { r: 0.96, g: 0.35, b: 0.12, a: 1 },
|
||||
visible: true,
|
||||
opacity: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
store.clearSelection()
|
||||
store.requestRender()
|
||||
})
|
||||
await editor.canvas.waitForRender()
|
||||
editor.canvas.assertNoErrors()
|
||||
const buffer = await editor.canvas.canvas.screenshot()
|
||||
expect(buffer).toMatchSnapshot('corner-smoothing-profiles.png')
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
103
tests/e2e/properties/corner-smoothing.spec.ts
Normal file
103
tests/e2e/properties/corner-smoothing.spec.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
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
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage()
|
||||
await page.goto('/')
|
||||
canvas = new CanvasHelper(page)
|
||||
await canvas.waitForInit()
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close()
|
||||
})
|
||||
|
||||
async function drawFrame(x: number, y: number) {
|
||||
await canvas.pressKey('f')
|
||||
await canvas.drag(x, y, x + 120, y + 80)
|
||||
await canvas.waitForRender()
|
||||
}
|
||||
|
||||
async function selectedSmoothing() {
|
||||
return page.evaluate(() => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('OpenPencil store not initialized')
|
||||
return [...store.state.selectedIds].map(
|
||||
(id) => store.graph.getNode(id)?.cornerSmoothing ?? null
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function fillSmoothing(value: string) {
|
||||
const section = propertySection(page, 'Appearance')
|
||||
await section.getByRole('spinbutton', { name: 'Corner smoothing' }).focus()
|
||||
const input = section.getByRole('spinbutton', { name: 'Corner smoothing' })
|
||||
await input.fill(value)
|
||||
await input.press('Enter')
|
||||
await canvas.waitForRender()
|
||||
}
|
||||
|
||||
test('shows smoothing for corner-capable nodes and keeps it with independent radii', async () => {
|
||||
await drawFrame(100, 100)
|
||||
const smoothing = page.locator('[data-property="corner-smoothing"]')
|
||||
await expect(smoothing).toBeVisible()
|
||||
|
||||
const independent = propertySection(page, 'Appearance').getByRole('button', {
|
||||
name: 'Independent corner radii'
|
||||
})
|
||||
await independent.click()
|
||||
await canvas.waitForRender()
|
||||
await expect(page.locator('[data-corner-grid]')).toBeVisible()
|
||||
await expect(smoothing).toBeVisible()
|
||||
|
||||
await canvas.pressKey('l')
|
||||
await canvas.drag(300, 100, 420, 160)
|
||||
await canvas.waitForRender()
|
||||
await expect(smoothing).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('updates normalized smoothing and undoes the committed edit', async () => {
|
||||
await drawFrame(100, 240)
|
||||
await page.evaluate(() => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('OpenPencil store not initialized')
|
||||
const id = [...store.state.selectedIds][0]
|
||||
if (id) store.updateNode(id, { cornerRadius: 24 })
|
||||
})
|
||||
await fillSmoothing('75')
|
||||
expect(await selectedSmoothing()).toEqual([0.75])
|
||||
|
||||
await canvas.pressKey('Meta+z')
|
||||
await canvas.waitForRender()
|
||||
expect(await selectedSmoothing()).toEqual([0])
|
||||
})
|
||||
|
||||
test('restores mixed per-node values in one undo step', async () => {
|
||||
await canvas.clearCanvas()
|
||||
await drawFrame(80, 80)
|
||||
await drawFrame(260, 80)
|
||||
await canvas.pressKey('Meta+a')
|
||||
await canvas.waitForRender()
|
||||
await page.evaluate(() => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('OpenPencil store not initialized')
|
||||
const ids = [...store.state.selectedIds]
|
||||
if (ids[0]) store.updateNode(ids[0], { cornerRadius: 20, cornerSmoothing: 0.2 })
|
||||
if (ids[1]) store.updateNode(ids[1], { cornerRadius: 20, cornerSmoothing: 0.8 })
|
||||
})
|
||||
await canvas.waitForRender()
|
||||
|
||||
await fillSmoothing('60')
|
||||
expect(await selectedSmoothing()).toEqual([0.6, 0.6])
|
||||
|
||||
await canvas.pressKey('Meta+z')
|
||||
await canvas.waitForRender()
|
||||
expect(await selectedSmoothing()).toEqual([0.2, 0.8])
|
||||
})
|
||||
40
tests/engine/io/fig/export/corner-smoothing.test.ts
Normal file
40
tests/engine/io/fig/export/corner-smoothing.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
import { sceneNodeToKiwi } from '#core/kiwi/fig/node-change/serialize'
|
||||
|
||||
function serializeCornerSmoothing(value: number, importedValue?: number) {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const node = graph.createNode('RECTANGLE', page.id, { cornerRadius: 24, cornerSmoothing: value })
|
||||
if (importedValue !== undefined) {
|
||||
graph.updateNode(node.id, {
|
||||
source: {
|
||||
...node.source,
|
||||
id: '1:2',
|
||||
fig: {
|
||||
...node.source.fig,
|
||||
rawNodeFields: {
|
||||
...node.source.fig.rawNodeFields,
|
||||
cornerSmoothing: importedValue
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
const current = graph.getNode(node.id)
|
||||
if (!current) throw new Error('Expected rectangle node')
|
||||
return sceneNodeToKiwi(current, { sessionID: 1, localID: 1 }, 0, { value: 2 }, graph, [])[0]
|
||||
.cornerSmoothing
|
||||
}
|
||||
|
||||
describe('Figma corner smoothing export', () => {
|
||||
test('exports normalized smoothing values', () => {
|
||||
expect(serializeCornerSmoothing(0.72)).toBe(0.72)
|
||||
})
|
||||
|
||||
test('overrides stale imported smoothing when edited back to zero', () => {
|
||||
expect(serializeCornerSmoothing(0, 0.72)).toBe(0)
|
||||
})
|
||||
})
|
||||
22
tests/engine/io/fig/import/legacy/corners.test.ts
Normal file
22
tests/engine/io/fig/import/legacy/corners.test.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { importNodeChanges } from '@open-pencil/core'
|
||||
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
|
||||
|
||||
import { canvas, doc, node } from './helpers'
|
||||
|
||||
describe('fig-import: corner geometry', () => {
|
||||
test('imports corner smoothing', () => {
|
||||
const graph = importNodeChanges([
|
||||
doc(),
|
||||
canvas(),
|
||||
node('RECTANGLE', 10, 1, {
|
||||
cornerRadius: 24,
|
||||
cornerSmoothing: 0.72
|
||||
} as Partial<NodeChange>)
|
||||
])
|
||||
const imported = graph.getChildren(graph.getPages()[0].id)[0]
|
||||
expect(imported.cornerRadius).toBe(24)
|
||||
expect(imported.cornerSmoothing).toBe(0.72)
|
||||
})
|
||||
})
|
||||
|
|
@ -2,10 +2,11 @@ import { describe, expect, test } from 'bun:test'
|
|||
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { createEditor } from '@open-pencil/core/editor'
|
||||
import type { SceneNode } from '@open-pencil/scene-graph'
|
||||
import type { MixedValue } from '@open-pencil/vue'
|
||||
import { MIXED, type MixedValue } from '@open-pencil/vue'
|
||||
|
||||
import { createAppearanceState } from '#vue/controls/appearance/helpers'
|
||||
import { createAppearanceActions, createAppearanceState } from '#vue/controls/appearance/helpers'
|
||||
|
||||
import { createRect, firstPageId, makeSceneGraph } from '#tests/helpers/scene'
|
||||
|
||||
|
|
@ -61,4 +62,62 @@ describe('appearance control state', () => {
|
|||
const state = appearanceState(node, true)
|
||||
expect(state.showIndependentCorners.value).toBe(false)
|
||||
})
|
||||
|
||||
test('presents normalized corner smoothing as a percentage', () => {
|
||||
const node = rectangle()
|
||||
node.cornerSmoothing = 0.735
|
||||
const state = appearanceState(node)
|
||||
expect(state.cornerSmoothingPercent.value).toBe(74)
|
||||
})
|
||||
|
||||
test('keeps independent corner preview and undo behavior', () => {
|
||||
const graph = makeSceneGraph()
|
||||
const pageId = firstPageId(graph)
|
||||
const rect = graph.createNode('RECTANGLE', pageId, {
|
||||
independentCorners: true,
|
||||
topLeftRadius: 8
|
||||
})
|
||||
const editor = createEditor({ graph })
|
||||
const actions = createAppearanceActions({
|
||||
editor,
|
||||
node: computed(() => graph.getNode(rect.id) ?? null),
|
||||
nodes: computed(() => []),
|
||||
isMulti: computed(() => false),
|
||||
merged: (key) => graph.getNode(rect.id)?.[key] ?? MIXED
|
||||
})
|
||||
|
||||
actions.updateCornerProp('topLeftRadius', 20)
|
||||
actions.commitCornerProp('topLeftRadius', 20, 8)
|
||||
expect(graph.getNode(rect.id)?.topLeftRadius).toBe(20)
|
||||
editor.undo.undo()
|
||||
expect(graph.getNode(rect.id)?.topLeftRadius).toBe(8)
|
||||
})
|
||||
|
||||
test('restores each mixed smoothing value in one undo step', () => {
|
||||
const graph = makeSceneGraph()
|
||||
const pageId = firstPageId(graph)
|
||||
const first = graph.createNode('RECTANGLE', pageId, { cornerSmoothing: 0.2 })
|
||||
const second = graph.createNode('RECTANGLE', pageId, { cornerSmoothing: 0.8 })
|
||||
const editor = createEditor({ graph })
|
||||
const nodes = computed(() => {
|
||||
const selected = [graph.getNode(first.id), graph.getNode(second.id)]
|
||||
return selected.filter((value): value is SceneNode => value !== undefined)
|
||||
})
|
||||
const actions = createAppearanceActions({
|
||||
editor,
|
||||
node: computed(() => null),
|
||||
nodes,
|
||||
isMulti: computed(() => true),
|
||||
merged: () => MIXED
|
||||
})
|
||||
|
||||
actions.updateCornerProp('cornerSmoothing', 1.4)
|
||||
expect(graph.getNode(first.id)?.cornerSmoothing).toBe(1)
|
||||
expect(graph.getNode(second.id)?.cornerSmoothing).toBe(1)
|
||||
actions.commitCornerProp('cornerSmoothing', 1, 0)
|
||||
|
||||
editor.undo.undo()
|
||||
expect(graph.getNode(first.id)?.cornerSmoothing).toBe(0.2)
|
||||
expect(graph.getNode(second.id)?.cornerSmoothing).toBe(0.8)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue