From 24d13ec591172c8d577d092ca22c0583be7eb0de Mon Sep 17 00:00:00 2001 From: Brian Heston <47367562+bheston@users.noreply.github.com> Date: Wed, 1 Nov 2023 14:05:34 -0700 Subject: [PATCH] Designer: Added feature to create interactive component states (#156) --- package-lock.json | 16 +- .../adaptive-ui-figma-designer/package.json | 2 +- .../src/core/model.ts | 39 ++++- .../src/core/node.ts | 36 ++++- .../src/figma/controller.ts | 23 ++- .../src/figma/main.ts | 5 +- .../src/figma/node.ts | 137 +++++++++++++++++- .../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, 361 insertions(+), 66 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..0d7b8a7c 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"; -/** - * 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. - */ -export const TOOL_PARENT_FILL_COLOR = "tool-parent-fill-color"; +export const AdditionalDataKeys = { + /** + * 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. + */ + 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..4bef6f3c 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,20 @@ 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; + +export type State = "Rest" | "Hover" | "Active" | "Focus" | "Disabled"; + /** * The abstract class the plugin Controller interacts with. * Acts as a basic intermediary for node structure and data storage only. @@ -147,6 +158,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 +250,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..c83b9af5 100644 --- a/packages/adaptive-ui-figma-designer/src/figma/node.ts +++ b/packages/adaptive-ui-figma-designer/src/figma/node.ts @@ -2,9 +2,12 @@ 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, State, StatesState } from "../core/node.js"; import { variantBooleanHelper } from "./utility.js"; +const stateVariant = "State"; +const disabledVariant = "Disabled"; + const SOLID_BLACK: SolidPaint = { type: "SOLID", visible: true, @@ -93,7 +96,9 @@ export class FigmaPluginNode extends PluginNode { public type: string; public name: string; public fillColor: ColorRGBA64 | null; + public states: StatesState; private _node: BaseNode; + private _state: State | null = null; private static NodeCache: Map = new Map(); @@ -216,6 +221,22 @@ 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") { + const disabled: string | null = this._node.variantProperties ? this._node.variantProperties[disabledVariant] : null; + const state: State | null = this._node.variantProperties ? this._node.variantProperties[stateVariant] as State : null; + this._state = disabled === "true" ? "Disabled" : state; + } else if (this._node.type === "INSTANCE") { + const disabled: string | null = this._node.componentProperties[disabledVariant]?.value as string; + const state: State = this._node.componentProperties[stateVariant]?.value as State; + this._state = disabled === "true" ? "Disabled" : state; + } } public static get(node: BaseNode): FigmaPluginNode { @@ -263,6 +284,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 +318,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 +825,107 @@ 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"); + this._node.addComponentProperty(disabledVariant, "VARIANT", "false"); + + // 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, setIndex, setChildren) => { + x = paddingLeft + maxWidth + spacing; + const states: State[] = ["Hover", "Active", "Focus", "Disabled"]; + states.forEach((state, stateIndex) => { + const stateComponent = restComponent.clone() as ComponentNode; + + // Keep the layer order consistent with the layout + if (setIndex < setChildren.length - 1) { + (this._node as ComponentSetNode).insertChild((setIndex * (states.length + 1)) + stateIndex + 1, stateComponent); + } else { + (this._node as ComponentSetNode).appendChild(stateComponent); + } + + // Confusing "API" from Figma here, just rename the layer to adjust variant values: + if (state === "Disabled") { + stateComponent.name = stateComponent.name.replace("Disabled=false", "Disabled=true"); + } else { + 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`