From ecfb90d249b71bee3d21a361d4e4635f6649e427 Mon Sep 17 00:00:00 2001 From: Brian Heston <47367562+bheston@users.noreply.github.com> Date: Sat, 28 Oct 2023 16:21:35 -0700 Subject: [PATCH 1/3] Designer: Added feature to create interactive component states --- package-lock.json | 16 +-- .../adaptive-ui-figma-designer/package.json | 2 +- .../src/core/model.ts | 35 +++++- .../src/core/node.ts | 34 ++++- .../src/figma/controller.ts | 23 +++- .../src/figma/main.ts | 5 +- .../src/figma/node.ts | 119 +++++++++++++++++- .../adaptive-ui-figma-designer/src/ui/app.ts | 86 ++++++++++--- .../src/ui/index.ts | 12 +- .../src/ui/ui-controller-states.ts | 26 ++++ .../src/ui/ui-controller.ts | 45 ++++--- 11 files changed, 339 insertions(+), 64 deletions(-) create mode 100644 packages/adaptive-ui-figma-designer/src/ui/ui-controller-states.ts diff --git a/package-lock.json b/package-lock.json index 2143d84d..8210cf84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3104,9 +3104,9 @@ } }, "node_modules/@figma/plugin-typings": { - "version": "1.58.0", - "resolved": "https://registry.npmjs.org/@figma/plugin-typings/-/plugin-typings-1.58.0.tgz", - "integrity": "sha512-to6hFysqZYACz4VNgaBXflOVS+1pbXGNVcezDmQEMcADDkIZeAZ71Zfqf/B2YDRmU3sM1xX5Q5NkRGhjCuLOLg==", + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@figma/plugin-typings/-/plugin-typings-1.80.0.tgz", + "integrity": "sha512-dosTVp5wj8u0pn5/pP+Ii7u/iurwbhEkT9kNbxkyrRf59cjsfbMKw5i7PyRrOUOyBmsH3edhCfh9jMff7G6tjQ==", "dev": true }, "node_modules/@floating-ui/core": { @@ -31149,7 +31149,7 @@ "@csstools/css-calc": "^1.1.1", "@csstools/css-parser-algorithms": "^2.2.0", "@csstools/css-tokenizer": "^2.1.1", - "@figma/plugin-typings": "^1.58.0", + "@figma/plugin-typings": "^1.80.0", "concurrently": "^7.6.0", "esbuild": "^0.17.10", "rimraf": "^3.0.2", @@ -31768,7 +31768,7 @@ "@csstools/css-calc": "^1.1.1", "@csstools/css-parser-algorithms": "^2.2.0", "@csstools/css-tokenizer": "^2.1.1", - "@figma/plugin-typings": "^1.58.0", + "@figma/plugin-typings": "^1.80.0", "@microsoft/fast-colors": "^5.3.1", "concurrently": "^7.6.0", "esbuild": "^0.17.10", @@ -34101,9 +34101,9 @@ } }, "@figma/plugin-typings": { - "version": "1.58.0", - "resolved": "https://registry.npmjs.org/@figma/plugin-typings/-/plugin-typings-1.58.0.tgz", - "integrity": "sha512-to6hFysqZYACz4VNgaBXflOVS+1pbXGNVcezDmQEMcADDkIZeAZ71Zfqf/B2YDRmU3sM1xX5Q5NkRGhjCuLOLg==", + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@figma/plugin-typings/-/plugin-typings-1.80.0.tgz", + "integrity": "sha512-dosTVp5wj8u0pn5/pP+Ii7u/iurwbhEkT9kNbxkyrRf59cjsfbMKw5i7PyRrOUOyBmsH3edhCfh9jMff7G6tjQ==", "dev": true }, "@floating-ui/core": { diff --git a/packages/adaptive-ui-figma-designer/package.json b/packages/adaptive-ui-figma-designer/package.json index 6c5c6db5..a38632f3 100644 --- a/packages/adaptive-ui-figma-designer/package.json +++ b/packages/adaptive-ui-figma-designer/package.json @@ -47,7 +47,7 @@ "@csstools/css-calc": "^1.1.1", "@csstools/css-parser-algorithms": "^2.2.0", "@csstools/css-tokenizer": "^2.1.1", - "@figma/plugin-typings": "^1.58.0", + "@figma/plugin-typings": "^1.80.0", "concurrently": "^7.6.0", "esbuild": "^0.17.10", "rimraf": "^3.0.2", diff --git a/packages/adaptive-ui-figma-designer/src/core/model.ts b/packages/adaptive-ui-figma-designer/src/core/model.ts index 188a1625..c9bb4912 100644 --- a/packages/adaptive-ui-figma-designer/src/core/model.ts +++ b/packages/adaptive-ui-figma-designer/src/core/model.ts @@ -1,10 +1,29 @@ +import { ValuesOf } from "@microsoft/fast-foundation"; import { StyleProperty } from "@adaptive-web/adaptive-ui"; import { PluginNode } from "./node.js"; +import { SerializableNodeData } from "./serialization.js"; +export const AdditionalDataKeys = { /** - * A key for passing the fill color from the tool to the plugin. Keeping it out of main design tokens to avoid a lot more special handling. + * A key for passing the fill color from the tool to the plugin. + * + * @remarks + * Keeping it out of main design tokens to avoid a lot more special handling. */ -export const TOOL_PARENT_FILL_COLOR = "tool-parent-fill-color"; + toolParentFillColor: "tool-parent-fill-color", + + /** + * The state of interactive state configuration. Applies to component sets. + */ + states: "states", + + /** + * The interactive state of the node. Applies to all nodes. + */ + state: "state", +} as const; + +export type AdditionalDataKeys = ValuesOf; /** * A design token value. @@ -269,3 +288,15 @@ export const pluginNodesToUINodes = ( return convertedNodes; } + +export interface CreateStatesMessage { + readonly type: 'CREATE_STATES'; + id: string; +} + +export interface NodeDataMessage { + readonly type: 'NODE_DATA'; + nodes: SerializableNodeData[]; +} + +export type PluginMessage = CreateStatesMessage | NodeDataMessage; diff --git a/packages/adaptive-ui-figma-designer/src/core/node.ts b/packages/adaptive-ui-figma-designer/src/core/node.ts index 3a10f085..670fbe3b 100644 --- a/packages/adaptive-ui-figma-designer/src/core/node.ts +++ b/packages/adaptive-ui-figma-designer/src/core/node.ts @@ -1,7 +1,9 @@ import { ColorRGBA64 } from "@microsoft/fast-colors"; +import { ValuesOf } from "@microsoft/fast-foundation"; import { StyleProperty } from "@adaptive-web/adaptive-ui"; import { AdditionalData, + AdditionalDataKeys, AppliedDesignTokens, AppliedStyleModules, AppliedStyleValues, @@ -10,11 +12,18 @@ import { ReadonlyAppliedDesignTokens, ReadonlyAppliedStyleModules, ReadonlyDesignTokenValues, - TOOL_PARENT_FILL_COLOR, } from "./model.js"; const DesignTokenCache: Map = new Map(); +export const StatesState = { + notAvailable: "notAvailable", + available: "available", + configured: "configured", +} as const; + +export type StatesState = ValuesOf; + /** * The abstract class the plugin Controller interacts with. * Acts as a basic intermediary for node structure and data storage only. @@ -147,6 +156,16 @@ export abstract class PluginNode { */ public abstract readonly fillColor: ColorRGBA64 | null; + /** + * The state of stateful component capabilities for this node. + */ + public abstract readonly states: StatesState; + + /** + * The interactive state of the node. + */ + public abstract get state(): string | null; + /** * Gets whether this type of node can have children or not. */ @@ -229,10 +248,17 @@ export abstract class PluginNode { * Gets additional data associated with this node. */ public get additionalData(): AdditionalData { - if (!this._additionalData.has(TOOL_PARENT_FILL_COLOR) && this.parent?.fillColor) { - // console.log("PluginNode.get_additionalData - adding:", TOOL_PARENT_FILL_COLOR, this.debugInfo, this.parent?.fillColor.toStringHexARGB()); - this._additionalData.set(TOOL_PARENT_FILL_COLOR, this.parent.fillColor.toStringHexARGB()); + this._additionalData.set(AdditionalDataKeys.states, this.states); + + if (this.state) { + this._additionalData.set(AdditionalDataKeys.state, this.state); } + + if (!this._additionalData.has(AdditionalDataKeys.toolParentFillColor) && this.parent?.fillColor) { + // console.log("PluginNode.get_additionalData - adding:", AdditionalDataKeys.toolParentFillColor, this.debugInfo, this.parent?.fillColor.toStringHexARGB()); + this._additionalData.set(AdditionalDataKeys.toolParentFillColor, this.parent.fillColor.toStringHexARGB()); + } + return this._additionalData; } diff --git a/packages/adaptive-ui-figma-designer/src/figma/controller.ts b/packages/adaptive-ui-figma-designer/src/figma/controller.ts index b880e53e..ed144800 100644 --- a/packages/adaptive-ui-figma-designer/src/figma/controller.ts +++ b/packages/adaptive-ui-figma-designer/src/figma/controller.ts @@ -1,4 +1,5 @@ import { Controller, PluginUIState } from "../core/controller.js"; +import type { PluginMessage } from "../core/model.js"; import { deserializeUINodes, SerializableUIState, serializeUINodes } from "../core/serialization.js"; import { FigmaPluginNode } from "./node.js"; @@ -12,13 +13,22 @@ export class FigmaController extends Controller { } } - public handleMessage(state: SerializableUIState): void { - const pluginNodes = deserializeUINodes(state.selectedNodes); - super.receiveStateFromUI({ - selectedNodes: pluginNodes - }) + public handleMessage(message: PluginMessage): void { + if (message.type === "NODE_DATA") { + const pluginNodes = deserializeUINodes(message.nodes); + super.receiveStateFromUI({ + selectedNodes: pluginNodes + }); - FigmaPluginNode.clearCache(); + FigmaPluginNode.clearCache(); + } else if (message.type === "CREATE_STATES") { + const node = this.getNode(message.id); + // Create the interactive state components + node?.createStates(); + // Resend the nodes to the plugin UI + FigmaPluginNode.clearCache(); + this.setSelectedNodes([message.id]); + } } public sendStateToUI(state: PluginUIState): void { @@ -26,6 +36,7 @@ export class FigmaController extends Controller { selectedNodes: serializeUINodes(state.selectedNodes), }; + // Goes to ../ui/index.ts window.onmessage figma.ui.postMessage(message); } } diff --git a/packages/adaptive-ui-figma-designer/src/figma/main.ts b/packages/adaptive-ui-figma-designer/src/figma/main.ts index 7b1a8b93..35a6f039 100644 --- a/packages/adaptive-ui-figma-designer/src/figma/main.ts +++ b/packages/adaptive-ui-figma-designer/src/figma/main.ts @@ -1,4 +1,4 @@ -import { SerializableUIState } from "../core/serialization.js"; +import { PluginMessage } from "../core/model.js"; import { FigmaController } from "./controller.js"; const controller = new FigmaController(); @@ -62,7 +62,8 @@ function debounceSelection() { figma.on("selectionchange", debounceSelection); -figma.ui.onmessage = (message: SerializableUIState): void => { +// Comes from ../ui/index.ts parent.postMessage +figma.ui.onmessage = (message: PluginMessage): void => { notifyProcessing(() => { controller.handleMessage(message); }); diff --git a/packages/adaptive-ui-figma-designer/src/figma/node.ts b/packages/adaptive-ui-figma-designer/src/figma/node.ts index 13e53562..791bbf87 100644 --- a/packages/adaptive-ui-figma-designer/src/figma/node.ts +++ b/packages/adaptive-ui-figma-designer/src/figma/node.ts @@ -2,9 +2,11 @@ import { ColorRGBA64, parseColor, rgbToRelativeLuminance } from "@microsoft/fast import { StyleProperty } from "@adaptive-web/adaptive-ui"; import { Controller, STYLE_REMOVE } from "../core/controller.js"; import { AppliedDesignTokens, AppliedStyleModules, AppliedStyleValues, DesignTokenValues, PluginNodeData } from "../core/model.js"; -import { PluginNode } from "../core/node.js"; +import { PluginNode, StatesState } from "../core/node.js"; import { variantBooleanHelper } from "./utility.js"; +const stateVariant = "State"; + const SOLID_BLACK: SolidPaint = { type: "SOLID", visible: true, @@ -93,7 +95,9 @@ export class FigmaPluginNode extends PluginNode { public type: string; public name: string; public fillColor: ColorRGBA64 | null; + public states: StatesState; private _node: BaseNode; + private _state: string | null = null; private static NodeCache: Map = new Map(); @@ -216,6 +220,18 @@ export class FigmaPluginNode extends PluginNode { // } this.fillColor = this.getFillColor(); + + this.states = this._node.type === "COMPONENT_SET" ? + this._node.componentPropertyDefinitions[stateVariant] === undefined ? + StatesState.available : + StatesState.configured : + StatesState.notAvailable; + + if (this._node.type === "COMPONENT") { + this._state = this._node.variantProperties ? this._node.variantProperties[stateVariant] : null; + } else if (this._node.type === "INSTANCE") { + this._state = this._node.componentProperties[stateVariant]?.value as string; + } } public static get(node: BaseNode): FigmaPluginNode { @@ -263,6 +279,16 @@ export class FigmaPluginNode extends PluginNode { return value; } + public get state(): string | null { + if (this._state) { + return this._state; + } + if (this.parent) { + return this.parent.state; + } + return null; + } + public get canHaveChildren(): boolean { return canHaveChildren(this._node); } @@ -287,7 +313,6 @@ export class FigmaPluginNode extends PluginNode { switch (key) { case StyleProperty.backgroundFill: return [ - isPageNode, isContainerNode, isShapeNode, ].some((test: (node: BaseNode) => boolean) => test(this._node)); @@ -795,4 +820,94 @@ export class FigmaPluginNode extends PluginNode { (this._node as CornerMixin).cornerRadius = numValue; } } + + public createStates() { + if (this._node.type === "COMPONENT_SET" && this.states === StatesState.available) { + this._node.addComponentProperty(stateVariant, "VARIANT", "Rest"); + + // Lots of numbers to track laying components out in a grid + let paddingTop = 16; + let paddingRight = 16; + let paddingBottom = 16; + let paddingLeft = 16; + let spacing = 16; + + // Turn off auto layout + if (this._node.layoutMode !== "NONE") { + paddingTop = this._node.paddingTop; + paddingRight = this._node.paddingRight; + paddingBottom = this._node.paddingBottom; + paddingLeft = this._node.paddingLeft; + spacing = this._node.itemSpacing; + this._node.layoutMode = "NONE"; + } + + let x = paddingLeft; + let y = paddingTop; + let maxWidth = 0; + + // Initial layout + this._node.children.forEach((componentNode) => { + componentNode.x = x; + componentNode.y = y; + maxWidth = Math.max(maxWidth, componentNode.width); + y += componentNode.height + spacing; + }); + + y = paddingTop; + + // Create states + let hoverComponent: ComponentNode; + + this._node.children.forEach((restComponent) => { + x = paddingLeft + maxWidth + spacing; + ["Hover", "Active", "Focus", "Disabled"].forEach((state) => { + const stateComponent = restComponent.clone() as ComponentNode; + (this._node as ComponentSetNode).appendChild(stateComponent); + // Confusing "API" from Figma here, just rename the layer to adjust variant values: + stateComponent.name = stateComponent.name.replace("Rest", state); + + stateComponent.x = x; + stateComponent.y = y; + + if (state === "Hover") { + // Save it for later + hoverComponent = stateComponent; + } else if (state === "Active") { + (hoverComponent as ComponentNode).reactions = [{ + trigger: { + type: "ON_PRESS", + }, + actions: [{ + type: "NODE", + navigation: "CHANGE_TO", + destinationId: stateComponent.id, + transition: null, + preserveScrollPosition: true, + }] + }]; + } + + x += maxWidth + spacing; + }); + + (restComponent as ComponentNode).reactions = [{ + trigger: { + type: "ON_HOVER", + }, + actions: [{ + type: "NODE", + navigation: "CHANGE_TO", + destinationId: hoverComponent.id, + transition: null, + preserveScrollPosition: true, + }] + }]; + + y += restComponent.height + spacing; + }); + + this._node.resize(x - spacing + paddingRight, y - spacing + paddingBottom); + } + } } diff --git a/packages/adaptive-ui-figma-designer/src/ui/app.ts b/packages/adaptive-ui-figma-designer/src/ui/app.ts index 4be71b14..856eda67 100644 --- a/packages/adaptive-ui-figma-designer/src/ui/app.ts +++ b/packages/adaptive-ui-figma-designer/src/ui/app.ts @@ -12,7 +12,8 @@ import { neutralStrokeReadableRest, neutralStrokeStrongRest } from "@adaptive-web/adaptive-ui/reference"; -import { PluginUINodeData } from "../core/model.js"; +import type { PluginMessage, PluginUINodeData } from "../core/model.js"; +import { StatesState } from "../core/node.js"; import { DesignTokenDefinition } from "../core/registry/design-token-registry.js"; import SubtractIcon from "./assets/subtract.svg"; import { UIController } from "./ui-controller.js"; @@ -165,8 +166,8 @@ const revertLabel = "Remove all plugin data from the current selection."; const footerTemplate = html`