refactor: share behaviour control roles through dom-css (#960)
* refactor: share behaviour control roles through dom-css Code export needs the same control model and layer roles preview islands use. They move from packages/vue to packages/dom-css/src/behaviours as behaviourControls and controlRoles, and the layerPath, findLayerByPath and hasBehaviour helpers they need move from Core down to scene-graph, since dom-css cannot depend on Core. Preview behaves the same. * fix(scene-graph): escape layer path names and stop on parent cycles A layer named Wifi#1 or Row/1 did not round-trip through its path, so preview skipped that control or reveal target, and a parent cycle in imported data hung the walk. Names escape % / and #, and the walk goes through closest(), which stops on a cycle.
This commit is contained in:
parent
6a06b31dd8
commit
c47d1b78e6
|
|
@ -18,8 +18,8 @@ export type { PageSnapshot } from './history/snapshot'
|
|||
export { graphFromPageSnapshot } from './history/snapshot-graph'
|
||||
export type { ClipboardPayload, ClipboardSnapshot } from './clipboard/copy'
|
||||
export { resolvePasteTarget } from './clipboard/paste-target'
|
||||
export { hasBehaviour, playIslandRoots } from './play/islands'
|
||||
export { layerPath, resolvePlayState, type InstanceState } from './play/states'
|
||||
export { playIslandRoots } from './play/islands'
|
||||
export { resolvePlayState, type InstanceState } from './play/states'
|
||||
export type { PlayState } from './play/actions'
|
||||
export type { Editor } from './create'
|
||||
export type { VariableTokenFields } from './variables'
|
||||
|
|
|
|||
|
|
@ -1,17 +1,4 @@
|
|||
import {
|
||||
behaviourOwner,
|
||||
readBehaviour,
|
||||
type SceneGraph,
|
||||
type SceneNode
|
||||
} from '@open-pencil/scene-graph'
|
||||
|
||||
/** Whether an instance's main component, or its set, has a behaviour. */
|
||||
export function hasBehaviour(graph: SceneGraph, node: SceneNode): boolean {
|
||||
if (node.type !== 'INSTANCE' || !node.componentId) return false
|
||||
const component = graph.getNode(node.componentId)
|
||||
const owner = component && behaviourOwner(graph, component)
|
||||
return !!owner && !!readBehaviour(owner)
|
||||
}
|
||||
import { hasBehaviour, type SceneGraph, type SceneNode } from '@open-pencil/scene-graph'
|
||||
|
||||
function containsControl(graph: SceneGraph, node: SceneNode): boolean {
|
||||
if (hasBehaviour(graph, node)) return true
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
applyComponentPropertyValue,
|
||||
componentPropertyDefinitions,
|
||||
findLayerByPath,
|
||||
SceneGraph,
|
||||
type SceneNode
|
||||
} from '@open-pencil/scene-graph'
|
||||
|
|
@ -22,37 +23,6 @@ export interface InstanceState {
|
|||
reveal?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A layer's path below a root: the names of the layers down to it, with the position among
|
||||
* same-named siblings when names repeat. It stays the same when a variant switch rebuilds an
|
||||
* instance's layers, so it identifies controls and keeps their DOM in place.
|
||||
*/
|
||||
export function layerPath(graph: SceneGraph, rootId: string, nodeId: string): string {
|
||||
const segments: string[] = []
|
||||
let current = graph.getNode(nodeId)
|
||||
while (current && current.id !== rootId) {
|
||||
const parent = current.parentId ? graph.getNode(current.parentId) : undefined
|
||||
const { id, name } = current
|
||||
const twins = parent ? graph.getChildren(parent.id).filter((child) => child.name === name) : []
|
||||
const index = twins.findIndex((child) => child.id === id)
|
||||
segments.unshift(twins.length > 1 ? `${name}#${index}` : name)
|
||||
current = parent
|
||||
}
|
||||
return segments.join('/')
|
||||
}
|
||||
|
||||
function findByPath(graph: SceneGraph, rootId: string, path: string): SceneNode | undefined {
|
||||
let current = graph.getNode(rootId)
|
||||
for (const segment of path ? path.split('/') : []) {
|
||||
if (!current) return undefined
|
||||
const hash = segment.lastIndexOf('#')
|
||||
const name = hash === -1 ? segment : segment.slice(0, hash)
|
||||
const twins = graph.getChildren(current.id).filter((child) => child.name === name)
|
||||
current = twins.at(hash === -1 ? 0 : Number(segment.slice(hash + 1)))
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
/** The root's layers and every component they show, so instances can switch variant. */
|
||||
function closure(source: SceneGraph, rootId: string): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
|
|
@ -158,7 +128,7 @@ function applyState(
|
|||
reflow(graph, instance.id)
|
||||
}
|
||||
for (const path of state.reveal ?? []) {
|
||||
const layer = findByPath(graph, rootId, path)
|
||||
const layer = findLayerByPath(graph, rootId, path)
|
||||
if (layer && !layer.visible) {
|
||||
graph.updateNode(layer.id, { visible: true })
|
||||
reflow(graph, layer.id)
|
||||
|
|
@ -179,7 +149,7 @@ export function resolvePlayState(
|
|||
const graph = copyGraph(source, closure(source, rootId))
|
||||
const ordered = [...states].sort(([a], [b]) => a.split('/').length - b.split('/').length)
|
||||
for (const [path, state] of ordered) {
|
||||
const instance = findByPath(graph, rootId, path)
|
||||
const instance = findLayerByPath(graph, rootId, path)
|
||||
if (instance?.type === 'INSTANCE') applyState(graph, rootId, instance, state)
|
||||
}
|
||||
computeAllLayouts(graph, rootId)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { createEditor, playIslandRoots, resolvePlayState } from '@open-pencil/core/editor'
|
||||
import {
|
||||
createEditor,
|
||||
emptyBehaviour,
|
||||
instanceMainComponent,
|
||||
layerPath,
|
||||
playIslandRoots,
|
||||
resolvePlayState
|
||||
} from '@open-pencil/core/editor'
|
||||
import { emptyBehaviour, instanceMainComponent, SceneGraph } from '@open-pencil/scene-graph'
|
||||
SceneGraph
|
||||
} from '@open-pencil/scene-graph'
|
||||
|
||||
/** A Switch set (State × Interaction) and a card frame holding an Off instance with a label. */
|
||||
function switchCard() {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ Preview is a per-pane mode (`EditorViewState.play`). It runs controls as real co
|
|||
2. **Shadow DOM.** Each island renders into its own shadow root: app CSS does not reach in, island CSS does not leak out, fonts already loaded by the app apply, and the island moves with the canvas in the same frame. Wheel gestures over an island are passed to the canvas.
|
||||
3. **State to design.** An island keeps each control's state (values, choice, number, typed text, hover, pressed, focus). Core's `resolvePlayState` copies the island's layers into a private graph and shows each instance in its state: the matching variant, boolean properties, and layers a control reveals, such as tab panels. The document is never changed.
|
||||
4. **Design to DOM.** The private graph is projected to DOM through `@open-pencil/dom-css`, the same projection HTML export uses, so island fidelity and export fidelity improve together.
|
||||
5. **DOM to Reka.** Each projected layer has a role: a control's root, a part, a group's item, a tab trigger or panel, or a text input. Roles wrap the layer in its Reka UI primitive with `asChild`, so Reka renders through the designed element. Typing, focus, keyboard navigation, and accessibility are the browser's and Reka's.
|
||||
5. **DOM to Reka.** Each projected layer has a role: a control's root, a part, a group's item, a tab trigger or panel, or a text input. `behaviourControls` and `controlRoles` in `@open-pencil/dom-css` (`packages/dom-css/src/behaviours/`) assign them, so code export reads the same roles. Roles wrap the layer in its Reka UI primitive with `asChild`, so Reka renders through the designed element. Typing, focus, keyboard navigation, and accessibility are the browser's and Reka's.
|
||||
|
||||
Controls and DOM keys use layer paths (names below the island root), not node ids: a variant switch rebuilds an instance's layers with new ids, and a stable path keeps the DOM in place, including a focused input.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { hasBehaviour, layerPath } from '@open-pencil/core/editor'
|
||||
import {
|
||||
behaviourContract,
|
||||
behaviourOwner,
|
||||
behaviourProperties,
|
||||
booleanBinding,
|
||||
findComponentPropertyTargets,
|
||||
hasBehaviour,
|
||||
instanceMainComponent,
|
||||
instanceSlotFrames,
|
||||
layerPath,
|
||||
partBinding,
|
||||
readBehaviour,
|
||||
slotPropertyId,
|
||||
|
|
@ -36,23 +37,23 @@ export interface StatesModel {
|
|||
designedDisabled: boolean
|
||||
}
|
||||
|
||||
/** An instance with a behaviour inside an island, as the island runs it. */
|
||||
/** An instance with a behaviour below a root layer: its values, states, and bound layers. */
|
||||
export interface ControlModel {
|
||||
/** The instance's layer path below the island root. */
|
||||
/** The instance's layer path below the root. */
|
||||
path: string
|
||||
kind: Behaviour['kind']
|
||||
behaviour: Behaviour
|
||||
booleans: Record<string, BooleanModel>
|
||||
states: StatesModel | null
|
||||
/** Each bound part's slot frame, by part id, as a layer path below the island root. */
|
||||
/** Each bound part's slot frame, by part id, as a layer path below the root. */
|
||||
parts: Record<string, string>
|
||||
/** Each bound text value's text layer, by value id, as a layer path below the island root. */
|
||||
/** Each bound text value's text layer, by value id, as a layer path below the root. */
|
||||
texts: Record<string, { path: string; designed: string }>
|
||||
/** The children of the items slot that are controls themselves, in order. */
|
||||
items: ControlModel[]
|
||||
/**
|
||||
* Layers the control decides to show, which the design may hide: tab panels, or a
|
||||
* collapsible's content, as layer paths below the island root.
|
||||
* collapsible's content, as layer paths below the root.
|
||||
*/
|
||||
reveal: string[]
|
||||
/** For tabs, each panel's layer path, in order. */
|
||||
|
|
@ -173,10 +174,11 @@ function controlModel(graph: SceneGraph, rootId: string, instance: SceneNode): C
|
|||
}
|
||||
|
||||
/**
|
||||
* The controls of an island, by layer path, outermost first. Items of a group are listed both
|
||||
* in the group's `items` and on their own, since each keeps its own state.
|
||||
* The controls below a root layer, by layer path, outermost first. Items of a group are listed
|
||||
* both in the group's `items` and on their own, since each keeps its own state. Preview runs
|
||||
* them as live islands; export generates components from them.
|
||||
*/
|
||||
export function islandControls(graph: SceneGraph, rootId: string): Map<string, ControlModel> {
|
||||
export function behaviourControls(graph: SceneGraph, rootId: string): Map<string, ControlModel> {
|
||||
const controls = new Map<string, ControlModel>()
|
||||
const visit = (node: SceneNode) => {
|
||||
if (hasBehaviour(graph, node)) {
|
||||
8
packages/dom-css/src/behaviours/index.ts
Normal file
8
packages/dom-css/src/behaviours/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export {
|
||||
behaviourControls,
|
||||
booleanOf,
|
||||
type BooleanModel,
|
||||
type ControlModel,
|
||||
type StatesModel
|
||||
} from './controls'
|
||||
export { controlRoles, groupOf, type ControlRole } from './roles'
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import type { ControlModel } from './model'
|
||||
import type { ControlModel } from './controls'
|
||||
|
||||
/** What a layer of an island is to the controls in it. */
|
||||
export type IslandRole =
|
||||
/** What a layer below a root is to the controls in it. */
|
||||
export type ControlRole =
|
||||
| { type: 'root'; control: ControlModel }
|
||||
| { type: 'part'; control: ControlModel; part: string }
|
||||
| { type: 'item'; group: ControlModel; control: ControlModel; index: number }
|
||||
|
|
@ -14,8 +14,10 @@ export type IslandRole =
|
|||
* and panels, and the text layers that become inputs. Items of a group take the item role in
|
||||
* place of their root role, since the group drives them.
|
||||
*/
|
||||
export function islandRoles(controls: ReadonlyMap<string, ControlModel>): Map<string, IslandRole> {
|
||||
const roles = new Map<string, IslandRole>()
|
||||
export function controlRoles(
|
||||
controls: ReadonlyMap<string, ControlModel>
|
||||
): Map<string, ControlRole> {
|
||||
const roles = new Map<string, ControlRole>()
|
||||
for (const control of controls.values()) {
|
||||
if (!roles.has(control.path)) roles.set(control.path, { type: 'root', control })
|
||||
for (const [part, path] of Object.entries(control.parts))
|
||||
|
|
@ -34,7 +36,7 @@ export function islandRoles(controls: ReadonlyMap<string, ControlModel>): Map<st
|
|||
|
||||
/** The group a control is an item of, if any. */
|
||||
export function groupOf(
|
||||
roles: ReadonlyMap<string, IslandRole>,
|
||||
roles: ReadonlyMap<string, ControlRole>,
|
||||
control: ControlModel
|
||||
): ControlModel | undefined {
|
||||
const role = roles.get(control.path)
|
||||
|
|
@ -9,6 +9,7 @@ export {
|
|||
} from './tailwind-jsx'
|
||||
export { serializeHTML } from './html'
|
||||
export * from '../tokens'
|
||||
export * from '../behaviours'
|
||||
export type {
|
||||
ExportHTMLBundle,
|
||||
ExportHTMLBundleOptions,
|
||||
|
|
|
|||
61
packages/dom-css/tests/behaviours/controls.test.ts
Normal file
61
packages/dom-css/tests/behaviours/controls.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { behaviourControls, booleanOf, controlRoles } from '#dom-css/behaviours'
|
||||
|
||||
import { emptyBehaviour, SceneGraph, withBehaviour } from '@open-pencil/scene-graph'
|
||||
|
||||
/** A Switch set (State) and a card frame holding an On instance named Wifi. */
|
||||
function switchCard() {
|
||||
const graph = new SceneGraph()
|
||||
const pageId = graph.getPages()[0].id
|
||||
const set = graph.createNode('COMPONENT_SET', pageId, {
|
||||
name: 'Switch',
|
||||
componentPropertyDefinitions: [
|
||||
{
|
||||
id: 'state',
|
||||
name: 'State',
|
||||
type: 'VARIANT',
|
||||
defaultValue: 'Off',
|
||||
variantOptions: ['Off', 'On']
|
||||
}
|
||||
]
|
||||
})
|
||||
graph.createNode('COMPONENT', set.id, {
|
||||
name: 'State=Off',
|
||||
componentPropertyValues: { State: 'Off' }
|
||||
})
|
||||
const on = graph.createNode('COMPONENT', set.id, {
|
||||
name: 'State=On',
|
||||
componentPropertyValues: { State: 'On' }
|
||||
})
|
||||
graph.updateNode(set.id, {
|
||||
pluginData: withBehaviour(set, {
|
||||
...emptyBehaviour('switch'),
|
||||
booleans: { value: { propertyId: 'state', on: 'On', off: 'Off' } }
|
||||
})
|
||||
})
|
||||
const card = graph.createNode('FRAME', pageId, { name: 'Card' })
|
||||
const instance = graph.createInstance(on.id, card.id)
|
||||
if (!instance) throw new Error('No instance')
|
||||
graph.updateNode(instance.id, { name: 'Wifi' })
|
||||
return { graph, card }
|
||||
}
|
||||
|
||||
describe('behaviour controls', () => {
|
||||
test('finds the controls below a root by layer path with the values their design shows', () => {
|
||||
const { graph, card } = switchCard()
|
||||
const controls = behaviourControls(graph, card.id)
|
||||
expect([...controls.keys()]).toEqual(['Wifi'])
|
||||
const control = controls.get('Wifi')
|
||||
expect(control?.kind).toBe('switch')
|
||||
expect(control && booleanOf(control, 'value')?.designed).toBe(true)
|
||||
expect(control && booleanOf(control, 'open')).toBeUndefined()
|
||||
})
|
||||
|
||||
test('gives a control root its role by layer path', () => {
|
||||
const { graph, card } = switchCard()
|
||||
const roles = controlRoles(behaviourControls(graph, card.id))
|
||||
expect(roles.get('Wifi')?.type).toBe('root')
|
||||
expect(roles.size).toBe(1)
|
||||
})
|
||||
})
|
||||
51
packages/scene-graph/src/behaviours/layers.ts
Normal file
51
packages/scene-graph/src/behaviours/layers.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import type { SceneGraph } from '../index'
|
||||
import type { SceneNode } from '../types'
|
||||
import { behaviourOwner, readBehaviour } from './model'
|
||||
|
||||
/** Whether an instance's main component, or its set, has a behaviour. */
|
||||
export function hasBehaviour(graph: SceneGraph, node: SceneNode): boolean {
|
||||
if (node.type !== 'INSTANCE' || !node.componentId) return false
|
||||
const component = graph.getNode(node.componentId)
|
||||
const owner = component && behaviourOwner(graph, component)
|
||||
return !!owner && !!readBehaviour(owner)
|
||||
}
|
||||
|
||||
/** A name as a path segment: `/` separates segments and `#` the twin index, so both are escaped. */
|
||||
const pathName = (name: string) => name.replace(/[%/#]/g, (char) => encodeURIComponent(char))
|
||||
|
||||
/**
|
||||
* A layer's path below a root: the names of the layers down to it, with the position among
|
||||
* same-named siblings when names repeat. It stays the same when a variant switch rebuilds an
|
||||
* instance's layers, so it identifies controls and keeps their DOM in place.
|
||||
*/
|
||||
export function layerPath(graph: SceneGraph, rootId: string, nodeId: string): string {
|
||||
const segments: string[] = []
|
||||
// `closest` stops on a parent cycle in bad data, where a hand-written walk would not.
|
||||
graph.closest(nodeId, (node) => {
|
||||
if (node.id === rootId) return true
|
||||
const twins = node.parentId
|
||||
? graph.getChildren(node.parentId).filter((child) => child.name === node.name)
|
||||
: []
|
||||
const index = twins.findIndex((child) => child.id === node.id)
|
||||
segments.unshift(twins.length > 1 ? `${pathName(node.name)}#${index}` : pathName(node.name))
|
||||
return false
|
||||
})
|
||||
return segments.join('/')
|
||||
}
|
||||
|
||||
/** The layer at `path` below a root, the inverse of `layerPath`. */
|
||||
export function findLayerByPath(
|
||||
graph: SceneGraph,
|
||||
rootId: string,
|
||||
path: string
|
||||
): SceneNode | undefined {
|
||||
let current = graph.getNode(rootId)
|
||||
for (const segment of path ? path.split('/') : []) {
|
||||
if (!current) return undefined
|
||||
const hash = segment.lastIndexOf('#')
|
||||
const name = hash === -1 ? segment : segment.slice(0, hash)
|
||||
const twins = graph.getChildren(current.id).filter((child) => pathName(child.name) === name)
|
||||
current = twins.at(hash === -1 ? 0 : Number(segment.slice(hash + 1)))
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ export * from './slots/content'
|
|||
export * from './slots/authoring'
|
||||
export * from './slots/limits'
|
||||
export * from './behaviours/kinds'
|
||||
export * from './behaviours/layers'
|
||||
export * from './behaviours/model'
|
||||
export * from './behaviours/spec'
|
||||
export * from './copy'
|
||||
|
|
|
|||
29
packages/scene-graph/tests/behaviours/layers.test.ts
Normal file
29
packages/scene-graph/tests/behaviours/layers.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { findLayerByPath, layerPath, SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
describe('layer paths', () => {
|
||||
test('round-trip names that contain the path separators', () => {
|
||||
const graph = new SceneGraph()
|
||||
const root = graph.createNode('FRAME', graph.getPages()[0].id, { name: 'Card' })
|
||||
const group = graph.createNode('FRAME', root.id, { name: 'Row/1' })
|
||||
for (const name of ['Wifi#1', '50%', 'Label', 'Label']) {
|
||||
const node = graph.createNode('FRAME', group.id, { name })
|
||||
const path = layerPath(graph, root.id, node.id)
|
||||
expect(findLayerByPath(graph, root.id, path)?.id).toBe(node.id)
|
||||
}
|
||||
expect(layerPath(graph, root.id, group.id)).toBe('Row%2F1')
|
||||
})
|
||||
|
||||
test('stops on a parent cycle in bad data', () => {
|
||||
const graph = new SceneGraph()
|
||||
const pageId = graph.getPages()[0].id
|
||||
const root = graph.createNode('FRAME', pageId, { name: 'Card' })
|
||||
const a = graph.createNode('FRAME', pageId, { name: 'A' })
|
||||
const b = graph.createNode('FRAME', a.id, { name: 'B' })
|
||||
// Imported data can point parents at each other; the walk must still end.
|
||||
const loose = graph.getNode(a.id)
|
||||
if (loose) loose.parentId = b.id
|
||||
expect(typeof layerPath(graph, root.id, b.id)).toBe('string')
|
||||
})
|
||||
})
|
||||
|
|
@ -18,6 +18,7 @@ The app's `DesignPanel` retains at most one selection-property subtree through `
|
|||
## Preview islands
|
||||
|
||||
- Preview runs controls as real components, never as canvas simulations: `packages/vue/src/canvas/islands/` renders each top-level layer that holds an instance with a behaviour as DOM in a shadow root over the canvas, projected through `@open-pencil/dom-css`, and mounts each behaviour's Reka UI primitives on its layers. Typing, focus, and keyboard handling belong to the browser and Reka.
|
||||
- Islands only map layers to Reka components. Which layer is a control, part, item, trigger, panel, or input comes from `behaviourControls` and `controlRoles` in `@open-pencil/dom-css`, which code export shares; never derive roles in Vue (`packages/dom-css/tests/behaviours/controls.test.ts`).
|
||||
- The concept, the pipeline from state to DOM, and how to add a kind are in `packages/docs/development/behaviours-and-preview.md`.
|
||||
- Variants draw control states: Core's `resolvePlayState` shows instances in a state on a private graph, and controls are keyed by layer path so a variant switch keeps their DOM, including a focused input. The document is never changed (`packages/core/tests/editor/play/states.test.ts`, `tests/e2e/components/behaviours.spec.ts`).
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,15 @@
|
|||
import { computed, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import { resolvePlayState } from '@open-pencil/core/editor'
|
||||
import { sceneNodeToDesignDocument } from '@open-pencil/dom-css/export'
|
||||
import {
|
||||
behaviourControls,
|
||||
controlRoles,
|
||||
sceneNodeToDesignDocument
|
||||
} from '@open-pencil/dom-css/export'
|
||||
|
||||
import { useEditor } from '#vue/editor/context'
|
||||
|
||||
import { islandControls } from './model'
|
||||
import { renderNode } from './render'
|
||||
import { islandRoles } from './roles'
|
||||
import { useShadowContainer } from './shadow'
|
||||
import { createIslandState, instanceStates } from './state'
|
||||
|
||||
|
|
@ -30,9 +32,9 @@ const container = useShadowContainer(useTemplateRef<HTMLElement>('host'), ISLAND
|
|||
|
||||
const controls = computed(() => {
|
||||
void editor.state.sceneVersion
|
||||
return islandControls(editor.graph, rootId)
|
||||
return behaviourControls(editor.graph, rootId)
|
||||
})
|
||||
const roles = computed(() => islandRoles(controls.value))
|
||||
const roles = computed(() => controlRoles(controls.value))
|
||||
const state = shallowRef(createIslandState(controls.value))
|
||||
watch(
|
||||
() => [rootId, revision, [...controls.value.keys()].join('\n')],
|
||||
|
|
|
|||
|
|
@ -6,11 +6,10 @@ import {
|
|||
} from 'reka-ui'
|
||||
import { h, type VNode } from 'vue'
|
||||
|
||||
import { booleanOf, type ControlModel, type ControlRole } from '@open-pencil/dom-css/export'
|
||||
import { numberSettings } from '@open-pencil/scene-graph'
|
||||
|
||||
import { booleanOf, type ControlModel } from '#vue/canvas/islands/model'
|
||||
import type { ElementOverride, IslandRenderContext } from '#vue/canvas/islands/render'
|
||||
import type { IslandRole } from '#vue/canvas/islands/roles'
|
||||
|
||||
import { disabled, interaction, stateOf } from './shared'
|
||||
|
||||
|
|
@ -53,7 +52,7 @@ export function fieldRoot(island: IslandRenderContext, control: ControlModel, ba
|
|||
/** The text layer of a field, as the input it is, or a number field's stepper part. */
|
||||
export function fieldRole(
|
||||
island: IslandRenderContext,
|
||||
role: IslandRole,
|
||||
role: ControlRole,
|
||||
base: Base
|
||||
): VNode | undefined {
|
||||
if (role.type === 'part' && role.control.kind === 'numberField') {
|
||||
|
|
|
|||
|
|
@ -28,12 +28,10 @@ import {
|
|||
} from 'reka-ui'
|
||||
import { h, type Component, type VNode } from 'vue'
|
||||
|
||||
import type { DesignElement } from '@open-pencil/dom-css/export'
|
||||
import { groupOf, type ControlRole, type DesignElement } from '@open-pencil/dom-css/export'
|
||||
import { numberSettings } from '@open-pencil/scene-graph'
|
||||
|
||||
import type { ElementOverride, IslandRenderContext } from '#vue/canvas/islands/render'
|
||||
import type { IslandRole } from '#vue/canvas/islands/roles'
|
||||
import { groupOf } from '#vue/canvas/islands/roles'
|
||||
|
||||
import { fieldRole, fieldRoot } from './fields'
|
||||
import { chooseItem, disabled, interaction, stateOf, type RoleContext } from './shared'
|
||||
|
|
@ -45,7 +43,7 @@ function asChild(component: Component, props: Record<string, unknown>, child: ()
|
|||
|
||||
const ON_OFF: Record<string, Component> = { switch: SwitchRoot, toggle: Toggle }
|
||||
|
||||
function root({ island, role, base }: RoleContext<Extract<IslandRole, { type: 'root' }>>): VNode {
|
||||
function root({ island, role, base }: RoleContext<Extract<ControlRole, { type: 'root' }>>): VNode {
|
||||
const { control } = role
|
||||
const state = stateOf(island, control)
|
||||
const off = disabled(island, control)
|
||||
|
|
@ -170,7 +168,7 @@ function groupRoot(
|
|||
}
|
||||
|
||||
/** An item of a group: a radio, a toggle, or a collapsible inside an accordion. */
|
||||
function item({ island, role, base }: RoleContext<Extract<IslandRole, { type: 'item' }>>): VNode {
|
||||
function item({ island, role, base }: RoleContext<Extract<ControlRole, { type: 'item' }>>): VNode {
|
||||
const state = stateOf(island, role.control)
|
||||
const value = String(role.index)
|
||||
const pointer = interaction(state)
|
||||
|
|
@ -187,7 +185,7 @@ const SLIDER_PARTS: Record<string, { component: Component; omit: readonly string
|
|||
thumb: { component: SliderThumb, omit: ['left', 'transform'] }
|
||||
}
|
||||
|
||||
function part({ island, role, base }: RoleContext<Extract<IslandRole, { type: 'part' }>>): VNode {
|
||||
function part({ island, role, base }: RoleContext<Extract<ControlRole, { type: 'part' }>>): VNode {
|
||||
const { control } = role
|
||||
const sliderPart = control.kind === 'slider' ? SLIDER_PARTS[role.part] : undefined
|
||||
if (sliderPart) return asChild(sliderPart.component, {}, () => base({ omit: sliderPart.omit }))
|
||||
|
|
@ -209,7 +207,7 @@ function part({ island, role, base }: RoleContext<Extract<IslandRole, { type: 'p
|
|||
/** A collapsible's trigger and content, or an accordion item's when it is one. */
|
||||
function disclosurePart(
|
||||
island: IslandRenderContext,
|
||||
role: Extract<IslandRole, { type: 'part' }>,
|
||||
role: Extract<ControlRole, { type: 'part' }>,
|
||||
base: RoleContext['base']
|
||||
): VNode {
|
||||
const inAccordion = groupOf(island.roles, role.control)?.kind === 'accordion'
|
||||
|
|
@ -227,7 +225,7 @@ function disclosurePart(
|
|||
/** Render a layer with the role it has for the controls of its island. */
|
||||
export function wrapRole(
|
||||
island: IslandRenderContext,
|
||||
role: IslandRole,
|
||||
role: ControlRole,
|
||||
element: DesignElement,
|
||||
base: (override?: ElementOverride) => VNode
|
||||
): VNode {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
import type { VNode } from 'vue'
|
||||
|
||||
import type { DesignElement } from '@open-pencil/dom-css/export'
|
||||
import type { ControlModel, ControlRole, DesignElement } from '@open-pencil/dom-css/export'
|
||||
|
||||
import type { ControlModel } from '#vue/canvas/islands/model'
|
||||
import type { ElementOverride, IslandRenderContext } from '#vue/canvas/islands/render'
|
||||
import type { IslandRole } from '#vue/canvas/islands/roles'
|
||||
import { isDisabled, type ControlState } from '#vue/canvas/islands/state'
|
||||
|
||||
/** What a role's wrapper gets: the island, the layer, and how to render it as designed. */
|
||||
export interface RoleContext<R extends IslandRole = IslandRole> {
|
||||
export interface RoleContext<R extends ControlRole = ControlRole> {
|
||||
island: IslandRenderContext
|
||||
role: R
|
||||
element: DesignElement
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import { h, type VNode } from 'vue'
|
||||
|
||||
import { layerPath } from '@open-pencil/core/editor'
|
||||
import type { DesignElement, DesignNode } from '@open-pencil/dom-css/export'
|
||||
import type { SceneGraph } from '@open-pencil/scene-graph'
|
||||
import type {
|
||||
ControlModel,
|
||||
ControlRole,
|
||||
DesignElement,
|
||||
DesignNode
|
||||
} from '@open-pencil/dom-css/export'
|
||||
import { layerPath, type SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
import { wrapRole } from './controls'
|
||||
import type { ControlModel } from './model'
|
||||
import type { IslandRole } from './roles'
|
||||
import type { IslandState } from './state'
|
||||
|
||||
export interface IslandRenderContext {
|
||||
|
|
@ -14,7 +16,7 @@ export interface IslandRenderContext {
|
|||
graph: SceneGraph
|
||||
rootId: string
|
||||
controls: ReadonlyMap<string, ControlModel>
|
||||
roles: ReadonlyMap<string, IslandRole>
|
||||
roles: ReadonlyMap<string, ControlRole>
|
||||
state: IslandState
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { reactive } from 'vue'
|
||||
|
||||
import type { InstanceState } from '@open-pencil/core/editor'
|
||||
import { booleanOf, type ControlModel } from '@open-pencil/dom-css/export'
|
||||
import { numberSettings, type InteractionState } from '@open-pencil/scene-graph'
|
||||
|
||||
import { booleanOf, type ControlModel } from './model'
|
||||
|
||||
/** What one control of an island holds while it runs. */
|
||||
export interface ControlState {
|
||||
booleans: Partial<Record<string, boolean>>
|
||||
|
|
|
|||
Loading…
Reference in a new issue