diff --git a/developer-knowledgebase/async-message-channel.md b/developer-knowledgebase/async-message-channel.md new file mode 100644 index 000000000..05b45c967 --- /dev/null +++ b/developer-knowledgebase/async-message-channel.md @@ -0,0 +1,147 @@ +# AsyncMessageChannel + +## `AsyncMessageChannel` Data Flow + +```mermaid +graph TD + A["Figma Plugin Controller\n(Sandbox Env)\ncontroller.ts"] + B[AsyncMessageChannel\nPluginInstance\nEnvironment.CONTROLLER] + C[Figma Plugin UI\nUI entrypoint\napp/index.tsx] + D[AsyncMessageChannel\nReactInstance\nEnvironment.UI] + E[Web Browser Preview\nhttp://localhost:9000] + F[AsyncMessageChannel\nReactInstance\nEnvironment.BROWSER] + + A -->|"PluginInstance.connect()"| B + B -->|"PluginInstance.handle(...)"| A + B -->|"ReactInstance.connect()"| C + C -->|"ReactInstance.handle(...)"| B + C -->|"sendMessageToUi\n(figma.ui.postMessage(...))"| D + D -->|"sendMessageToController\n(parent.postMessage({ pluginMessage: {...} }))"| C + D -->|"ReactInstance.connect()"| E + E -->|"ReactInstance.handle(...)"| D + E -->|"sendMessageToBrowser\n(ws.send(...))"| F + F -->|"sendMessageFromBrowser\n(ws.send(...))"| E +``` + +## Instances + +Static instances of `AsyncMessageChannel` are initialised when the class is loaded: + +- `PluginInstance` - used from inside of `controller` entrypoint +- `ReactInstance` - used from inside of `ui` entrypoint + +```ts +class AsyncMessageChannel { + public static PluginInstance: AsyncMessageChannel = new AsyncMessageChannel(true); + public static ReactInstance: AsyncMessageChannel = new AsyncMessageChannel(false); + + protected inFigmaSandbox = false; + + constructor(inFigmaSandbox: boolean) { + this.inFigmaSandbox = inFigmaSandbox + } +} + +``` + +- + +## Environments + +There are currently three environments: + +```ts +enum Environment { + PLUGIN = 'PLUGIN', + UI = 'UI', + BROWSER = 'BROWSER', +``` + +- `Environment.PLUGIN` – `controller` entrypoint +- `Environment.UI` – `ui` entrypoint + - Has access to `parent.postMessage` +- `Environment.BROWSER` – `ui` entrypoint + - Need to use WebSockets to send messages to the plugin `ui` + +## Lifecycle + +**`.connect()`** + +Example: `AsyncMessageChannel.PluginInstance.connect();` or `AsyncMessageChannel.ReactInstance.connect();` + +If in a web preview environment (`Environment.BROWSER` or `Environment.UI`), a WebSocket client listener is registered here (`this.startWebSocketConnection();`) + +Registers message listeners with `this.attachMessageListener(callback)`, where `callback` in this case is [`this.onMessageEvent`](#onmessageeventmsg) + +**`.attachMessageListener(callback)`** + +Conditionally registers message event handlers depending on the environment: + +- `Environment.CONTROLLER` + - `figma.ui.on('message', listener)` +- `Environment.UI` + - `window.addEventListener('message', listener)` – listens to messages controller + - *IF process.env.PREVIEW_MODE IS SET* + - `this.ws?.addEventListener('message', listener)` + - Where if this condition is true, `UI` has two message listeners, one to listen +- `Environment.CONTROLLER` + - `this.ws?.addEventListener('message', listener)` + +Where `listener` is a function that is wrapping `callback`: + +### `.onMessageEvent(msg)` + +If the environment is preview, and message is not async, the UI environment will forward the message to the browser. Else, non async messages are discarded with `return;` + +Next, if the environment is `UI` and `PREVIEW_MODE` is truthy, the message is forwarded via WebSockets to the browser, or to the controller. + +Then the handler is called; the function is retrieved from `$handlers[msg.message.type]`. + +The result of the handler function is `await`ed and a message is sent back to the source with the same message type, and the payload from the handler function result. + +## Message Handling + +`AsyncMessageChannel` handles messages with `.message` to send messages and receives messages (say in a different instance) by registering a handler with `.handle()`. Each handler is stored in the class/instance in `$handlers`, keyed by the message type. + +Example: `AsyncMessageChannel.ReactInstance.handle(AsyncMessageTypes.STARTUP, asyncHandlers.startup)`. + +### Startup Process + +**`controller.ts`** + +```ts +AsyncMessageChannel.PluginInstance.connect(); +``` + + +**`init.ts`** + +```ts +// Creating the plugin UI instance (AsyncMessageChannel.ReactInstance) +figma.showUI(__html__, { + themeColors: true, + width: params.settings.width ?? DefaultWindowSize.width, + height: params.settings.height ?? DefaultWindowSize.height, +}); + +// +await AsyncMessageChannel.PluginInstance.message({ + type: AsyncMessageTypes.STARTUP, + ...params, +}); +``` + +**`asyncMessageHandlers/startup.tsx` / `StartupApp`** + +```tsx + useEffect(() => { + AsyncMessageChannel.ReactInstance.handle(AsyncMessageTypes.STARTUP, async (startupParams) => { + setParams(startupParams); + }); + + return () => { + AsyncMessageChannel.ReactInstance.handle(AsyncMessageTypes.STARTUP, (() => {}) as any); + }; + }, []); +``` + diff --git a/developer-knowledgebase/web-preview.md b/developer-knowledgebase/web-preview.md new file mode 100644 index 000000000..58d7321b0 --- /dev/null +++ b/developer-knowledgebase/web-preview.md @@ -0,0 +1,21 @@ +# Web Preview + +## Getting Started + +1. Open two Terminal windows/tabs + + > Terminal 1 (Plugin) + + ```sh + npm run preview:plugin + ``` + + > Terminal 2 (Browser) + + ```sh + npm run preview:browser + ``` + + + + diff --git a/packages/tokens-studio-for-figma/e2e/example.spec.ts b/packages/tokens-studio-for-figma/e2e/example.spec.ts new file mode 100644 index 000000000..f4a629598 --- /dev/null +++ b/packages/tokens-studio-for-figma/e2e/example.spec.ts @@ -0,0 +1,18 @@ +import { test, expect } from '@playwright/test'; + +test('has title', async ({ page }) => { + await page.goto('http://localhost:58630/#theme=system&tab=start&action=STARTUP.default'); + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/Preview/); +}); + +// test('get started link', async ({ page }) => { +// await page.goto('https://playwright.dev/'); + +// // Click the get started link. +// await page.getByRole('link', { name: 'Get started' }).click(); + +// // Expects page to have a heading with the name of Installation. +// await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); +// }); diff --git a/packages/tokens-studio-for-figma/e2e/support/commands.ts b/packages/tokens-studio-for-figma/e2e/support/commands.ts new file mode 100644 index 000000000..d16ce0d7a --- /dev/null +++ b/packages/tokens-studio-for-figma/e2e/support/commands.ts @@ -0,0 +1,18 @@ +import { Page } from '@playwright/test'; + +async function startup(page, params) { + await page.evaluate((params) => { + const message = { + pluginMessage: { + id: 'startup', + message: { + type: 'AsyncMessageTypes.STARTUP', // Replace with the actual string value of AsyncMessageTypes.STARTUP if needed + ...params, + }, + }, + }; + window.postMessage(message, '*'); + }, params); +} + +export default startup; diff --git a/packages/tokens-studio-for-figma/e2e/support/mockEnv.ts b/packages/tokens-studio-for-figma/e2e/support/mockEnv.ts new file mode 100644 index 000000000..8e95243b2 --- /dev/null +++ b/packages/tokens-studio-for-figma/e2e/support/mockEnv.ts @@ -0,0 +1,201 @@ +import { test, expect } from '@playwright/test'; + +const MockEnv = async (context) => { + await context.route('http://localhost:58630/six7/user', route => { + route.fulfill({ + status: 200, + contentType: 'application/json', + json: { + type: 'User', + id: 1000, + login: 'six7', + name: 'Jan', + email: 'example@domain.com', + }, + }); + }); + + await context.route('https://api-eu.mixpanel.com/**', route => { + route.fulfill({ + status: 200, + contentType: 'application/json', + json: { success: true }, + }); + }); + + await context.route('https://api.storyblok.com/**', route => { + route.fulfill({ + status: 200, + contentType: 'application/json', + json: { success: true }, + }); + }); + + await context.route('https://app.launchdarkly.com/**', route => { + route.fulfill({ + status: 200, + contentType: 'application/json', + json: { success: true }, + }); + }); + + await context.route('https://events.launchdarkly.com/**', route => { + route.fulfill({ + status: 200, + contentType: 'application/json', + json: { success: true }, + }); + }); + + await context.route('**/get-license*', route => { + route.fulfill({ + status: 200, + contentType: 'application/json', + json: { plan: 'pro' }, + }); + }); + + await context.route('**/validate-license*', route => { + route.fulfill({ + status: 200, + contentType: 'application/json', + json: { plan: 'pro' }, + }); + }); + + await context.route('http://localhost:58630/six7/repos/122/figma-tokens/collaborators/six7/permission', route => { + route.fulfill({ + status: 200, + contentType: 'application/json', + json: { + permission: 'admin', + role_name: 'admin', + }, + }); + }); + + await context.route('http://localhost:58630/six7/repos/122/figma-tokens/contents/tokens.json?ref=main', route => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + global: { + red: { + type: 'color', + name: 'red', + value: '#ff0000', + }, + black: { + type: 'color', + name: 'black', + value: '#000000', + }, + }, + $themes: [ + { + id: 'light', + name: 'Light', + selectedTokenSets: { + global: 'enabled', + }, + }, + ], + }), + }); + }); + + await context.route('http://localhost:58630/six7/repos/122/figma-tokens/branches?per_page=30', route => { + route.fulfill({ + status: 200, + contentType: 'application/json', + json: [ + { name: 'main' }, + { name: 'development' }, + ], + }); + }); + + await context.route('http://localhost:58630/six7/repos/122/figma-tokens/git/ref/heads%2Fmain', route => { + route.fulfill({ + status: 200, + contentType: 'application/json', + json: { + object: { + sha: 'main-sha', + }, + }, + }); + }); + + await context.route('http://localhost:58630/six7/repos/122/figma-tokens/git/refs', route => { + route.fulfill({ + status: 201, // typically POST responses return a 201 for created resources + contentType: 'application/json', + json: { + ref: 'new-branch', + }, + }); + }); + + await context.route('http://localhost:58630/six7/repos/122/figma-tokens/contents/tokens.json?ref=new-branch', route => { + route.fulfill({ + status: 200, + contentType: 'application/json', + json: {}, + }); + }); + + await context.route('http://localhost:58630/six7/repos/122/figma-tokens/git/ref/heads%2Fnew-branch', route => { + route.fulfill({ + status: 200, + contentType: 'application/json', + json: { + object: { + sha: 'new-branch-sha', + }, + }, + }); + }); + + await context.route('http://localhost:58630/six7/repos/122/figma-tokens/git/blobs', route => { + route.fulfill({ + status: 201, + contentType: 'application/json', + json: { content: {} }, + }); + }); + + await context.route('http://localhost:58630/six7/repos/122/figma-tokens/git/trees', route => { + route.fulfill({ + status: 201, + contentType: 'application/json', + json: { content: {} }, + }); + }); + + await context.route('http://localhost:58630/six7/repos/122/figma-tokens/git/commits', route => { + route.fulfill({ + status: 201, + contentType: 'application/json', + json: { content: {} }, + }); + }); + + await context.route('http://localhost:58630/six7/repos/122/figma-tokens/git/refs/heads%2Fnew-branch', route => { + route.fulfill({ + status: 200, + contentType: 'application/json', + json: { content: {} }, + }); + }); + + await context.route('http://localhost:58630/six7/repos/122/figma-tokens/git/refs/heads%2Fmain', route => { + route.fulfill({ + status: 200, + contentType: 'application/json', + json: { content: {} }, + }); + }); +}; + +export default MockEnv; diff --git a/packages/tokens-studio-for-figma/e2e/themes.spec.ts b/packages/tokens-studio-for-figma/e2e/themes.spec.ts new file mode 100644 index 000000000..f4eeaa667 --- /dev/null +++ b/packages/tokens-studio-for-figma/e2e/themes.spec.ts @@ -0,0 +1,106 @@ +import { test, expect } from '@playwright/test'; +import MockEnv from './support/mockEnv'; +import startup from './support/commands'; + +test.describe('has title', () => { + const mockStartupParams = { + activeTheme: {}, + lastOpened: Date.now(), + onboardingExplainer: { + sets: true, + inspect: true, + syncProviders: true, + }, + localApiProviders: [], + licenseKey: 'valid-license-key', + settings: { + width: 800, + height: 500, + ignoreFirstPartForStyles: false, + inspectDeep: false, + prefixStylesWithThemeName: false, + showEmptyGroups: true, + updateMode: 'PAGE', + updateOnChange: false, + updateRemote: true, + updateStyles: true, + }, + storageType: { provider: 'LOCAL' }, + user: { + figmaId: 'figma:1234', + userId: 'uid:1234', + name: 'Jan Six', + }, + localTokenData: { + activeTheme: {}, + checkForChanges: false, + themes: [], + usedTokenSet: {}, + updatedAt: new Date().toISOString(), + values: { + options: [{ + name: 'sizing.xs', + value: 4, + type: 'sizing' + }], + global: [{ + name: 'sizing.xs', + value: 4, + type: 'sizing' + }], + }, + version: '91', + }, + }; + + test.beforeEach(async ({ page, context }) => { + // await page.route('**/*', route => { + // route.continue(); + // }); + await page.goto('http://127.0.0.1:58630/#fullscreen=true'); + await MockEnv(context); + }); + + test('Can create a new theme', async ({ page }) => { + await startup(page, mockStartupParams); + console.log(123); + + // await page.goto('/#fullscreen=true'); + + // Interacting with the dropdown and clicking through the UI + await page.locator('[data-testid="themeselector-dropdown"]').click(); + await page.locator('[data-testid="themeselector-managethemes"]').click(); + await page.locator('[data-testid="button-manage-themes-modal-new-theme"]').click(); + await page.locator('[data-testid="button-manage-themes-modal-new-group"]').click(); + + // Typing into input fields + await page.locator('[data-testid="create-or-edit-theme-form--group--name"]').fill('GroupA'); + await page.locator('[data-testid="create-or-edit-theme-form--input--name"]').fill('My first theme'); + + // Interacting with a toggle or checkbox + await page.locator('[data-testid="tokensettheme-item--ToggleGroup-content--global--source"]').click(); + + // Saving the theme + await page.locator('[data-testid="button-manage-themes-modal-save-theme"]').click(); + + // Assertion to check the number of theme entries + const themeEntries = page.locator('[data-testid="singlethemeentry"]'); + await expect(themeEntries).toHaveCount(1); + + }); + + // await page.goto('/#theme=system&fullscreen=true'); + + // // Expect a title "to contain" a substring. + // await expect(page).toHaveTitle(/Preview/); +}); + +// test('get started link', async ({ page }) => { +// await page.goto('https://playwright.dev/'); + +// // Click the get started link. +// await page.getByRole('link', { name: 'Get started' }).click(); + +// // Expects page to have a heading with the name of Installation. +// await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); +// }); diff --git a/packages/tokens-studio-for-figma/package.json b/packages/tokens-studio-for-figma/package.json index 9e33333cb..4170e51db 100644 --- a/packages/tokens-studio-for-figma/package.json +++ b/packages/tokens-studio-for-figma/package.json @@ -9,8 +9,13 @@ "watch-transform": "webpack --mode=development --watch --config webpack-transform.config.js", "build": "cross-env NODE_ENV=production webpack --mode=production", "build:dev": "cross-env NODE_ENV=development webpack --mode=development", + "build:preview": "cross-env NODE_ENV=development webpack --mode=development --PREVIEW_ENV=browser", "build:cy": "cross-env LAUNCHDARKLY_FLAGS=tokenThemes,gitBranchSelector,multiFileSync,tokenFlowButton yarn build", "start": "cross-env webpack --mode=development --watch", + "preview:ws": "node preview-server.js", + "preview:plugin": "webpack --mode=development --PREVIEW_ENV=figma", + "preview:browser": "webpack-dev-server --mode=development --PREVIEW_ENV=browser", + "preview": "cross-env WEBSOCKETS_PORT=9001 run-p \"preview:*\"", "build-transform": "webpack --mode=production --config webpack-transform.config.js", "benchmark:build": "webpack --config webpack-benchmark.config.js", "benchmark:run": "node benchmark/index.mjs", @@ -24,6 +29,7 @@ "cy:open": "cypress open", "cy:run": "cypress run --headless", "serve": "serve dist -p 58630", + "serve:preview": "serve preview -p 58630", "changeset": "changeset", "translate": "node ./scripts/translate.mjs", "lint": "eslint . --quiet --fix", @@ -146,6 +152,8 @@ "@babel/preset-typescript": "^7.12.16", "@changesets/cli": "^2.26.2", "@figma/plugin-typings": "^1.96.0", + "@playwright/test": "^1.45.3", + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.13", "@sentry/webpack-plugin": "^2.2.0", "@storybook/addon-actions": "^6.5.8", "@storybook/addon-docs": "^6.5.8", @@ -200,10 +208,11 @@ "eslint-plugin-react": "^7.27.1", "eslint-plugin-react-hooks": "^4.4.0", "eslint-plugin-validate-jsx-nesting": "^0.1.1", + "express": "^4.19.2", "figma-api-stub": "^0.0.56", "figma-plugin-ds": "^1.0.1", "file-loader": "^6.2.0", - "fork-ts-checker-webpack-plugin": "^7.2.11", + "fork-ts-checker-webpack-plugin": "7.2.11", "fs-extra": "^11.1.1", "html-inline-script-webpack-plugin": "^3.2.0", "html-webpack-plugin": "^5.0.0", @@ -218,22 +227,27 @@ "postcss-cli": "^8.3.1", "prettier": "^2.0.5", "react-devtools": "^4.28.4", + "react-refresh-typescript": "^2.0.9", "react-svg-loader": "^3.0.3", "react-test-renderer": "17.0.0", "round-to": "^6.0.0", "serve": "^11.3.2", + "speed-measure-webpack-plugin": "^1.5.0", "style-loader": "^3.3.2", "svg-url-loader": "^7.1.1", "swc-loader": "^0.2.3", "translate": "^2.0.2", "ts-jest": "^29.1.1", + "ts-loader": "^9.5.1", "ts-node": "^10.8.1", "tslint": "^5.18.0", "tslint-react": "^4.0.0", "typescript": "~4.7.4", "url-loader": "^2.1.0", "webpack": "5", + "webpack-bundle-analyzer": "^4.10.2", "webpack-cli": "^3.3.6", + "webpack-dev-server": "3.x", "whatwg-fetch": "^3.6.2" }, "husky": { diff --git a/packages/tokens-studio-for-figma/playwright.config.ts b/packages/tokens-studio-for-figma/playwright.config.ts new file mode 100644 index 000000000..fb3ac42e8 --- /dev/null +++ b/packages/tokens-studio-for-figma/playwright.config.ts @@ -0,0 +1,78 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './e2e', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'yarn serve:preview', + url: 'http://127.0.0.1:58630', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/packages/tokens-studio-for-figma/preview-server.js b/packages/tokens-studio-for-figma/preview-server.js new file mode 100644 index 000000000..4c92af05b --- /dev/null +++ b/packages/tokens-studio-for-figma/preview-server.js @@ -0,0 +1,52 @@ +const express = require("express"); +const http = require("http"); +const WebSocket = require("ws"); +const path = require("path"); + +const PORT = process.env.WEBSOCKETS_PORT || 9001; + +const app = express(); + +app.use(express.static(__dirname + '/dist')) + +app.get("/", (req, res) => { + // res.sendFile(path.join(__dirname, 'dist', 'index.html')); + res.status(200).send("working"); +}); + +const server = http.createServer(app); + +// initialize the WebSocket server instance +const wss = new WebSocket.Server({ server }); +wss.on("connection", (ws) => { + ws.isAlive = true; + ws.on("pong", () => { + ws.isAlive = true; + }); + + // connection is up, let's add a simple simple event + ws.on("message", (data, isBinary) => { + const message = isBinary ? data : data.toString(); + // send back the message to the other clients + wss.clients.forEach((client) => { + if (client != ws) { + client.send(JSON.stringify({ message, src: 'server' })); + } + }); + }); + + // send immediatly a feedback to the incoming connection + // ws.send('Hi there, I am a WebSocket server'); +}); + +setInterval(() => { + wss.clients.forEach((ws) => { + if (!ws.isAlive) return ws.terminate(); + ws.isAlive = false; + ws.ping(); + }); +}, 10000); + +server.listen(PORT, () => { + console.log(`Preview server started on port: ${PORT}`); +}); diff --git a/packages/tokens-studio-for-figma/src/AsyncMessageChannel.ts b/packages/tokens-studio-for-figma/src/AsyncMessageChannel.ts index ca8e91dcb..b570413a4 100644 --- a/packages/tokens-studio-for-figma/src/AsyncMessageChannel.ts +++ b/packages/tokens-studio-for-figma/src/AsyncMessageChannel.ts @@ -3,6 +3,8 @@ import { AsyncMessageResults, AsyncMessageResultsMap, AsyncMessages, AsyncMessagesMap, AsyncMessageTypes, } from './types/AsyncMessages'; +import { AsyncMessageChannelPreview } from './AsyncMessageChannelPreview'; + // credits goes to https://github.com/microsoft/TypeScript/issues/23182#issuecomment-379091887 type IsTypeOnlyObject> = [keyof Obj] extends ['type'] ? true : false; @@ -26,7 +28,7 @@ export type AsyncMessageChannelHandlers = { > }; -export class AsyncMessageChannel { +class AsyncMessageChannel { public static PluginInstance: AsyncMessageChannel = new AsyncMessageChannel(true); public static ReactInstance: AsyncMessageChannel = new AsyncMessageChannel(false); @@ -142,3 +144,7 @@ export class AsyncMessageChannel { return promise; } } + +const ExportedAsyncMessageChannel = !process.env.PREVIEW_ENV ? AsyncMessageChannel : AsyncMessageChannelPreview; + +export { ExportedAsyncMessageChannel as AsyncMessageChannel }; diff --git a/packages/tokens-studio-for-figma/src/AsyncMessageChannelPreview.ts b/packages/tokens-studio-for-figma/src/AsyncMessageChannelPreview.ts new file mode 100644 index 000000000..6cc7e528a --- /dev/null +++ b/packages/tokens-studio-for-figma/src/AsyncMessageChannelPreview.ts @@ -0,0 +1,307 @@ +import hash from 'object-hash'; +import { + AsyncMessageResults, AsyncMessageResultsMap, AsyncMessages, AsyncMessagesMap, AsyncMessageTypes, +} from './types/AsyncMessages'; + +// credits goes to https://github.com/microsoft/TypeScript/issues/23182#issuecomment-379091887 +type IsTypeOnlyObject> = [keyof Obj] extends ['type'] ? true : false; + +type IncomingMessageEvent = { + data: { + pluginMessage: { + id: string + message: Message + } | { + id: string + error: unknown + } + } +}; + +export type AsyncMessageChannelHandlers = { + [K in AsyncMessageTypes]: (incoming: AsyncMessagesMap[K]) => Promise< + IsTypeOnlyObject extends true + ? void + : Omit + > +}; + +export const WEBSOCKET_SERVER_URL = 'ws://localhost:9001/ws'; + +const sendWsMessage = (ws, msg: Message) => { + const message = JSON.stringify(msg); + if (ws && ws.readyState === 1) { + ws.send(message); + } else { + setTimeout(() => { + sendWsMessage(ws, msg); + }, 1000); + } +}; + +const sendMessageToController = (pluginMessage) => { + parent.postMessage( + { pluginMessage }, + '*', + ); +}; + +const sendMessageToUi = (pluginMessage) => { + figma.ui.postMessage(pluginMessage); +}; + +const parseWsEvent = (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.src === 'server') { + const temp = JSON.parse(msg.message); + return temp; + } + } catch (err) { + console.warn('not a valid message', err); + return null; + } + return null; +}; + +enum Environment { + PLUGIN = 'PLUGIN', + UI = 'UI', + BROWSER = 'BROWSER', +} + +enum WebSocketsSource { + browser = 'browser', + ui = 'ui', + figma = 'figma', +} + +enum PreviewEnvVar { + browser = 'browser', + figma = 'figma', +} + +export class AsyncMessageChannelPreview { + public static PluginInstance: AsyncMessageChannelPreview = new AsyncMessageChannelPreview(true); + + public static ReactInstance: AsyncMessageChannelPreview = new AsyncMessageChannelPreview(false); + + protected $handlers: Partial = {}; + + protected isInFigmaSandbox = false; + + protected environment: Environment | null = null; + + protected isPreview: boolean = false; + + protected ws: WebSocket | null = null; + + public isWsConnected: boolean = false; + + constructor(inFigmaSandbox: boolean) { + this.isInFigmaSandbox = inFigmaSandbox; + if (inFigmaSandbox) { + this.environment = Environment.PLUGIN; + } else if (process.env.PREVIEW_ENV === PreviewEnvVar.browser) { + this.environment = Environment.BROWSER; + } else { + this.environment = Environment.UI; + } + + if (process.env.PREVIEW_ENV) { + this.isPreview = true; + } + } + + private sendMessageToBrowser = (msg) => { + sendWsMessage(this.ws, { ...msg, src: WebSocketsSource.ui }); + }; + + private sendMessageFromBrowser = (msg) => { + sendWsMessage(this.ws, { ...msg, src: WebSocketsSource.browser }); + }; + + public getWs() { + return this.ws; + } + + private listenerFactory(callback, removeEventListener, parseEvent = (event) => event) { + const listener = async (msg: Message) => { + const possiblePromise = callback(parseEvent(msg)); + if (possiblePromise === false || (possiblePromise && await possiblePromise === false)) { + removeEventListener('message', listener); + } + }; + + return listener; + } + + public attachMessageListener(callback: (msg: Message) => void | false | Promise) { + switch (this.environment) { + case Environment.PLUGIN: { + const listener = this.listenerFactory(callback, figma.ui.off); + figma.ui.on('message', listener); + return () => figma.ui.off('message', listener); + } + case Environment.BROWSER: + case Environment.UI: { + const wsListener = this.isPreview + ? this.listenerFactory(callback, this.ws?.removeEventListener, parseWsEvent) + : null; + const listener = this.listenerFactory(callback, window.removeEventListener, (event: { data: { pluginMessage: Message } }) => event.data.pluginMessage); + window.addEventListener('message', listener); + if (wsListener) { + this.ws?.addEventListener('message', wsListener); + } + return () => { + window.removeEventListener('message', listener); + if (wsListener) { + this.ws?.removeEventListener('message', wsListener); + } + }; + } + default: { + return null; + } + } + } + + private startWebSocketConnection() { + if (this.ws === null) { + this.ws = new WebSocket(WEBSOCKET_SERVER_URL); + const self = this; + this.ws.addEventListener('open', () => { + self.isWsConnected = true; + }); + this.ws.addEventListener('close', () => { + self.isWsConnected = false; + setTimeout(() => { + self.ws = null; + this.startWebSocketConnection(); + }, 5000); + }); + } + + return () => { + if (this.ws) { + this.ws.close(); + } + }; + } + + private onMessageEvent = async (msg: { id?: string; message?: AsyncMessages, src?: WebSocketsSource }) => { + // This appears to be related to the monaco editor being opened. It appears to post a message to the window message event listener with no data. + if (!msg || !msg.id || !msg.message || !msg.message.type.startsWith('async/')) { + // eslint-disable-next-line no-console + // console.warn('Invalid message received', msg); + if ((msg as any)?.type && this.environment === Environment.UI) { + if (msg.src !== WebSocketsSource.browser) { + this.sendMessageToBrowser({ ...msg, src: WebSocketsSource.ui }); + } else { + sendMessageToController(msg); + } + return; + } + return; + } + const handler = this.$handlers[msg.message.type] as AsyncMessageChannelHandlers[AsyncMessageTypes] | undefined; + if (this.environment === Environment.UI && this.isPreview) { + if (msg.src !== WebSocketsSource.browser) { + this.sendMessageToBrowser({ ...msg, src: WebSocketsSource.ui }); + } else { + sendMessageToController(msg); + } + return; + } + if (handler) { + try { + // @README need to cast to any to make this work + // it causes a complex type which can not be resolved due to its depth + const result = await (handler as any)(msg.message); + const payload = result + ? { ...result, type: msg.message.type } + : { type: msg.message.type }; + + if (this.isInFigmaSandbox) { + sendMessageToUi({ + id: msg.id, + message: payload, + }); + } else { // eslint-disable-next-line + if (this.environment === Environment.BROWSER) { + this.sendMessageFromBrowser({ id: msg.id, message: payload, src: WebSocketsSource.browser }); + } else { + sendMessageToController({ id: msg.id, message: payload }); + } + } + } catch (err) { + console.error(err); + if (this.isInFigmaSandbox) { + sendMessageToUi({ + id: msg.id, + error: err, + }); + } else { + sendMessageToController({ id: msg.id, error: err }); + } + } + } + }; + + public connect() { + if (this.environment !== Environment.PLUGIN && this.isPreview) { + this.startWebSocketConnection(); + } + + return this.attachMessageListener(this.onMessageEvent); + } + + public handle( + type: T, + fn: AsyncMessageChannelHandlers[T], + ) { + this.$handlers[type] = fn; + } + + public async message(message: Message) { + const messageId = hash({ + message, + datetime: Date.now(), + }); + const promise = new Promise((resolve, reject) => { + this.attachMessageListener((msg: IncomingMessageEvent['data']['pluginMessage']) => { + if (msg?.id === messageId) { + if ('message' in msg) { + resolve(msg.message); + } else { + reject(msg.error); + } + return false; + } + return undefined; + }); + }); + switch (this.environment) { + case Environment.PLUGIN: { + sendMessageToUi({ id: messageId, message, src: WebSocketsSource.figma }); + break; + } + case Environment.UI: { + if (this.isPreview) { + this.sendMessageToBrowser({ id: messageId, message }); + } else { + sendMessageToController({ id: messageId, message }); + } + break; + } + case Environment.BROWSER: { + this.sendMessageFromBrowser({ id: messageId, message }); + break; + } + default: { + break; + } + } + return promise; + } +} diff --git a/packages/tokens-studio-for-figma/src/__fixtures__/startup.ts b/packages/tokens-studio-for-figma/src/__fixtures__/startup.ts new file mode 100644 index 000000000..d5a47791a --- /dev/null +++ b/packages/tokens-studio-for-figma/src/__fixtures__/startup.ts @@ -0,0 +1,56 @@ +import { AsyncMessageTypes, StartupMessage } from '@/types/AsyncMessages'; + +const startupParams = {}; + +const baseStartupParams = { + settings: {}, + activeTheme: {}, + authData: null, + lastOpened: 0, + onboardingExplainer: { + exportSets: true, + inspect: true, + sets: true, + syncProviders: true, + }, + storageType: { provider: 'local' }, + localApiProviders: null, + licenseKey: null, + initialLoad: false, + localTokenData: null, + user: null, + usedEmail: null, +}; + +export const startupMessage: Omit = { + ...baseStartupParams, + type: AsyncMessageTypes.STARTUP, + activeTheme: {}, + lastOpened: Date.now(), + initialLoad: false, + usedEmail: null, + authData: null, + onboardingExplainer: { + sets: true, + exportSets: true, + inspect: true, + syncProviders: true, + }, + localApiProviders: [], + settings: mockSettings, + storageType: { + provider: StorageProviderType.LOCAL, + }, + user: mockUser, + localTokenData: { + activeTheme: '', + checkForChanges: true, + themes: [], + usedTokenSet: null, + updatedAt: new Date().toISOString(), + values: {}, + collapsedTokenSets: null, + tokenFormat: TokenFormatOptions.Legacy, + version: '91', + }, +}; diff --git a/packages/tokens-studio-for-figma/src/app/asyncMessageHandlers/startup.tsx b/packages/tokens-studio-for-figma/src/app/asyncMessageHandlers/startup.tsx index c75c4b33d..833001684 100644 --- a/packages/tokens-studio-for-figma/src/app/asyncMessageHandlers/startup.tsx +++ b/packages/tokens-studio-for-figma/src/app/asyncMessageHandlers/startup.tsx @@ -1,27 +1,68 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { createRoot } from 'react-dom/client'; import * as Sentry from '@sentry/react'; -import { Provider } from 'react-redux'; +import { Provider, useStore } from 'react-redux'; import i18next from 'i18next'; import * as Tooltip from '@radix-ui/react-tooltip'; -import { AsyncMessageChannelHandlers } from '@/AsyncMessageChannel'; -import { AsyncMessageTypes } from '@/types/AsyncMessages'; + +import { AsyncMessageChannel } from '@/AsyncMessageChannel'; +import { AsyncMessageTypes, StartupMessage } from '@/types/AsyncMessages'; import { ErrorFallback } from '../components/ErrorFallback'; -import { store } from '../store'; +import { RootState, store } from '../store'; import { AppContainer } from '../components/AppContainer'; +import PreviewApp from '../preview/preview'; +import FigmaLoading from '../components/FigmaLoading'; + +// eslint-disable-next-line +const PREVIEW_ENV = process.env.PREVIEW_ENV; + +const StartupApp = () => { + const [params, setParams] = useState(null); + const rootStore = useStore(); + const state = rootStore.getState(); + + useEffect(() => { + i18next.changeLanguage(state?.settings?.language || 'en'); + }, [state?.settings?.language]); + + useEffect(() => { + AsyncMessageChannel.ReactInstance.handle(AsyncMessageTypes.STARTUP, async (startupParams) => { + setParams(startupParams); + }); + + return () => { + AsyncMessageChannel.ReactInstance.handle(AsyncMessageTypes.STARTUP, (() => {}) as any); + }; + }, []); + + const appContainer = ( + params ? : ( + + + + ) + ); + + return PREVIEW_ENV ? ( + + {appContainer} + + ) : appContainer; +}; -export const startup: AsyncMessageChannelHandlers[AsyncMessageTypes.STARTUP] = async (params) => { +export default async () => { const container = document.getElementById('app'); // Side effect from first load - i18next.changeLanguage(params.settings.language || 'en'); const root = createRoot(container!); root.render( - + , diff --git a/packages/tokens-studio-for-figma/src/app/components/Initiator.tsx b/packages/tokens-studio-for-figma/src/app/components/Initiator.tsx index 9f50e6e92..6dced5ef4 100644 --- a/packages/tokens-studio-for-figma/src/app/components/Initiator.tsx +++ b/packages/tokens-studio-for-figma/src/app/components/Initiator.tsx @@ -11,17 +11,33 @@ import { Properties } from '@/constants/Properties'; import { Tabs } from '@/constants/Tabs'; import { hasTokenValues } from '@/utils/hasTokenValues'; import { track } from '@/utils/analytics'; +import { AsyncMessageChannel } from '@/AsyncMessageChannel'; +import { AsyncMessageChannelPreview } from '@/AsyncMessageChannelPreview'; // @README this component is not the "Initiator" anymore - as it is named // but solely acts as the interface between the plugin and the UI +const parseWsEvent = (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.src === 'server') { + const temp = JSON.parse(msg.message); + return temp; + } + } catch (err) { + console.error('not a valid message', err); + return null; + } + return null; +}; + export function Initiator() { const dispatch = useDispatch(); const { pullTokens, fetchBranches } = useRemoteTokens(); const { setStorageType } = useStorage(); useEffect(() => { - window.onmessage = async (event: { + const onMessageEvent = async (event: { data: { pluginMessage: PostToUIMessage; }; @@ -149,6 +165,18 @@ export function Initiator() { } } }; + if (process.env.PREVIEW_ENV === 'browser') { + const listener = (e) => { + const event = parseWsEvent(e); + onMessageEvent({ data: { pluginMessage: event } }); + }; + (AsyncMessageChannel as typeof AsyncMessageChannelPreview).ReactInstance.getWs()?.addEventListener('message', listener); + return () => { + (AsyncMessageChannel as typeof AsyncMessageChannelPreview).ReactInstance.getWs()?.removeEventListener('message', listener); + }; + } + window.onmessage = onMessageEvent; + return () => {}; }, [dispatch, pullTokens, fetchBranches, setStorageType]); return null; diff --git a/packages/tokens-studio-for-figma/src/app/index.tsx b/packages/tokens-studio-for-figma/src/app/index.tsx index d513726d3..ab72f5052 100644 --- a/packages/tokens-studio-for-figma/src/app/index.tsx +++ b/packages/tokens-studio-for-figma/src/app/index.tsx @@ -3,6 +3,7 @@ import './assets/fonts/jetbrainsmono.css'; import './styles/preflight.css'; import '@/i18n'; import * as asyncHandlers from './asyncMessageHandlers'; +import startup from './asyncMessageHandlers/startup'; import { initializeAnalytics } from '../utils/analytics'; import { AsyncMessageChannel } from '@/AsyncMessageChannel'; import { AsyncMessageTypes } from '@/types/AsyncMessages'; @@ -13,4 +14,5 @@ initializeSentry(); AsyncMessageChannel.ReactInstance.connect(); AsyncMessageChannel.ReactInstance.handle(AsyncMessageTypes.GET_THEME_INFO, asyncHandlers.getThemeInfo); -AsyncMessageChannel.ReactInstance.handle(AsyncMessageTypes.STARTUP, asyncHandlers.startup); + +startup(); diff --git a/packages/tokens-studio-for-figma/src/app/preview/preview.css b/packages/tokens-studio-for-figma/src/app/preview/preview.css new file mode 100644 index 000000000..ba8c83246 --- /dev/null +++ b/packages/tokens-studio-for-figma/src/app/preview/preview.css @@ -0,0 +1,11 @@ +div:has(> #corner) { + position: relative; +} + +#corner { + position: absolute; +} + +div[data-testid="figmaloading"] { + max-height: 100%; +} diff --git a/packages/tokens-studio-for-figma/src/app/preview/preview.tsx b/packages/tokens-studio-for-figma/src/app/preview/preview.tsx new file mode 100644 index 000000000..35aa477e1 --- /dev/null +++ b/packages/tokens-studio-for-figma/src/app/preview/preview.tsx @@ -0,0 +1,507 @@ +import React, { + FormEvent, + ReactNode, + useCallback, + useEffect, + useState, +} from 'react'; +import { + Box, Button, DropdownMenu, IconButton, Stack, Text, +} from '@tokens-studio/ui'; +import { useDispatch, useSelector } from 'react-redux'; +import hash from 'object-hash'; +import { Code, Expand, Collapse } from 'iconoir-react'; + +import { Editor } from '@monaco-editor/react'; +import { CSS } from '@stitches/react'; +import { Dispatch } from '../store'; +import { AsyncMessageChannel } from '../../AsyncMessageChannel'; +import { AsyncMessageChannelPreview } from '../../AsyncMessageChannelPreview'; +import { AsyncMessageTypes, StartupMessage } from '@/types/AsyncMessages'; +import { SavedSettings } from '@/plugin/notifiers'; +import { UpdateMode } from '@/constants/UpdateMode'; +import { StorageProviderType } from '@/constants/StorageProviderType'; +import Modal from '../components/Modal'; + +import './preview.css'; +import { settingsStateSelector, uiStateSelector } from '@/selectors'; +import { Tabs } from '@/constants/Tabs'; +import { setFigmaBrowserTheme } from './previewUtils'; +import { useFigmaTheme } from '@/hooks/useFigmaTheme'; +import { usePreviewState } from './usePreviewState'; +import { TokenFormatOptions } from '@/plugin/TokenFormatStoreClass'; + +// eslint-disable-next-line +const PREVIEW_ENV = process.env.PREVIEW_ENV; + +const mockUser = { + figmaId: 'figma:1234', + userId: 'uid:1234', + name: 'Jan Six', +}; + +// @ts-ignore +const mockSettings: SavedSettings = { + language: 'en', + width: 500, + height: 800, + ignoreFirstPartForStyles: false, + inspectDeep: false, + prefixStylesWithThemeName: false, + showEmptyGroups: true, + updateMode: UpdateMode.PAGE, + updateOnChange: false, + updateRemote: true, + updateStyles: true, +}; + +// const mockStartupParams: Omit = { +const mockStartupParams: Omit = { + type: AsyncMessageTypes.STARTUP, + activeTheme: {}, + lastOpened: Date.now(), + initialLoad: true, + usedEmail: null, + authData: null, + onboardingExplainer: { + sets: true, + exportSets: true, + inspect: true, + syncProviders: true, + }, + localApiProviders: [], + settings: mockSettings, + storageType: { + provider: StorageProviderType.LOCAL, + }, + user: mockUser, + localTokenData: { + activeTheme: '', + checkForChanges: true, + themes: [], + usedTokenSet: null, + updatedAt: new Date().toISOString(), + values: {}, + collapsedTokenSets: null, + tokenFormat: TokenFormatOptions.Legacy, + version: '91', + }, +}; + +const mockActions = { + STARTUP: { + default: mockStartupParams, + }, +}; + +const dispatchMockMessage = (message) => { + const messageId = hash({ + message, + datetime: Date.now(), + }); + + const msg = { id: messageId, message }; + window.postMessage({ pluginMessage: msg }); +}; + +const MockMessageForm = ({ type, handleClose }: { type?: string, handleClose: () => void }) => { + const [value, setValue] = useState({ + STARTUP: JSON.stringify(mockStartupParams, null, 2), + DEFAULT: '', + }[type || 'DEFAULT'] || ''); + const [error, setError] = useState(''); + const handleJsonEditChange = useCallback((val) => { + try { + // eslint-disable-next-line + const a = JSON.parse(val); + if (error) { + setError(''); + } + setValue(val); + } catch (err) { + setError('Not valid JSON'); + } + }, [setValue, error]); + const { isDarkTheme } = useFigmaTheme(); + + const isValid = true; + + const checkAndSubmitMessage = useCallback((e: FormEvent) => { + e.preventDefault(); + const message = JSON.parse(value); + dispatchMockMessage(message); + + handleClose(); + }, [value, handleClose]); + + return ( +
+ + WIP + + + + + {error || '0'} + + + + + + +
+ ); +}; + +const PreviewMockMessageModal = ({ type, handleClose }: { type: string | undefined, handleClose: () => void }) => ( + + + +); + +const PreviewPluginWindow = ({ + height = 600, width = '100%', children, css = {}, fullscreen, updateHash, +}: { children: ReactNode, height?: number | string, width?: number | string, css: CSS | undefined, fullscreen?: boolean, updateHash: any }) => { + useEffect(() => { + AsyncMessageChannelPreview.ReactInstance.message({ + type: AsyncMessageTypes.PREVIEW_REQUEST_STARTUP, + }); + }, []); + const toggleFullscreen = useCallback(() => { + updateHash({ fullscreen: !fullscreen }); + }, [fullscreen, updateHash]); + const { isDarkTheme } = useFigmaTheme(); + + return ( + + + + + + + Tokens Studio for Figma + + + {fullscreen ? ( + + )} + size="small" + variant="invisible" + onClick={toggleFullscreen} + /> + ) : ( + + )} + size="small" + variant="invisible" + onClick={toggleFullscreen} + /> + )} + + + + {children} + + + ); +}; + +const themes = { + light: 'Light', + dark: 'Dark', + system: 'System', +}; + +function PreviewApp({ children }: { children: ReactNode }) { + const isConnected = (AsyncMessageChannel as typeof AsyncMessageChannelPreview).ReactInstance.isWsConnected; + const [mockMessageModalOpen, setMockMessageModalOpen] = useState(''); + const dispatch = useDispatch(); + const settings = useSelector(settingsStateSelector); + const uiState = useSelector(uiStateSelector); + const { + data: { + tab, action, subAction, theme, fullscreen, + }, updateHash, + } = usePreviewState(); + // const [websocketsServer, setWebsocketsServer] = useState(WEBSOCKET_SERVER_URL); + + useEffect(() => { + document.title = 'Tokens Studio for Figma – Web Preview'; + // if (tab && tab !== 'loading') { + if (action) { + if (mockActions[action][subAction]) { + dispatchMockMessage(mockActions[action][subAction]); + } else if (mockActions[action]) { + dispatchMockMessage(mockActions[action]); + } + } + if (theme) { + setFigmaBrowserTheme(theme, updateHash); + } + if (tab) { + dispatch.uiState.setActiveTab(Tabs[tab]); + } + }, []); + + useEffect(() => { + if (uiState.activeTab && tab !== uiState.activeTab) { + updateHash({ + tab: uiState.activeTab, + }); + } + }, [uiState.activeTab, tab, updateHash]); + + const onThemeSelected = useCallback((type) => () => { + setFigmaBrowserTheme(type, updateHash); + }, []); + const onActionSelected = React.useCallback( + (type: string) => () => { + if (type === 'CUSTOM') { + setMockMessageModalOpen(type); + } else { + const message = mockActions[type]?.type ? mockActions[type] : mockActions[type].default; + if (message) { + dispatchMockMessage(message); + updateHash({ + action: mockActions[type]?.type ? type : `${type}.default`, + }); + } + } + }, + [], + ); + + const handleCloseCustomModal = useCallback(() => { + setMockMessageModalOpen(''); + }, []); + // const resetApp = useCallback(() => { + // store.dispatch({ type: 'RESET_APP' }); + // }, []); + + const previewHeader = ( + + Web Preview + + {mockMessageModalOpen && } + {/* */} + + + + + + + { + Object.keys(themes).map((type) => ( + + {themes[type]} + + )) + } + + + + + + + + + + { + [{ type: 'STARTUP' }, { type: 'CUSTOM' }].map((action) => ( + + {action.type} + + )) + } + + + + + + {isConnected ? 'Connected' : 'Disconnected'} + + + + ); + + return ( + + {PREVIEW_ENV === 'browser' ? ( + <> + {previewHeader} + + {children} + + + ) : ( + <> + {previewHeader} + {/* + + + { + setWebsocketsServer(e.target.value); + }} + type="text" + name="websocketconnect" + data-testid="websocket-connect" + required + /> + + + */} + + )} + + ); +} + +export default PreviewApp; diff --git a/packages/tokens-studio-for-figma/src/app/preview/previewUtils.ts b/packages/tokens-studio-for-figma/src/app/preview/previewUtils.ts new file mode 100644 index 000000000..86639613b --- /dev/null +++ b/packages/tokens-studio-for-figma/src/app/preview/previewUtils.ts @@ -0,0 +1,45 @@ +const getPreferredColorScheme = () => { + if (window.matchMedia) { + if (window.matchMedia('(prefers-color-scheme: dark)').matches) { + return 'dark'; + } + } + return 'light'; +}; + +export const setFigmaBrowserTheme = (theme, updateHash) => { + const htmlClassList = document.documentElement?.classList || []; + const isDark = htmlClassList.contains('figma-dark'); + + switch (theme) { + case 'light': { + if (isDark) { + htmlClassList.remove('figma-dark'); + } + + break; + } + case 'dark': { + if (!isDark) { + htmlClassList.add('figma-dark'); + } + + break; + } + case 'system': { + const systemTheme = getPreferredColorScheme(); + if (systemTheme === 'dark' && !isDark) { + htmlClassList.add('figma-dark'); + } else if (systemTheme === 'light' && isDark) { + htmlClassList.remove('figma-dark'); + } + break; + } + default: { + break; + } + } + if (updateHash) { + updateHash({ theme }); + } +}; diff --git a/packages/tokens-studio-for-figma/src/app/preview/usePreviewState.tsx b/packages/tokens-studio-for-figma/src/app/preview/usePreviewState.tsx new file mode 100644 index 000000000..2db44ac73 --- /dev/null +++ b/packages/tokens-studio-for-figma/src/app/preview/usePreviewState.tsx @@ -0,0 +1,71 @@ +import { + useCallback, useEffect, useState, +} from 'react'; + +export const getHashParams = () => { + const params = new URLSearchParams(window.location.hash.slice(1)); + const tab = params.get('tab') || ''; + const [action, subAction] = params.get('action')?.split('.') || []; + const theme = params.get('theme') || 'system'; + const fullscreen = Boolean(params.get('fullscreen')); + + return { + tab, + action, + subAction, + theme, + fullscreen, + }; +}; + +type State = { + tab: string, + action: string, + subAction: string, + theme: string, + fullscreen: boolean, +}; + +export function usePreviewState() { + const [data, setData] = useState(getHashParams()); + // const [count, setCount] = useState(0); + + useEffect(() => { + const updateStateFromHash = () => { + const hashParams = getHashParams(); + const statePatch = Object.keys(data).reduce((acc, id) => { + if (data[id] !== hashParams[id]) { + acc[id] = hashParams[id]; + } + return acc; + }, {}); + + if (Object.keys(statePatch).length > 0) { + setData((state) => ({ ...state, ...statePatch })); + } + // dispatch({ type: 'UPDATE', payload: statePatch }); + // setState((s) => ({ + // ...s, + // ...statePatch, + // })); + }; + // updateStateFromHash(); + + window.addEventListener('hashchange', updateStateFromHash); + + return () => window.removeEventListener('hashchange', updateStateFromHash); + }, [data]); + const updateHash = useCallback((hashData) => { + const urlParams = new URLSearchParams(window.location.hash.slice(1)); + Object.keys(hashData).forEach((k) => { + let value = hashData[k]; + if (k === 'fullscreen') { + value = hashData[k] ? 'true' : ''; + } + urlParams.set(k, value); + }); + window.location.hash = `#${urlParams.toString()}`; + }, []); + + return { data, updateHash }; +} diff --git a/packages/tokens-studio-for-figma/src/app/store/updateSources.tsx b/packages/tokens-studio-for-figma/src/app/store/updateSources.tsx index 813d8b425..201e9fbab 100644 --- a/packages/tokens-studio-for-figma/src/app/store/updateSources.tsx +++ b/packages/tokens-studio-for-figma/src/app/store/updateSources.tsx @@ -169,7 +169,9 @@ export default async function updateTokensOnSources({ collapsedTokenSets, tokenFormat, }).then((result) => { - transaction.setMeasurement('nodes', result.nodes, ''); - transaction.finish(); + if (transaction) { + transaction.setMeasurement('nodes', result.nodes, ''); + transaction.finish(); + } }); } diff --git a/packages/tokens-studio-for-figma/src/asyncMessageChannel.test.ts b/packages/tokens-studio-for-figma/src/asyncMessageChannel.test.ts index a2510a786..489737140 100644 --- a/packages/tokens-studio-for-figma/src/asyncMessageChannel.test.ts +++ b/packages/tokens-studio-for-figma/src/asyncMessageChannel.test.ts @@ -1,4 +1,5 @@ import { AsyncMessageChannel } from './AsyncMessageChannel'; +import { AsyncMessageChannelPreview } from './AsyncMessageChannelPreview'; import { INTERNAL_THEMES_NO_GROUP } from './constants/InternalTokenGroup'; import { AsyncMessageTypes, GetThemeInfoMessageResult, @@ -6,7 +7,7 @@ import { describe('Testing the mock functionality of the AsyncMessageChannel', () => { it('should be able to communicate between UI and plugin', async () => { - const runAfter: (() => void)[] = []; + const runAfter: ((() => void) | null)[] = []; const getThemeInfoHandler = async (): Promise => ({ type: AsyncMessageTypes.GET_THEME_INFO, @@ -31,11 +32,11 @@ describe('Testing the mock functionality of the AsyncMessageChannel', () => { themes: [{ id: 'light', name: 'Light', selectedTokenSets: {} }], }); - runAfter.forEach((fn) => fn()); + runAfter.forEach((fn) => fn?.()); }); it('should be able to communicate between plugin and UI', async () => { - const runAfter: (() => void)[] = []; + const runAfter: ((() => void) | null)[] = []; const getThemeInfoHandler = async (): Promise => ({ type: AsyncMessageTypes.GET_THEME_INFO, @@ -60,11 +61,11 @@ describe('Testing the mock functionality of the AsyncMessageChannel', () => { themes: [{ id: 'light', name: 'Light', selectedTokenSets: {} }], }); - runAfter.forEach((fn) => fn()); + runAfter.forEach((fn) => fn?.()); }); it('should handle errors', async () => { - const runAfter: (() => void)[] = []; + const runAfter: ((() => void) | null)[] = []; const getThemeInfoHandler = async (): Promise => { throw new Error('error'); @@ -78,6 +79,34 @@ describe('Testing the mock functionality of the AsyncMessageChannel', () => { type: AsyncMessageTypes.GET_THEME_INFO, })).rejects.toEqual(new Error('error')); - runAfter.forEach((fn) => fn()); + runAfter.forEach((fn) => fn?.()); + }); + it('should handle browser preview communication between UI and plugin', async () => { + const runAfter: ((() => void) | null)[] = []; + + const getThemeInfoHandler = async (): Promise => ({ + type: AsyncMessageTypes.GET_THEME_INFO, + activeTheme: { + [INTERNAL_THEMES_NO_GROUP]: 'light', + }, + themes: [{ id: 'light', name: 'Light', selectedTokenSets: {} }], + }); + + runAfter.push(AsyncMessageChannelPreview.PluginInstance.connect()); + AsyncMessageChannelPreview.PluginInstance.handle(AsyncMessageTypes.GET_THEME_INFO, getThemeInfoHandler); + + runAfter.push(AsyncMessageChannelPreview.ReactInstance.connect()); + const result = await AsyncMessageChannelPreview.ReactInstance.message({ + type: AsyncMessageTypes.GET_THEME_INFO, + }); + expect(result).toEqual({ + type: AsyncMessageTypes.GET_THEME_INFO, + activeTheme: { + [INTERNAL_THEMES_NO_GROUP]: 'light', + }, + themes: [{ id: 'light', name: 'Light', selectedTokenSets: {} }], + }); + + runAfter.forEach((fn) => fn?.()); }); }); diff --git a/packages/tokens-studio-for-figma/src/plugin/asyncMessageHandlers/index.ts b/packages/tokens-studio-for-figma/src/plugin/asyncMessageHandlers/index.ts index c1a738bd3..b48e0393f 100644 --- a/packages/tokens-studio-for-figma/src/plugin/asyncMessageHandlers/index.ts +++ b/packages/tokens-studio-for-figma/src/plugin/asyncMessageHandlers/index.ts @@ -38,3 +38,4 @@ export * from './attachLocalVariablesToTheme'; export * from './renameVariables'; export * from './updateVariables'; export * from './setInitialLoad'; +export * from './preview'; diff --git a/packages/tokens-studio-for-figma/src/plugin/asyncMessageHandlers/preview.ts b/packages/tokens-studio-for-figma/src/plugin/asyncMessageHandlers/preview.ts new file mode 100644 index 000000000..253461bb1 --- /dev/null +++ b/packages/tokens-studio-for-figma/src/plugin/asyncMessageHandlers/preview.ts @@ -0,0 +1,12 @@ +import { AsyncMessageChannel, AsyncMessageChannelHandlers } from '@/AsyncMessageChannel'; +import { AsyncMessageTypes } from '@/types/AsyncMessages'; +import { startup } from '@/utils/plugin'; + +export const previewRequestStartup: AsyncMessageChannelHandlers[AsyncMessageTypes.PREVIEW_REQUEST_STARTUP] = async () => { + const params = await startup(); + + AsyncMessageChannel.PluginInstance.message({ + type: AsyncMessageTypes.STARTUP, + ...params, + }); +}; diff --git a/packages/tokens-studio-for-figma/src/plugin/controller.ts b/packages/tokens-studio-for-figma/src/plugin/controller.ts index 0c5b5a18b..fecec2df2 100644 --- a/packages/tokens-studio-for-figma/src/plugin/controller.ts +++ b/packages/tokens-studio-for-figma/src/plugin/controller.ts @@ -71,6 +71,7 @@ AsyncMessageChannel.PluginInstance.handle( AsyncMessageChannel.PluginInstance.handle(AsyncMessageTypes.RENAME_VARIABLES, asyncHandlers.renameVariables); AsyncMessageChannel.PluginInstance.handle(AsyncMessageTypes.UPDATE_VARIABLES, asyncHandlers.updateVariables); AsyncMessageChannel.PluginInstance.handle(AsyncMessageTypes.SET_USED_EMAIL, asyncHandlers.setUsedEmail); +AsyncMessageChannel.PluginInstance.handle(AsyncMessageTypes.PREVIEW_REQUEST_STARTUP, asyncHandlers.previewRequestStartup); figma.on('close', () => { defaultWorker.stop(); diff --git a/packages/tokens-studio-for-figma/src/types/AsyncMessages.ts b/packages/tokens-studio-for-figma/src/types/AsyncMessages.ts index dbc4fb8ed..8fea64819 100644 --- a/packages/tokens-studio-for-figma/src/types/AsyncMessages.ts +++ b/packages/tokens-studio-for-figma/src/types/AsyncMessages.ts @@ -70,6 +70,7 @@ export enum AsyncMessageTypes { RENAME_VARIABLES = 'async/rename-variables', UPDATE_VARIABLES = 'async/update-variables', SET_INITIAL_LOAD = 'async/set-initial-load', + PREVIEW_REQUEST_STARTUP = 'async/preview-request-startup', } export type AsyncMessage = P & { type: T }; @@ -335,6 +336,9 @@ AsyncMessageTypes.REMOVE_RELAUNCH_DATA, } >; +export type PreviewRequestStartupAsyncMessage = AsyncMessage; +export type PreviewRequestStartupAsyncMessageResult = AsyncMessage; + export type AsyncMessages = CreateStylesAsyncMessage | RenameStylesAsyncMessage @@ -378,6 +382,7 @@ export type AsyncMessages = | AttachLocalVariablesToTheme | RenameVariablesAsyncMessage | UpdateVariablesAsyncMessage + | PreviewRequestStartupAsyncMessage | RemoveRelaunchDataMessage; export type AsyncMessageResults = @@ -423,6 +428,7 @@ export type AsyncMessageResults = | AttachLocalVariablesToThemeResult | RenameVariablesAsyncMessageResult | UpdateVariablesAsyncMessageResult + | PreviewRequestStartupAsyncMessageResult | RemoveRelaunchDataMessageResult; export type AsyncMessagesMap = { diff --git a/packages/tokens-studio-for-figma/webpack.config.js b/packages/tokens-studio-for-figma/webpack.config.js index b52889adf..9b7f0d272 100644 --- a/packages/tokens-studio-for-figma/webpack.config.js +++ b/packages/tokens-studio-for-figma/webpack.config.js @@ -3,6 +3,10 @@ const Dotenv = require('dotenv-webpack'); const webpack = require('webpack'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const HtmlInlineScriptPlugin = require('html-inline-script-webpack-plugin'); +const ReactRefreshPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); +const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); +const SpeedMeasurePlugin = require("speed-measure-webpack-plugin"); + const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); const { sentryWebpackPlugin } = require("@sentry/webpack-plugin"); const dotenv = require('dotenv'); @@ -11,7 +15,18 @@ const dotenv = require('dotenv'); const { version } = require('./package.json'); -module.exports = (env, argv) => { +const wrapper = (callback) => { + const measureSpeed = false; + if (!measureSpeed) { // Set to true to enable SpeedMeasurePlugin (breaks Figma UI build) + return callback; + } + const smp = new SpeedMeasurePlugin(); + + return smp.wrap(callback); +}; + +module.exports = wrapper((env, argv) => { + const isDevServer = process.env.WEBPACK_DEV_SERVER; //Needed to load the process.env variables for sentry dotenv.config({ @@ -29,17 +44,48 @@ module.exports = (env, argv) => { code: './src/plugin/controller.ts', // The entry point for your plugin code }, + devServer: { + contentBase: path.join(__dirname, 'dist'), + compress: true, + open: false, + // openPage: '/ui.html', + hot: true, + inline: true, + historyApiFallback: true, + port: 9000, + overlay: false, + }, + module: { rules: [ // Converts TypeScript code to JavaScript - { + // Development fast reload + ...(argv.PREVIEW_ENV === 'browser' && isDevServer ? [{ + test: /\.[jt]sx?$/, + exclude: /node_modules/, + use: [ + { + loader: 'swc-loader', + options: { + jsc: { + transform: { + react: { + development: argv.mode === 'development', + refresh: argv.mode === 'development' + } + } + } + } + }, + ], + }] : [{ test: /\.tsx?$/, use: [ { loader: 'swc-loader', }, ], - exclude: /node_modules/, + exclude: /(node_modules|.*\.test\.(js|ts))/ }, { test: /\.c?js$/, @@ -50,7 +96,7 @@ module.exports = (env, argv) => { loader: 'swc-loader', }, ], - }, + }]), // Enables including CSS by doing "import './file.css'" in your TypeScript code { test: /\.css$/, use: [{ loader: 'style-loader' }, { loader: 'css-loader' }] }, // Imports webfonts @@ -102,10 +148,11 @@ module.exports = (env, argv) => { publicPath: '', filename: '[name].js', sourceMapFilename: "[name].js.map", - path: path.resolve(__dirname, 'dist'), // Compile into a folder called "dist" + path: path.resolve(__dirname, argv.PREVIEW_ENV === 'browser' && !isDevServer ? 'preview' : 'dist'), // Compile into a folder called "dist" }, // Tells Webpack to generate "ui.html" and to inline "ui.ts" into it plugins: [ + isDevServer && argv.PREVIEW_ENV === 'browser' && new ReactRefreshPlugin({ overlay: false }), new webpack.ProvidePlugin({ process: 'process/browser', }), @@ -145,10 +192,11 @@ module.exports = (env, argv) => { chunks: ['ui'], cache: argv.mode === 'production', }), - new HtmlInlineScriptPlugin({ + argv.PREVIEW_ENV !== 'browser' && new HtmlInlineScriptPlugin({ assetPreservePattern: [/\.js$/], }), new webpack.DefinePlugin({ + 'process.env.PREVIEW_ENV': JSON.stringify(argv.PREVIEW_ENV), 'process.env.LAUNCHDARKLY_FLAGS': JSON.stringify(process.env.LAUNCHDARKLY_FLAGS), }), new ForkTsCheckerWebpackPlugin({ @@ -160,7 +208,8 @@ module.exports = (env, argv) => { new webpack.ProvidePlugin({ Buffer: ['buffer', 'Buffer'], }), - + argv.ANALYZE_BUNDLE && new BundleAnalyzerPlugin({ openAnalyzer: false }), ].filter(Boolean), } -}; +}); +// });