├── .editorconfig ├── .eslintrc.cjs ├── .github └── workflows │ ├── check-rewrite.yml │ └── deploy-rewrite.yml ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets ├── code-dark.png ├── code-light.png ├── deep-dark.png ├── deep-light.png ├── duplicate-dark.png ├── duplicate-light.png ├── hero-dark.svg ├── hero-light.svg ├── measure-dark.png ├── measure-light.png ├── plugins-dark.png ├── plugins-light.png ├── scroll-dark.png ├── scroll-light.png ├── unit-dark.png └── unit-light.png ├── build └── readme.ts ├── codegen ├── codegen.ts ├── safe.ts ├── types.ts └── worker.ts ├── components ├── Badge.vue ├── Code.vue ├── Copyable.vue ├── IconButton.vue ├── Panel.vue ├── PluginInstaller.vue ├── PluginItem.vue ├── Section.vue ├── Toast.vue ├── icons │ ├── Check.vue │ ├── Copy.vue │ ├── Discord.vue │ ├── GitHub.vue │ ├── Info.vue │ ├── Inspect.vue │ ├── Measure.vue │ ├── Minus.vue │ ├── Plus.vue │ ├── Preferences.vue │ ├── Preview.vue │ ├── Refresh.vue │ ├── Select.vue │ ├── Times.vue │ └── Warning.vue └── sections │ ├── CodeSection.vue │ ├── ErrorSection.vue │ ├── MetaSection.vue │ ├── PluginsSection.vue │ └── PrefSection.vue ├── composables ├── copy.ts ├── index.ts ├── input.ts ├── key-lock.ts ├── plugin.ts ├── selection.ts └── toast.ts ├── entrypoints ├── background.ts ├── content.ts └── ui │ ├── App.vue │ ├── global.d.ts │ ├── index.ts │ ├── prism.ts │ └── style.css ├── package.json ├── patches └── wxt.patch ├── plugins ├── README.md ├── available-plugins.json ├── package.json ├── src │ └── index.ts └── tsconfig.json ├── pnpm-lock.yaml ├── public ├── icon-128.png └── rules │ └── figma.json ├── rewrite ├── config.ts └── figma.ts ├── scripts └── check-rewrite.ts ├── shared └── types.ts ├── tsconfig.json ├── types.d.ts ├── ui ├── figma.ts ├── quirks │ ├── basic │ │ ├── css.ts │ │ ├── index.ts │ │ ├── parsers.ts │ │ └── types.ts │ ├── font │ │ ├── css.ts │ │ ├── index.ts │ │ ├── parsers.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── index.ts │ ├── stack │ │ ├── css.ts │ │ ├── index.ts │ │ ├── parsers.ts │ │ └── types.ts │ ├── style │ │ ├── css.ts │ │ ├── index.ts │ │ ├── parsers.ts │ │ └── types.ts │ └── types.ts └── state.ts ├── utils ├── codegen.ts ├── color.ts ├── component.ts ├── css.ts ├── dom.ts ├── figma.ts ├── index.ts ├── keyboard.ts ├── module.ts ├── number.ts ├── object.ts ├── string.ts └── tempad.ts └── wxt.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | require('@rushstack/eslint-patch/modern-module-resolution') 3 | 4 | module.exports = { 5 | root: true, 6 | plugins: ['perfectionist'], 7 | extends: [ 8 | 'plugin:vue/vue3-essential', 9 | 'eslint:recommended', 10 | '@vue/eslint-config-typescript', 11 | '@vue/eslint-config-prettier/skip-formatting' 12 | ], 13 | parserOptions: { 14 | ecmaVersion: 'latest' 15 | }, 16 | rules: { 17 | 'vue/multi-word-component-names': 'off', 18 | 'perfectionist/sort-imports': 'error' 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/check-rewrite.yml: -------------------------------------------------------------------------------- 1 | name: check-script-rewrite 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | schedule: 9 | - cron: '0 */2 * * *' 10 | 11 | jobs: 12 | check-rewrite: 13 | runs-on: ubuntu-latest 14 | 15 | env: 16 | FIGMA_EMAIL: ${{ secrets.FIGMA_EMAIL }} 17 | FIGMA_PASSWORD: ${{ secrets.FIGMA_PASSWORD }} 18 | 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v4 22 | 23 | - name: Setup Node.js 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: '22.x' 27 | 28 | - name: Setup pnpm 29 | uses: pnpm/action-setup@v4 30 | with: 31 | version: 10 32 | 33 | - name: Install dependencies 34 | run: pnpm install --frozen-lockfile 35 | 36 | - name: Run check script 37 | run: pnpm exec tsx scripts/check-rewrite.ts 38 | -------------------------------------------------------------------------------- /.github/workflows/deploy-rewrite.yml: -------------------------------------------------------------------------------- 1 | name: deploy-rewrite-script 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | check-rewrite: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v4 14 | 15 | - name: Setup Node.js 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: '22.x' 19 | 20 | - name: Setup pnpm 21 | uses: pnpm/action-setup@v4 22 | with: 23 | version: 10 24 | 25 | - name: Install dependencies 26 | run: pnpm install --frozen-lockfile 27 | 28 | - name: Build rewrite script 29 | run: pnpm run build:rewrite 30 | 31 | - name: Deploy to gh-pages 32 | uses: peaceiris/actions-gh-pages@v4 33 | with: 34 | github_token: ${{ secrets.GITHUB_TOKEN }} 35 | publish_dir: ./dist 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .output 12 | stats.html 13 | stats-*.json 14 | .wxt 15 | web-ext.config.ts 16 | dist 17 | 18 | # Editor directories and files 19 | .vscode/* 20 | !.vscode/extensions.json 21 | .idea 22 | .DS_Store 23 | *.suo 24 | *.ntvs* 25 | *.njsproj 26 | *.sln 27 | *.sw? 28 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/prettierrc", 3 | "semi": false, 4 | "tabWidth": 2, 5 | "singleQuote": true, 6 | "printWidth": 100, 7 | "trailingComma": "none", 8 | "overrides": [ 9 | { 10 | "files": "*.css", 11 | "options": { 12 | "singleQuote": false 13 | } 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Changelog 4 | 5 | ## 0.10.1 6 | 7 | - Fixed the copy button for code blocks. 8 | - Improved error handling when fetching live rules. 9 | 10 | ## 0.10.0 11 | 12 | - Variables in code panels are now copyable. 13 | 14 | ## 0.9.0 15 | 16 | - Updated the script rewrite logic. 17 | - Moved the rewriter script to a remote URL on GitHub repo. 18 | - Added dynamic DNR rules syncing mechanism. 19 | 20 | ## 0.8.5 21 | 22 | - Update WXT to fix the problem that multiple WXT-based web extensions invalidates each other. 23 | 24 | ## 0.8.4 25 | 26 | - Updated the script rewrite logic. 27 | 28 | ## 0.8.3 29 | 30 | - Improved user instructions when `window.figma` is unavailable. 31 | - Trime string props for codegen. 32 | 33 | ## 0.8.2 34 | 35 | - Fixed script replacement after Figma update. 36 | 37 | ## 0.8.1 38 | 39 | - Fixed script replacement after Figma update. 40 | 41 | ## 0.8.0 42 | 43 | - Fixed `window.figma` recovery for clients that are loading script files without `.br` extension. 44 | - Quirks mode is no longer available as Figma removed the `window.DebuggingHelpers.logSelected` API. 45 | 46 | ## 0.7.1 47 | 48 | - Improved component codegen. 49 | 50 | ## 0.7.0 51 | 52 | - Added a new option: `scale`. 53 | 54 | ## 0.6.3 55 | 56 | - Improved component codegen, remove `undefined` values automatically. 57 | 58 | ## 0.6.2 59 | 60 | - Added `visible` info to component codegen. 61 | 62 | ## 0.6.1 63 | 64 | - No longer supports built-in plugins. 65 | 66 | ## 0.6.0 67 | 68 | - Provided a brand new icon for the extension. 69 | 70 | ## 0.5.9 71 | 72 | - Fixed vector fills extraction. 73 | 74 | ## 0.5.8 75 | 76 | - Added vector node support for component codegen plugin. 77 | 78 | ## 0.5.7 79 | 80 | - Added main component info for component codegen plugins. 81 | 82 | ## 0.5.6 83 | 84 | - Fixed the problem that worker requester isn't properly cached. 85 | 86 | ## 0.5.5 87 | 88 | - Fixed that plugin update was not working. 89 | 90 | ## 0.5.4 91 | 92 | - Added plugin update support. 93 | - Improved focus styles. 94 | 95 | ## 0.5.3 96 | 97 | - Improve HTML escaping and indentation for component codegen. 98 | 99 | ## 0.5.2 100 | 101 | - Fix indentation for component codegen. 102 | 103 | ## 0.5.1 104 | 105 | - Improved the code output for component codegen. 106 | - Fixed a tiny UI issue under UI2. 107 | 108 | ## 0.5.0 109 | 110 | - Added children support to `DesignComponent` for component codegen. 111 | 112 | ## 0.4.10 113 | 114 | - Improved component codegen. 115 | 116 | ## 0.4.9 117 | 118 | - Fixed plugin import regression. 119 | - Improved component event handler codegen. 120 | 121 | ## 0.4.8 122 | 123 | - Added `transformComponent` support for plugins. 124 | 125 | ## 0.4.7 126 | 127 | - Fixed rule priority for removing CSP header. 128 | 129 | ## 0.4.6 130 | 131 | - Fixed CSP issue by temporarily remove `main_frame` CSP header. 132 | 133 | ## 0.4.5 134 | 135 | - Added a badge to show what plugin is transforming the code. 136 | 137 | ## 0.4.4 138 | 139 | - Added plugin registry. 140 | - Improved error reporting when importing plugins. 141 | 142 | ## 0.4.3 143 | 144 | - Plugins can now be exported with default exports. 145 | - Plugin transform hooks now accepts a new `options` parameter. 146 | 147 | ## 0.4.2 148 | 149 | - Fixed the regression that preferences were not reactive. 150 | 151 | ## 0.4.1 152 | 153 | - Added `host_permissions` so that `declarativeNetworkResources` can take effect. 154 | 155 | ## 0.4.0 156 | 157 | - Added plugins support. 158 | - Added experimental support for enabling `window.figma` in view-only mode. 159 | 160 | ## 0.3.5 161 | 162 | - Excluded individual border if `border-width` is `0` in quirks mode. 163 | 164 | ## 0.3.4 165 | 166 | - Fixed the problem that borders are not correctly recognized in quirks mode. 167 | - Fixed a style detail when the extension is minimized. 168 | 169 | ## 0.3.3 170 | 171 | - Improved toast. 172 | 173 | ## 0.3.2 174 | 175 | - Adapted toast and quirks mode hint to UI3. 176 | 177 | ## 0.3.1 178 | 179 | - Fixed CSS unit for `stroke-width`. 180 | 181 | ## 0.3.0 182 | 183 | - Added support for UI3. 184 | 185 | ## 0.2.10 186 | 187 | - Fixed that `stroke-dash-pattern` may be unavailable. 188 | 189 | ## 0.2.9 190 | 191 | - Updated text data retrieval for quirks mode. 192 | 193 | ## 0.2.8 194 | 195 | - Added compatibility for `Tem.RichText`. 196 | 197 | ## 0.2.7 198 | 199 | - Figma file paths may now start with `design`. 200 | 201 | ## 0.2.6 202 | 203 | - Used overlay scrollbar to prevent layout shift. 204 | 205 | ## 0.2.5 206 | 207 | - Quirks mode hint now shows how to duplicate to drafts upon click. 208 | 209 | ## 0.2.4 210 | 211 | - Fixed paint data and stroke data might be undefined in quirks mode. 212 | 213 | ## 0.2.3 214 | 215 | - Added codegen support for rotation in quirks mode. 216 | - Stopped forcing into quirks mode by mistake. 217 | 218 | ## 0.2.2 219 | 220 | - Fixed that font props were generated for non-text nodes in quirks mode. 221 | - Fixed that rgba colors were treated as multiple fill data in quirks mode. 222 | 223 | ## 0.2.1 224 | 225 | - Fixed `line-height` in quirks mode. 226 | 227 | ## 0.2.0 228 | 229 | - Added support for font related CSS codegen for text nodes in quirks mode. 230 | - Refactored quirks mode so that its now more modular and easier to maintain. 231 | 232 | ## 0.1.1 233 | 234 | - Improved codegen for `padding` in quirks mode. 235 | 236 | ## 0.1.0 237 | 238 | - Added experimental support for quirks mode that can run under view-only pages. 239 | 240 | ## 0.0.9 241 | 242 | - Lowered z-index to prevent Figma's own menus and popups from being covered by TemPad Dev. 243 | - Fixed the problem that double clicking pref button triggers the panel's toggle. 244 | 245 | ## 0.0.8 246 | 247 | - Improved compatibility with TemPad icon names. 248 | 249 | ## 0.0.7 250 | 251 | - Added lib name badge for TemPad components. 252 | - Improved display name for icons for TemPad components. 253 | 254 | ## 0.0.6 255 | 256 | - Fixed the problem that code copy doesn't update correctly. 257 | 258 | ## 0.0.5 259 | 260 | - Improved selection updates via keyboard shortcuts. 261 | - Improved TemPad code indentation. 262 | - Improved panel sizing. 263 | 264 | ## 0.0.4 265 | 266 | - Fixed lock mode when user    + drag with mouses. 267 | - Fixed selection updates triggered by objects panel or . 268 | - Fixed display when multiple nodes are selected. 269 | 270 | ## 0.0.3 271 | 272 | - Optimized panel drag range based on Figma's floating window handling to prevent dragging beyond visibility. 273 | 274 | ## 0.0.2 275 | 276 | - Added preferences: CSS unit and root font size. 277 | - Added toggling minimized mode by double clicking the header. 278 | - Improved JavaScript style object output. 279 | - Fixed `z-index` so that the panel won't be covered by nav bar. 280 | - Fixed text-overflow style for node names. 281 | 282 | ## 0.0.1 283 | 284 | - First version. 285 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024-Present Baidu EFE team 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /assets/code-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ecomfe/tempad-dev/55f169d23d7d9b968e27713609cac53e5fedf233/assets/code-dark.png -------------------------------------------------------------------------------- /assets/code-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ecomfe/tempad-dev/55f169d23d7d9b968e27713609cac53e5fedf233/assets/code-light.png -------------------------------------------------------------------------------- /assets/deep-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ecomfe/tempad-dev/55f169d23d7d9b968e27713609cac53e5fedf233/assets/deep-dark.png -------------------------------------------------------------------------------- /assets/deep-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ecomfe/tempad-dev/55f169d23d7d9b968e27713609cac53e5fedf233/assets/deep-light.png -------------------------------------------------------------------------------- /assets/duplicate-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ecomfe/tempad-dev/55f169d23d7d9b968e27713609cac53e5fedf233/assets/duplicate-dark.png -------------------------------------------------------------------------------- /assets/duplicate-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ecomfe/tempad-dev/55f169d23d7d9b968e27713609cac53e5fedf233/assets/duplicate-light.png -------------------------------------------------------------------------------- /assets/hero-dark.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/hero-light.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/measure-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ecomfe/tempad-dev/55f169d23d7d9b968e27713609cac53e5fedf233/assets/measure-dark.png -------------------------------------------------------------------------------- /assets/measure-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ecomfe/tempad-dev/55f169d23d7d9b968e27713609cac53e5fedf233/assets/measure-light.png -------------------------------------------------------------------------------- /assets/plugins-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ecomfe/tempad-dev/55f169d23d7d9b968e27713609cac53e5fedf233/assets/plugins-dark.png -------------------------------------------------------------------------------- /assets/plugins-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ecomfe/tempad-dev/55f169d23d7d9b968e27713609cac53e5fedf233/assets/plugins-light.png -------------------------------------------------------------------------------- /assets/scroll-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ecomfe/tempad-dev/55f169d23d7d9b968e27713609cac53e5fedf233/assets/scroll-dark.png -------------------------------------------------------------------------------- /assets/scroll-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ecomfe/tempad-dev/55f169d23d7d9b968e27713609cac53e5fedf233/assets/scroll-light.png -------------------------------------------------------------------------------- /assets/unit-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ecomfe/tempad-dev/55f169d23d7d9b968e27713609cac53e5fedf233/assets/unit-dark.png -------------------------------------------------------------------------------- /assets/unit-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ecomfe/tempad-dev/55f169d23d7d9b968e27713609cac53e5fedf233/assets/unit-light.png -------------------------------------------------------------------------------- /build/readme.ts: -------------------------------------------------------------------------------- 1 | import commentMark from 'comment-mark' 2 | import { readFileSync, writeFileSync } from 'node:fs' 3 | import { dirname, resolve } from 'node:path' 4 | import { fileURLToPath } from 'node:url' 5 | 6 | interface Vendor { 7 | name: string 8 | icon: string 9 | } 10 | 11 | const vendorDomainMap: Record = { 12 | 'raw.githubusercontent.com': { 13 | name: 'GitHub', 14 | icon: 'github' 15 | }, 16 | 'gist.githubusercontent.com': { 17 | name: 'GitHub Gist', 18 | icon: 'github' 19 | }, 20 | 'gitlab.com': { 21 | name: 'GitLab', 22 | icon: 'gitlab' 23 | } 24 | } 25 | 26 | interface PluginMeta { 27 | name: string 28 | description: string 29 | author: string 30 | repo: string 31 | url: string 32 | } 33 | 34 | const __filename = fileURLToPath(import.meta.url) 35 | const __dirname = dirname(__filename) 36 | 37 | const readmePath = resolve(__dirname, '../README.md') 38 | const pluginsPath = resolve(__dirname, '../plugins/available-plugins.json') 39 | 40 | const readme = readFileSync(readmePath, 'utf-8') 41 | const plugins = JSON.parse(readFileSync(pluginsPath, 'utf-8')) as PluginMeta[] 42 | 43 | function getDomain(url: string) { 44 | try { 45 | const { hostname } = new URL(url) 46 | return hostname 47 | } catch (error) { 48 | console.error('Invalid URL:', url) 49 | return null 50 | } 51 | } 52 | 53 | function getVendor(url: string): Vendor | string | null { 54 | const domain = getDomain(url) 55 | 56 | if (!domain) { 57 | return null 58 | } 59 | 60 | const vendor = vendorDomainMap[domain] 61 | 62 | return vendor || domain 63 | } 64 | 65 | function generatePluginTable(plugins: PluginMeta[]) { 66 | return `| Plugin name | Description | Author | Repository | 67 | | -- | -- | -- | -- | 68 | ${plugins 69 | .map(({ name, description, author, repo, url }) => { 70 | const vendor = getVendor(url) 71 | const link = vendor 72 | ? typeof vendor === 'string' 73 | ? `[${vendor}](${repo})` 74 | : `${vendor.name} [${vendor.name}](${repo})` 75 | : '' 76 | 77 | return `| \`@${name}\` | ${description} | [${author}](https://github.com/${author}) | ${link} |` 78 | }) 79 | .join('\n')}` 80 | } 81 | 82 | writeFileSync( 83 | readmePath, 84 | commentMark(readme, { 85 | availablePlugins: generatePluginTable(plugins) 86 | }), 87 | 'utf-8' 88 | ) 89 | -------------------------------------------------------------------------------- /codegen/codegen.ts: -------------------------------------------------------------------------------- 1 | import type { RequestPayload, ResponsePayload, CodeBlock } from '@/codegen/types' 2 | import type { Plugin } from '@/shared/types' 3 | 4 | import { serializeComponent } from '@/utils/component' 5 | import { serializeCSS } from '@/utils/css' 6 | import { evaluate } from '@/utils/module' 7 | 8 | import type { RequestMessage, ResponseMessage } from './worker' 9 | 10 | import safe from './safe' 11 | 12 | type Request = RequestMessage 13 | type Response = ResponseMessage 14 | 15 | const IMPORT_RE = /^\s*import\s+(([^'"\n]+|'[^']*'|"[^"]*")|\s*\(\s*[^)]*\s*\))/gm 16 | 17 | const postMessage = globalThis.postMessage 18 | 19 | globalThis.onmessage = async ({ data }: MessageEvent) => { 20 | const { id, payload } = data 21 | const codeBlocks: CodeBlock[] = [] 22 | 23 | const { style, component, options, pluginCode } = payload 24 | let plugin = null 25 | 26 | try { 27 | if (pluginCode) { 28 | if (IMPORT_RE.test(pluginCode)) { 29 | throw new Error('`import` is not allowed in plugins.') 30 | } 31 | 32 | const exports = await evaluate(pluginCode) 33 | plugin = (exports.default || exports.plugin) as Plugin 34 | } 35 | } catch (e) { 36 | console.error(e) 37 | const message: Response = { 38 | id, 39 | error: e 40 | } 41 | postMessage(message) 42 | return 43 | } 44 | 45 | const { 46 | component: componentOptions, 47 | css: cssOptions, 48 | js: jsOptions, 49 | ...rest 50 | } = plugin?.code ?? {} 51 | 52 | if (componentOptions && component) { 53 | const { lang, transformComponent } = componentOptions 54 | const componentCode = serializeComponent(component, { lang }, { transformComponent }) 55 | if (componentCode) { 56 | codeBlocks.push({ 57 | name: 'component', 58 | title: componentOptions?.title ?? 'Component', 59 | lang: componentOptions?.lang ?? 'jsx', 60 | code: componentCode 61 | }) 62 | } 63 | } 64 | 65 | if (cssOptions !== false) { 66 | const cssCode = serializeCSS(style, options, cssOptions) 67 | if (cssCode) { 68 | codeBlocks.push({ 69 | name: 'css', 70 | title: cssOptions?.title ?? 'CSS', 71 | lang: cssOptions?.lang ?? 'css', 72 | code: cssCode 73 | }) 74 | } 75 | } 76 | 77 | if (jsOptions !== false) { 78 | const jsCode = serializeCSS(style, { ...options, toJS: true }, jsOptions) 79 | if (jsCode) { 80 | codeBlocks.push({ 81 | name: 'js', 82 | title: jsOptions?.title ?? 'JavaScript', 83 | lang: jsOptions?.lang ?? 'js', 84 | code: jsCode 85 | }) 86 | } 87 | } 88 | 89 | codeBlocks.push( 90 | ...Object.keys(rest) 91 | .map((name) => { 92 | const extraOptions = rest[name] 93 | if (extraOptions === false) { 94 | return null 95 | } 96 | 97 | const code = serializeCSS(style, options, extraOptions) 98 | if (!code) { 99 | return null 100 | } 101 | return { 102 | name, 103 | title: extraOptions.title ?? name, 104 | lang: extraOptions.lang ?? 'css', 105 | code 106 | } 107 | }) 108 | .filter((item): item is CodeBlock => item != null) 109 | ) 110 | 111 | const message: Response = { 112 | id, 113 | payload: { codeBlocks, pluginName: plugin?.name } 114 | } 115 | postMessage(message) 116 | } 117 | 118 | // Only expose the necessary APIs to plugins 119 | Object.getOwnPropertyNames(globalThis) 120 | .filter((key) => !safe.has(key)) 121 | .forEach((key) => { 122 | // @ts-ignore 123 | globalThis[key] = undefined 124 | }) 125 | 126 | Object.defineProperties(globalThis, { 127 | name: { value: 'codegen', writable: false, configurable: false }, 128 | onmessage: { value: undefined, writable: false, configurable: false }, 129 | onmessageerror: { value: undefined, writable: false, configurable: false }, 130 | postMessage: { value: undefined, writable: false, configurable: false } 131 | }) 132 | -------------------------------------------------------------------------------- /codegen/safe.ts: -------------------------------------------------------------------------------- 1 | export default new Set([ 2 | 'Object', 3 | 'Function', 4 | 'Array', 5 | 'Number', 6 | 'parseFloat', 7 | 'parseInt', 8 | 'Infinity', 9 | 'NaN', 10 | 'undefined', 11 | 'Boolean', 12 | 'String', 13 | 'Symbol', 14 | 'Date', 15 | 'Promise', 16 | 'RegExp', 17 | 'Error', 18 | 'AggregateError', 19 | 'EvalError', 20 | 'RangeError', 21 | 'ReferenceError', 22 | 'SyntaxError', 23 | 'TypeError', 24 | 'URIError', 25 | 'globalThis', 26 | 'JSON', 27 | 'Math', 28 | 'Intl', 29 | 'ArrayBuffer', 30 | 'Atomics', 31 | 'Uint8Array', 32 | 'Int8Array', 33 | 'Uint16Array', 34 | 'Int16Array', 35 | 'Uint32Array', 36 | 'Int32Array', 37 | 'Float32Array', 38 | 'Float64Array', 39 | 'Uint8ClampedArray', 40 | 'BigUint64Array', 41 | 'BigInt64Array', 42 | 'DataView', 43 | 'Map', 44 | 'BigInt', 45 | 'Set', 46 | 'WeakMap', 47 | 'WeakSet', 48 | 'Proxy', 49 | 'Reflect', 50 | 'FinalizationRegistry', 51 | 'WeakRef', 52 | 'decodeURI', 53 | 'decodeURIComponent', 54 | 'encodeURI', 55 | 'encodeURIComponent', 56 | 'escape', 57 | 'unescape', 58 | 'isFinite', 59 | 'isNaN', 60 | 'console', 61 | 'URLSearchParams', 62 | 'URLPattern', 63 | 'URL', 64 | 'Blob', 65 | 'onmessage' 66 | ]) 67 | -------------------------------------------------------------------------------- /codegen/types.ts: -------------------------------------------------------------------------------- 1 | import type { DesignComponent, SupportedLang } from '@/shared/types' 2 | 3 | export interface SerializeOptions { 4 | useRem: boolean 5 | rootFontSize: number 6 | scale: number 7 | } 8 | 9 | export interface RequestPayload { 10 | style: Record 11 | component?: DesignComponent 12 | options: SerializeOptions 13 | pluginCode?: string 14 | } 15 | 16 | export interface ResponsePayload { 17 | pluginName?: string 18 | codeBlocks: CodeBlock[] 19 | } 20 | 21 | export type CodeBlock = { 22 | name: string 23 | title: string 24 | code: string 25 | lang: SupportedLang 26 | } 27 | -------------------------------------------------------------------------------- /codegen/worker.ts: -------------------------------------------------------------------------------- 1 | let id = 0 2 | 3 | export type RequestMessage = { 4 | id: number 5 | payload: T 6 | } 7 | 8 | export type ResponseMessage = { 9 | id: number 10 | payload?: T 11 | error?: unknown 12 | } 13 | 14 | type PendingRequest = { 15 | resolve: (result: T) => void 16 | reject: (reason?: unknown) => void 17 | } 18 | 19 | const pending = new Map>() 20 | 21 | type WorkerClass = { 22 | new (...args: any[]): Worker 23 | } 24 | 25 | const cache = new Map Promise>() 26 | 27 | export function createWorkerRequester(Worker: WorkerClass) { 28 | if (cache.has(Worker)) { 29 | return cache.get(Worker) as (payload: T) => Promise 30 | } 31 | 32 | const worker = new Worker() 33 | 34 | worker.onmessage = ({ data }: MessageEvent>) => { 35 | const { id, payload, error } = data 36 | 37 | const request = pending.get(id) 38 | if (request) { 39 | if (error) { 40 | request.reject(error) 41 | } else { 42 | request.resolve(payload) 43 | } 44 | pending.delete(id) 45 | } 46 | } 47 | 48 | const request = function(payload: T): Promise { 49 | return new Promise((resolve, reject) => { 50 | pending.set(id, { resolve, reject }) 51 | 52 | const message: RequestMessage = { id, payload } 53 | worker.postMessage(message) 54 | id++ 55 | }) 56 | } 57 | 58 | cache.set(Worker, request) 59 | 60 | return request 61 | } 62 | -------------------------------------------------------------------------------- /components/Badge.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | 30 | -------------------------------------------------------------------------------- /components/Code.vue: -------------------------------------------------------------------------------- 1 | 68 | 69 | 83 | 84 | 123 | 124 | 234 | -------------------------------------------------------------------------------- /components/Copyable.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 29 | 30 | 58 | -------------------------------------------------------------------------------- /components/IconButton.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 42 | 43 | 135 | -------------------------------------------------------------------------------- /components/Panel.vue: -------------------------------------------------------------------------------- 1 | 95 | 96 | 111 | 112 | 153 | 154 | 173 | -------------------------------------------------------------------------------- /components/PluginInstaller.vue: -------------------------------------------------------------------------------- 1 | 75 | 76 | 93 | 94 | 99 | -------------------------------------------------------------------------------- /components/PluginItem.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 89 | 90 | 205 | -------------------------------------------------------------------------------- /components/Section.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 66 | 67 | 131 | -------------------------------------------------------------------------------- /components/Toast.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 31 | 32 | 134 | -------------------------------------------------------------------------------- /components/icons/Check.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 31 | -------------------------------------------------------------------------------- /components/icons/Copy.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 38 | -------------------------------------------------------------------------------- /components/icons/Discord.vue: -------------------------------------------------------------------------------- 1 | 18 | -------------------------------------------------------------------------------- /components/icons/GitHub.vue: -------------------------------------------------------------------------------- 1 | 18 | -------------------------------------------------------------------------------- /components/icons/Info.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 39 | -------------------------------------------------------------------------------- /components/icons/Inspect.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 54 | -------------------------------------------------------------------------------- /components/icons/Measure.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 42 | -------------------------------------------------------------------------------- /components/icons/Minus.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 34 | -------------------------------------------------------------------------------- /components/icons/Plus.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 39 | -------------------------------------------------------------------------------- /components/icons/Preferences.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 39 | -------------------------------------------------------------------------------- /components/icons/Preview.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 39 | -------------------------------------------------------------------------------- /components/icons/Refresh.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 23 | 24 | 38 | -------------------------------------------------------------------------------- /components/icons/Select.vue: -------------------------------------------------------------------------------- 1 | 18 | -------------------------------------------------------------------------------- /components/icons/Times.vue: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /components/icons/Warning.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 18 | -------------------------------------------------------------------------------- /components/sections/CodeSection.vue: -------------------------------------------------------------------------------- 1 | 67 | 68 | 110 | 111 | 120 | -------------------------------------------------------------------------------- /components/sections/ErrorSection.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 35 | 36 | 78 | -------------------------------------------------------------------------------- /components/sections/MetaSection.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 65 | 66 | 75 | -------------------------------------------------------------------------------- /components/sections/PluginsSection.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 90 | 91 | 103 | -------------------------------------------------------------------------------- /components/sections/PrefSection.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 79 | 80 | 105 | -------------------------------------------------------------------------------- /composables/copy.ts: -------------------------------------------------------------------------------- 1 | import { useToast } from '@/composables' 2 | import { useClipboard } from '@vueuse/core' 3 | 4 | type CopySource = HTMLElement | string | null | undefined 5 | 6 | export function useCopy(content?: MaybeRefOrGetter) { 7 | const { copy } = useClipboard() 8 | const { show } = useToast() 9 | 10 | return (source?: CopySource) => { 11 | try { 12 | const value = toValue(source ?? content) 13 | copy(typeof value === 'string' ? value : value?.dataset?.copy || value?.textContent || '') 14 | show('Copied to clipboard') 15 | } catch (e) { 16 | console.error(e) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /composables/index.ts: -------------------------------------------------------------------------------- 1 | export * from './copy' 2 | export * from './input' 3 | export * from './key-lock' 4 | export * from './selection' 5 | export * from './toast' 6 | -------------------------------------------------------------------------------- /composables/input.ts: -------------------------------------------------------------------------------- 1 | import { useEventListener } from '@vueuse/core' 2 | 3 | export function useSelectAll(el: MaybeRefOrGetter) { 4 | useEventListener(el, 'focus', (e) => { 5 | ;(e.target as HTMLInputElement).select() 6 | }) 7 | } 8 | -------------------------------------------------------------------------------- /composables/key-lock.ts: -------------------------------------------------------------------------------- 1 | import { options } from '@/ui/state' 2 | import { getCanvas, setLockAltKey, setLockMetaKey } from '@/utils' 3 | 4 | let spacePressed = false 5 | 6 | function pause() { 7 | setLockMetaKey(false) 8 | setLockAltKey(false) 9 | } 10 | 11 | function resume() { 12 | if (spacePressed) { 13 | return 14 | } 15 | setLockMetaKey(options.value.deepSelectOn) 16 | setLockAltKey(options.value.measureOn) 17 | } 18 | 19 | let resuming: number | null = null 20 | function pauseMetaThenResume() { 21 | if (resuming != null) { 22 | clearTimeout(resuming) 23 | } 24 | 25 | setLockMetaKey(false) 26 | 27 | resuming = setTimeout(() => { 28 | resuming = null 29 | if (!spacePressed) { 30 | setLockMetaKey(options.value.deepSelectOn) 31 | } 32 | }, 200) 33 | } 34 | 35 | function keydown(e: KeyboardEvent) { 36 | if (!spacePressed && e.key === ' ') { 37 | spacePressed = true 38 | pause() 39 | } 40 | } 41 | 42 | function keyup(e: KeyboardEvent) { 43 | if (spacePressed && e.key === ' ') { 44 | spacePressed = false 45 | resume() 46 | } 47 | } 48 | 49 | export function useKeyLock() { 50 | const canvas = getCanvas() 51 | 52 | onMounted(() => { 53 | canvas.addEventListener('mouseleave', pause) 54 | canvas.addEventListener('mouseenter', resume) 55 | canvas.addEventListener('wheel', pauseMetaThenResume) 56 | window.addEventListener('keydown', keydown) 57 | window.addEventListener('keyup', keyup) 58 | }) 59 | 60 | onUnmounted(() => { 61 | canvas.removeEventListener('mouseleave', pause) 62 | canvas.removeEventListener('mouseenter', resume) 63 | canvas.removeEventListener('wheel', pauseMetaThenResume) 64 | window.removeEventListener('keydown', keydown) 65 | window.removeEventListener('keyup', keyup) 66 | }) 67 | 68 | watch( 69 | () => options.value.deepSelectOn, 70 | () => { 71 | setLockMetaKey(options.value.deepSelectOn) 72 | } 73 | ) 74 | } 75 | -------------------------------------------------------------------------------- /composables/plugin.ts: -------------------------------------------------------------------------------- 1 | import SNAPSHOT_PLUGINS from '@/plugins/available-plugins.json' 2 | import { codegen } from '@/utils' 3 | 4 | import { useToast } from './toast' 5 | 6 | const REGISTERED_SOURCE_RE = /@[a-z\d_-]+/ 7 | 8 | // plugin registry from the latest commit of the main branch 9 | const REGISTRY_URL = 10 | 'https://raw.githubusercontent.com/ecomfe/tempad-dev/refs/heads/main/plugins/available-plugins.json' 11 | 12 | async function getRegisteredPluginSource(source: string, signal?: AbortSignal) { 13 | const name = source.slice(1) 14 | let pluginList = null 15 | 16 | try { 17 | pluginList = (await fetch(REGISTRY_URL, { cache: 'no-cache', signal }).then((res) => 18 | res.json() 19 | )) as { 20 | name: string 21 | url: string 22 | }[] 23 | } catch (e) { 24 | pluginList = SNAPSHOT_PLUGINS 25 | } 26 | 27 | const plugins = Object.fromEntries(pluginList.map(({ name, url }) => [name, url])) 28 | 29 | if (!plugins[name]) { 30 | throw new Error(`"${name}" is not a registered plugin.`) 31 | } 32 | 33 | return plugins[name] 34 | } 35 | 36 | export type PluginData = { 37 | code: string 38 | pluginName: string 39 | source: string // can be a URL or a registered plugin name like `@{plugin-name}` 40 | } 41 | 42 | export function usePluginInstall() { 43 | const { show } = useToast() 44 | 45 | const validity = shallowRef('') 46 | 47 | const installing = shallowRef(false) 48 | let controller: AbortController | null = null 49 | 50 | function cancel() { 51 | controller?.abort() 52 | installing.value = false 53 | } 54 | 55 | async function install(src: string, isUpdate = false) { 56 | let installed: PluginData | null = null 57 | 58 | if (installing.value) { 59 | return null 60 | } 61 | 62 | controller?.abort() 63 | controller = new AbortController() 64 | const { signal } = controller 65 | let code: string | null = null 66 | 67 | installing.value = true 68 | 69 | try { 70 | const url = REGISTERED_SOURCE_RE.test(src) 71 | ? await getRegisteredPluginSource(src, signal) 72 | : src 73 | const response = await fetch(url, { cache: 'no-cache', signal }) 74 | if (response.status !== 200) { 75 | throw new Error('404: Not Found') 76 | } 77 | code = await response.text() 78 | 79 | try { 80 | const { pluginName } = await codegen( 81 | {}, 82 | null, 83 | { useRem: false, rootFontSize: 12, scale: 1 }, 84 | code 85 | ) 86 | if (!pluginName) { 87 | validity.value = 'The plugin name must not be empty.' 88 | } else { 89 | validity.value = '' 90 | show(`Plugin "${pluginName}" ${isUpdate ? 'updated' : 'installed'} successfully.`) 91 | installed = { code, pluginName, source: src } 92 | } 93 | } catch (e) { 94 | const message = e instanceof Error ? e.message : 'Unknown error' 95 | validity.value = `Failed to evaluate the code: ${message}` 96 | } 97 | } catch (e) { 98 | const message = e instanceof Error ? e.message : 'Network error' 99 | validity.value = `Failed to fetch the script content: ${message}` 100 | } 101 | 102 | controller = null 103 | installing.value = false 104 | 105 | return installed 106 | } 107 | 108 | return { 109 | validity, 110 | installing, 111 | cancel, 112 | install 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /composables/selection.ts: -------------------------------------------------------------------------------- 1 | import { createQuirksSelection } from '@/ui/quirks' 2 | import { selection } from '@/ui/state' 3 | import { getCanvas, getLeftPanel } from '@/utils' 4 | 5 | function syncSelection() { 6 | if (!window.figma) { 7 | selection.value = createQuirksSelection() 8 | return 9 | } 10 | selection.value = figma.currentPage.selection 11 | } 12 | 13 | function handleClick() { 14 | syncSelection() 15 | } 16 | 17 | function handleKeyDown(e: KeyboardEvent) { 18 | if ((e.target as Element).classList.contains('focus-target')) { 19 | // command + A or other shortcut that changes selection 20 | syncSelection() 21 | } 22 | } 23 | 24 | export function useSelection() { 25 | const canvas = getCanvas() 26 | const objectsPanel = getLeftPanel() 27 | 28 | const options = { capture: true } 29 | 30 | onMounted(() => { 31 | syncSelection() 32 | 33 | canvas.addEventListener('click', handleClick, options) 34 | objectsPanel.addEventListener('click', handleClick, options) 35 | window.addEventListener('keydown', handleKeyDown, options) 36 | }) 37 | 38 | onUnmounted(() => { 39 | canvas.removeEventListener('click', handleClick, options) 40 | objectsPanel.removeEventListener('click', handleClick, options) 41 | window.removeEventListener('keydown', handleKeyDown, options) 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /composables/toast.ts: -------------------------------------------------------------------------------- 1 | let tick: number | null = null 2 | const duration = 3000 3 | 4 | const message = shallowRef('') 5 | const shown = shallowRef(false) 6 | 7 | export function useToast() { 8 | function hide() { 9 | message.value = '' 10 | shown.value = false 11 | if (tick != null) { 12 | clearTimeout(tick) 13 | tick = null 14 | } 15 | } 16 | 17 | return { 18 | show(msg: string) { 19 | if (tick != null) { 20 | clearTimeout(tick) 21 | } 22 | message.value = msg 23 | shown.value = true 24 | 25 | tick = setTimeout(() => { 26 | hide() 27 | }, duration) 28 | }, 29 | hide, 30 | shown, 31 | message 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /entrypoints/background.ts: -------------------------------------------------------------------------------- 1 | const RULE_URL = 2 | 'https://raw.githubusercontent.com/ecomfe/tempad-dev/refs/heads/main/public/rules/figma.json' 3 | 4 | const SYNC_ALARM = 'sync-rules' 5 | const SYNC_INTERVAL_MINUTES = 10 6 | 7 | async function fetchRules() { 8 | try { 9 | const res = await fetch(RULE_URL, { cache: 'no-store' }) 10 | if (!res.ok) { 11 | console.error('[tempad-dev] Failed to fetch rules:', res.statusText) 12 | return 13 | } 14 | 15 | const newRules = await res.json() 16 | const oldIds = (await browser.declarativeNetRequest.getDynamicRules()).map(({ id }) => id) 17 | 18 | await browser.declarativeNetRequest.updateEnabledRulesets({ 19 | disableRulesetIds: ['figma'] 20 | }) 21 | 22 | await browser.declarativeNetRequest.updateDynamicRules({ 23 | removeRuleIds: oldIds, 24 | addRules: newRules 25 | }) 26 | console.log(`[tempad-dev] Updated ${newRules.length} rule${newRules.length === 1 ? '' : 's'}.`) 27 | } catch (error) { 28 | console.error('[tempad-dev] Error fetching rules:', error) 29 | } 30 | } 31 | 32 | export default defineBackground(() => { 33 | browser.runtime.onInstalled.addListener(fetchRules) 34 | 35 | browser.runtime.onStartup.addListener(fetchRules) 36 | 37 | browser.alarms.create(SYNC_ALARM, { periodInMinutes: SYNC_INTERVAL_MINUTES }) 38 | browser.alarms.onAlarm.addListener((a) => { 39 | if (a.name === SYNC_ALARM) { 40 | fetchRules() 41 | } 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /entrypoints/content.ts: -------------------------------------------------------------------------------- 1 | export default defineContentScript({ 2 | matches: ['https://www.figma.com/file/*', 'https://www.figma.com/design/*'], 3 | runAt: 'document_end', 4 | main(ctx) { 5 | const ui = createIntegratedUi(ctx, { 6 | tag: 'tempad', 7 | position: 'inline', 8 | onMount(root) { 9 | const script = document.createElement('script') 10 | script.src = browser.runtime.getURL('/ui.js') 11 | root.appendChild(script) 12 | script.onload = () => { 13 | script.remove() 14 | } 15 | 16 | // Prevent Figma's event capture so that text selection works. 17 | // Both of the following are required. 18 | root.tabIndex = -1 19 | root.classList.add('js-fullscreen-prevent-event-capture') 20 | } 21 | }) 22 | 23 | ui.mount() 24 | } 25 | }) 26 | -------------------------------------------------------------------------------- /entrypoints/ui/App.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 68 | 69 | 83 | -------------------------------------------------------------------------------- /entrypoints/ui/global.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | DebuggingHelpers: { 3 | logSelected?: () => string 4 | logNode?: (id: string) => string 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /entrypoints/ui/index.ts: -------------------------------------------------------------------------------- 1 | import 'overlayscrollbars/styles/overlayscrollbars.css' 2 | import { runtimeMode } from '@/ui/state' 3 | import { getCanvas, getLeftPanel } from '@/utils' 4 | import waitFor from 'p-wait-for' 5 | 6 | import './style.css' 7 | 8 | export default defineUnlistedScript(async () => { 9 | import('./prism') 10 | 11 | await waitFor(() => getCanvas() != null && getLeftPanel() != null) 12 | try { 13 | await waitFor(() => window.figma?.currentPage != null, { timeout: 1000 }) 14 | } catch (e) { 15 | if (window.DebuggingHelpers.logSelected) { 16 | runtimeMode.value = 'quirks' 17 | console.log('[tempad-dev] `window.figma` is not available. Start to enter quirks mode.') 18 | } else { 19 | runtimeMode.value = 'unavailable' 20 | console.log( 21 | '[tempad-dev] `window.figma` and `window.DebuggingHelpers.logSelected` are both not available. You need to duplicate to draft to use TemPad Dev.' 22 | ) 23 | } 24 | } 25 | 26 | const App = (await import('./App.vue')).default 27 | 28 | createApp(App).mount('tempad') 29 | }) 30 | -------------------------------------------------------------------------------- /entrypoints/ui/prism.ts: -------------------------------------------------------------------------------- 1 | import { evaluate } from '@/utils' 2 | import waitFor from 'p-wait-for' 3 | 4 | const EXTRA_LANGS = ['sass', 'scss', 'less', 'stylus', 'css-extras'] as const 5 | 6 | // We are importing this in this way is because if we use 7 | // `import('prismjs/components/prism-sass')` Rollup will not resolve it correctly 8 | // with our current setup. 9 | async function load(lang: (typeof EXTRA_LANGS)[number]) { 10 | const response = await fetch( 11 | `https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-${lang}.min.js` 12 | ) 13 | return await response.text() 14 | } 15 | 16 | Promise.all([waitFor(() => window.Prism != null), ...EXTRA_LANGS.map((lang) => load(lang))]).then( 17 | ([, ...scripts]) => { 18 | scripts.forEach((script) => { 19 | evaluate(script) 20 | }) 21 | } 22 | ) 23 | 24 | export {} 25 | -------------------------------------------------------------------------------- /entrypoints/ui/style.css: -------------------------------------------------------------------------------- 1 | tempad { 2 | display: block; 3 | position: absolute; 4 | 5 | font-family: var(--font-family-ui); 6 | font-size: 11px; 7 | color: var(--text-primary); 8 | } 9 | 10 | [data-fpl-version="ui3"] tempad { 11 | color: var(--color-text); 12 | } 13 | 14 | tempad *, 15 | tempad *::before, 16 | tempad *::after { 17 | box-sizing: border-box; 18 | } 19 | 20 | /** Tiny utility classes */ 21 | tempad .tp-row { 22 | display: flex; 23 | align-items: center; 24 | justify-content: flex-start; 25 | } 26 | 27 | tempad .tp-row-justify { 28 | justify-content: space-between; 29 | } 30 | 31 | tempad .tp-shrink { 32 | flex-shrink: 1; 33 | min-width: 0; 34 | } 35 | 36 | tempad .tp-gap { 37 | gap: 0; 38 | } 39 | 40 | tempad .tp-gap-l { 41 | gap: var(--spacer-1); 42 | } 43 | 44 | tempad .tp-ellipsis { 45 | overflow: hidden; 46 | text-overflow: ellipsis; 47 | white-space: nowrap; 48 | min-width: 0; 49 | } 50 | 51 | tempad select { 52 | appearance: none; 53 | height: 28px; 54 | border: 1px solid var(--color-border); 55 | border-radius: 2px; 56 | padding: 0 20px 0 7px; 57 | background-color: transparent; 58 | background-image: url("data:image/svg+xml,%3Csvg class='svg' xmlns='http://www.w3.org/2000/svg' width='8' height='7' viewBox='0 0 8 7'%3E%3Cpath fill='%230000004d' fill-opacity='1' fill-rule='evenodd' stroke='none' d='m3.646 5.354-3-3 .708-.708L4 4.293l2.646-2.647.708.708-3 3L4 5.707l-.354-.353z'%3E%3C/path%3E%3C/svg%3E"); 59 | background-repeat: no-repeat; 60 | background-position: center right 8px; 61 | } 62 | 63 | [data-preferred-theme="dark"] tempad select { 64 | background-image: url("data:image/svg+xml,%3Csvg class='svg' xmlns='http://www.w3.org/2000/svg' width='8' height='7' viewBox='0 0 8 7'%3E%3Cpath fill='%23ffffff66' fill-opacity='1' fill-rule='evenodd' stroke='none' d='m3.646 5.354-3-3 .708-.708L4 4.293l2.646-2.647.708.708-3 3L4 5.707l-.354-.353z'%3E%3C/path%3E%3C/svg%3E"); 65 | } 66 | 67 | tempad select:focus-visible { 68 | border: 1px solid var(--color-border-selected); 69 | outline: 1px solid var(--color-border-selected); 70 | outline-offset: -2px; 71 | } 72 | 73 | tempad input:not([type]), 74 | tempad input[type="text"], 75 | tempad input[type="number"] { 76 | height: 28px; 77 | border: 1px solid var(--color-border); 78 | border-radius: 2px; 79 | padding: 0 7px; 80 | background-color: var(--color-bg); 81 | } 82 | 83 | tempad input[type="number"]::-webkit-inner-spin-button, 84 | tempad input[type="number"]::-webkit-outer-spin-button { 85 | -webkit-appearance: none; 86 | margin: 0; 87 | } 88 | 89 | tempad input[type="text"]:focus-visible, 90 | tempad input[type="number"]:focus-visible { 91 | border: 1px solid var(--color-border-selected); 92 | outline: 1px solid var(--color-border-selected); 93 | outline-offset: -2px; 94 | } 95 | 96 | tempad ::-webkit-scrollbar { 97 | width: 11px; 98 | height: 11px; 99 | background-color: transparent; 100 | border: 0 solid var(--color-border); 101 | } 102 | 103 | tempad ::-webkit-scrollbar:horizontal { 104 | border-top-width: 1px; 105 | } 106 | 107 | tempad ::-webkit-scrollbar:vertical { 108 | border-left-width: 1px; 109 | } 110 | 111 | tempad ::-webkit-scrollbar-thumb { 112 | border-radius: 8px; 113 | border: solid transparent; 114 | border-width: 3px 2px 2px 3px; 115 | background-clip: content-box; 116 | background-color: var(--color-scrollbar); 117 | } 118 | 119 | [data-fpl-version="ui3"] tempad .tp-gap { 120 | gap: var(--spacer-1); 121 | } 122 | 123 | [data-fpl-version="ui3"] tempad .tp-gap-l { 124 | gap: var(--spacer-2); 125 | } 126 | 127 | [data-fpl-version="ui3"] tempad select { 128 | height: var(--spacer-4); 129 | border: 1px solid var(--color-border); 130 | border-radius: var(--radius-medium); 131 | background-image: url("data:image/svg+xml,%3Csvg class='svg' xmlns='http://www.w3.org/2000/svg' width='8' height='7' viewBox='0 0 8 7'%3E%3Cpath fill='%23000000e5' fill-opacity='1' fill-rule='evenodd' stroke='none' d='m3.646 5.354-3-3 .708-.708L4 4.293l2.646-2.647.708.708-3 3L4 5.707l-.354-.353z'%3E%3C/path%3E%3C/svg%3E"); 132 | font-family: var(--text-body-medium-font-family); 133 | font-size: var(--text-body-medium-font-size); 134 | font-weight: var(--text-body-medium-font-weight); 135 | letter-spacing: var(--text-body-medium-letter-spacing); 136 | line-height: var(--text-body-medium-line-height); 137 | } 138 | 139 | [data-preferred-theme="dark"][data-fpl-version="ui3"] tempad select { 140 | background-image: url("data:image/svg+xml,%3Csvg class='svg' xmlns='http://www.w3.org/2000/svg' width='8' height='7' viewBox='0 0 8 7'%3E%3Cpath fill='%23fff' fill-opacity='1' fill-rule='evenodd' stroke='none' d='m3.646 5.354-3-3 .708-.708L4 4.293l2.646-2.647.708.708-3 3L4 5.707l-.354-.353z'%3E%3C/path%3E%3C/svg%3E"); 141 | } 142 | 143 | [data-fpl-version="ui3"] tempad input[type="text"], 144 | [data-fpl-version="ui3"] tempad input[type="number"] { 145 | height: var(--spacer-4); 146 | border: 1px solid var(--color-border); 147 | border-radius: var(--radius-medium); 148 | border-color: var(--color-bg-secondary); 149 | background-color: var(--color-bg-secondary); 150 | font-family: var(--text-body-medium-font-family); 151 | font-size: var(--text-body-medium-font-size); 152 | font-weight: var(--text-body-medium-font-weight); 153 | letter-spacing: var(--text-body-medium-letter-spacing); 154 | line-height: var(--text-body-medium-line-height); 155 | } 156 | 157 | [data-fpl-version="ui3"] tempad select:focus-visible, 158 | [data-fpl-version="ui3"] tempad input[type="text"]:focus, 159 | [data-fpl-version="ui3"] tempad input[type="number"]:focus { 160 | border-color: var(--color-border-selected); 161 | outline: unset; 162 | } 163 | 164 | #react-page:not(:has(#fullscreen-root .gpu-view-content canvas):has(#left-panel-container)) 165 | ~ tempad { 166 | display: none; 167 | } 168 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tempad-dev", 3 | "description": "Inspect panel on Figma, for everyone.", 4 | "private": true, 5 | "version": "0.10.1", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "wxt", 9 | "dev:firefox": "wxt -b firefox", 10 | "build": "wxt build && pnpm run build:rewriter && pnpm run build:plugins && pnpm run build:readme", 11 | "build:firefox": "wxt build -b firefox", 12 | "build:rewrite": "esbuild ./rewrite/figma.ts --outfile=./dist/figma.js --bundle --format=esm", 13 | "build:plugins": "tsc -p ./plugins/tsconfig.json", 14 | "build:readme": "tsx ./build/readme.ts", 15 | "npm:plugins": "pnpm build:plugins && cd plugins && pnpm publish --access public && cd ..", 16 | "zip": "wxt zip", 17 | "zip:firefox": "wxt zip -b firefox", 18 | "compile": "vue-tsc --noEmit", 19 | "postinstall": "wxt prepare", 20 | "lint": "eslint src --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", 21 | "format": "prettier --write src *.cjs *.json *.md" 22 | }, 23 | "devDependencies": { 24 | "@figma/plugin-typings": "^1.112.0", 25 | "@rushstack/eslint-patch": "^1.11.0", 26 | "@types/prismjs": "^1.26.5", 27 | "@types/stringify-object": "^4.0.5", 28 | "@vue/eslint-config-prettier": "^9.0.0", 29 | "@vue/eslint-config-typescript": "^13.0.0", 30 | "@vueuse/core": "^10.11.1", 31 | "@wxt-dev/module-vue": "^1.0.2", 32 | "comment-mark": "^1.1.1", 33 | "esbuild": "^0.25.4", 34 | "eslint": "^8.57.1", 35 | "eslint-plugin-perfectionist": "^3.9.1", 36 | "eslint-plugin-vue": "^9.33.0", 37 | "overlayscrollbars": "^2.11.3", 38 | "p-wait-for": "^5.0.2", 39 | "playwright-chromium": "^1.52.0", 40 | "prettier": "^3.5.3", 41 | "stringify-object": "^5.0.0", 42 | "tsx": "^4.19.4", 43 | "typescript": "^5.8.3", 44 | "vite-plugin-css-injected-by-js": "^3.5.2", 45 | "vue": "^3.5.14", 46 | "vue-tsc": "^2.2.10", 47 | "wxt": "^0.20.6" 48 | }, 49 | "pnpm": { 50 | "onlyBuiltDependencies": [ 51 | "playwright-chromium" 52 | ], 53 | "patchedDependencies": { 54 | "wxt": "patches/wxt.patch" 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /patches/wxt.patch: -------------------------------------------------------------------------------- 1 | diff --git a/dist/core/builders/vite/index.mjs b/dist/core/builders/vite/index.mjs 2 | index a86d54e7c3c172d59c793eca2dc5b1500967d2e1..030a7ed5b16a0cd42d0e8af62f8db20876091c8a 100644 3 | --- a/dist/core/builders/vite/index.mjs 4 | +++ b/dist/core/builders/vite/index.mjs 5 | @@ -1,15 +1,16 @@ 6 | -import * as wxtPlugins from "./plugins/index.mjs"; 7 | +import { relative } from "node:path"; 8 | +import { ViteNodeRunner } from "vite-node/client"; 9 | +import { ViteNodeServer } from "vite-node/server"; 10 | +import { installSourcemapsSupport } from "vite-node/source-map"; 11 | + 12 | +import { toArray } from "../../utils/arrays.mjs"; 13 | import { 14 | getEntrypointBundlePath, 15 | isHtmlEntrypoint 16 | } from "../../utils/entrypoints.mjs"; 17 | -import { toArray } from "../../utils/arrays.mjs"; 18 | -import { safeVarName } from "../../utils/strings.mjs"; 19 | -import { ViteNodeServer } from "vite-node/server"; 20 | -import { ViteNodeRunner } from "vite-node/client"; 21 | -import { installSourcemapsSupport } from "vite-node/source-map"; 22 | import { createExtensionEnvironment } from "../../utils/environments/index.mjs"; 23 | -import { relative } from "node:path"; 24 | +import { safeVarName } from "../../utils/strings.mjs"; 25 | +import * as wxtPlugins from "./plugins/index.mjs"; 26 | export async function createViteBuilder(wxtConfig, hooks, getWxtDevServer) { 27 | const vite = await import("vite"); 28 | const getBaseConfig = async (baseConfigOptions) => { 29 | @@ -69,13 +70,6 @@ export async function createViteBuilder(wxtConfig, hooks, getWxtDevServer) { 30 | const libMode = { 31 | mode: wxtConfig.mode, 32 | plugins, 33 | - esbuild: { 34 | - // Add a footer with the returned value so it can return values to `scripting.executeScript` 35 | - // Footer is added a part of esbuild to make sure it's not minified. It 36 | - // get's removed if added to `build.rollupOptions.output.footer` 37 | - // See https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/executeScript#return_value 38 | - footer: iifeReturnValueName + ";" 39 | - }, 40 | build: { 41 | lib: { 42 | entry, 43 | -------------------------------------------------------------------------------- /plugins/README.md: -------------------------------------------------------------------------------- 1 | # @tempad-dev/plugins 2 | 3 | ## Usage 4 | 5 | ```sh 6 | npm i -D @tempad-dev/plugins # pnpm add -D @tempad-dev/plugins 7 | ``` 8 | 9 | ```ts 10 | import { definePlugin } from '@tempad-dev/plugins' 11 | 12 | export const plugin = definePlugin({ 13 | name: 'My Plugin', 14 | code: { 15 | // customize transform options 16 | } 17 | }) 18 | ``` 19 | -------------------------------------------------------------------------------- /plugins/available-plugins.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "kong", 4 | "author": "@Justineo", 5 | "description": "Kong Design System", 6 | "repo": "https://github.com/Justineo/tempad-dev-plugin-kong", 7 | "url": "https://raw.githubusercontent.com/Justineo/tempad-dev-plugin-kong/refs/heads/main/dist/kong.mjs" 8 | }, 9 | { 10 | "name": "kong/advanced", 11 | "author": "@Justineo", 12 | "description": "Kong Design System (Advanced)", 13 | "repo": "https://github.com/Justineo/tempad-dev-plugin-kong", 14 | "url": "https://raw.githubusercontent.com/Justineo/tempad-dev-plugin-kong/refs/heads/main/dist/kong-advanced.mjs" 15 | }, 16 | { 17 | "name": "fubukicss/unocss", 18 | "author": "@zouhangwithsweet", 19 | "description": "UnoCSS by FubukiCSS", 20 | "repo": "https://github.com/zouhangwithsweet/fubukicss-tool", 21 | "url": "https://raw.githubusercontent.com/zouhangwithsweet/fubukicss-tool/refs/heads/main/plugin/lib/index.js" 22 | }, 23 | { 24 | "name": "nuxt", 25 | "author": "@Justineo", 26 | "description": "Nuxt UI", 27 | "repo": "https://github.com/Justineo/tempad-dev-plugin-nuxt-ui", 28 | "url": "https://raw.githubusercontent.com/Justineo/tempad-dev-plugin-nuxt-ui/refs/heads/main/dist/nuxt-ui.mjs" 29 | }, 30 | { 31 | "name": "nuxt/pro", 32 | "author": "@Justineo", 33 | "description": "Nuxt UI Pro", 34 | "repo": "https://github.com/Justineo/tempad-dev-plugin-nuxt-ui", 35 | "url": "https://raw.githubusercontent.com/Justineo/tempad-dev-plugin-nuxt-ui/refs/heads/main/dist/nuxt-ui-pro.mjs" 36 | }, 37 | { 38 | "name": "baidu-health/wz-style", 39 | "author": "@KangXinzhi", 40 | "description": "Custom style for Baidu Health wz-style", 41 | "repo": "https://github.com/KangXinzhi/tempad-dev-plugin-wz-style", 42 | "url": "https://raw.githubusercontent.com/KangXinzhi/tempad-dev-plugin-wz-style/refs/heads/main/dist/index.mjs" 43 | }, 44 | { 45 | "name": "baidu-health/med-style", 46 | "author": "@KangXinzhi", 47 | "description": "Custom style for Baidu Health med-style", 48 | "repo": "https://github.com/KangXinzhi/tempad-dev-plugin-med-style", 49 | "url": "https://raw.githubusercontent.com/KangXinzhi/tempad-dev-plugin-med-style/refs/heads/main/dist/index.mjs" 50 | }, 51 | { 52 | "name": "tailwind", 53 | "author": "@haydenull", 54 | "description": "CSS to Tailwind CSS", 55 | "repo": "https://github.com/haydenull/tempad-dev-plugin-tailwind", 56 | "url": "https://raw.githubusercontent.com/haydenull/tempad-dev-plugin-tailwind/main/dist/index.mjs" 57 | } 58 | ] 59 | -------------------------------------------------------------------------------- /plugins/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tempad-dev/plugins", 3 | "description": "Plugin utils for TemPad Dev.", 4 | "version": "0.5.0", 5 | "type": "module", 6 | "main": "dist/index.js", 7 | "types": "dist/index.d.ts", 8 | "files": [ 9 | "dist/index.js", 10 | "dist/index.d.ts", 11 | "README.md" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /plugins/src/index.ts: -------------------------------------------------------------------------------- 1 | export type ComponentPropertyValue = string | number | boolean | DesignComponent 2 | 3 | export type SupportedDesignNodeType = 'GROUP' | 'FRAME' | 'VECTOR' | 'TEXT' | 'INSTANCE' 4 | 5 | interface DesignNodeBase { 6 | name: string 7 | type: SupportedDesignNodeType 8 | visible: boolean 9 | } 10 | 11 | export type DesignNode = GroupNode | FrameNode | VectorNode | TextNode | DesignComponent 12 | 13 | export interface TextNode extends DesignNodeBase { 14 | type: 'TEXT' 15 | characters: string 16 | } 17 | 18 | interface ContainerNodeBase extends DesignNodeBase { 19 | children: DesignNode[] 20 | } 21 | 22 | export interface GroupNode extends ContainerNodeBase { 23 | type: 'GROUP' 24 | } 25 | 26 | export interface FrameNode extends ContainerNodeBase { 27 | type: 'FRAME' 28 | } 29 | 30 | export interface Variable { 31 | name: string 32 | value: string 33 | } 34 | 35 | export interface Fill { 36 | color: string | Variable 37 | } 38 | 39 | export interface VectorNode extends DesignNodeBase { 40 | type: 'VECTOR' 41 | fills: Fill[] 42 | } 43 | 44 | export interface DesignComponent> 45 | extends ContainerNodeBase { 46 | type: 'INSTANCE' 47 | properties: T 48 | mainComponent?: { 49 | id: string 50 | name: string 51 | } | null 52 | } 53 | 54 | type ContainerNode = GroupNode | FrameNode | DesignComponent 55 | 56 | export interface DevComponent> { 57 | name: string 58 | props: T 59 | children: (DevComponent | string)[] 60 | } 61 | 62 | export type SupportedLang = 63 | | 'text' 64 | | 'tsx' 65 | | 'jsx' 66 | | 'ts' 67 | | 'js' 68 | | 'vue' 69 | | 'html' 70 | | 'css' 71 | | 'sass' 72 | | 'scss' 73 | | 'less' 74 | | 'stylus' 75 | | 'json' 76 | 77 | interface TransformBaseParams { 78 | /** 79 | * The user preferences related to code transformation 80 | * @example { useRem: true, rootFontSize: 16 } 81 | */ 82 | options: { 83 | useRem: boolean 84 | rootFontSize: number 85 | } 86 | } 87 | 88 | interface TransformParams extends TransformBaseParams { 89 | /** 90 | * The generated CSS code 91 | * @example 'background-color: red; color: blue;' 92 | */ 93 | code: string 94 | 95 | /** 96 | * The parsed CSS properties 97 | * @example { 'background-color': 'red', 'color': 'blue' } 98 | */ 99 | style: Record 100 | } 101 | 102 | interface TransformVariableParams extends TransformBaseParams { 103 | /** 104 | * The generated CSS variable code 105 | * @example 'var(--color-primary, #6699cc)' 106 | */ 107 | code: string 108 | 109 | /** 110 | * The variable name 111 | * @example 'color-primary' 112 | */ 113 | name: string 114 | 115 | /** 116 | * The variable value 117 | * @example '#6699cc' 118 | */ 119 | value?: string 120 | } 121 | 122 | interface TransformPxParams extends TransformBaseParams { 123 | /** 124 | * The length value 125 | * @example 16 126 | */ 127 | value: number 128 | } 129 | 130 | interface TransformComponentParams { 131 | /** 132 | * The design component 133 | */ 134 | component: DesignComponent 135 | } 136 | 137 | export type TransformOptions = { 138 | /** 139 | * The language of the code block for syntax highlighting 140 | * @example 'scss' 141 | */ 142 | lang?: SupportedLang 143 | 144 | /** 145 | * Transform the generated CSS code 146 | */ 147 | transform?: (params: TransformParams) => string 148 | 149 | /** 150 | * Transform the generated CSS variable code 151 | * @example 'var(--kui-color-primary, #6699cc)' -> '$ui-color-primary' 152 | */ 153 | transformVariable?: (params: TransformVariableParams) => string 154 | 155 | /** 156 | * Transform the pixel value to the desired unit and scale 157 | * @example 16 -> '1rem' 158 | */ 159 | transformPx?: (params: TransformPxParams) => string 160 | 161 | /** 162 | * Transform the design component to a dev component 163 | */ 164 | transformComponent?: (params: TransformComponentParams) => DevComponent | string 165 | } 166 | 167 | export type CodeBlockOptions = 168 | | (TransformOptions & { 169 | /** 170 | * The title of the code block 171 | * @example 'SCSS' 172 | */ 173 | title?: string 174 | }) 175 | | false 176 | 177 | type BuiltInCodeBlock = 'css' | 'js' 178 | 179 | type CodeOptions = Partial> & 180 | Record 181 | 182 | export interface Plugin { 183 | name: string 184 | code: CodeOptions 185 | } 186 | 187 | export function definePlugin(plugin: Plugin): Plugin { 188 | return plugin 189 | } 190 | 191 | export function h>( 192 | name: string, 193 | props?: T, 194 | children?: (DevComponent | string)[] 195 | ): DevComponent { 196 | return { 197 | name, 198 | props: (props ?? {}) as T, 199 | children: children ?? [] 200 | } 201 | } 202 | 203 | // Mapped type for queryable properties 204 | type QueryableProperties = { 205 | [K in keyof Pick]: DesignNode[K] extends string 206 | ? DesignNode[K] | DesignNode[K][] | RegExp 207 | : DesignNode[K] 208 | } 209 | 210 | type RequireAtLeastOne = { 211 | [K in keyof T]: Required> & Partial>> 212 | }[keyof T] 213 | 214 | export type NodeQuery = RequireAtLeastOne | ((node: DesignNode) => boolean) 215 | 216 | function matchProperty( 217 | value: T, 218 | condition: T extends string ? T | T[] | RegExp : T | undefined 219 | ): boolean { 220 | if (condition === undefined) { 221 | return true 222 | } 223 | 224 | if (typeof value === 'string') { 225 | if (Array.isArray(condition)) { 226 | return condition.includes(value) 227 | } 228 | 229 | if (condition instanceof RegExp) { 230 | return condition.test(value) 231 | } 232 | } 233 | 234 | return value === condition 235 | } 236 | 237 | function matchNode(node: DesignNode, query: NodeQuery): boolean { 238 | if (typeof query === 'function') return query(node) 239 | 240 | return ( 241 | matchProperty(node.type, query.type) && 242 | matchProperty(node.name, query.name) && 243 | matchProperty(node.visible, query.visible ?? true) 244 | ) 245 | } 246 | 247 | export function findChild( 248 | node: ContainerNode, 249 | query: NodeQuery 250 | ): T | null { 251 | return (node.children.find((child) => matchNode(child, query)) as T) ?? null 252 | } 253 | 254 | export function findChildren( 255 | node: ContainerNode, 256 | query: NodeQuery 257 | ): T[] { 258 | return node.children.filter((child) => matchNode(child, query)) as T[] 259 | } 260 | 261 | export function findOne( 262 | node: ContainerNode, 263 | query: NodeQuery 264 | ): T | null { 265 | for (const child of node.children) { 266 | if (matchNode(child, query)) { 267 | return child as T 268 | } 269 | if ('children' in child) { 270 | const result = findOne(child, query) 271 | if (result) { 272 | return result as T 273 | } 274 | } 275 | } 276 | return null 277 | } 278 | 279 | export function findAll( 280 | node: ContainerNode, 281 | query: NodeQuery 282 | ): T[] { 283 | const result: DesignNode[] = [] 284 | for (const child of node.children) { 285 | if (matchNode(child, query)) { 286 | result.push(child) 287 | } 288 | if ('children' in child) { 289 | result.push(...findAll(child, query)) 290 | } 291 | } 292 | return result as T[] 293 | } 294 | 295 | export type QueryType = 'child' | 'children' | 'one' | 'all' 296 | 297 | export function queryAll( 298 | node: ContainerNode, 299 | queries: (NodeQuery & { query: QueryType })[] 300 | ): T[] { 301 | if (queries.length === 0) { 302 | return [] 303 | } 304 | 305 | let current: DesignNode[] = [node] 306 | 307 | for (const query of queries) { 308 | const seen = new Set() 309 | const next: DesignNode[] = [] 310 | 311 | for (const node of current) { 312 | if (!('children' in node)) { 313 | continue 314 | } 315 | 316 | seen.add(node) 317 | 318 | if (query.query === 'child' || query.query === 'one') { 319 | const one = query.query === 'child' ? findChild(node, query) : findOne(node, query) 320 | if (one && !seen.has(one)) { 321 | seen.add(one) 322 | next.push(one) 323 | } 324 | } else { 325 | const all = query.query === 'children' ? findChildren(node, query) : findAll(node, query) 326 | for (const item of all) { 327 | if (!seen.has(item)) { 328 | seen.add(item) 329 | next.push(item) 330 | } 331 | } 332 | } 333 | } 334 | 335 | current = next 336 | } 337 | 338 | return current as T[] 339 | } 340 | 341 | export function queryOne( 342 | node: ContainerNode, 343 | queries: (NodeQuery & { query: QueryType })[] 344 | ): T | null { 345 | return queryAll(node, queries)[0] 346 | } 347 | -------------------------------------------------------------------------------- /plugins/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": [ 4 | "src/index.ts" 5 | ], 6 | "compilerOptions": { 7 | "outDir": "./dist", 8 | "target": "ESNext", 9 | "module": "ESNext", 10 | "declaration": true, 11 | "noEmit": false 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /public/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ecomfe/tempad-dev/55f169d23d7d9b968e27713609cac53e5fedf233/public/icon-128.png -------------------------------------------------------------------------------- /public/rules/figma.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "priority": 10, 5 | "action": { 6 | "type": "modifyHeaders", 7 | "responseHeaders": [ 8 | { 9 | "header": "Content-Security-Policy", 10 | "operation": "remove" 11 | } 12 | ] 13 | }, 14 | "condition": { 15 | "resourceTypes": ["main_frame"] 16 | } 17 | }, 18 | { 19 | "id": 2, 20 | "priority": 1, 21 | "action": { 22 | "type": "redirect", 23 | "redirect": { 24 | "url": "https://ecomfe.github.io/tempad-dev/figma.js" 25 | } 26 | }, 27 | "condition": { 28 | "regexFilter": "/webpack-artifacts/assets/figma_app[^.]+\\.min\\.js(\\.br)?$", 29 | "resourceTypes": ["script"] 30 | } 31 | } 32 | ] 33 | -------------------------------------------------------------------------------- /rewrite/config.ts: -------------------------------------------------------------------------------- 1 | export const SRC_PATTERN = /\/figma_app/ 2 | 3 | export const REWRITE_PATTERN = /\.appModel\.isReadOnly/g 4 | 5 | export const REWRITE_REPLACER = '.appModel.__isReadOnly__' 6 | 7 | const MARKERS = ['.appModel.isReadOnly'] 8 | 9 | export function matchFile(src: string, content: string) { 10 | return SRC_PATTERN.test(src) && MARKERS.every((marker) => content.includes(marker)) 11 | } 12 | -------------------------------------------------------------------------------- /rewrite/figma.ts: -------------------------------------------------------------------------------- 1 | import { matchFile, REWRITE_PATTERN, REWRITE_REPLACER } from './config' 2 | 3 | async function rewriteScript() { 4 | const current = document.currentScript as HTMLScriptElement 5 | const src = current.src 6 | 7 | const desc = Object.getOwnPropertyDescriptor(Document.prototype, 'currentScript') 8 | 9 | function replaceScript(src: string) { 10 | const script = document.createElement('script') 11 | script.src = src 12 | script.defer = true 13 | current.replaceWith(script) 14 | } 15 | 16 | try { 17 | let content = await (await fetch(src)).text() 18 | 19 | if (matchFile(src, content)) { 20 | content = content.replace(REWRITE_PATTERN, REWRITE_REPLACER) 21 | console.log(`Rewrote script: ${src}`) 22 | } 23 | 24 | // delete window.figma may throw Error in strict mode 25 | content = content.replaceAll('delete window.figma', 'window.figma = undefined') 26 | 27 | Object.defineProperty(document, 'currentScript', { 28 | configurable: true, 29 | get() { 30 | return current 31 | } 32 | }) 33 | 34 | new Function(content)() 35 | } catch (e) { 36 | console.error(e) 37 | replaceScript(`${src}?fallback`) 38 | } finally { 39 | Object.defineProperty(document, 'currentScript', desc as PropertyDescriptor) 40 | } 41 | } 42 | 43 | rewriteScript() 44 | -------------------------------------------------------------------------------- /scripts/check-rewrite.ts: -------------------------------------------------------------------------------- 1 | import { matchFile, REWRITE_PATTERN } from '@/rewrite/config' 2 | import { chromium } from 'playwright-chromium' 3 | 4 | import rules from '../public/rules/figma.json' 5 | 6 | const redirectRule = rules.find((rule) => rule.action.type === 'redirect') 7 | 8 | const ASSETS_PATTERN = new RegExp(redirectRule?.condition?.regexFilter || /a^/) 9 | const MAX_RETRIES = 3 10 | 11 | async function runCheck() { 12 | const scripts: { url: string; content: string }[] = [] 13 | 14 | const browser = await chromium.launch() 15 | const page = await browser.newPage() 16 | 17 | try { 18 | page.on('response', async (response) => { 19 | if (response.request().resourceType() === 'script') { 20 | const url = response.url() 21 | if (!ASSETS_PATTERN.test(url)) { 22 | return 23 | } 24 | 25 | const content = await response.text() 26 | scripts.push({ url, content }) 27 | console.log(`Captured script: ${url}`) 28 | } 29 | }) 30 | 31 | try { 32 | await page.goto('https://www.figma.com/login', { timeout: 10000 }) 33 | console.log('Logging in...') 34 | } catch { 35 | console.error('Failed to load the login page. Please check your internet connection.') 36 | return false 37 | } 38 | 39 | await page.fill('input[name="email"]', process.env.FIGMA_EMAIL || '') 40 | await page.fill('input[name="password"]', process.env.FIGMA_PASSWORD || '') 41 | await page.click('button[type="submit"]') 42 | 43 | try { 44 | await page.waitForURL(/^(?!.*login).*$/, { timeout: 10000 }) 45 | console.log('Logged in successfully.') 46 | } catch (error) { 47 | console.error('Login failed. Please check your credentials.') 48 | return false 49 | } 50 | 51 | await page.waitForLoadState('load') 52 | console.log(`Page loaded at ${page.url()}.`) 53 | 54 | let matched: string | null = null 55 | let rewritable = false 56 | scripts.forEach(({ url, content }) => { 57 | if (matchFile(url, content)) { 58 | matched = url 59 | console.log(`Matched script: ${url}`) 60 | if (REWRITE_PATTERN.test(content)) { 61 | rewritable = true 62 | console.log(`Rewritable script: ${url}`) 63 | } 64 | } 65 | }) 66 | 67 | if (!matched) { 68 | console.log('❌ No matched script found.') 69 | return false 70 | } 71 | 72 | console.log(`✅ Matched script: ${matched}`) 73 | 74 | if (!rewritable) { 75 | console.log(`❌ Rewrite pattern not found.`) 76 | return false 77 | } 78 | 79 | console.log(`✅ Rewrite pattern found.`) 80 | return true 81 | } finally { 82 | await browser.close() 83 | } 84 | } 85 | 86 | async function main() { 87 | for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { 88 | if (attempt > 1) { 89 | console.log(`\nRetry attempt ${attempt} of ${MAX_RETRIES}...`) 90 | } 91 | 92 | const success = await runCheck() 93 | 94 | if (success) { 95 | process.exit(0) 96 | } 97 | 98 | if (attempt < MAX_RETRIES) { 99 | console.log(`Attempt ${attempt} failed. Waiting before retry...`) 100 | // Wait a bit before retrying 101 | await new Promise((resolve) => setTimeout(resolve, 3000)) 102 | } 103 | } 104 | 105 | console.log(`❌ All ${MAX_RETRIES} attempts failed.`) 106 | process.exit(1) 107 | } 108 | 109 | main().catch((error) => { 110 | console.error('Unexpected error:', error) 111 | process.exit(1) 112 | }) 113 | -------------------------------------------------------------------------------- /shared/types.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | Plugin, 3 | TextNode, 4 | ComponentPropertyValue, 5 | TransformOptions, 6 | DesignNode, 7 | DesignComponent, 8 | DevComponent, 9 | SupportedLang 10 | } from '@/plugins/src' 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.wxt/tsconfig.json", 3 | "compilerOptions": { 4 | "typeRoots": ["node_modules/@types", "node_modules/@figma"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | figma: PluginAPI 3 | } 4 | -------------------------------------------------------------------------------- /ui/figma.ts: -------------------------------------------------------------------------------- 1 | const NATIVE_PANEL_WIDTH = 241 2 | const TEMPAD_PANEL_WIDTH = 240 3 | 4 | const ui = reactive({ 5 | isUi3: false, 6 | 7 | get nativePanelWidth() { 8 | return NATIVE_PANEL_WIDTH 9 | }, 10 | 11 | get tempadPanelWidth() { 12 | return TEMPAD_PANEL_WIDTH 13 | }, 14 | 15 | get topBoundary() { 16 | return sumLength(this.isUi3 ? 12 : '--toolbar-height', '--editor-banner-height') 17 | }, 18 | 19 | get bottomBoundary() { 20 | return this.isUi3 ? 12 : 0 21 | } 22 | }) 23 | 24 | function updateIsUi3() { 25 | ui.isUi3 = document.body.dataset.fplVersion === 'ui3' 26 | } 27 | 28 | const observer = new MutationObserver((mutations) => { 29 | for (const mutation of mutations) { 30 | if (mutation.type === 'attributes' && mutation.attributeName === 'data-fpl-version') { 31 | updateIsUi3() 32 | break 33 | } 34 | } 35 | }) 36 | 37 | updateIsUi3() 38 | observer.observe(document.body, { 39 | attributes: true, 40 | attributeFilter: ['data-fpl-version'] 41 | }) 42 | 43 | function sumLength(...values: (string | number)[]): number { 44 | return values.reduce((total: number, val: string | number) => { 45 | if (typeof val === 'string') { 46 | return total + parseInt(getComputedStyle(document.body).getPropertyValue(val), 10) 47 | } else { 48 | return total + val 49 | } 50 | }, 0) 51 | } 52 | 53 | export { ui } 54 | -------------------------------------------------------------------------------- /ui/quirks/basic/css.ts: -------------------------------------------------------------------------------- 1 | import { toDecimalPlace } from '@/utils' 2 | 3 | import type { QuirksNodeProps, StyleRecord } from '../types' 4 | 5 | export function getBasicCSS(props: QuirksNodeProps): StyleRecord { 6 | const result: StyleRecord = {} 7 | 8 | const [width, height] = props.size! 9 | Object.assign(result, { 10 | width: `${toDecimalPlace(width)}px`, 11 | height: `${toDecimalPlace(height)}px` 12 | }) 13 | 14 | const maxSize = props['max-size'] 15 | if (maxSize) { 16 | const [width, height] = maxSize 17 | Object.assign(result, { 18 | ...(width !== Infinity ? { 'max-width': `${toDecimalPlace(width)}px` } : {}), 19 | ...(height !== Infinity ? { 'max-height': `${toDecimalPlace(height)}px` } : {}) 20 | }) 21 | } 22 | 23 | const minSize = props['min-size'] 24 | if (minSize) { 25 | const [width, height] = minSize 26 | Object.assign(result, { 27 | ...(width !== 0 ? { 'min-width': `${toDecimalPlace(width)}px` } : {}), 28 | ...(height !== 0 ? { 'min-height': `${toDecimalPlace(height)}px` } : {}) 29 | }) 30 | } 31 | 32 | // We only need to calculate the rotation part 33 | const matrix = props['relative-transform'] 34 | const { a, c } = matrix 35 | const angle = Math.atan2(c, a) * (180 / Math.PI) 36 | if (angle) { 37 | result.transform = `rotate(${toDecimalPlace(angle, 2)}deg)` 38 | } 39 | 40 | return result 41 | } 42 | -------------------------------------------------------------------------------- /ui/quirks/basic/index.ts: -------------------------------------------------------------------------------- 1 | export * from './parsers' 2 | export * from './css' 3 | export type { BasicProps } from './types' 4 | -------------------------------------------------------------------------------- /ui/quirks/basic/parsers.ts: -------------------------------------------------------------------------------- 1 | const PARENT_INDEX_RE = /^(\d+):(\d+);/ 2 | 3 | export function getParentIndex(raw: string): string | null { 4 | const [, index] = raw.match(PARENT_INDEX_RE) || [] 5 | return index || null 6 | } 7 | 8 | export function getBool(raw: string): boolean { 9 | return raw === 'true' 10 | } 11 | 12 | export function getFloat(raw: string): number { 13 | if (raw === 'inf') { 14 | return Infinity 15 | } 16 | if (raw === 'nan') { 17 | return Number.NaN 18 | } 19 | return parseFloat(raw) 20 | } 21 | 22 | export function getInt(raw: string): number { 23 | return parseInt(raw, 10) 24 | } 25 | 26 | export function getString(raw: string): string { 27 | return raw.substring(1, raw.length - 1) 28 | } 29 | 30 | export function getEnumString(raw: string): string { 31 | return raw.substring(3) 32 | } 33 | 34 | export function getFloatArray(raw: string): number[] { 35 | return JSON.parse(`[${raw.substring(1, raw.length - 1)}]`) 36 | } 37 | 38 | export function getStringArray(raw: string): string[] { 39 | return raw 40 | .substring(1, raw.length - 1) 41 | .split(',') 42 | .map((s) => s.trim()) 43 | .filter(Boolean) 44 | } 45 | 46 | const VECTOR_RE = /^VectorF\(([^,]+), ([^)]+)\)$/ 47 | 48 | export function getFloatVector2(raw: string): [number, number] | null { 49 | if (raw === '(null)') { 50 | return null 51 | } 52 | 53 | const [, v0, v1] = raw.match(VECTOR_RE) || [] 54 | if (!v0 || !v1) { 55 | return null 56 | } 57 | 58 | return [getFloat(v0), getFloat(v1)] 59 | } 60 | 61 | const NODE_TYPE_MAP: Record = { 62 | ROUNDED_RECTANGLE: 'RECTANGLE', 63 | REGULAR_POLYGON: 'POLYGON', 64 | SYMBOL: 'COMPONENT' 65 | } 66 | 67 | export function getNodeType(type: string): NodeType { 68 | const typeString = getEnumString(type) 69 | return NODE_TYPE_MAP[typeString] || typeString 70 | } 71 | 72 | export const basicParsers = { 73 | ParentIndex: getParentIndex, 74 | ImmutableString: getString, 75 | NodeType: getNodeType, 76 | bool: getBool, 77 | float: getFloat, 78 | int: getInt, 79 | 'TVector2': getFloatVector2, 80 | 'Optional': getFloatVector2, 81 | 'ImmutableArray': getFloatArray 82 | } 83 | -------------------------------------------------------------------------------- /ui/quirks/basic/types.ts: -------------------------------------------------------------------------------- 1 | export interface BasicProps { 2 | name: string 3 | 'parent-index': string 4 | type: NodeType 5 | size: [number, number] | null 6 | } 7 | -------------------------------------------------------------------------------- /ui/quirks/font/css.ts: -------------------------------------------------------------------------------- 1 | import { toDecimalPlace } from '@/utils' 2 | 3 | import type { QuirksNodeProps, StyleRecord } from '../types' 4 | 5 | import { getFontFace, getLineHeight, getVariantNumeric } from './utils' 6 | 7 | const ALIGN_FLEX_MAP = { 8 | top: 'flex-start', 9 | center: 'center', 10 | bottom: 'flex-end', 11 | left: 'flex-start', 12 | right: 'flex-end' 13 | } as const 14 | 15 | export function getFontCSS(props: QuirksNodeProps): StyleRecord { 16 | const result: StyleRecord = {} 17 | 18 | const fontFace = getFontFace(props['font-handle'], props['derived-text-data']) 19 | if (fontFace) { 20 | Object.assign(result, fontFace) 21 | } 22 | 23 | const size = toDecimalPlace(props['font-size'] || 0) 24 | result['font-size'] = `${size}px` 25 | 26 | const lineHeight = getLineHeight(props['line-height']) 27 | if (lineHeight !== 'normal') { 28 | result['line-height'] = lineHeight 29 | } 30 | 31 | const textCase = props['text-case'] || 'none' 32 | const fontCaps = props['font-variant-caps'] || 'normal' 33 | if (textCase !== 'none') { 34 | result['text-transform'] = textCase 35 | } 36 | if (fontCaps !== 'normal') { 37 | result['font-variant-caps'] = fontCaps 38 | } 39 | 40 | const autoResize = props['text-auto-resize'] || 'width' 41 | const alignX = props['text-align-horizontal'] || 'left' 42 | const alignY = props['text-align-vertical'] || 'top' 43 | if (autoResize === 'width') { 44 | result.width = '' 45 | result.height = '' 46 | } else if (autoResize === 'height') { 47 | result.height = '' 48 | if (alignX !== 'left') { 49 | result['text-align'] = alignX 50 | } 51 | } else { 52 | result.display = 'flex' 53 | if (alignX !== 'left' && alignX !== 'justify') { 54 | result['justify-content'] = ALIGN_FLEX_MAP[alignX] 55 | } 56 | if (alignY !== 'top') { 57 | result['align-items'] = ALIGN_FLEX_MAP[alignY] 58 | } 59 | } 60 | 61 | const decoration = props['text-decoration'] || 'none' 62 | if (decoration !== 'none') { 63 | result['text-decoration'] = decoration 64 | } 65 | 66 | const indent = toDecimalPlace(props['paragraph-indent'] || 0, 5) 67 | if (indent !== 0) { 68 | result['text-indent'] = `${indent}px` 69 | } 70 | 71 | const truncation = props['text-truncation'] 72 | const maxLines = props['max-lines'] || 1 73 | if (truncation) { 74 | if (autoResize === 'none') { 75 | result.display = 'block' 76 | } else if (maxLines > 1) { 77 | result.display = '-webkit-box' 78 | result['-webkit-line-clamp'] = `${maxLines}` 79 | result['-webkit-box-orient'] = 'vertical' 80 | } 81 | result['text-overflow'] = 'ellipsis' 82 | result['white-space'] = 'nowrap' 83 | result.overflow = 'hidden' 84 | } 85 | 86 | const figure = props['font-variant-numeric-figure'] || 'normal' 87 | const spacing = props['font-variant-numeric-spacing'] || 'normal' 88 | const fraction = props['font-variant-numeric-fraction'] || 'normal' 89 | const slashedZero = props['font-variant-slashed-zero'] || false 90 | const ordinal = props['font-variant-ordinal'] || false 91 | 92 | const numericFeatures = getVariantNumeric({ figure, spacing, fraction, slashedZero, ordinal }) 93 | if (numericFeatures) { 94 | result['font-variant-numeric'] = numericFeatures 95 | } 96 | 97 | const onFeatures = props['toggled-on-ot-features'] || [] 98 | const offFeatures = props['toggled-off-ot-features'] || [] 99 | if (onFeatures.length || offFeatures.length) { 100 | result['font-feature-settings'] = [ 101 | ...onFeatures.map((tag) => `"${tag.toLowerCase()}"`), 102 | ...offFeatures.map((tag) => `"${tag.toLowerCase()}" 0`) 103 | ].join(', ') 104 | } 105 | 106 | const position = props['font-vairant-position'] || 'normal' 107 | if (position !== 'normal') { 108 | result['font-variant-position'] = position 109 | } 110 | 111 | return result 112 | } 113 | -------------------------------------------------------------------------------- /ui/quirks/font/index.ts: -------------------------------------------------------------------------------- 1 | export * from './parsers' 2 | export * from './css' 3 | export type { FontProps } from './types' 4 | -------------------------------------------------------------------------------- /ui/quirks/font/parsers.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | TextCase, 3 | TextAutoResize, 4 | TextAlignX, 5 | TextAlignY, 6 | TextDecoration, 7 | NumericFigure, 8 | NumericSpacing, 9 | NumericFraction, 10 | FontCaps, 11 | FontPosition 12 | } from './types' 13 | 14 | import { getEnumString, getStringArray } from '../basic' 15 | 16 | const TEXT_CASE_MAP: Record = { 17 | ORIGINAL: 'none', 18 | UPPER: 'uppercase', 19 | LOWER: 'lowercase', 20 | TITLE: 'capitalize' 21 | } 22 | 23 | export function getTextCase(raw: string): TextCase { 24 | const caseString = getEnumString(raw) 25 | return TEXT_CASE_MAP[caseString] || caseString 26 | } 27 | 28 | const TEXT_AUTO_RESIZE_MAP: Record = { 29 | NONE: 'none', 30 | WIDTH_AND_HEIGHT: 'width', 31 | HEIGHT: 'height' 32 | } 33 | 34 | export function getTextAutoResize(raw: string): TextAutoResize { 35 | const resizeString = getEnumString(raw) 36 | return TEXT_AUTO_RESIZE_MAP[resizeString] || resizeString 37 | } 38 | 39 | const TEXT_ALIGN_X_MAP: Record = { 40 | LEFT: 'left', 41 | CENTER: 'center', 42 | RIGHT: 'right', 43 | JUSIFIED: 'justify' 44 | } 45 | 46 | export function getAlignX(raw: string): TextAlignX { 47 | const alignString = getEnumString(raw) 48 | return TEXT_ALIGN_X_MAP[alignString] || alignString 49 | } 50 | 51 | const TEXT_ALIGN_Y_MAP: Record = { 52 | TOP: 'top', 53 | CENTER: 'center', 54 | BOTTOM: 'bottom' 55 | } 56 | 57 | export function getAlignY(raw: string): TextAlignY { 58 | const alignString = getEnumString(raw) 59 | return TEXT_ALIGN_Y_MAP[alignString] || alignString 60 | } 61 | 62 | const TEXT_DECORATION_MAP: Record = { 63 | NONE: 'none', 64 | UNDERLINE: 'underline', 65 | STRIKETHROUGH: 'line-through' 66 | } 67 | 68 | export function getTextDecoration(raw: string): TextDecoration { 69 | const decorationString = getEnumString(raw) 70 | return TEXT_DECORATION_MAP[decorationString] || decorationString 71 | } 72 | 73 | export function getTextTruncation(raw: string): boolean { 74 | const truncationString = getEnumString(raw) 75 | return truncationString !== 'DISABLED' 76 | } 77 | 78 | const NUMERIC_FIGURE_MAP: Record = { 79 | NORMAL: 'normal', 80 | LINING: 'lining-nums', 81 | OLDSTYLE: 'oldstyle-nums' 82 | } 83 | 84 | export function getNumericFigure(raw: string): NumericFigure { 85 | const figureString = getEnumString(raw) 86 | return NUMERIC_FIGURE_MAP[figureString] || figureString 87 | } 88 | 89 | const NUMERIC_SPACING_MAP: Record = { 90 | NORMAL: 'normal', 91 | PROPORTIONAL: 'proportional-nums', 92 | TABULAR: 'tabular-nums' 93 | } 94 | 95 | export function getNumericSpacing(raw: string): NumericSpacing { 96 | const spacingString = getEnumString(raw) 97 | return NUMERIC_SPACING_MAP[spacingString] || spacingString 98 | } 99 | 100 | const NUMERIC_FRACTION_MAP: Record = { 101 | NORMAL: 'normal', 102 | STACKED: 'stacked-fractions' 103 | } 104 | 105 | export function getNumericFraction(raw: string): NumericFraction { 106 | const fractionString = getEnumString(raw) 107 | return NUMERIC_FRACTION_MAP[fractionString] || fractionString 108 | } 109 | 110 | const FONT_CAPS_MAP: Record = { 111 | NORMAL: 'normal', 112 | SMALL: 'smallcaps', 113 | ALL_SMALL: 'all-small-caps' 114 | } 115 | 116 | export function getFontCaps(raw: string): FontCaps { 117 | const capsString = getEnumString(raw) 118 | return FONT_CAPS_MAP[capsString] || capsString 119 | } 120 | 121 | const FONT_POSITION_MAP: Record = { 122 | NORMAL: 'normal', 123 | SUB: 'sub', 124 | SUPER: 'super' 125 | } 126 | 127 | export function getFontPosition(raw: string): FontPosition { 128 | const positionString = getEnumString(raw) 129 | return FONT_POSITION_MAP[positionString] || positionString 130 | } 131 | 132 | export function getOpenTypeFeatures(raw: string): string[] { 133 | return getStringArray(raw) 134 | } 135 | 136 | export const fontParsers = { 137 | TextCase: getTextCase, 138 | TextAutoResize: getTextAutoResize, 139 | AlignX: getAlignX, 140 | AlignY: getAlignY, 141 | TextDecoration: getTextDecoration, 142 | TextTruncation: getTextTruncation, 143 | FontVariantNumericFigure: getNumericFigure, 144 | FontVariantNumericSpacing: getNumericSpacing, 145 | FontVariantNumericFraction: getNumericFraction, 146 | FontVariantCaps: getFontCaps, 147 | FontVariantPosition: getFontPosition, 148 | 'ImmutableArray': getOpenTypeFeatures 149 | } 150 | -------------------------------------------------------------------------------- /ui/quirks/font/types.ts: -------------------------------------------------------------------------------- 1 | export type TextCase = 'none' | 'uppercase' | 'lowercase' | 'capitalize' // text-transform 2 | export type TextAutoResize = 'none' | 'width' | 'height' 3 | export type TextAlignX = 'left' | 'center' | 'right' | 'justify' 4 | export type TextAlignY = 'top' | 'center' | 'bottom' 5 | export type TextDecoration = 'underline' | 'line-through' | 'none' 6 | export type NumericSpacing = 'normal' | 'proportional-nums' | 'tabular-nums' 7 | export type NumericFigure = 'normal' | 'lining-nums' | 'oldstyle-nums' 8 | export type NumericFraction = 'normal' | 'stacked-fractions' 9 | export type FontCaps = 'normal' | 'smallcaps' | 'all-small-caps' 10 | export type FontPosition = 'normal' | 'sub' | 'super' 11 | 12 | export interface FontProps { 13 | 'font-handle'?: string 14 | 'derived-text-data'?: string 15 | 'font-size'?: number 16 | 'text-case'?: TextCase 17 | 'text-align-horizontal'?: TextAlignX 18 | 'text-align-vertical'?: TextAlignY 19 | 'text-auto-resize'?: TextAutoResize 20 | 'text-decoration'?: TextDecoration 21 | 'paragraph-indent'?: number 22 | 'text-truncation'?: boolean 23 | 'max-lines'?: number 24 | 'line-height'?: string 25 | 'font-variant-numeric-figure'?: NumericFigure 26 | 'font-variant-numeric-spacing'?: NumericSpacing 27 | 'font-variant-numeric-fraction'?: NumericFraction 28 | 'font-variant-slashed-zero'?: boolean // 'zero' 29 | 'font-variant-ordinal'?: boolean // 'ordn' 30 | 'font-variant-caps'?: FontCaps 31 | 'font-vairant-position'?: FontPosition 32 | 'toggled-on-ot-features'?: string[] 33 | 'toggled-off-ot-features'?: string[] 34 | } 35 | -------------------------------------------------------------------------------- /ui/quirks/font/utils.ts: -------------------------------------------------------------------------------- 1 | import { parseNumber, toDecimalPlace } from '@/utils' 2 | 3 | import type { StyleRecord } from '../types' 4 | import type { NumericFigure, NumericSpacing, NumericFraction } from './types' 5 | 6 | interface FontMetaData { 7 | style: string 8 | weight: number 9 | } 10 | 11 | const FONT_META_SECTION_RE = /fontMetaData:\s*\{([^}]+)}/ 12 | const FONT_META_RE = /\s+([^:]+):\s*FontMetaData\([^,]+,\s*[^,]+,\s*([^,]+),\s*([^)]+)/g 13 | 14 | export function getFontFace(raw?: string, textData?: string): Record | null { 15 | if (!textData || !raw) { 16 | return null 17 | } 18 | 19 | const [, fontMetaDataSection] = textData.match(FONT_META_SECTION_RE) || [] 20 | const fonts = [...fontMetaDataSection.matchAll(FONT_META_RE)].reduce( 21 | (acc, cur) => { 22 | const [, fullName, style, weight] = cur 23 | 24 | if (!fullName) { 25 | return acc 26 | } 27 | 28 | return { 29 | ...acc, 30 | [fullName]: { 31 | style, 32 | weight: parseNumber(weight) || 400 33 | } 34 | } 35 | }, 36 | {} as Record 37 | ) 38 | 39 | // Noto Sans Display Bold 2 -> Noto Sans Display 40 | const fullName = raw.replace(/\s+\d+(?:\.\d+)?$/, '') 41 | const font = fonts[fullName] 42 | if (!font) { 43 | return null 44 | } 45 | 46 | const { style, weight } = font 47 | 48 | const result: StyleRecord = { 49 | 'font-family': getFontFamily(fullName, style) 50 | } 51 | 52 | if (style !== 'normal') { 53 | result['font-style'] = style 54 | } 55 | 56 | if (weight !== 400) { 57 | result['font-weight'] = `${weight}` 58 | } 59 | 60 | return result 61 | } 62 | 63 | // Based on Figma's own heuristics: 64 | // https://youtu.be/kVD-sjtFoEI?si=5rdjpzZTGalH1Nix&t=247 65 | const WEIGHT_MODIFIERS = [ 66 | // 100 67 | 'hairline', 68 | 'thin', 69 | 70 | // 200 71 | 'ultra light', 72 | 'extra light', 73 | 74 | // 300 75 | 'demi', 76 | 'light', 77 | 'book', 78 | 79 | // 400 80 | 'regular', 81 | // 'book', 82 | 'normal', 83 | 84 | // 500 85 | 'medium', 86 | 87 | // 600 88 | 'demi bold', 89 | 'semi bold', 90 | 91 | // 700 92 | 'bold', 93 | 94 | // 800 95 | 'ultra bold', 96 | 'extra bold', 97 | 98 | // 900 99 | 'black', 100 | 'heavy' 101 | ] 102 | const WEIGHT_MODIFIER_RE = new RegExp( 103 | `\\s+(?:${WEIGHT_MODIFIERS.map((mod) => mod.replace(' ', '[ -]?')).join('|')})$`, 104 | 'i' 105 | ) 106 | 107 | const ITALIC_MODIFIER_RE = /\s+italic/i 108 | 109 | const CONTEXTUAL_MODIFIERS = ['display', 'text', 'caption', 'headline', 'subhead', 'poster', 'body'] 110 | const CONTEXTUAL_MODIFIER_RE = new RegExp(`\\s+(?:${CONTEXTUAL_MODIFIERS.join('|')})$`, 'i') 111 | 112 | export function getFontFamily(fullName: string, style: string): string { 113 | let family = fullName 114 | if (style === 'italic') { 115 | family = fullName.replace(ITALIC_MODIFIER_RE, '').trim() 116 | } 117 | 118 | family = family.replace(WEIGHT_MODIFIER_RE, '').trim() 119 | family = family.replace(CONTEXTUAL_MODIFIER_RE, '').trim() 120 | 121 | return family.includes(' ') ? `"${family}"` : family 122 | } 123 | 124 | interface FontVariantNumericFeatures { 125 | figure: NumericFigure 126 | spacing: NumericSpacing 127 | fraction: NumericFraction 128 | slashedZero: boolean 129 | ordinal: boolean 130 | } 131 | 132 | export function getVariantNumeric({ 133 | figure, 134 | spacing, 135 | fraction, 136 | slashedZero, 137 | ordinal 138 | }: FontVariantNumericFeatures): string { 139 | return [ 140 | ...[figure, spacing, fraction].filter((feature) => feature !== 'normal'), 141 | ...(slashedZero ? ['slashed-zero'] : []), 142 | ...(ordinal ? ['ordinal'] : []) 143 | ].join(' ') 144 | } 145 | 146 | export function getLineHeight(raw?: string): string { 147 | // undefined -> normal 148 | // 100% -> normal 149 | if (!raw || raw === '100%') { 150 | return 'normal' 151 | } 152 | 153 | // 24.123456789px -> 24.123px 154 | if (raw.endsWith('px')) { 155 | return `${toDecimalPlace(raw.slice(0, -2))}px` 156 | } 157 | 158 | // 1.234567 -> 1.235 159 | return `${toDecimalPlace(raw)}` 160 | } 161 | -------------------------------------------------------------------------------- /ui/quirks/index.ts: -------------------------------------------------------------------------------- 1 | import type { QuirksNodeProps, StyleRecord } from './types' 2 | 3 | import { basicParsers, getBasicCSS } from './basic' 4 | import { fontParsers, getFontCSS } from './font' 5 | import { stackParsers, getStackCSS } from './stack' 6 | import { styleParsers, getStyleCSS } from './style' 7 | 8 | const TEMPAD_PLUGIN_ID = '1126010039932614529' 9 | 10 | const KV_SECTION_RE = /\n({\s+[\s\S]+?\n})/ 11 | const KV_ITEMS_RE = /\n {2}([a-zA-Z0-9-]+): <([\s\S]*?)>(?=\n(?: {2}[a-z]|\}))/g 12 | 13 | function parseLog(id: string, log: string) { 14 | const [, kvStr] = log.match(KV_SECTION_RE) || [] 15 | 16 | if (!kvStr) { 17 | return null 18 | } 19 | 20 | const props = [...kvStr.matchAll(KV_ITEMS_RE)].reduce>((acc, cur) => { 21 | const [, key, value] = cur || [] 22 | 23 | if (!key || !value) { 24 | return acc 25 | } 26 | 27 | const parsedValue = parseTypeAndValue(value) 28 | if (!parsedValue) { 29 | return acc 30 | } 31 | 32 | return { 33 | ...acc, 34 | [key]: parsedValue.value 35 | } 36 | }, {}) as QuirksNodeProps 37 | 38 | return new QuirksNode(id, props) 39 | } 40 | 41 | function parseItem(itemStr: string): [string, string] | null { 42 | let depth = 0 43 | 44 | for (let i = 0; i < itemStr.length; ++i) { 45 | switch (itemStr[i]) { 46 | case '<': 47 | depth++ 48 | break 49 | case '>': 50 | depth-- 51 | break 52 | case ':': 53 | if (depth === 0 && itemStr[i - 1] !== ':') { 54 | return [itemStr.slice(0, i), itemStr.slice(i + 1)] 55 | } 56 | } 57 | } 58 | 59 | return null 60 | } 61 | 62 | interface RawValue { 63 | type: string 64 | value: string 65 | } 66 | 67 | interface ParsedValue { 68 | type: string 69 | value: unknown 70 | } 71 | 72 | function parseTypeAndValue(raw: string): ParsedValue | null { 73 | const [type, rawValue] = parseItem(raw) || [] 74 | 75 | if (!type || !rawValue) { 76 | return null 77 | } 78 | 79 | return { 80 | type: type, 81 | value: parseValue({ type, value: rawValue }) 82 | } 83 | } 84 | 85 | const parsers: Record unknown) | undefined> = { 86 | ...basicParsers, 87 | ...styleParsers, 88 | ...stackParsers, 89 | ...fontParsers, 90 | PluginData: (str: string) => JSON.parse(str) 91 | } 92 | 93 | function parseValue(rawValue: RawValue) { 94 | const { type, value } = rawValue 95 | 96 | const parser = parsers[type] 97 | return parser == null ? value : parser(value) 98 | } 99 | 100 | export class QuirksNode { 101 | constructor(id: string, props: QuirksNodeProps) { 102 | this.id = id 103 | this.props = props 104 | this.name = props.name 105 | this.type = props.type 106 | 107 | this.warning = this.getWarning() 108 | } 109 | 110 | props: QuirksNodeProps 111 | private parentCache: QuirksNode | null = null 112 | 113 | id: string 114 | name: string 115 | type: NodeType 116 | 117 | warning: string 118 | 119 | get parent(): QuirksNode | null { 120 | // parent is document or canvas, assume it's the root 121 | const parentId = this.props['parent-index'] 122 | if (this.props['parent-index']?.startsWith('0:')) { 123 | return null 124 | } 125 | 126 | if (!this.parentCache && window.DebuggingHelpers.logNode) { 127 | this.parentCache = parseLog(parentId, window.DebuggingHelpers.logNode(parentId)) 128 | } 129 | 130 | return this.parentCache 131 | } 132 | 133 | async getCSSAsync(): Promise { 134 | if (this.type === 'SECTION') { 135 | return {} 136 | } 137 | return this.getCSS() 138 | } 139 | 140 | getSharedPluginData(namespace: string, key: string) { 141 | const pluginData = this.props['plugin-data'] || [] 142 | 143 | const data = pluginData.find(({ pluginID, key: itemKey }) => { 144 | return ( 145 | (pluginID === '' && itemKey === `${namespace}-${key}`) || 146 | (pluginID === TEMPAD_PLUGIN_ID && itemKey === key) 147 | ) 148 | }) 149 | 150 | return data?.value || '' 151 | } 152 | 153 | findChild() { 154 | return null 155 | } 156 | 157 | private getWarning(): string { 158 | const { props } = this 159 | const effectCount = props['effect-data'] || 0 160 | const hasGradient = [...(props['fill-paint-data'] || []), ...(props['stroke-paint-data'] || [])] 161 | .filter(Boolean) 162 | .find((paint) => paint.includes('linear-gradient')) 163 | 164 | const warnings = [] 165 | const unsupported: string[] = [] 166 | 167 | if (this.type === 'TEXT') { 168 | warnings.push( 169 | '`font-family`, `font-style` and `font-weight` are calculated based on heuristics and may not be accurate.' 170 | ) 171 | } 172 | 173 | if (effectCount) { 174 | unsupported.push('effects') 175 | } 176 | 177 | if (hasGradient) { 178 | unsupported.push('gradients') 179 | } 180 | 181 | if (unsupported.length) { 182 | warnings.push( 183 | `The node has ${unsupported.join(' and ')} on it, which are not fully supported in quirks mode codegen.` 184 | ) 185 | } 186 | 187 | return warnings.join('\n\n') 188 | } 189 | 190 | // Unsupported CSS properties: 191 | // - background-blend-mode 192 | // - box-shadow 193 | // - filter 194 | // - backdrop-filter 195 | private getCSS(): StyleRecord { 196 | return { 197 | ...getBasicCSS(this.props), 198 | ...getStackCSS(this.props, this.parent), 199 | ...getStyleCSS(this.props), 200 | ...(this.type === 'TEXT' ? getFontCSS(this.props) : {}) 201 | } 202 | } 203 | } 204 | 205 | export class GhostNode { 206 | constructor(id: string) { 207 | this.id = id 208 | } 209 | 210 | id: string 211 | name: string = '-' 212 | type: 'GHOST' = 'GHOST' 213 | 214 | async getCSSAsync() { 215 | return {} 216 | } 217 | } 218 | 219 | const LOG_SEP_RE = /\n*logging node state for (\d+:\d+)\n*/ 220 | 221 | export function createQuirksSelection(): (QuirksNode | GhostNode)[] { 222 | if (!window.DebuggingHelpers.logSelected) { 223 | return [] 224 | } 225 | 226 | const log = window.DebuggingHelpers.logSelected() 227 | 228 | // selected node is document or canvas, means no selection 229 | if (log.startsWith('logging node state for 0:')) { 230 | return [] 231 | } 232 | 233 | const parts = log.split(LOG_SEP_RE).filter(Boolean) 234 | const selectedIds: string[] = [] 235 | const nodeLogs: string[] = [] 236 | for (let i = 0; i < parts.length; i += 2) { 237 | if (parts[i] && parts[i + 1]) { 238 | selectedIds.push(parts[i]) 239 | nodeLogs.push(parts[i + 1]) 240 | } 241 | } 242 | 243 | if (selectedIds.length > 1) { 244 | // multiple nodes are selected, no need to parse 245 | // `id` is necessary for the `scrollAndZoomIntoView` feature 246 | // when forcing quirks mode with `window.figma` available 247 | return selectedIds.map((id) => new GhostNode(id)) 248 | } 249 | 250 | return [parseLog(selectedIds[0], nodeLogs[0]) as QuirksNode] 251 | } 252 | -------------------------------------------------------------------------------- /ui/quirks/stack/css.ts: -------------------------------------------------------------------------------- 1 | import { toDecimalPlace } from '@/utils' 2 | 3 | import type { QuirksNodeProps, StyleRecord } from '../types' 4 | 5 | import { QuirksNode } from '..' 6 | 7 | export function getStackCSS(props: QuirksNodeProps, parent: QuirksNode | null): StyleRecord { 8 | return { 9 | ...getFlexCSS(props), 10 | ...getFlexItemCSS(props, parent) 11 | } 12 | } 13 | 14 | function getFlexCSS(props: QuirksNodeProps): StyleRecord { 15 | const mode = props['stack-mode'] 16 | if (!mode || mode === 'none') { 17 | return {} 18 | } 19 | 20 | const widthSizing = props[mode === 'column' ? 'stack-counter-sizing' : 'stack-primary-sizing'] 21 | 22 | const result: StyleRecord = { 23 | display: widthSizing === 'hug' ? 'inline-flex' : 'flex' 24 | } 25 | 26 | if (mode === 'column') { 27 | result['flex-direction'] = 'column' 28 | } 29 | 30 | const wrap = props['stack-wrap'] 31 | if (wrap === 'wrap') { 32 | result['flex-wrap'] = 'wrap' 33 | } 34 | 35 | const justify = props['stack-primary-align-items'] 36 | if (justify && justify !== 'flex-start') { 37 | result['justify-content'] = justify 38 | } 39 | 40 | if (justify !== 'space-between') { 41 | const gapMain = toDecimalPlace(props['stack-spacing'] || 0) 42 | const gapAxis = toDecimalPlace(props['stack-counter-spacing'] || 0) 43 | if (gapMain !== 0 && gapAxis !== 0) { 44 | result.gap = gapMain === gapAxis ? `${gapMain}px` : `${gapMain}px ${gapAxis}px` 45 | } 46 | } 47 | 48 | const align = props['stack-counter-align-items'] 49 | result['align-items'] = align || 'flex-start' 50 | if (wrap === 'wrap') { 51 | result['align-content'] = result['align-items'] 52 | } 53 | 54 | const paddingTop = toDecimalPlace(props['stack-padding-top'] || 0) 55 | const paddingRight = toDecimalPlace(props['stack-padding-right'] || 0) 56 | const paddingBottom = toDecimalPlace(props['stack-padding-bottom'] || 0) 57 | const paddingLeft = toDecimalPlace(props['stack-padding-left'] || 0) 58 | 59 | if (paddingTop === paddingBottom && paddingTop === paddingRight && paddingTop === paddingLeft) { 60 | if (paddingTop !== 0) { 61 | result.padding = `${paddingTop}px` 62 | } 63 | } else if (paddingTop === paddingBottom && paddingRight === paddingLeft) { 64 | result.padding = `${paddingTop}px ${paddingRight}px` 65 | } else { 66 | result.padding = `${paddingTop}px ${paddingRight}px ${paddingBottom}px ${paddingLeft}px` 67 | } 68 | 69 | return result 70 | } 71 | 72 | function getFlexItemCSS(props: QuirksNodeProps, parent: QuirksNode | null): StyleRecord { 73 | const result: StyleRecord = {} 74 | 75 | const parentFlex = parent ? getFlexCSS(parent.props) : null 76 | const position = props['stack-positioning'] 77 | 78 | if (parent?.props.size && (position === 'absolute' || !parentFlex)) { 79 | const hConstraint = props['horizontal-constraint'] 80 | const vConstraint = props['vertical-constraint'] 81 | 82 | const matrix = props['relative-transform'] 83 | const { e: left, f: top } = matrix 84 | 85 | const [width, height] = props.size! 86 | const [parentWidth, parentHeight] = parent.props.size 87 | 88 | const right = parentWidth - width - left 89 | const bottom = parentHeight - height - top 90 | 91 | const [l, t, r, b] = [left, top, right, bottom].map((v) => toDecimalPlace(v)) 92 | 93 | result.position = 'absolute' 94 | 95 | switch (hConstraint) { 96 | case 'min': 97 | result.left = `${l}px` 98 | break 99 | case 'max': 100 | result.right = `${r}px` 101 | break 102 | case 'center': 103 | result.left = `calc(50% - ${toDecimalPlace(width / 2 + (parentWidth / 2 - width / 2 - left))}px)` 104 | break 105 | case 'stretch': 106 | result.left = `${l}px` 107 | result.right = `${r}px` 108 | break 109 | case 'scale': 110 | result.left = `${toDecimalPlace((left / parentWidth) * 100)}%` 111 | result.right = `${toDecimalPlace((right / parentWidth) * 100)}%` 112 | break 113 | } 114 | 115 | switch (vConstraint) { 116 | case 'min': 117 | result.top = `${t}px` 118 | break 119 | case 'max': 120 | result.bottom = `${b}px` 121 | break 122 | case 'center': 123 | result.top = `calc(50% - ${toDecimalPlace(height / 2 + (parentHeight / 2 - height / 2 - top))}px)` 124 | break 125 | case 'stretch': 126 | result.top = `${t}px` 127 | result.bottom = `${b}px` 128 | break 129 | case 'scale': 130 | result.top = `${toDecimalPlace((t / parentHeight) * 100)}%` 131 | result.bottom = `${toDecimalPlace((b / parentHeight) * 100)}%` 132 | break 133 | } 134 | } 135 | 136 | if (!parentFlex) { 137 | return result 138 | } 139 | 140 | const grow = props['stack-child-primary-grow'] || 0 141 | const align = props['stack-child-align-self'] || 'auto' 142 | const direction = parentFlex['flex-direction'] || 'row' 143 | 144 | if (grow === 1) { 145 | result.flex = '1 0 0' 146 | } else if (align === 'auto') { 147 | result['flex-shrink'] = '0' 148 | } 149 | 150 | if (align === 'stretch') { 151 | result['align-self'] = 'stretch' 152 | } 153 | 154 | if (direction === 'row') { 155 | if (grow === 1) { 156 | result.width = '' 157 | } 158 | if (align === 'stretch') { 159 | result.height = '' 160 | } 161 | } else { 162 | if (grow === 1) { 163 | result.height = '' 164 | } 165 | if (align === 'stretch') { 166 | result.width = '' 167 | } 168 | } 169 | 170 | return result 171 | } 172 | -------------------------------------------------------------------------------- /ui/quirks/stack/index.ts: -------------------------------------------------------------------------------- 1 | export * from './parsers' 2 | export * from './css' 3 | export type { StackProps } from './types' 4 | -------------------------------------------------------------------------------- /ui/quirks/stack/parsers.ts: -------------------------------------------------------------------------------- 1 | import { snakeToKebab } from '@/utils' 2 | 3 | import type { 4 | StackAlign, 5 | StackJustify, 6 | StackMode, 7 | StackPosition, 8 | StackSize, 9 | StackWrap 10 | } from './types' 11 | 12 | import { getEnumString } from '../basic' 13 | 14 | const STACK_MODE_MAP: Record = { 15 | HORIZONTAL: 'row', 16 | VERTICAL: 'column', 17 | NONE: 'none' 18 | } 19 | 20 | export function getStackMode(raw: string): StackMode { 21 | const modeString = getEnumString(raw) 22 | return STACK_MODE_MAP[modeString] || modeString 23 | } 24 | 25 | const STACK_JUSTIFY_MAP: Record = { 26 | MIN: 'flex-start', 27 | CENTER: 'center', 28 | MAX: 'flex-end', 29 | SPACE_EVENLY: 'space-between' 30 | } 31 | 32 | export function getStackJustify(raw: string): StackJustify { 33 | const justifyString = getEnumString(raw) 34 | return STACK_JUSTIFY_MAP[justifyString] || justifyString 35 | } 36 | 37 | const STACK_ALIGN_MAP: Record = { 38 | MIN: 'flex-start', 39 | CENTER: 'center', 40 | MAX: 'flex-end', 41 | BASELINE: 'baseline', 42 | STRETCH: 'stretch', 43 | AUTO: 'auto' 44 | } 45 | 46 | export function getStackAlign(raw: string): StackAlign { 47 | const alignString = getEnumString(raw) 48 | return STACK_ALIGN_MAP[alignString] || alignString 49 | } 50 | 51 | const STACK_WRAP_MAP: Record = { 52 | WRAP: 'wrap', 53 | NO_WRAP: 'nowrap' 54 | } 55 | 56 | export function getStackWrap(raw: string): StackWrap { 57 | const wrapString = getEnumString(raw) 58 | return STACK_WRAP_MAP[wrapString] || wrapString 59 | } 60 | 61 | const POSITION_MAP: Record = { 62 | ABSOLUTE: 'absolute', 63 | AUTO: 'static' 64 | } 65 | 66 | export function getStackPosition(raw: string): StackPosition { 67 | const positionString = getEnumString(raw) 68 | return POSITION_MAP[positionString] || positionString 69 | } 70 | 71 | const STACK_SIZE_MAP: Record = { 72 | RESIZE_TO_FIT_WITH_IMPLICIT_SIZE: 'hug', 73 | FIXED: 'fixed' 74 | } 75 | 76 | export function getStackSize(raw: string): StackSize { 77 | const sizeString = getEnumString(raw) 78 | return STACK_SIZE_MAP[sizeString] || sizeString 79 | } 80 | 81 | export function getConstraintType(raw: string): string { 82 | const typeString = getEnumString(raw) 83 | return snakeToKebab(typeString) || typeString 84 | } 85 | 86 | const TRANSFORM_RE = /^AffineTransformF\(([^)]+)\)$/ 87 | 88 | export function getTransform(raw: string): DOMMatrixReadOnly | null { 89 | const [, args] = raw.match(TRANSFORM_RE) || [] 90 | if (!args) { 91 | return null 92 | } 93 | 94 | // For transform matrix: 95 | // a c e 96 | // b d f 97 | // 0 0 1 98 | // CSS transform matrix() order: a, b, c, d, e, f 99 | // AffineTransformF here: a, c, e, b, d, f 100 | // So we need to reorder them first. 101 | const [a, c, e, b, d, f] = JSON.parse(`[${args}]`) 102 | 103 | return new DOMMatrixReadOnly(`matrix(${a}, ${b}, ${c}, ${d}, ${e}, ${f})`) 104 | } 105 | 106 | export const stackParsers = { 107 | StackMode: getStackMode, 108 | StackJustify: getStackJustify, 109 | StackAlign: getStackAlign, 110 | StackCounterAlign: getStackAlign, 111 | StackWrap: getStackWrap, 112 | StackPositioning: getStackPosition, 113 | StackSize: getStackSize, 114 | ConstraintType: getConstraintType, 115 | AffineTransformF: getTransform 116 | } 117 | -------------------------------------------------------------------------------- /ui/quirks/stack/types.ts: -------------------------------------------------------------------------------- 1 | export type StackMode = 'row' | 'column' | 'none' 2 | export type StackWrap = 'wrap' | 'nowrap' 3 | export type StackJustify = 'flex-start' | 'center' | 'flex-end' | 'space-between' 4 | export type StackAlign = 'flex-start' | 'center' | 'flex-end' | 'baseline' | 'stretch' | 'auto' 5 | export type StackPosition = 'absolute' | 'static' 6 | export type StackSize = 'hug' | 'fixed' 7 | export type Constraint = 'min' | 'max' | 'center' | 'stretch' | 'scale' 8 | 9 | export interface StackProps { 10 | 'horizontal-constraint': Constraint 11 | 'vertical-constraint': Constraint 12 | 'relative-transform': DOMMatrixReadOnly 13 | 'max-size'?: [number, number] | null 14 | 'min-size'?: [number, number] | null 15 | 'stack-mode'?: StackMode 16 | 'stack-wrap'?: StackWrap 17 | 'stack-primary-sizing'?: StackSize 18 | 'stack-counter-sizing'?: StackSize 19 | 'stack-primary-align-items'?: StackJustify 20 | 'stack-counter-align-items'?: StackAlign 21 | 'stack-spacing'?: number 22 | 'stack-counter-spacing'?: number 23 | 'stack-padding-top'?: number 24 | 'stack-padding-right'?: number 25 | 'stack-padding-bottom'?: number 26 | 'stack-padding-left'?: number 27 | 'stack-child-primary-grow'?: number 28 | 'stack-child-align-self'?: StackAlign 29 | 'stack-positioning'?: StackPosition 30 | } 31 | -------------------------------------------------------------------------------- /ui/quirks/style/css.ts: -------------------------------------------------------------------------------- 1 | import { toDecimalPlace } from '@/utils' 2 | 3 | import type { QuirksNodeProps, StyleRecord } from '../types' 4 | 5 | function isShape(type: NodeType) { 6 | return ['VECTOR', 'ELLIPSE', 'STAR', 'POLYGON'].includes(type) 7 | } 8 | 9 | export function getStyleCSS(props: QuirksNodeProps): StyleRecord { 10 | const result: StyleRecord = {} 11 | 12 | const opacity = props.opacity 13 | if (opacity !== 1) { 14 | result.opacity = `${toDecimalPlace(opacity, 4)}` 15 | } 16 | 17 | const blendMode = props['blend-mode'] 18 | if (blendMode !== 'pass-through') { 19 | result['mix-blend-mode'] = blendMode 20 | } 21 | 22 | const fill = props['fill-paint-data'] || [] 23 | if (fill.length) { 24 | const fillString = fill.join(', ') 25 | if (props.type === 'TEXT') { 26 | if ( 27 | fill.length > 1 || 28 | fill.some((f) => f.includes('linear-gradient')) || 29 | fill.some((f) => f.includes('url(')) 30 | ) { 31 | // not simple color 32 | result.background = fillString 33 | result['-webkit-background-clip'] = 'text' 34 | result['background-clip'] = 'text' 35 | result['-webkit-text-fill-color'] = 'transparent' 36 | result['text-fill-color'] = 'transparent' 37 | } else { 38 | result.color = fillString 39 | } 40 | } else if (isShape(props.type)) { 41 | result.fill = fillString 42 | } else { 43 | result.background = fillString 44 | } 45 | } 46 | 47 | return { 48 | ...result, 49 | ...(isShape(props.type) ? getStrokeCSS(props) : getBordersCSS(props)), 50 | ...getBorderRadiusCSS(props) 51 | } 52 | } 53 | 54 | function getStrokeCSS(props: QuirksNodeProps): StyleRecord | null { 55 | const strokeWidth = toDecimalPlace(props['stroke-weight'] || 0) 56 | const strokeColor = props['stroke-paint-data'] || [] 57 | if (!strokeWidth || strokeColor.length === 0) { 58 | return null 59 | } 60 | return { 61 | 'stroke-width': `${strokeWidth}px`, 62 | stroke: strokeColor.join(', ') 63 | } 64 | } 65 | 66 | function getBordersCSS(props: QuirksNodeProps): StyleRecord | null { 67 | const borderTopWidth = props['border-top-weight'] 68 | const borderRightWidth = props['border-right-weight'] 69 | const borderBottomWidth = props['border-bottom-weight'] 70 | const borderLeftWidth = props['border-left-weight'] 71 | const borderColor = props['stroke-paint-data'] || [] 72 | 73 | const anyWidth = borderTopWidth || borderRightWidth || borderBottomWidth || borderLeftWidth 74 | 75 | if (!anyWidth || borderColor.length === 0) { 76 | return null 77 | } 78 | 79 | const borderStyle = props['stroke-dash-pattern']?.length ? 'dashed' : 'solid' 80 | 81 | if (!props['border-stroke-weights-independent']) { 82 | const borderWidth = toDecimalPlace(anyWidth) 83 | if (!borderWidth) { 84 | return null 85 | } 86 | return { 87 | border: `${borderWidth}px ${borderStyle} ${borderColor}` 88 | } 89 | } 90 | 91 | const topWidth = toDecimalPlace(props['border-top-weight'] || 0) 92 | const rightWidth = toDecimalPlace(props['border-right-weight'] || 0) 93 | const bottomWidth = toDecimalPlace(props['border-bottom-weight'] || 0) 94 | const leftWidth = toDecimalPlace(props['border-left-weight'] || 0) 95 | 96 | const borders: StyleRecord = {} 97 | 98 | if (topWidth) { 99 | borders['border-top'] = `${topWidth}px ${borderStyle} ${borderColor}` 100 | } 101 | if (rightWidth) { 102 | borders['border-right'] = `${rightWidth}px ${borderStyle} ${borderColor}` 103 | } 104 | if (bottomWidth) { 105 | borders['border-bottom'] = `${bottomWidth}px ${borderStyle} ${borderColor}` 106 | } 107 | if (leftWidth) { 108 | borders['border-left'] = `${leftWidth}px ${borderStyle} ${borderColor}` 109 | } 110 | 111 | return borders 112 | } 113 | 114 | function getBorderRadiusCSS(props: QuirksNodeProps): StyleRecord | null { 115 | if (!props['rectangle-corner-radii-independent']) { 116 | const borderRadius = toDecimalPlace(props['rectangle-top-left-corner-radius'] || 0) 117 | if (!borderRadius) { 118 | return null 119 | } 120 | return { 121 | 'border-radius': `${borderRadius}px` 122 | } 123 | } 124 | 125 | const topLeft = toDecimalPlace(props['rectangle-top-left-corner-radius'] || 0) 126 | const topRight = toDecimalPlace(props['rectangle-top-right-corner-radius'] || 0) 127 | const bottomLeft = toDecimalPlace(props['rectangle-bottom-left-corner-radius'] || 0) 128 | const bottomRight = toDecimalPlace(props['rectangle-bottom-right-corner-radius'] || 0) 129 | 130 | return { 131 | 'border-radius': `${topLeft}px ${topRight}px ${bottomRight}px ${bottomLeft}px` 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /ui/quirks/style/index.ts: -------------------------------------------------------------------------------- 1 | export * from './parsers' 2 | export * from './css' 3 | export type { StyleProps } from './types' 4 | -------------------------------------------------------------------------------- /ui/quirks/style/parsers.ts: -------------------------------------------------------------------------------- 1 | import { snakeToKebab, fadeTo, parseNumber, toDecimalPlace } from '@/utils' 2 | 3 | import { getEnumString } from '../basic' 4 | 5 | export function getBlendMode(raw: string): string { 6 | const modeString = getEnumString(raw) 7 | return snakeToKebab(modeString) || modeString 8 | } 9 | 10 | const PAINT_DATA_RE = /^PaintData\(([\s\S]*)\)$/ 11 | const PAINT_RE = /(Solid|Gradient|Raster)Paint\((.+)\)/g 12 | const PAINT_VALUE_RE = /([^,]+)(?:,\s*colorVar[^)]+\))?(?:,\s*opacity (.*))?/ 13 | 14 | export function getPaint(raw: string): string[] { 15 | const [, rawPaints] = raw.match(PAINT_DATA_RE) || [] 16 | 17 | if (!rawPaints) { 18 | return [] 19 | } 20 | 21 | const paints: string[] = [] 22 | 23 | ;[...rawPaints.matchAll(PAINT_RE)].forEach((match) => { 24 | const [, type, paintValue] = match 25 | const [, paint, opacity] = paintValue.match(PAINT_VALUE_RE) || [] 26 | if (!paint) { 27 | return 28 | } 29 | 30 | const opacityValue = toDecimalPlace(opacity || 1, 2) 31 | switch (type) { 32 | case 'Solid': 33 | if (opacityValue > 0) { 34 | paints.push(fadeTo(paint, opacityValue)) 35 | } 36 | break 37 | case 'Gradient': 38 | paints.push(`linear-gradient()`) 39 | break 40 | case 'Raster': 41 | paints.push(`url() no-repeat`) 42 | break 43 | default: 44 | break 45 | } 46 | }) 47 | 48 | return paints.reverse() 49 | } 50 | 51 | const EFFECT_RE = /EffectData\[([^\]]+)\]/ 52 | export function getEffectCount(raw: string): number { 53 | const [, effectCount = ''] = raw.match(EFFECT_RE) || [] 54 | return parseNumber(effectCount) || 0 55 | } 56 | 57 | export const styleParsers = { 58 | BlendMode: getBlendMode, 59 | 'Immutable': getPaint, 60 | 'Immutable': getEffectCount 61 | } 62 | -------------------------------------------------------------------------------- /ui/quirks/style/types.ts: -------------------------------------------------------------------------------- 1 | export interface StyleProps { 2 | opacity: number 3 | 'blend-mode': string 4 | 'fill-paint-data'?: string[] 5 | 'stroke-paint-data'?: string[] 6 | 'stroke-weight': number 7 | 'stroke-dash-pattern': number[] 8 | 'border-stroke-weights-independent'?: boolean 9 | 'border-top-weight'?: number 10 | 'border-right-weight'?: number 11 | 'border-bottom-weight'?: number 12 | 'border-left-weight'?: number 13 | 'rectangle-corner-radii-independent'?: boolean 14 | 'rectangle-top-left-corner-radius'?: number 15 | 'rectangle-top-right-corner-radius'?: number 16 | 'rectangle-bottom-left-corner-radius'?: number 17 | 'rectangle-bottom-right-corner-radius'?: number 18 | 'effect-data'?: number // we can only access the count of effects 19 | } 20 | -------------------------------------------------------------------------------- /ui/quirks/types.ts: -------------------------------------------------------------------------------- 1 | import type { BasicProps } from './basic' 2 | import type { FontProps } from './font' 3 | import type { StackProps } from './stack' 4 | import type { StyleProps } from './style' 5 | 6 | export interface PluginData { 7 | pluginID: string 8 | key: string 9 | value: string 10 | } 11 | 12 | // https://stackoverflow.com/a/66140779 13 | type KebabCase = T extends `${infer F}${infer R}` 14 | ? KebabCase ? '' : '-'}${Lowercase}`> 15 | : A 16 | 17 | type SupplementProperty = 18 | | '-webkit-line-clamp' 19 | | '-webkit-box-orient' 20 | | '-webkit-background-clip' 21 | | '-webkit-text-fill-color' 22 | | 'text-fill-color' 23 | 24 | export type StyleRecord = Partial< 25 | Record | SupplementProperty, string> 26 | > 27 | 28 | export interface QuirksNodeProps extends BasicProps, StackProps, FontProps, StyleProps { 29 | 'plugin-data'?: PluginData[] 30 | } 31 | -------------------------------------------------------------------------------- /ui/state.ts: -------------------------------------------------------------------------------- 1 | import { getTemPadComponent } from '@/utils' 2 | import { useStorage, computedAsync } from '@vueuse/core' 3 | 4 | import type { QuirksNode, GhostNode } from './quirks' 5 | 6 | import { ui } from './figma' 7 | 8 | interface PluginData { 9 | name: string 10 | code: string 11 | source: string 12 | } 13 | 14 | export type Options = { 15 | minimized: boolean 16 | panelPosition: { 17 | left: number 18 | top: number 19 | } 20 | prefOpen: boolean 21 | deepSelectOn: boolean 22 | measureOn: boolean 23 | cssUnit: 'px' | 'rem' 24 | rootFontSize: number 25 | scale: number 26 | plugins: { 27 | [source: string]: PluginData 28 | } 29 | activePluginSource: string | null 30 | } 31 | 32 | export type SelectionNode = SceneNode | QuirksNode | GhostNode 33 | 34 | export const options = useStorage('tempad-dev', { 35 | minimized: false, 36 | panelPosition: { 37 | left: window.innerWidth - ui.nativePanelWidth - ui.tempadPanelWidth, 38 | top: ui.topBoundary 39 | }, 40 | prefOpen: false, 41 | deepSelectOn: false, 42 | measureOn: false, 43 | cssUnit: 'px', 44 | rootFontSize: 16, 45 | scale: 1, 46 | plugins: {}, 47 | activePluginSource: null 48 | }) 49 | 50 | export const runtimeMode = shallowRef<'standard' | 'quirks' | 'unavailable'>('standard') 51 | export const selection = shallowRef([]) 52 | export const selectedNode = computed(() => selection.value?.[0] ?? null) 53 | export const selectedTemPadComponent = computed(() => getTemPadComponent(selectedNode.value)) 54 | 55 | export const activePlugin = computedAsync(async () => { 56 | if (!options.value.activePluginSource) { 57 | return null 58 | } 59 | 60 | return options.value.plugins[options.value.activePluginSource] 61 | }, null) 62 | -------------------------------------------------------------------------------- /utils/codegen.ts: -------------------------------------------------------------------------------- 1 | import type { RequestPayload, ResponsePayload, SerializeOptions } from '@/codegen/types' 2 | import type { DesignComponent } from '@/shared/types' 3 | 4 | import Codegen from '@/codegen/codegen?worker&inline' 5 | import { createWorkerRequester } from '@/codegen/worker' 6 | 7 | export async function codegen( 8 | style: Record, 9 | component: DesignComponent | null, 10 | options: SerializeOptions, 11 | pluginCode?: string 12 | ): Promise { 13 | const request = createWorkerRequester(Codegen) 14 | 15 | return await request({ 16 | style, 17 | component: component ?? undefined, 18 | options, 19 | pluginCode 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /utils/color.ts: -------------------------------------------------------------------------------- 1 | export function fadeTo(hex: string, alpha: number = 1): string { 2 | let h = hex.replace(/^#/, '') 3 | 4 | if (alpha === 1) { 5 | if (h.length === 6 && h[0] === h[1] && h[2] === h[3] && h[4] === h[5]) { 6 | return `#${h[0]}${h[2]}${h[4]}` 7 | } 8 | return hex 9 | } 10 | 11 | if (h.length === 3) { 12 | h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2] 13 | } 14 | 15 | if (h.length !== 6) { 16 | throw new Error('Invalid hex color') 17 | } 18 | 19 | // Ensure valid alpha value 20 | alpha = alpha < 0 ? 0 : alpha > 1 ? 1 : alpha 21 | 22 | const r = parseInt(h.slice(0, 2), 16) 23 | const g = parseInt(h.slice(2, 4), 16) 24 | const b = parseInt(h.slice(4, 6), 16) 25 | 26 | return `rgba(${r}, ${g}, ${b}, ${alpha})` 27 | } 28 | 29 | function toHex(c: number) { 30 | return Math.round(c * 255) 31 | .toString(16) 32 | .padStart(2, '0') 33 | } 34 | 35 | export function rgbToHex({ r, g, b }: { r: number; g: number; b: number }): string { 36 | // r, g, b are all 0~1 37 | return `#${toHex(r)}${toHex(g)}${toHex(b)}` 38 | } 39 | -------------------------------------------------------------------------------- /utils/component.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | SupportedLang, 3 | TransformOptions, 4 | ComponentPropertyValue, 5 | DesignNode, 6 | DesignComponent, 7 | DevComponent 8 | } from '@/shared/types' 9 | import type { SelectionNode } from '@/ui/state' 10 | 11 | import { Fill, Variable } from '@/plugins/src' 12 | 13 | import { prune } from './object' 14 | import { camelToKebab, escapeHTML, looseEscapeHTML, stringify, indentAll } from './string' 15 | 16 | export function getDesignComponent(node: SelectionNode): DesignComponent | null { 17 | if (!('componentProperties' in node)) { 18 | return null 19 | } 20 | 21 | const { name, componentProperties, mainComponent } = node 22 | const properties: Record = {} 23 | 24 | for (const [name, data] of Object.entries(componentProperties)) { 25 | const key = name.split('#')[0] 26 | if (data.type === 'INSTANCE_SWAP') { 27 | const component = figma.getNodeById(data.value as string) 28 | if (component?.type === 'COMPONENT') { 29 | properties[key] = { 30 | name: component.name, 31 | type: 'INSTANCE', 32 | properties: {}, 33 | children: [], 34 | visible: true 35 | } 36 | } 37 | } else { 38 | properties[key] = data.value 39 | } 40 | } 41 | 42 | const main = mainComponent ? { id: mainComponent.id, name: mainComponent.name } : null 43 | 44 | return { 45 | name, 46 | type: 'INSTANCE', 47 | properties, 48 | visible: node.visible, 49 | mainComponent: main, 50 | children: getChildren(node) ?? [] 51 | } 52 | } 53 | 54 | function getChildren(node: SelectionNode): DesignNode[] | null { 55 | if (!('children' in node)) { 56 | return null 57 | } 58 | 59 | const result: DesignNode[] = [] 60 | for (const child of node.children) { 61 | const { visible } = child 62 | switch (child.type) { 63 | case 'INSTANCE': { 64 | const component = getDesignComponent(child) 65 | if (component) { 66 | result.push(component) 67 | } 68 | break 69 | } 70 | case 'TEXT': { 71 | result.push({ 72 | name: child.name, 73 | type: 'TEXT', 74 | visible, 75 | characters: child.characters 76 | }) 77 | break 78 | } 79 | case 'FRAME': 80 | case 'GROUP': { 81 | result.push({ 82 | name: child.name, 83 | type: child.type, 84 | visible, 85 | children: getChildren(child) ?? [] 86 | }) 87 | break 88 | } 89 | case 'VECTOR': { 90 | type FillArray = Exclude 91 | if (!Array.isArray(child.fills)) { 92 | break 93 | } 94 | const fills: Fill[] = [] 95 | ;(child.fills as FillArray).forEach((fill) => { 96 | if (fill.type !== 'SOLID') { 97 | return 98 | } 99 | 100 | const { color: rgb, boundVariables } = fill 101 | const hex = rgbToHex(rgb) 102 | let color: string | Variable 103 | 104 | if (figma && boundVariables?.color) { 105 | const variable = figma.variables.getVariableById(boundVariables.color.id) 106 | color = variable ? { name: variable.name, value: hex } : hex 107 | } else { 108 | color = hex 109 | } 110 | 111 | fills.push({ color }) 112 | }) 113 | 114 | result.push({ 115 | name: child.name, 116 | type: 'VECTOR', 117 | visible, 118 | fills 119 | }) 120 | break 121 | } 122 | default: 123 | break 124 | } 125 | } 126 | 127 | return result 128 | } 129 | 130 | function stringifyComponent(component: DevComponent, lang: SupportedLang): string { 131 | // output as HTML 132 | switch (lang) { 133 | case 'vue': { 134 | return stringifyVueComponent(component) 135 | } 136 | case 'jsx': 137 | case 'tsx': 138 | default: { 139 | return stringifyJSXComponent(component) 140 | } 141 | } 142 | } 143 | 144 | const INDENT_UNIT = ' ' 145 | 146 | function stringifyBaseComponent( 147 | component: DevComponent, 148 | stringifyProp: (key: string, value: unknown) => string, 149 | indentLevel = 0 150 | ) { 151 | const indent = INDENT_UNIT.repeat(indentLevel) 152 | const { name, props, children: rawChildren } = component 153 | 154 | const propItems = Object.entries(props) 155 | .filter(([, value]) => value != null) 156 | .map((entry) => stringifyProp(...entry)) 157 | .filter(Boolean) 158 | 159 | const firstItem = propItems[0] 160 | 161 | const propsString = 162 | propItems.length === 0 163 | ? '' 164 | : propItems.length === 1 165 | ? firstItem.includes('\n') 166 | ? ` ${indentAll(firstItem, indent, true)}` 167 | : ` ${firstItem}` 168 | : `\n${propItems 169 | .map((prop) => `${indentAll(prop, indent + INDENT_UNIT)}`) 170 | .join('\n')}\n${indent}` 171 | 172 | const children = rawChildren.filter((child) => child != null) 173 | 174 | const childrenString = 175 | children.length === 0 176 | ? '' 177 | : `\n${children 178 | .map((child): string => { 179 | if (typeof child === 'string') { 180 | return `${indent + INDENT_UNIT}${child}` 181 | } 182 | 183 | return stringifyBaseComponent(child, stringifyProp, indentLevel + 1) 184 | }) 185 | .join('\n')}\n${indent}` 186 | 187 | return `${indent}<${name}${propsString}${ 188 | childrenString ? `>` : propItems.length > 1 ? '/>' : ' />' 189 | }${childrenString}${childrenString ? `` : ''}${indentLevel === 0 ? '\n' : ''}` 190 | } 191 | 192 | const EVENT_HANDLER_RE = /^on[A-Z]/ 193 | 194 | function getEventName(key: string) { 195 | if (EVENT_HANDLER_RE.test(key)) { 196 | return key[2].toLowerCase() + key.slice(3) 197 | } 198 | 199 | if (key.startsWith('@')) { 200 | return key.slice(1) 201 | } 202 | 203 | if (key.startsWith('v-on:')) { 204 | return key.slice(5) 205 | } 206 | 207 | return null 208 | } 209 | 210 | function stringifyVueComponent(component: DevComponent, indentLevel = 0) { 211 | return stringifyBaseComponent( 212 | component, 213 | (key, value) => { 214 | const eventName = getEventName(key) 215 | if (eventName) { 216 | const callback = typeof value === 'string' ? looseEscapeHTML(value) : '() => {}' 217 | return `@${camelToKebab(eventName)}="${callback.trim()}"` 218 | } 219 | 220 | const name = camelToKebab(key) 221 | 222 | if (typeof value === 'string') { 223 | return `${name}="${escapeHTML(value).trim()}"` 224 | } 225 | 226 | if (typeof value === 'boolean') { 227 | return value ? name : `:${name}="false"` 228 | } 229 | 230 | if (typeof value === 'object' && value != null) { 231 | const pruned = prune(value) 232 | if (pruned == null) { 233 | return '' 234 | } 235 | return `:${name}="${looseEscapeHTML(stringify(pruned)).trim()}"` 236 | } 237 | 238 | return `:${name}="${looseEscapeHTML(stringify(value)).trim()}"` 239 | }, 240 | indentLevel 241 | ) 242 | } 243 | 244 | function stringifyJSXComponent(component: DevComponent, indentLevel = 0) { 245 | return stringifyBaseComponent( 246 | component, 247 | (key, value) => { 248 | if (EVENT_HANDLER_RE.test(key)) { 249 | const callback = typeof value === 'string' ? value : '() => {}' 250 | return `${key}="{${callback.trim()}}"` 251 | } 252 | 253 | if (typeof value === 'string') { 254 | return `${key}="${escapeHTML(value).trim()}"` 255 | } 256 | 257 | if (typeof value === 'boolean') { 258 | return value ? key : `${key}={false}` 259 | } 260 | 261 | if (typeof value === 'object' && value != null) { 262 | const pruned = prune(value) 263 | if (pruned == null) { 264 | return '' 265 | } 266 | return `${key}={${stringify(pruned)}}` 267 | } 268 | 269 | return `${key}={${stringify(value)}}` 270 | }, 271 | indentLevel 272 | ) 273 | } 274 | 275 | type SerializeOptions = { 276 | lang?: SupportedLang 277 | } 278 | 279 | export function serializeComponent( 280 | component: DesignComponent, 281 | { lang = 'jsx' }: SerializeOptions, 282 | { transformComponent }: TransformOptions = {} 283 | ) { 284 | if (typeof transformComponent === 'function') { 285 | const result = transformComponent({ component }) 286 | if (typeof result === 'string') { 287 | return result 288 | } 289 | 290 | return stringifyComponent(result, lang) 291 | } 292 | 293 | return '' 294 | } 295 | -------------------------------------------------------------------------------- /utils/css.ts: -------------------------------------------------------------------------------- 1 | import type { TransformOptions } from '@/shared/types' 2 | 3 | import { parseNumber, toDecimalPlace } from './number' 4 | import { kebabToCamel } from './string' 5 | 6 | function escapeSingleQuote(value: string) { 7 | return value.replace(/'/g, "\\'") 8 | } 9 | 10 | function trimComments(value: string) { 11 | return value.replace(/\/\*[\s\S]*?\*\//g, '') 12 | } 13 | 14 | const PX_VALUE_RE = /\b(-?\d+(?:.\d+)?)px\b/g 15 | const VARIABLE_RE = /var\(--([a-zA-Z\d-]+)(?:,\s*([^)]+))?\)/g 16 | const KEEP_PX_PROPS = ['border', 'box-shadow', 'filter', 'backdrop-filter', 'stroke-width'] 17 | 18 | function transformPxValue(value: string, transform: (value: number) => string) { 19 | return value.replace(PX_VALUE_RE, (_, val) => { 20 | const parsed = parseNumber(val) 21 | if (parsed == null) { 22 | return val 23 | } 24 | if (parsed === 0) { 25 | return '0' 26 | } 27 | return transform(toDecimalPlace(parsed, 5)) 28 | }) 29 | } 30 | 31 | function scalePxValue(value: string, scale: number): string { 32 | return transformPxValue(value, (val) => `${toDecimalPlace(scale * val)}px`) 33 | } 34 | 35 | function pxToRem(value: string, rootFontSize: number) { 36 | return transformPxValue(value, (val) => `${toDecimalPlace(val / rootFontSize)}rem`) 37 | } 38 | 39 | type ProcessValueOptions = { 40 | useRem: boolean 41 | rootFontSize: number 42 | scale: number 43 | } 44 | 45 | type SerializeOptions = { 46 | toJS?: boolean 47 | } & ProcessValueOptions 48 | 49 | export function serializeCSS( 50 | style: Record, 51 | { toJS = false, useRem, rootFontSize, scale }: SerializeOptions, 52 | { transform, transformVariable, transformPx }: TransformOptions = {} 53 | ) { 54 | const options = { useRem, rootFontSize, scale } 55 | 56 | function processValue(key: string, value: string) { 57 | let current = trimComments(value).trim() 58 | 59 | if (typeof scale === 'number' && scale !== 1) { 60 | current = scalePxValue(current, scale) 61 | } 62 | 63 | if (typeof transformVariable === 'function') { 64 | current = current.replace(VARIABLE_RE, (_, name: string, value: string) => 65 | transformVariable({ code: current, name, value, options }) 66 | ) 67 | } 68 | 69 | if (KEEP_PX_PROPS.includes(key)) { 70 | return current 71 | } 72 | 73 | if (typeof transformPx === 'function') { 74 | current = transformPxValue(current, (value) => transformPx({ value, options })) 75 | } 76 | 77 | if (useRem) { 78 | current = pxToRem(current, rootFontSize) 79 | } 80 | 81 | return current 82 | } 83 | 84 | function stringifyValue(value: string) { 85 | if (value.includes('\0')) { 86 | // Check if the entire string is a single variable enclosed by \0 87 | if ( 88 | value.startsWith('\0') && 89 | value.endsWith('\0') && 90 | value.indexOf('\0', 1) === value.length - 1 91 | ) { 92 | return value.substring(1, value.length - 1) 93 | } 94 | 95 | const parts = value.split('\0') 96 | 97 | const template = parts 98 | .map((part, index) => (index % 2 === 0 ? part.replace(/`/g, '\\`') : '${' + part + '}')) 99 | .join('') 100 | 101 | return '`' + template + '`' 102 | } 103 | 104 | return `'${escapeSingleQuote(value)}'` 105 | } 106 | 107 | const processedStyle = Object.fromEntries( 108 | Object.entries(style) 109 | .filter(([, value]) => value) 110 | .map(([key, value]) => [key, processValue(key, value)]) 111 | ) 112 | 113 | if (!Object.keys(processedStyle).length) { 114 | return '' 115 | } 116 | 117 | let code = toJS 118 | ? '{\n' + 119 | Object.entries(processedStyle) 120 | .map(([key, value]) => ` ${kebabToCamel(key)}: ${stringifyValue(value)}`) 121 | .join(',\n') + 122 | '\n}' 123 | : Object.entries(processedStyle) 124 | .map(([key, value]) => `${key}: ${value};`) 125 | .join('\n') 126 | 127 | if (typeof transform === 'function') { 128 | code = transform({ code, style: processedStyle, options }) 129 | } 130 | 131 | return code 132 | } 133 | -------------------------------------------------------------------------------- /utils/dom.ts: -------------------------------------------------------------------------------- 1 | export function transformHTML(html: string, transform: (frag: DocumentFragment) => void): string { 2 | const template = document.createElement('template') 3 | template.innerHTML = html 4 | 5 | transform(template.content) 6 | 7 | return template.innerHTML 8 | } 9 | -------------------------------------------------------------------------------- /utils/figma.ts: -------------------------------------------------------------------------------- 1 | import { ui } from '@/ui/figma' 2 | 3 | export function getCanvas() { 4 | // Need to ensure the whole plugin is rendered after canvas is ready 5 | // so that we can cast the result to HTMLElement here. 6 | // The `waitFor` logic is in `./index.ts`. 7 | return document.querySelector('#fullscreen-root .gpu-view-content canvas') as HTMLElement 8 | } 9 | 10 | export function getLeftPanel() { 11 | // Similar to `getCanvas()`. 12 | return document.querySelector('#left-panel-container') as HTMLElement 13 | } 14 | 15 | function getChevron() { 16 | return ( 17 | document.querySelector( 18 | '#fullscreen-filename [class^="filename_view--chevronNoMainContainer--"]' 19 | ) ?? document.querySelector('[data-testid="filename-menu-chevron"]') 20 | ) 21 | } 22 | 23 | function getDuplicateItem() { 24 | return document.querySelector( 25 | '[data-testid="dropdown-option-Duplicate to your drafts"]' 26 | ) 27 | } 28 | 29 | export function showDuplicateItem() { 30 | const chevron = getChevron() 31 | 32 | if (!chevron) { 33 | return 34 | } 35 | 36 | chevron.dispatchEvent(new MouseEvent(ui.isUi3 ? 'click' : 'mousedown', { bubbles: true })) 37 | 38 | setTimeout(() => { 39 | const el = getDuplicateItem() 40 | el?.focus() 41 | }, 100) 42 | } 43 | -------------------------------------------------------------------------------- /utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './string' 2 | export * from './keyboard' 3 | export * from './figma' 4 | export * from './css' 5 | export * from './number' 6 | export * from './color' 7 | export * from './tempad' 8 | export * from './module' 9 | export * from './codegen' 10 | export * from './component' 11 | -------------------------------------------------------------------------------- /utils/keyboard.ts: -------------------------------------------------------------------------------- 1 | const metaKey = Reflect.getOwnPropertyDescriptor(MouseEvent.prototype, 'metaKey')! 2 | const altKey = Reflect.getOwnPropertyDescriptor(MouseEvent.prototype, 'altKey')! 3 | 4 | export function setLockMetaKey(lock: boolean) { 5 | if (lock) { 6 | Reflect.defineProperty(MouseEvent.prototype, 'metaKey', { 7 | get: () => true 8 | }) 9 | } else { 10 | Reflect.defineProperty(MouseEvent.prototype, 'metaKey', metaKey) 11 | } 12 | } 13 | 14 | export function setLockAltKey(lock: boolean) { 15 | if (lock) { 16 | Reflect.defineProperty(MouseEvent.prototype, 'altKey', { 17 | get: () => true 18 | }) 19 | } else { 20 | Reflect.defineProperty(MouseEvent.prototype, 'altKey', altKey) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /utils/module.ts: -------------------------------------------------------------------------------- 1 | export async function evaluate(code: string) { 2 | const blob = new Blob([code], { type: 'text/javascript' }) 3 | const url = URL.createObjectURL(blob) 4 | const module = await import(url) 5 | URL.revokeObjectURL(url) 6 | 7 | return module 8 | } 9 | -------------------------------------------------------------------------------- /utils/number.ts: -------------------------------------------------------------------------------- 1 | export function parseNumber(value: string) { 2 | if (value.trim() === '') { 3 | return null 4 | } 5 | 6 | const parsed = Number(value) 7 | if (!Number.isFinite(parsed)) { 8 | return null 9 | } 10 | 11 | return parsed 12 | } 13 | 14 | export function toDecimalPlace(value: string | number, decimalPlaces: number = 3): number { 15 | const val = typeof value === 'string' ? parseNumber(value) : value 16 | 17 | if (val == null) { 18 | return 0 19 | } 20 | 21 | return Number(val.toFixed(decimalPlaces)) 22 | } 23 | -------------------------------------------------------------------------------- /utils/object.ts: -------------------------------------------------------------------------------- 1 | type Prune = T extends undefined 2 | ? never 3 | : T extends any[] 4 | ? PruneArray 5 | : T extends object 6 | ? PruneObject 7 | : T 8 | 9 | type PruneArray = { 10 | [K in keyof T]: PruneArrayItem 11 | } 12 | 13 | type PruneArrayItem = Item extends undefined 14 | ? Item 15 | : Item extends any[] 16 | ? PruneArray 17 | : Item extends object 18 | ? PruneObjectInArray 19 | : Item 20 | 21 | type PruneObjectInArray = { 22 | [K in keyof T as T[K] extends undefined 23 | ? never 24 | : Prune extends never 25 | ? never 26 | : K]: Prune 27 | } 28 | 29 | type PruneObject = { 30 | [K in keyof T as T[K] extends undefined 31 | ? never 32 | : Prune extends never 33 | ? never 34 | : K]: Prune 35 | } extends infer O 36 | ? O extends object 37 | ? keyof O extends never 38 | ? never 39 | : O 40 | : never 41 | : never 42 | 43 | export function prune( 44 | obj: T, 45 | ): Prune extends never ? undefined : Prune { 46 | return _prune(obj, false) as any 47 | } 48 | 49 | function _prune(value: any, insideArray: boolean): any { 50 | if (value === null || typeof value !== 'object') { 51 | return value 52 | } 53 | 54 | if (Array.isArray(value)) { 55 | return value.map((item) => _prune(item, true)) 56 | } 57 | 58 | const result: Record = {} 59 | for (const [k, v] of Object.entries(value)) { 60 | if (v === undefined) continue 61 | 62 | const pruned = _prune(v, false) 63 | 64 | if (pruned === undefined) continue 65 | 66 | if ( 67 | typeof pruned === 'object' && 68 | !Array.isArray(pruned) && 69 | Object.keys(pruned).length === 0 70 | ) { 71 | continue 72 | } 73 | 74 | result[k] = pruned 75 | } 76 | 77 | if (Object.keys(result).length === 0) { 78 | return insideArray ? {} : undefined 79 | } 80 | return result 81 | } 82 | -------------------------------------------------------------------------------- /utils/string.ts: -------------------------------------------------------------------------------- 1 | import stringifyObject from 'stringify-object' 2 | 3 | export function kebabToCamel(str: string) { 4 | return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase()) 5 | } 6 | 7 | export function camelToKebab(str: string) { 8 | return str.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`) 9 | } 10 | 11 | export function snakeToKebab(str: string) { 12 | return str.replace(/_/g, '-').toLowerCase() 13 | } 14 | 15 | const ESCAPE_MAP: Record = { 16 | '&': '&', 17 | '<': '<', 18 | '>': '>', 19 | '"': '"' 20 | } 21 | 22 | const ESCAPE_RE = /[&<>"]/g 23 | 24 | export function escapeHTML(str: string) { 25 | return str.replace(ESCAPE_RE, (match) => ESCAPE_MAP[match]) 26 | } 27 | 28 | export function looseEscapeHTML(str: string) { 29 | return str.replaceAll('"', '"') 30 | } 31 | 32 | export function stringify(value: unknown) { 33 | return stringifyObject(value, { indent: ' ' }) 34 | } 35 | 36 | export function indentAll(str: string, indent: string, skipFirst = false) { 37 | return str 38 | .split('\n') 39 | .map((line, index) => (skipFirst && index === 0 ? '' : indent) + line) 40 | .join('\n') 41 | } 42 | -------------------------------------------------------------------------------- /utils/tempad.ts: -------------------------------------------------------------------------------- 1 | import { GhostNode, QuirksNode } from '@/ui/quirks' 2 | 3 | type TemPadComponent = { 4 | code: string 5 | name?: string 6 | libName?: string 7 | libDisplayName?: string 8 | link: string 9 | } 10 | 11 | type TemPadSource = { 12 | name: string 13 | libName: string 14 | } 15 | 16 | const NS = 'tempad.baidu.com' 17 | const LIB_DISPLAY_NAMES = { 18 | '@baidu/one-ui': 'ONE UI', 19 | '@baidu/one-ui-pro': 'ONE UI Pro', 20 | '@baidu/one-charts': 'ONE Charts', 21 | '@baidu/light-ai-react': 'Light AI', 22 | 'dls-icons-react': 'DLS Icons', 23 | 'dls-illustrations-react': 'DLS Illus.' 24 | } as Record 25 | 26 | export function getTemPadComponent( 27 | node: SceneNode | QuirksNode | GhostNode 28 | ): TemPadComponent | null { 29 | if (!('type' in node) || node.type !== 'FRAME' || !node.name.startsWith('🧩')) { 30 | return null 31 | } 32 | 33 | const tempadData = JSON.parse( 34 | node.getSharedPluginData(NS, 'source') || 'null' 35 | ) as TemPadSource | null 36 | 37 | if ( 38 | tempadData?.libName === '@baidu/one-ui' && 39 | (tempadData?.name === 'Icon' || tempadData?.name === 'Illustration') 40 | ) { 41 | const tree = JSON.parse(node.getSharedPluginData(NS, 'tree') || 'null') 42 | try { 43 | const iconNode = tree.slots.default.children[0] 44 | tempadData.libName = 45 | tempadData.name === 'Illustration' ? 'dls-illustrations-react' : iconNode.props.libName.v 46 | tempadData.name = iconNode.props.name.v?.name || iconNode.props.name.v 47 | } catch (e) { 48 | console.error(e) 49 | } 50 | } else if (tempadData?.name === 'Tem.RichText') { 51 | tempadData.name = 'Typography' 52 | tempadData.libName = '@baidu/light-ai-react' 53 | } 54 | 55 | const libDisplayName = tempadData?.libName ? LIB_DISPLAY_NAMES[tempadData.libName] : null 56 | 57 | let code = node.getSharedPluginData(NS, 'code') || null 58 | let link = node.getSharedPluginData(NS, 'link') || null 59 | 60 | if (!code) { 61 | code = (node.findChild((n) => n.type === 'TEXT' && n.name === '代码') as TextNode)?.characters 62 | } 63 | if (!link) { 64 | link = ( 65 | (node.findChild((n) => n.type === 'TEXT' && n.name === '🔗') as TextNode) 66 | ?.hyperlink as HyperlinkTarget 67 | )?.value 68 | } 69 | 70 | if (!code) { 71 | return null 72 | } 73 | 74 | code = extractJSX(code) 75 | 76 | return { 77 | code, 78 | link, 79 | name: node.name, 80 | ...tempadData, 81 | ...(libDisplayName ? { libDisplayName } : null) 82 | } 83 | } 84 | 85 | const COMPONENT_RE = /<>[\s\n]+]*>[\s\n]+?(\s*)([\s\S]+?)[\s\n]+<\/Stack>[\s\n]+<\/>/ 86 | const COMPONENT_PROVIDER_RE = 87 | /]*>[\s\n]+]*>[\s\n]+?(\s*)([\s\S]+?)[\s\n]+<\/Stack>[\s\n]+<\/ProviderConfig>/ 88 | export function extractJSX(code: string) { 89 | const [, indent = '', jsx = ''] = 90 | code.match(COMPONENT_RE) || code.match(COMPONENT_PROVIDER_RE) || [] 91 | return jsx 92 | .split('\n') 93 | .map((line) => line.replace(new RegExp(`^${indent}`), '')) 94 | .join('\n') 95 | } 96 | -------------------------------------------------------------------------------- /wxt.config.ts: -------------------------------------------------------------------------------- 1 | import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js' 2 | import { defineConfig } from 'wxt' 3 | 4 | export default defineConfig({ 5 | modules: ['@wxt-dev/module-vue'], 6 | vite: () => ({ 7 | plugins: [cssInjectedByJsPlugin()], 8 | optimizeDeps: { 9 | include: [] 10 | } 11 | }), 12 | webExt: { 13 | disabled: true 14 | }, 15 | manifest: { 16 | name: 'TemPad Dev', 17 | web_accessible_resources: [ 18 | { 19 | resources: ['/ui.js'], 20 | matches: ['https://www.figma.com/*'] 21 | }, 22 | { 23 | resources: ['/codegen.js'], 24 | matches: ['https://www.figma.com/*'] 25 | } 26 | ], 27 | permissions: ['declarativeNetRequest', 'declarativeNetRequestWithHostAccess', 'alarms'], 28 | host_permissions: [ 29 | 'https://www.figma.com/file/*', 30 | 'https://www.figma.com/design/*', 31 | 'https://raw.githubusercontent.com/*' 32 | ], 33 | declarative_net_request: { 34 | rule_resources: [ 35 | { 36 | id: 'figma', 37 | enabled: true, 38 | path: 'rules/figma.json' 39 | } 40 | ] 41 | } 42 | } 43 | }) 44 | --------------------------------------------------------------------------------