diff --git a/.idea/misc.xml b/.idea/misc.xml index 03f397c..8c4f656 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,5 +1,8 @@ + + diff --git a/packages/code/src/generate.ts b/packages/code/src/generate.ts index 9cd4c3f..63bfc3a 100644 --- a/packages/code/src/generate.ts +++ b/packages/code/src/generate.ts @@ -1,10 +1,11 @@ +import type { ComponentTreeNode } from '@onyx-gen/types' import FigmaNodeParser from './parsers/figma-node.parser' import HTMLGenerator from './generators/html.generator' -import { getSelectedNodes } from './utils' +import { getSelectedNodes, getUniqueInstanceNodes } from './utils' import ComponentSetProcessor from './set/component-set-processor' import { sendExecutionTimeMessage, - sendHtmlMessage, + sendGeneratedComponentsMessage, sendIsLoadingMessage, sendSelectedMessage, sendUnselectedMessage, @@ -86,11 +87,42 @@ export default async function generate(config: Configuration): Promise getUniqueInstanceNodes(node)))).flat() + + const mainComponentsOfInstanceNodes = await Promise.all( + instanceNodes + .map(instanceNode => instanceNode.getMainComponentAsync()) + .filter((mainComponent): mainComponent is Promise => mainComponent !== null), + ) + + const instances = await Promise.all(mainComponentsOfInstanceNodes.map(instanceNode => generateComponentTree(instanceNode, config))) + + console.log('SELECTED NODE', nodes[0]) + + const mainNode = nodes[0] + let mainNodeName = mainNode.name + + if (mainNode.type === 'COMPONENT') { + const hasComponentSetParent = mainNode.parent?.type === 'COMPONENT_SET' + + if (hasComponentSetParent) + mainNodeName = mainNode.parent.name + } + + const componentTree: ComponentTreeNode = { + name: mainNodeName, + figmaNode: nodes[0] as ComponentNode, // TODO MF: Should also work for other node types + code: html, + instances, + } + + console.log('### COMPONENT TREE ###', componentTree) + // only send message if html is not empty if (html) - sendHtmlMessage(html) - else - sendUnselectedMessage() + sendGeneratedComponentsMessage({ componentTree }) + + else sendUnselectedMessage() } catch (e) { figma.notify('Onyx: Unexpected Error') @@ -103,3 +135,48 @@ export default async function generate(config: Configuration): Promise { + const parser = new FigmaNodeParser({ default: 'default' }, config) + const generator = new HTMLGenerator([], {}, config) + + const tree = await parser.parse(node) + const html = tree ? await generator.generate(tree) : '' + + const instances: ComponentTreeNode[] = [] + + const instanceNodes = await getUniqueInstanceNodes(node) + for (const instanceNode of instanceNodes) { + if (!config.ignoredComponentInstances.includes(instanceNode.name)) { + const mainComponent = await instanceNode.getMainComponentAsync() + if (mainComponent) { + const instanceTree = await generateComponentTree(mainComponent, config) + instances.push(instanceTree) + } + } + } + + let nodeName = node.name + + if (node.type === 'COMPONENT') { + const hasComponentSetParent = node.parent?.type === 'COMPONENT_SET' + + if (hasComponentSetParent) + nodeName = node.parent.name + } + + return { + name: nodeName, + code: html, + figmaNode: node, + instances, + } +} diff --git a/packages/code/src/messages.ts b/packages/code/src/messages.ts index e94a4b8..ea8d55b 100644 --- a/packages/code/src/messages.ts +++ b/packages/code/src/messages.ts @@ -1,7 +1,8 @@ import type { ConfigurationPluginMessage, ExecutionTimePluginMessage, - HtmlPluginMessage, + GeneratedComponentsPluginMessage, + GeneratedComponentsPluginMessageData, IConfiguration, IsLoadingPluginMessage, PluginMessage, @@ -9,6 +10,7 @@ import type { SelectedPluginMessage, UnselectedPluginMessage, } from '@onyx-gen/types' + import EventBus from './event-bus' /** @@ -56,12 +58,13 @@ export function sendSelectedMessage(nodes: SelectedNode[] | null): void { /** * Sends an HTML message to the UI plugin. * - * @param {string} html - The HTML content to send. + * @param {GeneratedComponentsPluginMessageData} data - The generated components data. * * @return {void} */ -export function sendHtmlMessage(html: string): void { - const pluginMessage: HtmlPluginMessage = { event: 'html', data: { html } } +export function sendGeneratedComponentsMessage(data: GeneratedComponentsPluginMessageData): void { + const pluginMessage: GeneratedComponentsPluginMessage = { event: 'generated-components', data } + console.log('sent', pluginMessage) figma.ui.postMessage(pluginMessage) } diff --git a/packages/code/src/utils.ts b/packages/code/src/utils.ts index ba29cf0..943d126 100644 --- a/packages/code/src/utils.ts +++ b/packages/code/src/utils.ts @@ -35,6 +35,52 @@ export function getSelectedNode(): SceneNode | null { return null } +/** + * Checks if the node is an instance node. + * @param node - The node to check. + */ +function isInstanceNode(node: SceneNode): node is InstanceNode { + return node.type === 'INSTANCE' +} + +/** + * Retrieves all instance nodes in the node tree. + * @param node + */ +export function getInstanceNodes(node: SceneNode): InstanceNode[] { + if (isInstanceNode(node)) + return [node] + + else if ('children' in node) + return node.children.flatMap(getInstanceNodes) + + else return [] +} + +/** + * Retrieves all unique instance nodes in the node tree. + * @param node + */ +export async function getUniqueInstanceNodes(node: SceneNode): Promise { + const instanceNodes = getInstanceNodes(node) + + const mainComponentsInstanceNodePairs = ( + await Promise.all( + instanceNodes + .map(async (node) => { + const mainComponent = await node.getMainComponentAsync() + return [node, mainComponent] as [InstanceNode, ComponentNode | null] + }), + ) + ).filter((d): d is [InstanceNode, ComponentNode] => d[1] !== null) + + const uniqueMainComponentIds = Array.from(new Set(mainComponentsInstanceNodePairs.map(([_, mainComponent]) => mainComponent.id))) + return uniqueMainComponentIds.map((id) => { + const foundPair = mainComponentsInstanceNodePairs.find(([_, mainComponent]) => mainComponent.id === id) + return foundPair![0] + }) +} + /** * Zips two arrays together, creating pairs of corresponding elements. * diff --git a/packages/icons/CHANGELOG.md b/packages/icons/CHANGELOG.md index 6234de8..d1e5502 100644 --- a/packages/icons/CHANGELOG.md +++ b/packages/icons/CHANGELOG.md @@ -1,5 +1,17 @@ # @onyx-gen/icons +## 0.0.3 + +### Patch Changes + +- Add logo + +## 0.0.2 + +### Patch Changes + +- Add eye icons + ## 0.0.1 ### Patch Changes diff --git a/packages/icons/package.json b/packages/icons/package.json index f438eb7..b76de26 100644 --- a/packages/icons/package.json +++ b/packages/icons/package.json @@ -1,7 +1,7 @@ { "name": "@onyx-gen/icons", "type": "module", - "version": "0.0.1", + "version": "0.0.3", "private": false, "license": "MIT", "publishConfig": { diff --git a/packages/renderer/src/main.ts b/packages/renderer/src/main.ts index ff538e8..2c84b83 100644 --- a/packages/renderer/src/main.ts +++ b/packages/renderer/src/main.ts @@ -1,5 +1,9 @@ -import { type App, createApp } from 'vue' +import type { App, ComponentOptions } from 'vue' +import { createApp, defineComponent } from 'vue' import type { + ComponentTreeNode, + GeneratedComponentsPluginMessageData, + RendererPluginMessage, } from '@onyx-gen/types' @@ -9,21 +13,49 @@ import './unocss' let app: App | null = null // Listen for messages from the parent frame -window.addEventListener('message', async (event) => { +window.addEventListener('message', async (event: MessageEvent & { data: GeneratedComponentsPluginMessageData }) => { + console.log('[iFrame] Received message', event.data) + + // Unmount the previous app if (app !== null) app.unmount() + const data: GeneratedComponentsPluginMessageData = event.data + + // Construct Vue components recursively from the component tree + const rootComponentOptions = createVueComponent(data.componentTree) + + // Create the Vue app and mount it to the root element try { - app = createApp({ template: event.data.vue }) + app = createApp(rootComponentOptions) + app.mount('#app') } catch (e) { console.error('[iFrame] Error creating Vue app', e) - return } - - app.mount('#app') }) +/** + * Recursively creates a Vue component from a component tree node. + * + * @param node - The current node of the component tree. + * @returns A Vue component options object representing the node and its children. + */ +function createVueComponent(node: ComponentTreeNode): ComponentOptions { + const childComponents: Record = {} + node.instances.forEach((instance) => { + childComponents[instance.name] = createVueComponent(instance) + }) + + const options: ComponentOptions = { + name: node.name, + template: node.code, + components: childComponents, + } + + return defineComponent(options as any) as any +} + function observeHeight() { const targetElement = document.getElementById('app')! diff --git a/packages/types/src/events.ts b/packages/types/src/events.ts index be29512..767c65b 100644 --- a/packages/types/src/events.ts +++ b/packages/types/src/events.ts @@ -1,4 +1,4 @@ -import type { ComponentProps, IConfiguration, Mode, Unit, VariableNameTransformations } from './types' +import type { ComponentProps, ComponentTreeNode, IConfiguration, Mode, Unit, VariableNameTransformations } from './types' export type PluginMessageEvent = MessageEvent<{ pluginMessage: PluginMessage }> @@ -23,10 +23,10 @@ interface RendererPluginMessageData { height: number } -export type HtmlPluginMessage = BasePluginMessage<'html', HtmlPluginMessageData> +export type GeneratedComponentsPluginMessage = BasePluginMessage<'generated-components', GeneratedComponentsPluginMessageData> -interface HtmlPluginMessageData { - html: string +export interface GeneratedComponentsPluginMessageData { + componentTree: ComponentTreeNode } export type ConfigurationPluginMessage = BasePluginMessage<'configuration', ConfigurationPluginMessageData> @@ -110,7 +110,7 @@ interface SelectedPluginMessageData { export type PluginMessage = | RendererPluginMessage - | HtmlPluginMessage + | GeneratedComponentsPluginMessage | UnselectedPluginMessage | ExecutionTimePluginMessage | IsLoadingPluginMessage diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index 643b58b..37f9e27 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -24,3 +24,10 @@ export interface IConfiguration { variableNameTransformations: VariableNameTransformations ignoredComponentInstances: string[] } + +export interface ComponentTreeNode { + name: string + code: string + figmaNode: ComponentNode + instances: ComponentTreeNode[] +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 181aa72..30c4ca3 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -19,7 +19,7 @@ }, "devDependencies": { "@figma/plugin-typings": "^1.91.0", - "@onyx-gen/icons": "0.0.1", + "@onyx-gen/icons": "0.0.3", "@onyx-gen/tsconfig": "workspace:^", "@onyx-gen/types": "workspace:^", "@unocss/transformer-variant-group": "^0.59.4", diff --git a/packages/ui/src/components/code.vue b/packages/ui/src/components/code.vue index 6408c45..5580823 100644 --- a/packages/ui/src/components/code.vue +++ b/packages/ui/src/components/code.vue @@ -2,38 +2,154 @@ import { codeToHtml } from 'shiki' import { computedAsync, useClipboard } from '@vueuse/core' import { storeToRefs } from 'pinia' +import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/vue' +import { computed } from 'vue' +import type { ComponentTreeNode } from '@onyx-gen/types' import { useTheme } from '@/composables/useTheme' import { useNotification } from '@/composables/useNotification' import { useCode } from '@/stores/useCode' import Wrapper from '@/components/layout/wrapper.vue' -const { code, isLoading, executionTime } = storeToRefs(useCode()) +const { isLoading, executionTime, components } = storeToRefs(useCode()) const { theme } = useTheme() -const html = computedAsync( - async () => codeToHtml(code.value, { - lang: 'vue-html', - theme: theme.value, - }), - '', -) +const componentTree = computed(() => components.value?.componentTree) +const mainComponentCode = computed(() => componentTree.value?.code || '') -const { copy } = useClipboard({ source: code, legacy: true }) +const { copy } = useClipboard({ source: mainComponentCode, legacy: true }) const { notify } = useNotification() function onCopy() { - copy(code.value) + copy(mainComponentCode.value) notify('Copied to clipboard!') } + +export type ComponentTreeNodeWithHTML = ComponentTreeNode & { + html: string // Ensure that html is always present in this type + instances: ComponentTreeNodeWithHTML[] // Make sure instances are of the same extended type +} + +/** + * Traverses a tree of `ComponentTreeNode` and adds an `html` property to each node. + * The `html` property is generated from the `code` property of each node. + * @param _node The root node of the tree or subtree to traverse and modify. + * @returns The modified node with the `html` property added. + */ +async function addHtmlPropToTree(_node: ComponentTreeNode): Promise { + const node = { ..._node, html: '' } as ComponentTreeNodeWithHTML + + // Generate HTML from the code; this can be customized as needed. + node.html = await generateHtmlFromCode(node.code) + + // Recursively apply this function to all child instances + const instances = [] + for (const instance of node.instances) + instances.push(await addHtmlPropToTree(instance)) + node.instances = instances + + return node +} + +/** + * Generates HTML from a given code string. + * @param code The code to generate HTML from. + * @returns The generated HTML. + */ +async function generateHtmlFromCode(code: string): Promise { + return await codeToHtml(code, { + lang: 'vue-html', + theme: theme.value, + }) +} + +interface FlattenedTreeNode { + name: string + code: string + html: string +} + +/** + * Flattens a tree of `ComponentTreeNodeWithHTML` into an array of `FlattenedTreeNode`. + * Each node in the tree will be transformed into an object containing `name`, `code`, and `html`. + * @param node The root node of the tree or subtree to flatten. + * @param accumulator An array that accumulates the flattened results. It's used internally by the recursion. + * @returns An array of `FlattenedTreeNode` containing the flattened tree nodes. + */ +function flattenTree(node: ComponentTreeNodeWithHTML, accumulator: FlattenedTreeNode[] = []): FlattenedTreeNode[] { + // Create a simple object with the desired properties and add it to the accumulator + const flatNode: FlattenedTreeNode = { + name: node.name, + code: node.code, + html: node.html, + } + accumulator.push(flatNode) + + // Recursively flatten each child node + node.instances.forEach(child => flattenTree(child, accumulator)) + + return accumulator +} + +const componentTreeWithHTML = computedAsync(async () => { + if (!componentTree.value) + return + + return await addHtmlPropToTree(componentTree.value) +}) + +const flattenedTree = computed(() => { + if (!componentTreeWithHTML.value) + return [] + + return flattenTree(componentTreeWithHTML.value) +})