* feat(app): add slot property controls and a shared picker AppPicker is a searchable, grouped list that opens beside the properties panel, built on Reka's popover and listbox so search keeps arrow-key navigation. It has comfortable rows with a thumbnail and description and compact rows for plain names, plus an optional footer action. The slot property row shows whether an instance's slot is Default or Modified, its item count, its limits with a checklist popover, Add instances on AppPicker, and Reset slot and Delete contents. These are presentational; wiring them to the editor follows. Strings are English only until the locale files catch up. * feat(app): open variable, style, and instance-swap choices in the shared picker The variable binding picker, the shared style fields, and instance-swap properties now open AppPicker: a titled panel beside the properties panel with search that keeps arrow-key navigation, a check on the current choice, and footer actions. Variable binding keeps its detach and create-variable actions. Instance-swap choices list the property's preferred components first; instanceSwapOptions keeps the preferred flag it already computed. AppPickerField gives select-shaped fields the same picker with a combobox trigger. * feat(core): edit instance slot content Only an instance's slots take layers now. slotScope classifies a parent as free, a slot of an instance, or the locked rest of an instance. Moves, reorders, layer-panel drops, paste, duplicate, and instance creation claim an untouched slot first, as Figma does on the first edit, and refuse the locked part; drops over it land in the instance's parent. Claiming keeps the layers but unlinks them from the component and moves the instance's overrides on nested instances onto those instances. Reset slot, Delete contents, and Add instance are editor actions, each one undo step that restores the instance's subtree. A canvas drop or reorder that claims a slot undoes together with the move. * feat(app): show and edit slot properties of the selected instance The component properties section lists each slot of the selected instance with its state, item count and limits, and adds instances, resets or clears its content through the editor's slot actions. The slot model lives in the Vue SDK as useSlotProperties. * feat(app): outline slots on the canvas and mark them in the layers panel Hovering or selecting a component, an instance, or a layer inside a slot draws its slots with a dashed pink outline and tints empty ones. Slot frames are selected and hovered in pink and show a dashed-square icon in the layers panel. * test(app): cover slot outlines with canvas snapshots * feat(app): translate slot and picker strings * docs: note slot editing and the shared picker * refactor: share slot test and story setup * refactor(app): name the picker's header prop heading * feat(core): create, configure, and remove slots on main components A frame of a main component becomes a slot through a SLOT property named after it; other sibling layers are first wrapped in an auto layout frame. Slot settings and removal are single undo steps. Instances now follow the component's property bindings on sync, so a slot created or removed on the component reaches existing instances. Slot helpers move to a slots domain folder in Scene Graph and Core. * feat(app): create and configure slots from the menu and properties panel Create slot joins the canvas context menu for layers of a main component. A Slots section on main components and their frames renames slots, sets their description, layer limits, and preferred components, and removes them. * fix: keep slot claims in the same undo step and refuse wraps inside instances Creating an instance and pasting HTML now batch the slot claim with the edit. Undoing an added slot instance restores the previous selection. Grouping or wrapping layers in the locked part of an instance is refused, and a section that cannot move no longer claims a slot. The picker clears its search however it closes, and its close and slot actions labels are translated. * feat(core): create and inspect slots through the plugin API component.createSlot() adds a 100x100 frame named Slot, Slot 2, and so on, bound to a new SLOT property, as live Figma does. Slot frames read type SLOT, resetSlot() restores an instance slot's component content, and limitViolations reports BELOW_MIN, ABOVE_MAX, and HAS_NON_PREFERRED for instance slots. addComponentProperty and editComponentProperty take a description and slotSettings, a cloned slot is a plain frame, and componentPropertyReferences uses property keys in both directions. The limit checks move to Scene Graph so the Vue SDK and the plugin API share them. * fix: keep nested slot content across swaps and read nested instance properties A nested instance points at the instance it was cloned from, so its component properties resolved to nothing. Properties now resolve through those links to the main component, and a swap or variant switch parks the slot content nested instances own and restores it into nested instances of the same names, as live Figma does. Plugin appendChild and insertChild claim the slot they add to and refuse the locked part of an instance with Figma's error. * test(core): record resetSlot on a main component slot; fix the Slots roadmap row * fix(core): refuse deleting layers outside an instance's slots As in Figma, delete and plugin remove() leave an instance's own layers and its slot frames alone, while removing slot content claims the slot in the same undo step as the delete. * test(app): wait for the bulk rename dialog to close before the next shortcut * feat(app): unify add, remove, and settings controls in the properties panel A section's + adds an item and a row's - removes it everywhere: grid tracks and variant properties now follow the fill and effect lists, and the header + of a component set adds Property 1 ready to rename instead of an inline form. Removing a variant is Delete, as for any layer. Row settings share the sliders icon, the variables button opens with its own icon, every icon button requires a label, and the instance header buttons use sentence case. Create Slot joins the app menu. * docs: note the unified properties panel controls * feat(app): animate floating panels alike and share the severity icon Popovers, menus, selects, comboboxes, and pickers now fade and grow in from the side they open on and fade out on close, from one motion preset that respects reduced motion; tooltips keep the tooltip preset. SeverityIcon and its colours move from the design check to shared feedback UI, and slot limits use them, so a broken limit reads like a design-check warning. * docs: note consistent popover and menu animation * feat(app): give every floating panel one surface and close it at once Popovers, menus, selects, comboboxes, pickers, presence cards, chat history, and the issue tooltip share one rounded surface with a 1px ring that outlines it in both themes; one-line tooltips keep a compact shape with the same edge. The select theme's radius and elevation options and local shadow overrides are gone. Panels still grow in from their side but now close immediately: a fading modal menu kept blocking the canvas and shortcuts until it unmounted. * fix(app): keep a reopened context menu open and name option-drag undo Duplicate Closing the canvas menu hands focus back to the canvas; when that landed just after a quick reopen, the new menu closed as focus moved outside it. Shortcuts no longer wait for a panel that is already closing, and an option-drag duplicate that claims a slot is undone as Duplicate rather than Move. * test(app): wait for menus and popovers to close before the next key
191 lines
6.1 KiB
TypeScript
191 lines
6.1 KiB
TypeScript
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
|
|
import { expectDefined } from '#tests/helpers/assert'
|
|
|
|
const editor = useEditorSetup()
|
|
|
|
function getNodeById(id: string) {
|
|
return editor.page.evaluate((nodeId) => {
|
|
const store = window.openPencil?.getStore?.()
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
const n = store.graph.getNode(nodeId)
|
|
if (!n) return null
|
|
return { type: n.type, name: n.name, componentId: n.componentId, childIds: n.childIds }
|
|
}, id)
|
|
}
|
|
|
|
function getSelectedIds() {
|
|
return editor.page.evaluate(() => {
|
|
const store = window.openPencil?.getStore?.()
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
return [...store.state.selectedIds]
|
|
})
|
|
}
|
|
|
|
function getPageChildren() {
|
|
return editor.page.evaluate(() => {
|
|
const store = window.openPencil?.getStore?.()
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
return store.graph.getChildren(store.state.currentPageId).map((n) => ({
|
|
id: n.id,
|
|
type: n.type,
|
|
name: n.name,
|
|
componentId: n.componentId
|
|
}))
|
|
})
|
|
}
|
|
|
|
let componentId: string
|
|
|
|
test('create component from selection (⌘⌥K)', async () => {
|
|
await editor.canvas.drawRect(100, 100, 120, 80)
|
|
await editor.canvas.waitForRender()
|
|
|
|
await editor.page.keyboard.press('Meta+Alt+k')
|
|
await editor.canvas.waitForRender()
|
|
|
|
const ids = await getSelectedIds()
|
|
expect(ids).toHaveLength(1)
|
|
|
|
const selectedId = expectDefined(ids[0], 'selected component id')
|
|
const node = await getNodeById(selectedId)
|
|
expect(node?.type).toBe('COMPONENT')
|
|
componentId = selectedId
|
|
})
|
|
|
|
test('component shows its type icon in the design panel', async () => {
|
|
await expect(editor.page.getByRole('img', { name: 'COMPONENT' })).toBeVisible()
|
|
})
|
|
|
|
test('component visible in layers panel', async () => {
|
|
const layers = editor.page.locator('[data-node-id]')
|
|
const count = await layers.count()
|
|
expect(count).toBeGreaterThan(0)
|
|
|
|
const types = await editor.page.evaluate(() => {
|
|
const store = window.openPencil?.getStore?.()
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
return store.graph.getChildren(store.state.currentPageId).map((n) => n.type)
|
|
})
|
|
expect(types).toContain('COMPONENT')
|
|
})
|
|
|
|
test('create instance from component (context menu)', async () => {
|
|
const children = await getPageChildren()
|
|
const comp = children.find((c) => c.type === 'COMPONENT')
|
|
expect(comp).toBeTruthy()
|
|
|
|
// Use store directly to create instance
|
|
await editor.page.evaluate((compId) => {
|
|
const store = window.openPencil?.getStore?.()
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
store.createInstanceFromComponent(compId, 300, 100)
|
|
}, expectDefined(comp, 'component').id)
|
|
await editor.canvas.waitForRender()
|
|
|
|
const updated = await getPageChildren()
|
|
const instance = updated.find((c) => c.type === 'INSTANCE')
|
|
expect(instance).toBeTruthy()
|
|
expect(instance?.componentId).toBe(expectDefined(comp, 'component').id)
|
|
})
|
|
|
|
test('instance shows INSTANCE type in design panel', async () => {
|
|
const children = await getPageChildren()
|
|
const instance = expectDefined(
|
|
children.find((c) => c.type === 'INSTANCE'),
|
|
'instance node'
|
|
)
|
|
|
|
await editor.page.evaluate((id) => {
|
|
const store = window.openPencil?.getStore?.()
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
store.select([id])
|
|
}, instance.id)
|
|
await editor.canvas.waitForRender()
|
|
|
|
await expect(editor.page.getByRole('img', { name: 'INSTANCE' })).toBeVisible()
|
|
})
|
|
|
|
test('instance has "Go to main component" button', async () => {
|
|
await expect(editor.page.getByRole('button', { name: 'Go to main component' })).toBeVisible()
|
|
})
|
|
|
|
test('instance has "Detach" button', async () => {
|
|
await expect(editor.page.getByRole('button', { name: 'Detach instance' })).toBeVisible()
|
|
})
|
|
|
|
test('modifying component propagates to instance', async () => {
|
|
// Select the component
|
|
await editor.page.evaluate((id) => {
|
|
const store = window.openPencil?.getStore?.()
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
store.select([id])
|
|
}, componentId)
|
|
await editor.canvas.waitForRender()
|
|
|
|
// Change component fill
|
|
await editor.page.evaluate((id) => {
|
|
const store = window.openPencil?.getStore?.()
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
store.updateNodeWithUndo(
|
|
id,
|
|
{
|
|
fills: [
|
|
{
|
|
type: 'SOLID',
|
|
color: { r: 1, g: 0, b: 0, a: 1 },
|
|
opacity: 1,
|
|
visible: true,
|
|
blendMode: 'NORMAL'
|
|
}
|
|
]
|
|
},
|
|
'Change fill'
|
|
)
|
|
}, componentId)
|
|
await editor.canvas.waitForRender()
|
|
|
|
// Check instance got the same fill
|
|
const children = await getPageChildren()
|
|
const instance = expectDefined(
|
|
children.find((c) => c.type === 'INSTANCE'),
|
|
'instance node'
|
|
)
|
|
const instanceNode = await editor.page.evaluate((id) => {
|
|
const store = window.openPencil?.getStore?.()
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
const n = store.graph.getNode(id)
|
|
const child = store.graph.getChildren(id)[0]
|
|
return child ? { fills: child.fills } : { fills: n?.fills ?? [] }
|
|
}, instance.id)
|
|
|
|
expect(instanceNode.fills.length).toBeGreaterThan(0)
|
|
})
|
|
|
|
test('detach instance converts to frame', async () => {
|
|
const children = await getPageChildren()
|
|
const instance = expectDefined(
|
|
children.find((c) => c.type === 'INSTANCE'),
|
|
'instance node'
|
|
)
|
|
|
|
await editor.page.evaluate((id) => {
|
|
const store = window.openPencil?.getStore?.()
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
store.select([id])
|
|
}, instance.id)
|
|
await editor.canvas.waitForRender()
|
|
|
|
await editor.page.evaluate(() => {
|
|
const store = window.openPencil?.getStore?.()
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
store.detachInstance()
|
|
})
|
|
await editor.canvas.waitForRender()
|
|
|
|
const ids = await getSelectedIds()
|
|
const detached = await getNodeById(expectDefined(ids[0], 'detached selected id'))
|
|
expect(detached?.type).toBe('FRAME')
|
|
|
|
editor.canvas.assertNoErrors()
|
|
})
|