├── .editorconfig ├── .gitattributes ├── .github └── workflows │ ├── deploy-ghpages.yml │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .husky └── pre-commit ├── .nvmrc ├── .prettierignore ├── .yarn ├── plugins │ └── @yarnpkg │ │ └── plugin-postinstall-dev.cjs └── releases │ └── yarn-3.2.1.cjs ├── .yarnrc.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── package.json ├── scripts └── generateStaticFigmaFile │ ├── README.md │ ├── fetchFile.mjs │ ├── fetchNode.mjs │ └── index.mjs ├── src ├── FigspecFileViewer.ts ├── FigspecFrameViewer.ts ├── FrameCanvas │ ├── BoundingBoxMeasurement.spec.ts │ ├── BoundingBoxMeasurement.ts │ ├── FrameCanvas.ts │ ├── README.md │ ├── TooltipLayer.ts │ ├── distanceGuide.ts │ ├── getRenderBoundingBox.spec.ts │ └── getRenderBoundingBox.ts ├── dom.ts ├── figma.ts ├── index.ts ├── math.spec.ts ├── math.ts ├── preferences.ts ├── signal.spec.ts ├── signal.ts ├── state.ts ├── styles.ts └── ui │ ├── empty │ └── empty.ts │ ├── fullscreenPanel │ └── fullscreenPanel.ts │ ├── iconButton │ └── iconButton.ts │ ├── infoItems │ └── infoItems.ts │ ├── inspectorPanel │ ├── cssCode.ts │ ├── cssgen │ │ ├── CSSStyle.ts │ │ ├── colors.spec.ts │ │ ├── colors.ts │ │ ├── cssgen.ts │ │ ├── fromNode.spec.ts │ │ ├── fromNode.ts │ │ ├── gradient.spec.ts │ │ ├── gradient.ts │ │ ├── serialize.spec.ts │ │ └── serialize.ts │ ├── icons.ts │ ├── inspectorPanel.ts │ └── section.ts │ ├── menuBar │ ├── icons.ts │ └── menuBar.ts │ ├── preferencesPanel │ ├── choice.ts │ ├── icons.ts │ └── preferencesPanel.ts │ ├── selectBox │ └── selectBox.ts │ ├── snackbar │ └── snackbar.ts │ ├── styles.ts │ └── ui.ts ├── tsconfig.build.json ├── tsconfig.json ├── website ├── .gitignore ├── examples │ ├── demo-data │ │ ├── BXLAHpnTWaZL7Xcnp3aq3g │ │ │ ├── 7-29212.json │ │ │ └── 7-29212.svg │ │ └── Klm6pxIZSaJFiOMX5FpTul9F │ │ │ ├── 2:13.svg │ │ │ ├── 2:5.svg │ │ │ ├── 2:9.svg │ │ │ ├── 64:1.json │ │ │ ├── 64:1.svg │ │ │ ├── 93:14.svg │ │ │ ├── 93:32.svg │ │ │ └── file.json │ ├── events.html │ ├── events.ts │ ├── file.html │ ├── file.ts │ ├── many-nodes.html │ ├── many-nodes.ts │ ├── missing-image.html │ ├── missing-image.ts │ ├── parameter-missing-error.html │ ├── parameter-missing-error.ts │ └── style.css ├── index.html ├── index.ts ├── style.css ├── tsconfig.json └── vite.config.ts └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /website/examples/demo-data/**/* linguist-generated=true 2 | -------------------------------------------------------------------------------- /.github/workflows/deploy-ghpages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy website to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | pages: write 11 | id-token: write 12 | 13 | concurrency: 14 | group: "pages" 15 | cancel-in-progress: false 16 | 17 | jobs: 18 | deploy: 19 | environment: 20 | name: github-pages 21 | url: ${{ steps.deployment.outputs.page_url }} 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v4 26 | - name: Setup Pages 27 | uses: actions/configure-pages@v4 28 | - name: Setup Node.js 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version-file: .nvmrc 32 | cache: yarn 33 | - name: Install dependencies 34 | run: yarn install --immutable 35 | - name: Build website 36 | run: yarn build:website --base=/${{ github.event.repository.name }}/ 37 | - name: Upload artifact 38 | uses: actions/upload-pages-artifact@v3 39 | with: 40 | path: website/dist 41 | - name: Deploy to GitHub Pages 42 | id: deployment 43 | uses: actions/deploy-pages@v4 44 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to NPM 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | publish_npm: 10 | name: Publish to NPM 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v3 15 | - name: Setup Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version-file: .nvmrc 19 | cache: yarn 20 | - name: Install dependencies 21 | run: yarn install --immutable 22 | - name: Publish the package 23 | run: | 24 | npm config set //registry.npmjs.org/:_authToken=$NPM_AUTH_TOKEN 25 | npm publish --access public 26 | env: 27 | NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 28 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test and Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | test-and-lint: 11 | name: Test and Lint 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version-file: .nvmrc 20 | cache: yarn 21 | - name: Install dependencies 22 | run: yarn install --immutable 23 | - name: Check files are formatted with Prettier 24 | run: yarn prettier --check './**/src/**/*.{js,jsx,ts,tsx,css,html,md}' 25 | - name: Run unit tests 26 | run: yarn test 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # -- Do not put platform specific rules here (e.g. IDE config, OS meta files) 2 | 3 | # Common 4 | .env 5 | *.log 6 | 7 | # Node 8 | 9 | ## Dependencies 10 | node_modules 11 | package-lock.json 12 | 13 | ## Packed tarball 14 | *.tgz 15 | 16 | ## Yarn 17 | .pnp.* 18 | .yarn/* 19 | !.yarn/patches 20 | !.yarn/plugins 21 | !.yarn/releases 22 | !.yarn/sdks 23 | !.yarn/versions 24 | 25 | # Project specific 26 | /esm 27 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v16.15.1 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Prettier cannot handle large files 2 | website/examples/demo-data 3 | -------------------------------------------------------------------------------- /.yarn/plugins/@yarnpkg/plugin-postinstall-dev.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | //prettier-ignore 3 | module.exports = { 4 | name: "@yarnpkg/plugin-postinstall-dev", 5 | factory: function (require) { 6 | var plugin=(()=>{var i=Object.create,s=Object.defineProperty;var n=Object.getOwnPropertyDescriptor;var d=Object.getOwnPropertyNames;var l=Object.getPrototypeOf,p=Object.prototype.hasOwnProperty;var u=t=>s(t,"__esModule",{value:!0});var f=t=>{if(typeof require!="undefined")return require(t);throw new Error('Dynamic require of "'+t+'" is not supported')};var g=(t,e)=>{for(var o in e)s(t,o,{get:e[o],enumerable:!0})},k=(t,e,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let r of d(e))!p.call(t,r)&&r!=="default"&&s(t,r,{get:()=>e[r],enumerable:!(o=n(e,r))||o.enumerable});return t},P=t=>k(u(s(t!=null?i(l(t)):{},"default",t&&t.__esModule&&"default"in t?{get:()=>t.default,enumerable:!0}:{value:t,enumerable:!0})),t);var x={};g(x,{default:()=>w});var c=P(f("@yarnpkg/core")),a="postinstallDev",h={hooks:{async afterAllInstalled(t){let e=t.topLevelWorkspace.anchoredLocator;if(await c.scriptUtils.hasPackageScript(e,a,{project:t})){let o=await c.scriptUtils.executePackageScript(e,a,[],{project:t,stdin:process.stdin,stdout:process.stdout,stderr:process.stderr});if(o!==0){let r=new Error(`${a} script failed with exit code ${o}`);throw r.stack=void 0,r}}}}},w=h;return x;})(); 7 | return plugin; 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | plugins: 4 | - path: .yarn/plugins/@yarnpkg/plugin-postinstall-dev.cjs 5 | spec: "https://raw.githubusercontent.com/sachinraja/yarn-plugin-postinstall-dev/main/bundles/%40yarnpkg/plugin-postinstall-dev.js" 6 | 7 | yarnPath: .yarn/releases/yarn-3.2.1.cjs 8 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at pockawoooh@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | ## Issues 4 | 5 | ### Reporting bugs 6 | 7 | Please write a clear and concise description with short title. 8 | Codes to reproduce such as a sample repository or a link for an online playground (e.g. CodeSandbox) would be helpful. 9 | 10 | ### Requesting features 11 | 12 | Instead of simply describing the feature, I recommend you to write down a problem you facing and/or use-cases. 13 | These things help us discussing/implementing the feature and make your feature request more appealing. 14 | 15 | ## Pull Requests 16 | 17 | ### Submitting a Pull Request 18 | 19 | Please keep a Pull Request simple. Don't mix many things into one PR. 20 | We'll request you to break down your PR into small ones if required. 21 | 22 | ## Development guide 23 | 24 | ### Requirements 25 | 26 | - Node.js >= Maintenance LTS (If you have an NVM, just do `nvm install` or `nvm use` at the root of the repository) 27 | - Yarn 28 | 29 | ### Bootstraping 30 | 31 | ```sh 32 | $ yarn 33 | 34 | # or "yarn install" if you prefer... 35 | ``` 36 | 37 | ### Building packages 38 | 39 | You can compile TS files via `yarn build`. 40 | But most of development and testings can be done in dev server for the docs site. 41 | 42 | ```sh 43 | $ yarn build 44 | 45 | # to start dev server for the docs site 46 | $ yarn dev 47 | ``` 48 | 49 | ### Helper scripts 50 | 51 | Packages inside `scripts/` directory is only for our development, not meant to be published to a package registory. 52 | See README on each directory for more details. 53 | 54 | ## Release workflow 55 | 56 | 1. Bump the version by using [`npm version`](https://docs.npmjs.com/cli/v8/commands/npm-version) 57 | 2. Push the automatically created git commit and git tag 58 | 3. CI build and push the version to npm, wait for it 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Shota Fuji 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @figspec/components 2 | 3 | [![npm version](https://img.shields.io/npm/v/%40figspec/components)](https://www.npmjs.com/package/@figspec/components) 4 | [![docs HTML (with demo)]() 5 | ](https://pocka.github.io/figspec/) 6 | 7 | `@figspec/components` is a set of CustomElements that renders Figma file/frame and displays Figma's editor-like inspector for a selected node. 8 | 9 | The components are designed to work on Figma REST API result. 10 | This library does not provided a functionality to invoke Figma REST API endpoints. 11 | 12 | ## Installation 13 | 14 | ```sh 15 | $ npm i @figspec/components 16 | ``` 17 | 18 | This library does not provide bundled script. Please use CDN or bundle on your own. 19 | 20 | ## Usage 21 | 22 | Import the entry script (`import '@figspec/components'`) and it'll register our custom elements. 23 | Then you can now use these on your page. 24 | 25 | ```html 26 | 27 | 28 | 29 | ``` 30 | 31 | ```js 32 | // your script.js 33 | import "@figspec/components"; 34 | 35 | const figmaFrame = document.getElementById("figma_frame") 36 | 37 | figmaFrame.apiResponse = /* ... */; 38 | figmaFrame.renderedImage = /* ... */; 39 | ``` 40 | 41 | To display an entire Figma File, use `` instead. 42 | 43 | ```html 44 | 45 | 46 | 47 | ``` 48 | 49 | ```js 50 | // your script.js 51 | import "@figspec/components"; 52 | 53 | const figmaFile = document.getElementById("figma_file") 54 | 55 | figmaFrame.apiResponse = /* ... */; 56 | figmaFrame.renderedImages = /* ... */; 57 | ``` 58 | 59 | To see working examples and API docs, please check out [the docs site](https://pocka.github.io/figspec/). 60 | 61 | ## Browser supports 62 | 63 | This library works on browser implementing WebComponents v1 spec and ES2019. 64 | The bundled files are at `esm/es2019`. 65 | 66 | ## Related packages 67 | 68 | - [`@figspec/react`](https://github.com/pocka/figspec-react) ... React bindings for this package. 69 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@figspec/components", 3 | "description": "Unofficial Figma spec viewer: Live Embed Kit - Live + Guidelines + Inspector", 4 | "keywords": [ 5 | "figma", 6 | "webcomponents" 7 | ], 8 | "version": "2.0.4", 9 | "contributors": [ 10 | { 11 | "name": "Shota Fuji", 12 | "email": "pockawoooh@gmail.com" 13 | } 14 | ], 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/pocka/figspec.git" 18 | }, 19 | "license": "MIT", 20 | "main": "esm/es2019/index.js", 21 | "type": "module", 22 | "types": "esm/es2019/index.d.ts", 23 | "files": [ 24 | "esm" 25 | ], 26 | "sideEffects": [ 27 | "./src/index.ts", 28 | "./esm/*/index.js" 29 | ], 30 | "devDependencies": { 31 | "@picocss/pico": "^1.5.10", 32 | "commander": "^6.1.0", 33 | "dotenv": "^8.2.0", 34 | "figma-js": "^1.13.0", 35 | "glob": "^10.3.2", 36 | "husky": "^8.0.1", 37 | "lint-staged": "^13.0.3", 38 | "node-fetch": "^2.6.1", 39 | "prettier": "^3.0.2", 40 | "typescript": "^5.1.6", 41 | "vite": "^4.4.2", 42 | "vitest": "^0.34.2" 43 | }, 44 | "scripts": { 45 | "build": "tsc -p tsconfig.build.json", 46 | "prepublishOnly": "yarn build", 47 | "generate-static-figma-file": "node ./scripts/generateStaticFigmaFile/index.mjs", 48 | "postinstallDev": "husky install", 49 | "dev": "vite ./website", 50 | "build:website": "vite build ./website", 51 | "test": "vitest" 52 | }, 53 | "lint-staged": { 54 | "*.{js,jsx,ts,tsx,css,html,md,yml,json}": [ 55 | "prettier --write" 56 | ] 57 | }, 58 | "prettier": {}, 59 | "packageManager": "yarn@3.2.1" 60 | } 61 | -------------------------------------------------------------------------------- /scripts/generateStaticFigmaFile/README.md: -------------------------------------------------------------------------------- 1 | # generateStaticFigmaFile 2 | 3 | _Internal utility script (not published)_ 4 | 5 | A helper tool to download a Figma Frame as an API response JSON file and a rendered SVG image file. 6 | 7 | ## Usage 8 | 9 | ```sh 10 | $ yarn generate-static-figma-file -t -u -o 11 | 12 | # You can also invoke by below 13 | $ yarn workspace @figspec/generate-static-figma-file generate <...args> 14 | 15 | # or this 16 | $ cd scripts/generateStaticFigmaFile 17 | $ yarn generate <...args> 18 | ``` 19 | 20 | This script loads `.env` file at the root of the repository with `dotenv` package. 21 | If the `--token` option is absent, the script will use environment variable `FIGMA_TOKEN` instead. 22 | 23 | ``` 24 | # /.env 25 | FIGMA_TOKEN= 26 | ``` 27 | 28 | ```sh 29 | $ yarn generate-static-figma-file <...args except --token> 30 | ``` 31 | 32 | For more information, please refer tool's help. 33 | 34 | ```sh 35 | $ yarn generate-static-figma-file --help 36 | ``` 37 | -------------------------------------------------------------------------------- /scripts/generateStaticFigmaFile/fetchFile.mjs: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | 3 | export async function fetchFile(client, fileKey) { 4 | const fileResponse = await client.file(fileKey); 5 | 6 | const frames = listAllFrames(fileResponse.data.document); 7 | 8 | const imageResponse = await client.fileImages(fileKey, { 9 | ids: frames.map((frame) => frame.id), 10 | scale: 1, 11 | format: "svg", 12 | }); 13 | 14 | const images = []; 15 | 16 | for (const [nodeId, image] of Object.entries(imageResponse.data.images)) { 17 | const res = await fetch(image); 18 | 19 | images.push({ 20 | nodeId, 21 | data: await res.buffer(), 22 | }); 23 | } 24 | 25 | return { 26 | response: fileResponse.data, 27 | images, 28 | }; 29 | } 30 | 31 | function listAllFrames(node) { 32 | if ("absoluteBoundingBox" in node) { 33 | return [node]; 34 | } 35 | 36 | if (!node.children || node.children.length === 0) { 37 | return []; 38 | } 39 | 40 | return node.children.map(listAllFrames).flat(); 41 | } 42 | -------------------------------------------------------------------------------- /scripts/generateStaticFigmaFile/fetchNode.mjs: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | 3 | export async function fetchNode(client, fileKey, nodeId) { 4 | const [nodes, imageResponse] = await Promise.all([ 5 | client.fileNodes(fileKey, { ids: [nodeId] }), 6 | client.fileImages(fileKey, { 7 | ids: [nodeId], 8 | scale: 1, 9 | format: "svg", 10 | }), 11 | ]); 12 | 13 | if (imageResponse.data.err) { 14 | throw new Error(`Failed to render nodes: ${imageResponse.data.err}`); 15 | } 16 | 17 | const [image] = await Promise.all( 18 | Object.entries(imageResponse.data.images) 19 | .filter((image) => image[0] === nodeId) 20 | .map(async ([nodeId, image]) => { 21 | if (!image) { 22 | throw new Error(`Failed to render a node (node-id=${nodeId}`); 23 | } 24 | 25 | const res = await fetch(image); 26 | 27 | if (res.status !== 200) { 28 | throw new Error( 29 | `Failed to fetch a rendered image: node-id=${nodeId}, url=${image}`, 30 | ); 31 | } 32 | 33 | return res.buffer(); 34 | }), 35 | ); 36 | 37 | if (!image) { 38 | throw new Error("Image not found."); 39 | } 40 | 41 | return { response: nodes.data, image }; 42 | } 43 | -------------------------------------------------------------------------------- /scripts/generateStaticFigmaFile/index.mjs: -------------------------------------------------------------------------------- 1 | import { program } from "commander"; 2 | import * as dotenv from "dotenv"; 3 | import * as Figma from "figma-js"; 4 | import * as fs from "fs/promises"; 5 | import * as path from "path"; 6 | import { URL, fileURLToPath } from "url"; 7 | 8 | import pkg from "../../package.json" assert { type: "json" }; 9 | 10 | import { fetchFile } from "./fetchFile.mjs"; 11 | import { fetchNode } from "./fetchNode.mjs"; 12 | 13 | const isFigmaURL = (url) => 14 | /https:\/\/([w.-]+.)?figma.com\/(file|proto)\/([0-9a-zA-Z]{22,128})(?:\/.*)?$/.test( 15 | url, 16 | ); 17 | 18 | program 19 | .version(pkg.version) 20 | .option("-o, --outDir ", "Directory to download", process.cwd()) 21 | .option("-t, --token ", "Personal Access Token for Figma API") 22 | .option("-u, --url ", "Figma file/node url to fetch") 23 | .option("--pretty", "Pretty print JSON"); 24 | 25 | async function main() { 26 | const token = program.token || process.env.FIGMA_TOKEN; 27 | 28 | if (!token) { 29 | console.error("Personal Access Token is required."); 30 | process.exit(1); 31 | } 32 | 33 | if (!program.url) { 34 | console.error("File/Node url is required."); 35 | process.exit(1); 36 | } 37 | 38 | if (!isFigmaURL(program.url)) { 39 | console.error("The URL is not a valid Figma URL."); 40 | process.exit(1); 41 | } 42 | 43 | const url = new URL(program.url); 44 | 45 | const fileKey = url.pathname.split("/")[2]; 46 | const nodeId = url.searchParams.get("node-id") || null; 47 | 48 | const figma = Figma.Client({ 49 | personalAccessToken: token, 50 | }); 51 | 52 | const files = await (async () => { 53 | if (nodeId) { 54 | const node = await fetchNode(figma, fileKey, nodeId); 55 | 56 | return [ 57 | { 58 | filename: `${nodeId}.json`, 59 | data: program.pretty 60 | ? JSON.stringify(node.response, null, 2) 61 | : JSON.stringify(node.response), 62 | }, 63 | { 64 | filename: `${nodeId}.svg`, 65 | data: node.image, 66 | }, 67 | ]; 68 | } 69 | 70 | const file = await fetchFile(figma, fileKey); 71 | 72 | return [ 73 | { 74 | filename: "file.json", 75 | data: program.pretty 76 | ? JSON.stringify(file.response, null, 2) 77 | : JSON.stringify(file.response), 78 | }, 79 | ...file.images.map((image) => ({ 80 | filename: `${image.nodeId}.svg`, 81 | data: image.data, 82 | })), 83 | ]; 84 | })(); 85 | 86 | const outDir = path.resolve( 87 | path.isAbsolute(program.outDir) 88 | ? program.outDir 89 | : path.resolve(process.env.INIT_CWD, program.outDir), 90 | fileKey, 91 | ); 92 | 93 | try { 94 | await fs.rmdir(outDir, { 95 | recursive: true, 96 | }); 97 | } catch (err) { 98 | // Ignore when the dir does not exist 99 | if (err.code !== "ENOENT") { 100 | throw err; 101 | } 102 | } 103 | 104 | await fs.mkdir(outDir, { 105 | recursive: true, 106 | }); 107 | 108 | await Promise.all( 109 | files.map(async (file) => { 110 | // Node IDs returned by Figma API contains `:` character. In many operating system, 111 | // filename can include this. However, Windows (or NTFS?) cannot so we need to escape it. 112 | // See for the context. 113 | const safeFilename = file.filename.replace(/:/g, "-"); 114 | 115 | await fs.writeFile(path.resolve(outDir, safeFilename), file.data); 116 | }), 117 | ); 118 | } 119 | 120 | dotenv.config({ 121 | path: path.resolve( 122 | path.dirname(fileURLToPath(import.meta.url)), 123 | "../../.env", 124 | ), 125 | }); 126 | program.parse(); 127 | main().catch((err) => { 128 | console.error(err); 129 | process.exit(1); 130 | }); 131 | -------------------------------------------------------------------------------- /src/FigspecFileViewer.ts: -------------------------------------------------------------------------------- 1 | import { attr, el } from "./dom.js"; 2 | import * as figma from "./figma.js"; 3 | import { FrameCanvas } from "./FrameCanvas/FrameCanvas.js"; 4 | import { 5 | defaultPreferenecs, 6 | isEqual as isEqualPreferences, 7 | type Preferences, 8 | } from "./preferences.js"; 9 | import { compute, effect, Signal } from "./signal.js"; 10 | import { styles } from "./styles.js"; 11 | import * as state from "./state.js"; 12 | 13 | import { ui } from "./ui/ui.js"; 14 | import { infoItems } from "./ui/infoItems/infoItems.js"; 15 | import { selectBox } from "./ui/selectBox/selectBox.js"; 16 | 17 | export class FigspecFileViewer extends HTMLElement { 18 | #link = new Signal(null); 19 | #resp = new Signal(null); 20 | #images = new Signal | null>(null); 21 | 22 | #canvases = compute>(() => { 23 | const map = new Map(); 24 | 25 | const resp = this.#resp.get(); 26 | if (!resp) { 27 | return map; 28 | } 29 | 30 | for (const canvas of figma.getCanvases(resp.document)) { 31 | map.set(canvas.id, canvas); 32 | } 33 | 34 | return map; 35 | }); 36 | 37 | #selectedCanvasId = new Signal(null); 38 | 39 | #state = compute< 40 | state.State< 41 | [figma.GetFileResponse, Map, Map] 42 | > 43 | >(() => { 44 | const resp = this.#resp.get(); 45 | const images = this.#images.get(); 46 | 47 | if (!resp && !images) { 48 | return state.idle; 49 | } 50 | 51 | if (!images) { 52 | return state.setupError(new Error("Rendered image set is required")); 53 | } 54 | 55 | if (!resp) { 56 | return state.setupError( 57 | new Error("Returned result of Get File API is required"), 58 | ); 59 | } 60 | 61 | const canvases = this.#canvases.get(); 62 | if (!canvases.size) { 63 | return state.setupError(new Error("No node has type=CANVAS.")); 64 | } 65 | 66 | return state.loaded([resp, images, canvases]); 67 | }); 68 | 69 | #givenPreferences: Readonly = { ...defaultPreferenecs }; 70 | #preferences = new Signal({ ...this.#givenPreferences }); 71 | 72 | set preferences(value: Readonly) { 73 | this.#givenPreferences = value; 74 | this.#preferences.set({ ...value }); 75 | } 76 | 77 | get preferences(): Readonly { 78 | return this.#preferences.once(); 79 | } 80 | 81 | #ui = ui({ 82 | caller: "file", 83 | state: this.#state, 84 | preferences: this.#preferences, 85 | infoContents: ([resp, , canvases]) => { 86 | return infoItems([ 87 | { 88 | label: "Filename", 89 | content: [resp.name], 90 | }, 91 | { 92 | label: "Last modified", 93 | content: [new Date(resp.lastModified).toLocaleString()], 94 | }, 95 | compute(() => { 96 | const link = this.#link.get(); 97 | if (!link) { 98 | return null; 99 | } 100 | 101 | return { 102 | label: "File link", 103 | content: [ 104 | el( 105 | "a", 106 | [ 107 | attr("href", link), 108 | attr("target", "_blank"), 109 | attr("rel", "noopener"), 110 | ], 111 | [link], 112 | ), 113 | ], 114 | }; 115 | }), 116 | { 117 | label: "Number of canvases", 118 | content: [canvases.size.toString(10)], 119 | }, 120 | ]); 121 | }, 122 | menuSlot: ([, , canvases]) => { 123 | return compute(() => { 124 | return selectBox({ 125 | value: compute(() => this.#selectedCanvasId.get() ?? ""), 126 | options: Array.from(canvases).map(([, canvas]) => 127 | el("option", [attr("value", canvas.id)], [canvas.name]), 128 | ), 129 | onChange: (value) => { 130 | if (canvases.has(value)) { 131 | this.#selectedCanvasId.set(value); 132 | } 133 | }, 134 | }); 135 | }); 136 | }, 137 | frameCanvas: ([, images, canvases], $selected, $loadedState) => { 138 | const frameCanvas = new FrameCanvas(this.#preferences, $selected); 139 | 140 | effect(() => { 141 | $selected.set(null); 142 | 143 | const currentId = this.#selectedCanvasId.get(); 144 | if (typeof currentId !== "string") { 145 | return; 146 | } 147 | 148 | const node = canvases.get(currentId); 149 | if (!node) { 150 | return; 151 | } 152 | 153 | frameCanvas.render([node], images); 154 | 155 | return () => { 156 | frameCanvas.clear(); 157 | }; 158 | }); 159 | 160 | let isFirstRun = true; 161 | effect(() => { 162 | const selected = $selected.get(); 163 | if (isFirstRun) { 164 | isFirstRun = false; 165 | return; 166 | } 167 | 168 | this.dispatchEvent( 169 | new CustomEvent("nodeselect", { 170 | detail: { 171 | node: selected, 172 | }, 173 | }), 174 | ); 175 | }); 176 | 177 | effect(() => { 178 | if (!state.isCanvas($loadedState.get())) { 179 | return; 180 | } 181 | 182 | frameCanvas.connectedCallback(); 183 | 184 | return () => { 185 | frameCanvas.disconnectedCallback(); 186 | }; 187 | }); 188 | 189 | return frameCanvas.container; 190 | }, 191 | }); 192 | 193 | constructor() { 194 | super(); 195 | 196 | const shadow = this.attachShadow({ mode: "open" }); 197 | 198 | const style = document.createElement("style"); 199 | 200 | style.textContent += styles; 201 | style.textContent += FrameCanvas.styles; 202 | 203 | shadow.appendChild(style); 204 | 205 | effect(() => { 206 | for (const [id] of this.#canvases.get()) { 207 | this.#selectedCanvasId.set(id); 208 | 209 | return () => { 210 | this.#selectedCanvasId.set(null); 211 | }; 212 | } 213 | }); 214 | 215 | effect(() => { 216 | const node = this.#ui.get(); 217 | shadow.appendChild(node); 218 | 219 | return () => { 220 | shadow.removeChild(node); 221 | }; 222 | }); 223 | 224 | // Emit `preferencesupdate` event on preferences updates 225 | effect(() => { 226 | const preferences = this.#preferences.get(); 227 | 228 | if (!isEqualPreferences(this.#givenPreferences, preferences)) { 229 | this.dispatchEvent( 230 | new CustomEvent("preferencesupdate", { 231 | detail: { preferences }, 232 | }), 233 | ); 234 | } 235 | }); 236 | } 237 | 238 | set link(link: string | null) { 239 | this.#link.set(link); 240 | } 241 | 242 | get link(): string | null { 243 | return this.#link.once(); 244 | } 245 | 246 | get renderedImages(): Record | null { 247 | const map = this.#images.once(); 248 | if (!map) { 249 | return null; 250 | } 251 | 252 | return Object.fromEntries(map.entries()); 253 | } 254 | 255 | set renderedImages(set: Record) { 256 | const map = new Map(); 257 | 258 | for (const nodeId in set) { 259 | map.set(nodeId, set[nodeId]); 260 | } 261 | 262 | this.#images.set(map); 263 | } 264 | 265 | get apiResponse(): figma.GetFileResponse | null { 266 | return this.#resp.once(); 267 | } 268 | 269 | set apiResponse(resp: figma.GetFileResponse | undefined) { 270 | if (!resp) { 271 | return; 272 | } 273 | 274 | this.#resp.set(resp); 275 | } 276 | 277 | static observedAttributes = ["link"] as const; 278 | 279 | public attributeChangedCallback( 280 | name: (typeof FigspecFileViewer.observedAttributes)[number], 281 | oldValue: string | null, 282 | newValue: string | null, 283 | ): void { 284 | if (newValue === oldValue) { 285 | return; 286 | } 287 | 288 | switch (name) { 289 | case "link": { 290 | this.#link.set(newValue || null); 291 | return; 292 | } 293 | } 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /src/FigspecFrameViewer.ts: -------------------------------------------------------------------------------- 1 | import { attr, el } from "./dom.js"; 2 | import type * as figma from "./figma.js"; 3 | import { FrameCanvas } from "./FrameCanvas/FrameCanvas.js"; 4 | import { 5 | defaultPreferenecs, 6 | isEqual as isEqualPreferences, 7 | type Preferences, 8 | } from "./preferences.js"; 9 | import { compute, effect, Signal } from "./signal.js"; 10 | import { styles } from "./styles.js"; 11 | import * as state from "./state.js"; 12 | 13 | import { ui } from "./ui/ui.js"; 14 | import { infoItems } from "./ui/infoItems/infoItems.js"; 15 | 16 | export class FigspecFrameViewer extends HTMLElement { 17 | #link = new Signal(null); 18 | #resp = new Signal(null); 19 | #image = new Signal(null); 20 | 21 | #state: Signal< 22 | state.State<[figma.GetFileNodesResponse, figma.Node, string]> 23 | > = compute(() => { 24 | const resp = this.#resp.get(); 25 | const image = this.#image.get(); 26 | 27 | if (!resp && !image) { 28 | return state.idle; 29 | } 30 | 31 | if (!image) { 32 | return state.setupError(new Error("Image file URI is required")); 33 | } 34 | 35 | if (!resp) { 36 | return state.setupError( 37 | new Error("Returned result of Get File Nodes API is required"), 38 | ); 39 | } 40 | 41 | const node = findMainNode(resp); 42 | if (!node) { 43 | return state.setupError( 44 | new Error("No renderable node is found in the data"), 45 | ); 46 | } 47 | 48 | return state.loaded([resp, node, image]); 49 | }); 50 | 51 | #givenPreferences: Readonly = { ...defaultPreferenecs }; 52 | #preferences = new Signal({ ...this.#givenPreferences }); 53 | 54 | set preferences(value: Readonly) { 55 | this.#givenPreferences = value; 56 | this.#preferences.set({ ...value }); 57 | } 58 | 59 | get preferences(): Readonly { 60 | return this.#preferences.once(); 61 | } 62 | 63 | #ui = ui({ 64 | caller: "frame", 65 | state: this.#state, 66 | preferences: this.#preferences, 67 | infoContents: ([resp]) => { 68 | return infoItems([ 69 | { 70 | label: "Filename", 71 | content: [resp.name], 72 | }, 73 | { 74 | label: "Last modified", 75 | content: [new Date(resp.lastModified).toLocaleString()], 76 | }, 77 | compute(() => { 78 | const link = this.#link.get(); 79 | if (!link) { 80 | return null; 81 | } 82 | 83 | return { 84 | label: "Frame link", 85 | content: [ 86 | el( 87 | "a", 88 | [ 89 | attr("href", link), 90 | attr("target", "_blank"), 91 | attr("rel", "noopener"), 92 | ], 93 | [link], 94 | ), 95 | ], 96 | }; 97 | }), 98 | ]); 99 | }, 100 | frameCanvas: ([, node, image], $selected, $loadedState) => { 101 | const frameCanvas = new FrameCanvas(this.#preferences, $selected); 102 | 103 | frameCanvas.render([node], new Map([[node.id, image]])); 104 | 105 | let isFirstRun = true; 106 | effect(() => { 107 | const selected = $selected.get(); 108 | if (isFirstRun) { 109 | isFirstRun = false; 110 | return; 111 | } 112 | 113 | this.dispatchEvent( 114 | new CustomEvent("nodeselect", { 115 | detail: { 116 | node: selected, 117 | }, 118 | }), 119 | ); 120 | }); 121 | 122 | effect(() => { 123 | if (!state.isCanvas($loadedState.get())) { 124 | return; 125 | } 126 | 127 | frameCanvas.connectedCallback(); 128 | 129 | return () => { 130 | frameCanvas.disconnectedCallback(); 131 | }; 132 | }); 133 | 134 | effect(() => { 135 | return () => { 136 | frameCanvas.clear(); 137 | }; 138 | }); 139 | 140 | return frameCanvas.container; 141 | }, 142 | }); 143 | 144 | constructor() { 145 | super(); 146 | 147 | const shadow = this.attachShadow({ mode: "open" }); 148 | 149 | const styleEl = document.createElement("style"); 150 | 151 | styleEl.textContent += styles; 152 | styleEl.textContent += FrameCanvas.styles; 153 | 154 | shadow.appendChild(styleEl); 155 | 156 | effect(() => { 157 | const node = this.#ui.get(); 158 | shadow.appendChild(node); 159 | 160 | return () => { 161 | shadow.removeChild(node); 162 | }; 163 | }); 164 | 165 | // Emit `preferencesupdate` event on preferences updates 166 | effect(() => { 167 | const preferences = this.#preferences.get(); 168 | 169 | if (!isEqualPreferences(this.#givenPreferences, preferences)) { 170 | this.dispatchEvent( 171 | new CustomEvent("preferencesupdate", { 172 | detail: { preferences }, 173 | }), 174 | ); 175 | } 176 | }); 177 | } 178 | 179 | get link(): string | null { 180 | return this.#link.once(); 181 | } 182 | 183 | set link(link: string | null) { 184 | this.#link.set(link); 185 | } 186 | 187 | get renderedImage(): string | null { 188 | return this.#image.once(); 189 | } 190 | 191 | set renderedImage(uri: string | undefined) { 192 | if (uri) { 193 | this.#image.set(uri); 194 | } 195 | } 196 | 197 | get apiResponse(): figma.GetFileNodesResponse | null { 198 | return this.#resp.once(); 199 | } 200 | 201 | set apiResponse(resp: figma.GetFileNodesResponse | undefined) { 202 | if (!resp) { 203 | return; 204 | } 205 | 206 | this.#resp.set(resp); 207 | } 208 | 209 | static observedAttributes = ["link"] as const; 210 | 211 | public attributeChangedCallback( 212 | name: (typeof FigspecFrameViewer.observedAttributes)[number], 213 | oldValue: string | null, 214 | newValue: string | null, 215 | ): void { 216 | if (newValue === oldValue) { 217 | return; 218 | } 219 | 220 | switch (name) { 221 | case "link": { 222 | this.#link.set(newValue || null); 223 | return; 224 | } 225 | } 226 | } 227 | } 228 | 229 | function findMainNode(resp: figma.GetFileNodesResponse): figma.Node | null { 230 | for (const key in resp.nodes) { 231 | const node = resp.nodes[key]; 232 | 233 | switch (node.document.type) { 234 | case "CANVAS": 235 | case "FRAME": 236 | case "GROUP": 237 | case "COMPONENT": 238 | case "COMPONENT_SET": { 239 | return node.document; 240 | } 241 | } 242 | } 243 | 244 | return null; 245 | } 246 | -------------------------------------------------------------------------------- /src/FrameCanvas/BoundingBoxMeasurement.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { BoundingBoxMeasurement } from "./BoundingBoxMeasurement"; 4 | 5 | describe("BoundingBoxMeasurement", () => { 6 | it("Should measure bounding box for nodes", () => { 7 | const bbox = new BoundingBoxMeasurement(); 8 | 9 | bbox.addNode({ 10 | type: "DUMMY", 11 | id: "DUMMY", 12 | name: "DUMMY", 13 | absoluteBoundingBox: { 14 | x: -50, 15 | y: -50, 16 | width: 1, 17 | height: 1, 18 | }, 19 | }); 20 | 21 | bbox.addNode({ 22 | type: "DUMMY", 23 | id: "DUMMY", 24 | name: "DUMMY", 25 | absoluteBoundingBox: { 26 | x: 40, 27 | y: 40, 28 | width: 10, 29 | height: 10, 30 | }, 31 | }); 32 | 33 | expect(bbox.measure()).toMatchObject({ 34 | x: -50, 35 | y: -50, 36 | width: 100, 37 | height: 100, 38 | }); 39 | }); 40 | 41 | it("Should use NaN if no nodes were added", () => { 42 | const bbox = new BoundingBoxMeasurement(); 43 | 44 | expect(bbox.measure()).toMatchObject({ 45 | x: NaN, 46 | y: NaN, 47 | width: NaN, 48 | height: NaN, 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/FrameCanvas/BoundingBoxMeasurement.ts: -------------------------------------------------------------------------------- 1 | import type * as figma from "../figma.js"; 2 | 3 | /** 4 | * Measure bounding box for nodes. 5 | */ 6 | export class BoundingBoxMeasurement { 7 | #minX = Infinity; 8 | #maxX = -Infinity; 9 | #minY = Infinity; 10 | #maxY = -Infinity; 11 | 12 | /** 13 | * Add a node to the measurement. 14 | */ 15 | addNode(node: figma.Node & figma.HasBoundingBox): void { 16 | if (node.visible === false) { 17 | return; 18 | } 19 | 20 | const box = node.absoluteRenderBounds || node.absoluteBoundingBox; 21 | 22 | this.#minX = Math.min(this.#minX, box.x); 23 | this.#maxX = Math.max(this.#maxX, box.x + box.width); 24 | this.#minY = Math.min(this.#minY, box.y); 25 | this.#maxY = Math.max(this.#maxY, box.y + box.height); 26 | } 27 | 28 | /** 29 | * Returns a bounding box for added nodes. 30 | */ 31 | measure(): figma.Rectangle { 32 | return { 33 | x: Number.isFinite(this.#minX) ? this.#minX : NaN, 34 | y: Number.isFinite(this.#minY) ? this.#minY : NaN, 35 | width: 36 | Number.isFinite(this.#maxX) && Number.isFinite(this.#minX) 37 | ? this.#maxX - this.#minX 38 | : NaN, 39 | height: 40 | Number.isFinite(this.#maxY) && Number.isFinite(this.#minY) 41 | ? this.#maxY - this.#minY 42 | : NaN, 43 | }; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/FrameCanvas/README.md: -------------------------------------------------------------------------------- 1 | # FrameCanvas internals 2 | 3 | ## Space key drag mode state machine 4 | 5 | In `Dragging` state, pointer movements cause viewport pan. 6 | 7 | ```mermaid 8 | stateDiagram-v2 9 | [*] --> Disabled 10 | Idle --> Dragging: pointerdown 11 | Dragging --> Idle: pointerup 12 | Dragging --> Disabled: keyup 13 | Disabled --> Idle: keydown 14 | ``` 15 | 16 | ## Touch gesture state machine 17 | 18 | In `Panning` state, touch movements cause viewport pan. 19 | 20 | In `Scaling` state, touch movements cause viewport scaling. 21 | Scaling factor depends on distance between touch points. 22 | 23 | ```mermaid 24 | stateDiagram-v2 25 | [*] --> Idle 26 | Idle --> Touching: touchstart 27 | Touching --> Idle: touchend (touches = 0), touchcancel 28 | state Touching { 29 | [*] --> Panning: touches = 1 30 | [*] --> Scaling: touches >= 2 31 | Panning --> Scaling: touchstart (touches >= 2) 32 | Scaling --> Panning: touchend (touches = 1) 33 | } 34 | ``` 35 | -------------------------------------------------------------------------------- /src/FrameCanvas/TooltipLayer.ts: -------------------------------------------------------------------------------- 1 | import { attr, className, svg } from "../dom.js"; 2 | 3 | export const CENTER = 0x0; 4 | export const LEFT = 0x1; 5 | export const RIGHT = 0x2; 6 | export const TOP = 0x10; 7 | export const BOTTOM = 0x20; 8 | 9 | export const styles = /* css */ ` 10 | .tl-bg { 11 | stroke: none; 12 | fill: var(--tooltip-bg); 13 | } 14 | 15 | .tl-text { 16 | font-size: var(--tooltip-font-size); 17 | stroke: none; 18 | fill: var(--tooltip-fg); 19 | } 20 | `; 21 | 22 | export class TooltipLayer { 23 | #container: SVGElement; 24 | 25 | get container(): SVGElement { 26 | return this.#container; 27 | } 28 | 29 | constructor(container: SVGElement = svg("g")) { 30 | this.#container = container; 31 | } 32 | 33 | show(text: string, x: number, y: number, placement: number) { 34 | // 0 ... center, 1 ... left, 2 ... right 35 | const hp = placement & 0xf; 36 | // 0 ... center, 1 ... top, 2 ... bottom 37 | const vp = (placement & 0xf0) >> 4; 38 | 39 | // Elements should be added to DOM tree as soon as it created: 40 | // `SVGGraphicsElement.getBBox` does not work for orphan element (does not throw an error!) 41 | const group = svg("g"); 42 | this.#container.appendChild(group); 43 | 44 | const bg = svg("rect", [attr("rx", "2"), className("tl-bg")]); 45 | group.appendChild(bg); 46 | 47 | const el = svg( 48 | "text", 49 | [ 50 | attr("text-anchor", "middle"), 51 | // By default, `` locates it's baseline on `y`. 52 | // This attribute changes that stupid default 53 | attr("dominant-baseline", "central"), 54 | className("tl-text"), 55 | ], 56 | [text], 57 | ); 58 | group.appendChild(el); 59 | 60 | const bbox = el.getBBox(); 61 | 62 | const margin = bbox.height * 0.25; 63 | const vPadding = bbox.height * 0.15; 64 | const hPadding = bbox.height * 0.25; 65 | 66 | const px = 67 | hp === 1 68 | ? x - bbox.width * 0.5 - (margin + hPadding) 69 | : hp === 2 70 | ? x + bbox.width * 0.5 + (margin + hPadding) 71 | : x; 72 | 73 | const py = 74 | vp === 1 75 | ? y - bbox.height * 0.5 - (margin + vPadding) 76 | : vp === 2 77 | ? y + bbox.height * 0.5 + (margin + vPadding) 78 | : y; 79 | 80 | el.setAttribute("x", px.toString()); 81 | el.setAttribute("y", py.toString()); 82 | 83 | bg.setAttribute("x", (px - bbox.width * 0.5 - hPadding).toString()); 84 | bg.setAttribute("y", (py - bbox.height * 0.5 - vPadding).toString()); 85 | bg.setAttribute("width", (bbox.width + hPadding * 2).toString()); 86 | bg.setAttribute("height", (bbox.height + vPadding * 2).toString()); 87 | 88 | group.style.transform = `scale(calc(1 / var(--_scale)))`; 89 | 90 | const tox = hp === 1 ? x - margin : hp === 2 ? x + margin : x; 91 | 92 | const toy = vp === 1 ? y - margin : vp === 2 ? y + margin : y; 93 | 94 | group.style.transformOrigin = `${tox}px ${toy}px`; 95 | } 96 | 97 | clear() { 98 | this.#container.replaceChildren(); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/FrameCanvas/distanceGuide.ts: -------------------------------------------------------------------------------- 1 | import type * as figma from "../figma.js"; 2 | 3 | interface Point2D { 4 | x: number; 5 | y: number; 6 | } 7 | 8 | type DistanceGuide = { 9 | /** 10 | * Solid line 11 | */ 12 | points: [Point2D, Point2D]; 13 | 14 | /** 15 | * Dashed line 16 | */ 17 | bisector?: [Point2D, Point2D]; 18 | }; 19 | 20 | interface AbsRect { 21 | /** 22 | * min y of the rect. 23 | * y of the top line. 24 | */ 25 | top: number; 26 | 27 | /** 28 | * max x of the rect. 29 | * x of the right line. 30 | */ 31 | right: number; 32 | 33 | /** 34 | * max y of the rect. 35 | * y of the bottom line. 36 | */ 37 | bottom: number; 38 | 39 | /** 40 | * min x of the rect. 41 | * x of the left line. 42 | */ 43 | left: number; 44 | } 45 | 46 | function absRect(rect: figma.Rectangle): AbsRect { 47 | return { 48 | top: rect.y, 49 | right: rect.x + rect.width, 50 | bottom: rect.y + rect.height, 51 | left: rect.x, 52 | }; 53 | } 54 | 55 | export function getDistanceGuides( 56 | selected: figma.Rectangle, 57 | compared: figma.Rectangle, 58 | ): readonly DistanceGuide[] { 59 | const a = absRect(selected); 60 | const b = absRect(compared); 61 | 62 | const isYIntersecting = !(a.top > b.bottom || a.bottom < b.top); 63 | const isXIntersecting = !(a.left > b.right || a.right < b.left); 64 | 65 | // Rects are intersecting. 66 | if (isXIntersecting && isYIntersecting) { 67 | // Center of intersecting region. 68 | const intersectCenter: Point2D = { 69 | x: (Math.max(a.left, b.left) + Math.min(a.right, b.right)) / 2, 70 | y: (Math.max(a.top, b.top) + Math.min(a.bottom, b.bottom)) / 2, 71 | }; 72 | 73 | return [ 74 | { 75 | points: [ 76 | { x: a.left, y: intersectCenter.y }, 77 | { x: b.left, y: intersectCenter.y }, 78 | ], 79 | }, 80 | { 81 | points: [ 82 | { 83 | x: a.right, 84 | y: intersectCenter.y, 85 | }, 86 | { x: b.right, y: intersectCenter.y }, 87 | ], 88 | }, 89 | { 90 | points: [ 91 | { y: a.top, x: intersectCenter.x }, 92 | { y: b.top, x: intersectCenter.x }, 93 | ], 94 | }, 95 | { 96 | points: [ 97 | { 98 | y: a.bottom, 99 | x: intersectCenter.x, 100 | }, 101 | { y: b.bottom, x: intersectCenter.x }, 102 | ], 103 | }, 104 | ]; 105 | } 106 | 107 | const isALeft = a.left > b.right; 108 | const isABelow = a.top > b.bottom; 109 | 110 | const selectedCenter: Point2D = { 111 | x: selected.x + selected.width / 2, 112 | y: selected.y + selected.height / 2, 113 | }; 114 | 115 | const guides: readonly (DistanceGuide | null)[] = [ 116 | !isXIntersecting 117 | ? { 118 | points: [ 119 | { x: isALeft ? a.left : a.right, y: selectedCenter.y }, 120 | { x: isALeft ? b.right : b.left, y: selectedCenter.y }, 121 | ], 122 | bisector: !isYIntersecting 123 | ? [ 124 | { x: isALeft ? b.right : b.left, y: selectedCenter.y }, 125 | { 126 | x: isALeft ? b.right : b.left, 127 | y: isABelow ? b.bottom : b.top, 128 | }, 129 | ] 130 | : void 0, 131 | } 132 | : null, 133 | !isYIntersecting 134 | ? { 135 | points: [ 136 | { y: isABelow ? a.top : a.bottom, x: selectedCenter.x }, 137 | { y: isABelow ? b.bottom : b.top, x: selectedCenter.x }, 138 | ], 139 | bisector: !isXIntersecting 140 | ? [ 141 | { y: isABelow ? b.bottom : b.top, x: selectedCenter.x }, 142 | { 143 | y: isABelow ? b.bottom : b.top, 144 | x: isALeft ? b.right : b.left, 145 | }, 146 | ] 147 | : void 0, 148 | } 149 | : null, 150 | ]; 151 | 152 | return guides.filter((x): x is DistanceGuide => !!x); 153 | } 154 | -------------------------------------------------------------------------------- /src/FrameCanvas/getRenderBoundingBox.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import type * as figma from "../figma"; 4 | 5 | import { getRenderBoundingBox } from "./getRenderBoundingBox"; 6 | 7 | describe("getRenderBoundingBox", () => { 8 | it("Should accumulate blur effect radius", () => { 9 | const node: figma.Node & figma.HasEffects & figma.HasBoundingBox = { 10 | type: "DUMMY", 11 | id: "DUMMY", 12 | name: "DUMMY", 13 | absoluteBoundingBox: { 14 | x: 0, 15 | y: 0, 16 | width: 100, 17 | height: 100, 18 | }, 19 | effects: [ 20 | { 21 | type: "LAYER_BLUR", 22 | radius: 5, 23 | visible: true, 24 | }, 25 | ], 26 | }; 27 | 28 | expect(getRenderBoundingBox(node)).toMatchObject({ 29 | x: -5, 30 | y: -5, 31 | width: 110, 32 | height: 110, 33 | }); 34 | }); 35 | 36 | it("Should accumulate the size of drop shadow effect", () => { 37 | const node: figma.Node & figma.HasEffects & figma.HasBoundingBox = { 38 | type: "DUMMY", 39 | id: "DUMMY", 40 | name: "DUMMY", 41 | absoluteBoundingBox: { 42 | x: 0, 43 | y: 0, 44 | width: 100, 45 | height: 100, 46 | }, 47 | effects: [ 48 | { 49 | type: "DROP_SHADOW", 50 | radius: 2, 51 | visible: true, 52 | offset: { 53 | x: 3, 54 | y: 3, 55 | }, 56 | color: { r: 0, g: 0, b: 0, a: 0.3 }, 57 | blendMode: "NORMAL", 58 | }, 59 | ], 60 | }; 61 | 62 | expect(getRenderBoundingBox(node)).toMatchObject({ 63 | x: 0, 64 | y: 0, 65 | width: 105, 66 | height: 105, 67 | }); 68 | }); 69 | 70 | it("Should skip invisible effects", () => { 71 | const node: figma.Node & figma.HasEffects & figma.HasBoundingBox = { 72 | type: "DUMMY", 73 | id: "DUMMY", 74 | name: "DUMMY", 75 | absoluteBoundingBox: { 76 | x: 0, 77 | y: 0, 78 | width: 100, 79 | height: 100, 80 | }, 81 | effects: [ 82 | { 83 | type: "LAYER_BLUR", 84 | radius: 5, 85 | visible: false, 86 | }, 87 | { 88 | type: "DROP_SHADOW", 89 | radius: 2, 90 | visible: false, 91 | offset: { 92 | x: 5, 93 | y: 0, 94 | }, 95 | color: { r: 0, g: 0, b: 0, a: 0.3 }, 96 | blendMode: "NORMAL", 97 | }, 98 | ], 99 | }; 100 | 101 | expect(getRenderBoundingBox(node)).toMatchObject({ 102 | x: 0, 103 | y: 0, 104 | width: 100, 105 | height: 100, 106 | }); 107 | }); 108 | 109 | it("Should include desendants' effects too", () => { 110 | const grandChild: figma.Node & figma.HasBoundingBox & figma.HasEffects = { 111 | type: "DUMMY", 112 | id: "DUMMY", 113 | name: "DUMMY", 114 | absoluteBoundingBox: { 115 | x: 0, 116 | y: 0, 117 | width: 100, 118 | height: 100, 119 | }, 120 | effects: [ 121 | { 122 | type: "DROP_SHADOW", 123 | radius: 5, 124 | visible: true, 125 | offset: { 126 | x: 5, 127 | y: 5, 128 | }, 129 | color: { r: 0, g: 0, b: 0, a: 0.3 }, 130 | blendMode: "NORMAL", 131 | }, 132 | ], 133 | }; 134 | 135 | const child: figma.Node & 136 | figma.HasChildren & 137 | figma.HasBoundingBox & 138 | figma.HasEffects = { 139 | type: "DUMMY", 140 | id: "DUMMY", 141 | name: "DUMMY", 142 | effects: [ 143 | { 144 | type: "LAYER_BLUR", 145 | radius: 3, 146 | visible: true, 147 | }, 148 | ], 149 | absoluteBoundingBox: { 150 | x: 0, 151 | y: 0, 152 | width: 100, 153 | height: 100, 154 | }, 155 | children: [grandChild], 156 | }; 157 | 158 | const parent: figma.Node & figma.HasChildren & figma.HasBoundingBox = { 159 | type: "DUMMY", 160 | id: "DUMMY", 161 | name: "DUMMY", 162 | absoluteBoundingBox: { 163 | x: 0, 164 | y: 0, 165 | width: 100, 166 | height: 100, 167 | }, 168 | children: [child], 169 | }; 170 | 171 | expect(getRenderBoundingBox(parent)).toMatchObject({ 172 | x: -3, 173 | y: -3, 174 | width: 113, 175 | height: 113, 176 | }); 177 | }); 178 | 179 | it("Should skip node without bounding box", () => { 180 | const child: figma.Node & figma.HasEffects = { 181 | type: "DUMMY", 182 | id: "DUMMY", 183 | name: "DUMMY", 184 | effects: [ 185 | { 186 | type: "LAYER_BLUR", 187 | radius: 3, 188 | visible: true, 189 | }, 190 | ], 191 | }; 192 | 193 | const parent: figma.Node & figma.HasChildren & figma.HasBoundingBox = { 194 | type: "DUMMY", 195 | id: "DUMMY", 196 | name: "DUMMY", 197 | absoluteBoundingBox: { 198 | x: 0, 199 | y: 0, 200 | width: 100, 201 | height: 100, 202 | }, 203 | children: [child], 204 | }; 205 | expect(getRenderBoundingBox(parent)).toMatchObject({ 206 | x: 0, 207 | y: 0, 208 | width: 100, 209 | height: 100, 210 | }); 211 | }); 212 | }); 213 | -------------------------------------------------------------------------------- /src/FrameCanvas/getRenderBoundingBox.ts: -------------------------------------------------------------------------------- 1 | import * as figma from "../figma.js"; 2 | 3 | // Intermediate value, context object. 4 | interface MinMaxXY { 5 | minX: number; 6 | maxX: number; 7 | minY: number; 8 | maxY: number; 9 | } 10 | 11 | /** 12 | * Calculate the size and position of where to put an API rendered image. 13 | * Ealier API did not return `absoluteRenderBounds`, so in order to place an API rendered image 14 | * to correct position, client need to measure the effect radius of LAYER_BLUR and DROP_SHADOW. 15 | */ 16 | export function getRenderBoundingBox( 17 | node: figma.Node & figma.HasBoundingBox, 18 | ): figma.Rectangle { 19 | if (node.absoluteRenderBounds) { 20 | return node.absoluteRenderBounds; 21 | } 22 | 23 | let current: MinMaxXY | null = null; 24 | 25 | for (const target of figma.walk(node)) { 26 | if (target.visible === false || !figma.hasBoundingBox(target)) { 27 | continue; 28 | } 29 | 30 | const minmax = calculateRenderingBoundingBox(target); 31 | 32 | if (!current) { 33 | current = minmax; 34 | continue; 35 | } 36 | 37 | current.minX = Math.min(current.minX, minmax.minX); 38 | current.minY = Math.min(current.minY, minmax.minY); 39 | current.maxX = Math.max(current.maxX, minmax.maxX); 40 | current.maxY = Math.max(current.maxY, minmax.maxY); 41 | } 42 | 43 | return current 44 | ? { 45 | x: current.minX, 46 | y: current.minY, 47 | width: current.maxX - current.minX, 48 | height: current.maxY - current.minY, 49 | } 50 | : node.absoluteBoundingBox; 51 | } 52 | 53 | function calculateRenderingBoundingBox( 54 | node: figma.Node & figma.HasBoundingBox, 55 | ): MinMaxXY { 56 | if (!figma.hasEffects(node)) { 57 | return { 58 | minX: node.absoluteBoundingBox.x, 59 | maxX: node.absoluteBoundingBox.x + node.absoluteBoundingBox.width, 60 | minY: node.absoluteBoundingBox.y, 61 | maxY: node.absoluteBoundingBox.y + node.absoluteBoundingBox.height, 62 | }; 63 | } 64 | 65 | // If the frame has effects, the size of rendered image is larger than frame's size 66 | // because of rendered effects. 67 | const margins = { top: 0, right: 0, bottom: 0, left: 0 }; 68 | 69 | for (const effect of node.effects) { 70 | if (effect.visible === false) { 71 | continue; 72 | } 73 | 74 | if (figma.isShadowEffect(effect) && effect.type === "DROP_SHADOW") { 75 | margins.left = Math.max(margins.left, effect.radius - effect.offset.x); 76 | margins.top = Math.max(margins.top, effect.radius - effect.offset.y); 77 | margins.right = Math.max(margins.right, effect.radius + effect.offset.x); 78 | margins.bottom = Math.max( 79 | margins.bottom, 80 | effect.radius + effect.offset.y, 81 | ); 82 | continue; 83 | } 84 | 85 | if (effect.type === "LAYER_BLUR") { 86 | margins.top = Math.max(margins.top, effect.radius); 87 | margins.right = Math.max(margins.right, effect.radius); 88 | margins.bottom = Math.max(margins.bottom, effect.radius); 89 | margins.left = Math.max(margins.left, effect.radius); 90 | continue; 91 | } 92 | 93 | // Other effects does not changes a size of rendered image 94 | } 95 | 96 | return { 97 | minX: node.absoluteBoundingBox.x - margins.left, 98 | maxX: 99 | node.absoluteBoundingBox.x + 100 | node.absoluteBoundingBox.width + 101 | margins.right, 102 | minY: node.absoluteBoundingBox.y - margins.top, 103 | maxY: 104 | node.absoluteBoundingBox.y + 105 | node.absoluteBoundingBox.height + 106 | margins.bottom, 107 | }; 108 | } 109 | -------------------------------------------------------------------------------- /src/dom.ts: -------------------------------------------------------------------------------- 1 | import { effect, Signal } from "./signal.js"; 2 | 3 | export type ElementFn = (el: T) => void; 4 | 5 | // TypeScript somehow rejects Signal (maybe due to their web typings?) 6 | // A | B | C -> Signal | Signal | Signal 7 | type ToSignal = T extends any ? Signal : never; 8 | 9 | type AttrValue = string | boolean; 10 | 11 | /** 12 | * Set or remove an attribute. 13 | * 14 | * @param name - An attribute name. 15 | * @param value - `string` is set as-is. `boolean` follows HTML's boolean attribute semantics: 16 | * `true` sets an empty string and `false` removes the attribute itself. 17 | */ 18 | export function attr( 19 | name: string, 20 | value: AttrValue | ToSignal | Signal, 21 | ): ElementFn { 22 | return (el) => { 23 | if (value instanceof Signal) { 24 | effect(() => { 25 | const v = value.get(); 26 | 27 | if (typeof v === "string") { 28 | el.setAttribute(name, v); 29 | } else if (v === true) { 30 | el.setAttribute(name, ""); 31 | } else { 32 | el.removeAttribute(name); 33 | } 34 | }); 35 | } else if (typeof value === "string") { 36 | el.setAttribute(name, value); 37 | } else if (value === true) { 38 | el.setAttribute(name, ""); 39 | } 40 | }; 41 | } 42 | 43 | /** 44 | * Assign a value to the property. 45 | */ 46 | export function prop( 47 | key: K, 48 | value: T[K] | Signal, 49 | ): ElementFn { 50 | return (el) => { 51 | if (value instanceof Signal) { 52 | effect(() => { 53 | el[key] = value.get(); 54 | }); 55 | } else { 56 | el[key] = value; 57 | } 58 | }; 59 | } 60 | 61 | /** 62 | * Invoke the given callback after `requestAnimationFrame`. 63 | * 64 | * Provided as an escape-hatch for DOM quirks. 65 | * 66 | * @example 67 | * el("select", [ 68 | * raf(compute(() => (el) => { 69 | * el.value = value.get(); 70 | * })) 71 | * ]) 72 | */ 73 | export function raf( 74 | f: ((el: T) => void) | Signal<(el: T) => void>, 75 | ): ElementFn { 76 | return (el) => { 77 | requestAnimationFrame(() => { 78 | if (f instanceof Signal) { 79 | effect(() => { 80 | f.get()(el); 81 | }); 82 | } else { 83 | f(el); 84 | } 85 | }); 86 | }; 87 | } 88 | 89 | /** 90 | * Set element's inline style. 91 | * 92 | * This is not same as `HTMLElement.style.foo`: under the hood, `CSSStyleDeclaration.setProperty` is used. 93 | * Hence, property name must be hyphen-cased. 94 | * Property value can be one of `string`, `null`, or `undefined`. 95 | * 96 | * - `string` ... Sets the value to the property. 97 | * - `null` ... Removes the property from stylesheet. 98 | * - `undefined` ... Does nothing. 99 | * 100 | * When used with Signal, use of `undefined` would lead to confusing behavor. 101 | * 102 | * ```ts 103 | * const border = signal("1px solid #000"); 104 | * style({ border }); 105 | * border.set(undefined) 106 | * ``` 107 | * 108 | * In the above code, setting `undefined` does nothing: the actual border property's value 109 | * is still `1px solid #000`. In order to avoid these kind of surprising situation, use of 110 | * `string` is always recommended. 111 | * 112 | * ```ts 113 | * const border = signal("1px solid #000"); 114 | * style({ border }); 115 | * border.set("none") 116 | * ``` 117 | */ 118 | export function style( 119 | style: Record< 120 | string, 121 | string | null | undefined | Signal 122 | >, 123 | ): ElementFn { 124 | return (el) => { 125 | for (const key in style) { 126 | const value = style[key]; 127 | if (typeof value === "string") { 128 | el.style.setProperty(key, value); 129 | } else if (value instanceof Signal) { 130 | effect(() => { 131 | const v = value.get(); 132 | 133 | if (typeof v === "string") { 134 | el.style.setProperty(key, v); 135 | } else if (v === null) { 136 | el.style.removeProperty(key); 137 | } 138 | }); 139 | } else if (value === null) { 140 | el.style.removeProperty(key); 141 | } 142 | } 143 | }; 144 | } 145 | 146 | /** 147 | * Sets a class or a list of classes. 148 | * 149 | * This function does not accept Signal. 150 | * Use `data-*` attribute or property for dynamic values. 151 | */ 152 | export function className( 153 | ...value: readonly string[] 154 | ): ElementFn { 155 | return (el) => { 156 | el.classList.add(...value); 157 | }; 158 | } 159 | 160 | /** 161 | * Attach an event listener. 162 | */ 163 | export function on( 164 | eventName: E, 165 | callback: (event: HTMLElementEventMap[E]) => void, 166 | options?: AddEventListenerOptions, 167 | ): ElementFn; 168 | export function on( 169 | eventName: E, 170 | callback: (event: SVGElementEventMap[E]) => void, 171 | options?: AddEventListenerOptions, 172 | ): ElementFn; 173 | export function on< 174 | T extends HTMLElement | SVGElement, 175 | E extends keyof HTMLElementEventMap | keyof SVGElementEventMap, 176 | >( 177 | eventName: E, 178 | callback: (event: (HTMLElementEventMap & SVGElementEventMap)[E]) => void, 179 | options?: AddEventListenerOptions, 180 | ): ElementFn { 181 | return (el) => { 182 | // @ts-expect-error: This is a limit coming from TS being dirty hack illusion. 183 | el.addEventListener(eventName, callback, options); 184 | }; 185 | } 186 | 187 | type ElementChild = HTMLElement | SVGElement | string | null | undefined; 188 | 189 | function appendChild(parent: Element, child: ElementChild): void { 190 | if (child === null || typeof child === "undefined") { 191 | return; 192 | } 193 | 194 | if (typeof child === "string") { 195 | parent.appendChild(document.createTextNode(child)); 196 | } else { 197 | parent.appendChild(child); 198 | } 199 | } 200 | 201 | // `el` is parameterized because a function to create an `Element` depends on Element types. (sub-types?) 202 | function provision( 203 | el: T, 204 | attrs: readonly ElementFn[], 205 | children: readonly ( 206 | | ElementChild 207 | | ToSignal 208 | | Signal 209 | )[], 210 | ): T { 211 | for (const attr of attrs) { 212 | attr(el); 213 | } 214 | 215 | for (const child of children) { 216 | if (child instanceof Signal) { 217 | const start = document.createTextNode(""); 218 | const end = document.createTextNode(""); 219 | 220 | el.appendChild(start); 221 | el.appendChild(end); 222 | 223 | effect(() => { 224 | const childNode = child.get(); 225 | 226 | const prevNode = 227 | !start.nextSibling || start.nextSibling === end 228 | ? null 229 | : start.nextSibling; 230 | 231 | if (childNode === null || typeof childNode === "undefined") { 232 | if (prevNode) { 233 | prevNode.remove(); 234 | } 235 | return; 236 | } 237 | 238 | const node = 239 | typeof childNode === "string" 240 | ? document.createTextNode(childNode) 241 | : childNode; 242 | if (prevNode) { 243 | prevNode.replaceWith(node); 244 | } else { 245 | el.insertBefore(node, end); 246 | } 247 | }); 248 | } else { 249 | appendChild(el, child); 250 | } 251 | } 252 | 253 | return el; 254 | } 255 | 256 | /** 257 | * Create a HTML element. 258 | */ 259 | export function el( 260 | tagName: TagName, 261 | attrs: readonly ElementFn[] = [], 262 | children: readonly ( 263 | | ElementChild 264 | | ToSignal 265 | | Signal 266 | )[] = [], 267 | ): HTMLElementTagNameMap[TagName] { 268 | return provision(document.createElement(tagName), attrs, children); 269 | } 270 | 271 | /** 272 | * Create a SVG element. 273 | * 274 | * You don't need to set `xmlns` attribute for elements created by this function. 275 | */ 276 | export function svg( 277 | tagName: TagName, 278 | attrs: readonly ElementFn[] = [], 279 | children: readonly ( 280 | | ElementChild 281 | | ToSignal 282 | | Signal 283 | )[] = [], 284 | ): SVGElementTagNameMap[TagName] { 285 | return provision( 286 | document.createElementNS("http://www.w3.org/2000/svg", tagName), 287 | attrs, 288 | children, 289 | ); 290 | } 291 | -------------------------------------------------------------------------------- /src/figma.ts: -------------------------------------------------------------------------------- 1 | // This module defines data types used in Figma API. 2 | // The purpose of these type definition is for our rendering and inspector 3 | // panel only. Properties not used in those feature would be omitted. 4 | 5 | /** 6 | * https://www.figma.com/developers/api#color-type 7 | */ 8 | export interface Color { 9 | readonly r: number; 10 | readonly g: number; 11 | readonly b: number; 12 | readonly a: number; 13 | } 14 | 15 | function isColor(x: unknown): x is Color { 16 | return ( 17 | !!x && 18 | typeof x === "object" && 19 | "r" in x && 20 | Number.isFinite(x.r) && 21 | "g" in x && 22 | Number.isFinite(x.g) && 23 | "b" in x && 24 | Number.isFinite(x.b) && 25 | "a" in x && 26 | Number.isFinite(x.a) 27 | ); 28 | } 29 | 30 | /** 31 | * https://www.figma.com/developers/api#blendmode-type 32 | */ 33 | export type BlendMode = 34 | | "PASS_THROUGH" 35 | | "NORMAL" 36 | | "DARKEN" 37 | | "MULTIPLY" 38 | | "LINEAR_BURN" 39 | | "COLOR_BURN" 40 | | "LIGHTEN" 41 | | "SCREEN" 42 | | "LINEAR_DODGE" 43 | | "COLOR_DODGE" 44 | | "OVERLAY" 45 | | "SOFT_LIGHT" 46 | | "HARD_LIGHT" 47 | | "DIFFERENCE" 48 | | "EXCLUSION" 49 | | "HUE" 50 | | "SATURATION" 51 | | "COLOR" 52 | | "LUMINOSITY"; 53 | 54 | function isBlendMode(x: unknown): x is BlendMode { 55 | switch (x) { 56 | case "PASS_THROUGH": 57 | case "NORMAL": 58 | case "DARKEN": 59 | case "MULTIPLY": 60 | case "LINEAR_BURN": 61 | case "COLOR_BURN": 62 | case "LIGHTEN": 63 | case "SCREEN": 64 | case "LINEAR_DODGE": 65 | case "COLOR_DODGE": 66 | case "OVERLAY": 67 | case "SOFT_LIGHT": 68 | case "HARD_LIGHT": 69 | case "DIFFERENCE": 70 | case "EXCLUSION": 71 | case "HUE": 72 | case "SATURATION": 73 | case "COLOR": 74 | case "LUMINOSITY": 75 | return true; 76 | default: 77 | return false; 78 | } 79 | } 80 | 81 | /** 82 | * https://www.figma.com/developers/api#vector-type 83 | */ 84 | export interface Vector { 85 | readonly x: number; 86 | readonly y: number; 87 | } 88 | 89 | function isVector(x: unknown): x is Vector { 90 | return ( 91 | !!x && 92 | typeof x === "object" && 93 | "x" in x && 94 | Number.isFinite(x.x) && 95 | "y" in x && 96 | Number.isFinite(x.y) 97 | ); 98 | } 99 | 100 | export interface Effect { 101 | readonly type: "LAYER_BLUR" | "BACKGROUND_BLUR" | string; 102 | readonly visible: boolean; 103 | readonly radius: number; 104 | } 105 | 106 | function isEffect(x: unknown): x is Effect { 107 | return ( 108 | !!x && 109 | typeof x === "object" && 110 | "type" in x && 111 | typeof x.type === "string" && 112 | "visible" in x && 113 | typeof x.visible === "boolean" && 114 | "radius" in x && 115 | typeof x.radius === "number" 116 | ); 117 | } 118 | 119 | export interface ShadowEffect { 120 | readonly type: "INNER_SHADOW" | "DROP_SHADOW"; 121 | readonly visible: boolean; 122 | readonly radius: number; 123 | readonly color: Color; 124 | readonly blendMode: BlendMode; 125 | readonly offset: Vector; 126 | readonly spread?: number; 127 | readonly showShadowBehindNode?: boolean; 128 | } 129 | 130 | export function isShadowEffect(x: Effect): x is ShadowEffect { 131 | if (x.type !== "INNER_SHADOW" && x.type !== "DROP_SHADOW") { 132 | return false; 133 | } 134 | 135 | return ( 136 | "color" in x && 137 | isColor(x.color) && 138 | "blendMode" in x && 139 | isBlendMode(x.blendMode) && 140 | "offset" in x && 141 | isVector(x.offset) && 142 | (!("spread" in x) || Number.isFinite(x.spread)) && 143 | (!("showShadowBehindNode" in x) || 144 | typeof x.showShadowBehindNode === "boolean") 145 | ); 146 | } 147 | 148 | /** 149 | * https://www.figma.com/developers/api#rectangle-type 150 | */ 151 | export interface Rectangle { 152 | readonly x: number; 153 | 154 | readonly y: number; 155 | 156 | readonly width: number; 157 | 158 | readonly height: number; 159 | } 160 | 161 | function isRectangle(x: unknown): x is Rectangle { 162 | return ( 163 | !!x && 164 | typeof x === "object" && 165 | "x" in x && 166 | typeof x.x === "number" && 167 | "y" in x && 168 | typeof x.y === "number" && 169 | "width" in x && 170 | typeof x.width === "number" && 171 | "height" in x && 172 | typeof x.height === "number" 173 | ); 174 | } 175 | 176 | /** 177 | * https://www.figma.com/developers/api#colorstop-type 178 | */ 179 | export interface ColorStop { 180 | readonly position: number; 181 | 182 | readonly color: Color; 183 | } 184 | 185 | function isColorStop(x: unknown): x is ColorStop { 186 | return ( 187 | !!x && 188 | typeof x === "object" && 189 | "position" in x && 190 | typeof x.position === "number" && 191 | "color" in x && 192 | isColor(x.color) 193 | ); 194 | } 195 | 196 | export interface PaintGlobalProperties { 197 | readonly type: string; 198 | 199 | /** 200 | * @default true 201 | */ 202 | readonly visible?: boolean; 203 | 204 | /** 205 | * @default 1 206 | */ 207 | readonly opacity?: number; 208 | 209 | readonly blendMode: BlendMode; 210 | } 211 | 212 | function isPaintGlobalProperties(x: unknown): x is PaintGlobalProperties { 213 | return ( 214 | !!x && 215 | typeof x === "object" && 216 | "type" in x && 217 | typeof x.type === "string" && 218 | (!("visible" in x) || typeof x.visible === "boolean") && 219 | (!("opacity" in x) || typeof x.opacity === "number") && 220 | "blendMode" in x && 221 | isBlendMode(x.blendMode) 222 | ); 223 | } 224 | 225 | export interface SolidPaint extends PaintGlobalProperties { 226 | readonly type: "SOLID"; 227 | 228 | readonly color: Color; 229 | } 230 | 231 | function isSolidPaint(x: PaintGlobalProperties): x is SolidPaint { 232 | return x.type === "SOLID" && "color" in x && isColor(x.color); 233 | } 234 | 235 | export interface GradientPaint extends PaintGlobalProperties { 236 | readonly type: 237 | | "GRADIENT_LINEAR" 238 | | "GRADIENT_RADIAL" 239 | | "GRADIENT_ANGULAR" 240 | | "GRADIENT_DIAMOND"; 241 | 242 | readonly gradientHandlePositions: readonly [Vector, Vector, Vector]; 243 | 244 | readonly gradientStops: readonly ColorStop[]; 245 | } 246 | 247 | const GRADIENT_TYPE_PATTERN = /^GRADIENT_(LINEAR|RADIAL|ANGULAR|DIAMOND)$/; 248 | 249 | function isGradientPaint(x: PaintGlobalProperties): x is GradientPaint { 250 | return ( 251 | GRADIENT_TYPE_PATTERN.test(x.type) && 252 | "gradientHandlePositions" in x && 253 | Array.isArray(x.gradientHandlePositions) && 254 | x.gradientHandlePositions.every(isVector) && 255 | "gradientStops" in x && 256 | Array.isArray(x.gradientStops) && 257 | x.gradientStops.every(isColorStop) 258 | ); 259 | } 260 | 261 | export interface ImagePaint extends PaintGlobalProperties { 262 | readonly type: "IMAGE"; 263 | 264 | readonly scaleMode: "FILL" | "FIT" | "TILE" | "STRETCH"; 265 | } 266 | 267 | function isImagePaint(x: PaintGlobalProperties): x is ImagePaint { 268 | if (!("scaleMode" in x)) { 269 | return false; 270 | } 271 | 272 | switch (x.scaleMode) { 273 | case "FILL": 274 | case "FIT": 275 | case "TILE": 276 | case "STRETCH": 277 | return true; 278 | default: 279 | return false; 280 | } 281 | } 282 | 283 | export interface OtherPaint extends PaintGlobalProperties { 284 | readonly type: "VIDEO" | "EMOJI"; 285 | } 286 | 287 | function isOtherPaint(x: PaintGlobalProperties): x is OtherPaint { 288 | switch (x.type) { 289 | case "VIDEO": 290 | case "EMOJI": 291 | return true; 292 | default: 293 | return false; 294 | } 295 | } 296 | 297 | /** 298 | * https://www.figma.com/developers/api#paint-type 299 | */ 300 | export type Paint = SolidPaint | GradientPaint | ImagePaint | OtherPaint; 301 | 302 | function isPaint(x: unknown): x is Paint { 303 | if (!isPaintGlobalProperties(x)) { 304 | return false; 305 | } 306 | 307 | return ( 308 | isSolidPaint(x) || isGradientPaint(x) || isImagePaint(x) || isOtherPaint(x) 309 | ); 310 | } 311 | 312 | interface HasBackgroundColor { 313 | /** 314 | * Background color of the canvas. 315 | */ 316 | backgroundColor: Color; 317 | } 318 | 319 | export function hasBackgroundColor( 320 | node: Node, 321 | ): node is Node & HasBackgroundColor { 322 | return "backgroundColor" in node && isColor(node.backgroundColor); 323 | } 324 | 325 | interface HasFills { 326 | /** 327 | * @default [] 328 | */ 329 | readonly fills: Paint[]; 330 | } 331 | 332 | export function hasFills(node: Node): node is Node & HasFills { 333 | return ( 334 | "fills" in node && Array.isArray(node.fills) && node.fills.every(isPaint) 335 | ); 336 | } 337 | 338 | interface HasStroke { 339 | /** 340 | * @default [] 341 | */ 342 | readonly strokes: readonly Paint[]; 343 | 344 | readonly strokeWeight: number; 345 | 346 | readonly strokeAlign: "INSIDE" | "OUTSIDE" | "CENTER"; 347 | 348 | /** 349 | * @default [] 350 | */ 351 | readonly strokeDashes?: readonly number[]; 352 | } 353 | 354 | export function hasStroke(node: Node): node is Node & HasStroke { 355 | if (!("strokeAlign" in node)) { 356 | return false; 357 | } 358 | 359 | switch (node.strokeAlign) { 360 | case "INSIDE": 361 | case "OUTSIDE": 362 | case "CENTER": 363 | break; 364 | default: 365 | return false; 366 | } 367 | 368 | return ( 369 | "strokes" in node && 370 | Array.isArray(node.strokes) && 371 | node.strokes.every(isPaint) && 372 | "strokeWeight" in node && 373 | Number.isFinite(node.strokeWeight) && 374 | (!("strokeDashes" in node) || 375 | (Array.isArray(node.strokeDashes) && 376 | node.strokeDashes.every(Number.isFinite))) 377 | ); 378 | } 379 | 380 | export interface HasEffects { 381 | effects: readonly (Effect | ShadowEffect)[]; 382 | } 383 | 384 | export function hasEffects(node: Node): node is Node & HasEffects { 385 | return ( 386 | "effects" in node && 387 | Array.isArray(node.effects) && 388 | node.effects.every(isEffect) 389 | ); 390 | } 391 | 392 | interface HasCharacters { 393 | readonly characters: string; 394 | } 395 | 396 | export function hasCharacters(node: Node): node is Node & HasCharacters { 397 | return "characters" in node && typeof node.characters === "string"; 398 | } 399 | 400 | // https://www.figma.com/developers/api#typestyle-type 401 | interface HasTypeStyle { 402 | readonly style: { 403 | readonly fontFamily: string; 404 | readonly fontPostScriptName?: string; 405 | readonly italic: boolean; 406 | readonly fontWeight: number; 407 | readonly fontSize: number; 408 | readonly textCase?: 409 | | "ORIGINAL" 410 | | "UPPER" 411 | | "LOWER" 412 | | "TITLE" 413 | | "SMALL_CAPS" 414 | | "SMALL_CAPS_FORCED"; 415 | readonly textDecoration?: "NONE" | "STRIKETHROUGH" | "UNDERLINE"; 416 | readonly textAlignHorizontal: "LEFT" | "RIGHT" | "CENTER" | "JUSTIFIED"; 417 | readonly letterSpacing: number; 418 | readonly lineHeightPx: number; 419 | readonly lineHeightPercentFontSize?: number; 420 | readonly lineHeightUnit: "PIXELS" | "FONT_SIZE_%" | "INTRINSIC_%"; 421 | }; 422 | } 423 | 424 | export function hasTypeStyle(node: Node): node is Node & HasTypeStyle { 425 | return ( 426 | "style" in node && 427 | typeof node.style === "object" && 428 | !!node.style && 429 | "fontFamily" in node.style && 430 | typeof node.style.fontFamily === "string" 431 | ); 432 | } 433 | 434 | export interface HasBoundingBox { 435 | readonly absoluteBoundingBox: Rectangle; 436 | 437 | /** 438 | * Old data may not have this property. 439 | */ 440 | readonly absoluteRenderBounds?: Rectangle; 441 | } 442 | 443 | export function hasBoundingBox(node: Node): node is Node & HasBoundingBox { 444 | return ( 445 | "absoluteBoundingBox" in node && 446 | isRectangle(node.absoluteBoundingBox) && 447 | (!("absoluteRenderBounds" in node) || 448 | isRectangle(node.absoluteRenderBounds)) 449 | ); 450 | } 451 | 452 | export interface HasPadding { 453 | paddingTop: number; 454 | paddingRight: number; 455 | paddingBottom: number; 456 | paddingLeft: number; 457 | } 458 | 459 | export function hasPadding(node: Node): node is Node & HasPadding { 460 | return ( 461 | "paddingTop" in node && 462 | Number.isFinite(node.paddingTop) && 463 | "paddingRight" in node && 464 | Number.isFinite(node.paddingRight) && 465 | "paddingBottom" in node && 466 | Number.isFinite(node.paddingBottom) && 467 | "paddingLeft" in node && 468 | Number.isFinite(node.paddingLeft) 469 | ); 470 | } 471 | 472 | export interface HasLegacyPadding { 473 | horizontalPadding: number; 474 | 475 | verticalPadding: number; 476 | } 477 | 478 | export function hasLegacyPadding(node: Node): node is Node & HasLegacyPadding { 479 | return ( 480 | "horizontalPadding" in node && 481 | Number.isFinite(node.horizontalPadding) && 482 | "verticalPadding" in node && 483 | Number.isFinite(node.verticalPadding) 484 | ); 485 | } 486 | 487 | export interface HasChildren { 488 | readonly children: readonly Node[]; 489 | } 490 | 491 | export function hasChildren(node: Node): node is Node & HasChildren { 492 | return ( 493 | "children" in node && 494 | Array.isArray(node.children) && 495 | node.children.every(isNode) 496 | ); 497 | } 498 | 499 | interface HasRadius { 500 | readonly cornerRadius: number; 501 | } 502 | 503 | export function hasRadius(node: Node): node is Node & HasRadius { 504 | return "cornerRadius" in node && typeof node.cornerRadius === "number"; 505 | } 506 | 507 | interface HasRadii { 508 | readonly rectangleCornerRadii: readonly [number, number, number, number]; 509 | } 510 | 511 | export function hasRadii(node: Node): node is Node & HasRadii { 512 | return ( 513 | "rectangleCornerRadii" in node && 514 | Array.isArray(node.rectangleCornerRadii) && 515 | node.rectangleCornerRadii.length === 4 516 | ); 517 | } 518 | 519 | export type KnownNodeType = 520 | | "DOCUMENT" 521 | | "CANVAS" 522 | | "FRAME" 523 | | "GROUP" 524 | | "SECTION" 525 | | "VECTOR" 526 | | "BOOLEAN_OPERATION" 527 | | "STAR" 528 | | "LINE" 529 | | "ELLIPSE" 530 | | "REGULAR_POLYGON" 531 | | "RECTANGLE" 532 | | "TABLE" 533 | | "TABLE_CELL" 534 | | "TEXT" 535 | | "SLICE" 536 | | "COMPONENT" 537 | | "COMPONENT_SET" 538 | | "INSTANCE" 539 | | "STICKY" 540 | | "SHAPE_WITH_TEXT" 541 | | "CONNECTOR" 542 | | "WASHI_TAPE"; 543 | 544 | /** 545 | * https://www.figma.com/developers/api#global-properties 546 | */ 547 | export interface Node { 548 | readonly id: string; 549 | 550 | readonly name: string; 551 | 552 | /** 553 | * @default true 554 | */ 555 | readonly visible?: boolean; 556 | 557 | readonly type: Type; 558 | } 559 | 560 | export function isNode(x: unknown): x is Node { 561 | return ( 562 | typeof x === "object" && 563 | !!x && 564 | "id" in x && 565 | typeof x.id === "string" && 566 | "name" in x && 567 | typeof x.name === "string" && 568 | (!("visible" in x) || typeof x.visible === "boolean") && 569 | "type" in x && 570 | typeof x.type === "string" 571 | ); 572 | } 573 | 574 | /** 575 | * Walk over the node and its descendants. 576 | * Iterator is superior to the common callback-style walk function: 577 | * - Ability to abort the traverse with standard language feature (return, break) 578 | * - No implicit call timing convention - TypeScript's poor inference engine completely 579 | * ignores assignments even if the callback function will be called immediately. 580 | * The inference system is built upon unrealistic illusion. 581 | * https://github.com/microsoft/TypeScript/issues/9998 582 | * - (subjective) `for ~ of` over iterator is way more readable and easier to grasp than 583 | * function invocation with unknown callback. When will the callback be invoked? 584 | * What will happen when the callback function returned something? 585 | * 586 | * @param node - The root node to start traversing from. This function returns this parameter as a result at the very first. 587 | * @example 588 | * for (const node of walk(root)) { 589 | * console.log(node.id) 590 | * } 591 | */ 592 | export function* walk(node: Node): Generator { 593 | yield node; 594 | 595 | if (hasChildren(node)) { 596 | for (const child of node.children) { 597 | for (const iter of walk(child)) { 598 | yield iter; 599 | } 600 | } 601 | } 602 | } 603 | 604 | export type Canvas = Node & HasChildren & HasBackgroundColor; 605 | 606 | function isCanvas(node: Node): node is Canvas { 607 | return ( 608 | node.type === "CANVAS" && hasChildren(node) && hasBackgroundColor(node) 609 | ); 610 | } 611 | 612 | /** 613 | * Returns an iterator of CANVAS nodes. 614 | */ 615 | export function* getCanvases(node: Node): Generator { 616 | if (isCanvas(node)) { 617 | yield node; 618 | 619 | // CANVAS cannot be nested, so safe to quit lookup 620 | return; 621 | } 622 | 623 | if (!hasChildren(node)) { 624 | return; 625 | } 626 | 627 | for (const child of node.children) { 628 | for (const iter of getCanvases(child)) { 629 | yield iter; 630 | } 631 | } 632 | } 633 | 634 | export interface GetFileNodesResponse { 635 | readonly name: string; 636 | readonly lastModified: string; 637 | readonly nodes: Record< 638 | string, 639 | { 640 | readonly document: Node; 641 | } 642 | >; 643 | } 644 | 645 | export interface GetFileResponse { 646 | readonly name: string; 647 | readonly lastModified: string; 648 | readonly document: Node & HasChildren; 649 | } 650 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { FigspecFrameViewer } from "./FigspecFrameViewer.js"; 2 | import { FigspecFileViewer } from "./FigspecFileViewer.js"; 3 | 4 | if (!customElements.get("figspec-file-viewer")) { 5 | customElements.define("figspec-file-viewer", FigspecFileViewer); 6 | } 7 | 8 | if (!customElements.get("figspec-frame-viewer")) { 9 | customElements.define("figspec-frame-viewer", FigspecFrameViewer); 10 | } 11 | 12 | export { FigspecFrameViewer }; 13 | export { FigspecFileViewer }; 14 | -------------------------------------------------------------------------------- /src/math.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { roundTo } from "./math"; 4 | 5 | describe("roundTo", () => { 6 | it("Should round to int without `at` parameter", () => { 7 | expect(roundTo(1.23456789)).toBe(1); 8 | expect(roundTo(9.87654321)).toBe(10); 9 | }); 10 | 11 | it("Should round to specified decimal", () => { 12 | expect(roundTo(1.23456789, 2)).toBe(1.23); 13 | expect(roundTo(9.87654321, 2)).toBe(9.88); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/math.ts: -------------------------------------------------------------------------------- 1 | export function roundTo(x: number, to: number = 0) { 2 | if (to === 0) { 3 | return Math.round(x); 4 | } 5 | 6 | const p = Math.pow(10, to); 7 | 8 | return Math.round(x * p) / p; 9 | } 10 | -------------------------------------------------------------------------------- /src/preferences.ts: -------------------------------------------------------------------------------- 1 | export interface Preferences { 2 | /** 3 | * For future changes. 4 | */ 5 | version: 1; 6 | 7 | /** 8 | * How many decimal places are shown? 9 | * NOTE: Some properties uses fixed decimal places. 10 | */ 11 | decimalPlaces: number; 12 | 13 | viewportZoomSpeed: number; 14 | viewportPanSpeed: number; 15 | 16 | /** 17 | * What unit should be used in generated CSS code? 18 | */ 19 | lengthUnit: "px" | "rem"; 20 | rootFontSizeInPx: number; 21 | 22 | /** 23 | * How to display a color in generated CSS code? 24 | */ 25 | cssColorNotation: // #rrggbb / #rrggbbaa 26 | | "hex" 27 | // rgb(r g b / a) 28 | | "rgb" 29 | // hsl(h s l / a) 30 | | "hsl" 31 | // color(srgb r g b / a) 32 | | "color-srgb" 33 | // color(display-p3 r g b / a) 34 | // For showing colors in Display P3 color space, where the Figma file uses Display P3. 35 | // If a Figma file uses sRGB, colors are stretched to full Display P3 space. 36 | | "display-p3" 37 | // color(display-p3 r g b / a) 38 | // For showing colors in Display P3 color space, where the Figma file uses sRGB. 39 | // If a Figma file uses Display P3, colors are unnaturally compressed to sRGB space (same as other sRGB notations). 40 | | "srgb-to-display-p3"; 41 | 42 | enableColorPreview: boolean; 43 | } 44 | 45 | export const defaultPreferenecs = Object.freeze({ 46 | version: 1, 47 | decimalPlaces: 2, 48 | viewportPanSpeed: 500, 49 | viewportZoomSpeed: 500, 50 | cssColorNotation: "hex", 51 | lengthUnit: "px", 52 | rootFontSizeInPx: 16, 53 | enableColorPreview: true, 54 | }); 55 | 56 | export function isEqual( 57 | a: Readonly, 58 | b: Readonly, 59 | ): boolean { 60 | if (a.version !== b.version) { 61 | return false; 62 | } 63 | 64 | for (const [key, value] of Object.entries(a)) { 65 | if (b[key as keyof typeof a] !== value) { 66 | return false; 67 | } 68 | } 69 | 70 | return true; 71 | } 72 | -------------------------------------------------------------------------------- /src/signal.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | 3 | import { compute, Signal, effect } from "./signal"; 4 | 5 | describe("Signal", () => { 6 | it("Should execute callback", () => { 7 | const a = new Signal(5); 8 | const b = new Signal(6); 9 | 10 | expect(compute(() => a.get() + b.get()).get()).toBe(11); 11 | }); 12 | 13 | it("Should update callback", () => { 14 | const s = new Signal("foo"); 15 | 16 | expect(s.get()).toBe("foo"); 17 | 18 | s.set("bar"); 19 | 20 | expect(s.get()).toBe("bar"); 21 | }); 22 | 23 | it("Should run effect", () => { 24 | const s = new Signal("foo"); 25 | const fn = vi.fn(); 26 | 27 | effect(() => { 28 | fn(s.get()); 29 | }); 30 | 31 | s.set("bar"); 32 | s.set("baz"); 33 | 34 | expect(fn).toHaveBeenNthCalledWith(1, "foo"); 35 | expect(fn).toHaveBeenNthCalledWith(2, "bar"); 36 | expect(fn).toHaveBeenNthCalledWith(3, "baz"); 37 | expect(fn).toHaveBeenCalledTimes(3); 38 | }); 39 | 40 | it("Should discard child computation", () => { 41 | const s = new Signal("foo"); 42 | const first = vi.fn(); 43 | const second = vi.fn(); 44 | const third = vi.fn(); 45 | 46 | effect(() => { 47 | first(); 48 | s.get(); 49 | 50 | effect(() => { 51 | second(); 52 | s.get(); 53 | 54 | effect(() => { 55 | third(); 56 | }); 57 | }); 58 | }); 59 | 60 | s.set("bar"); 61 | s.set("baz"); 62 | 63 | expect(first).toHaveBeenCalledTimes(3); 64 | expect(second).toHaveBeenCalledTimes(3); 65 | expect(third).toHaveBeenCalledTimes(3); 66 | }); 67 | 68 | it("Should run effect minimally", () => { 69 | const s1 = new Signal("foo"); 70 | const s2 = new Signal(1); 71 | const first = vi.fn(); 72 | const second = vi.fn(); 73 | const third = vi.fn(); 74 | const fourth = vi.fn(); 75 | 76 | effect(() => { 77 | first(); 78 | s1.get(); 79 | 80 | effect(() => { 81 | second(); 82 | 83 | const s3 = compute(() => { 84 | const v2 = s2.get(); 85 | 86 | third(v2); 87 | 88 | return v2; 89 | }); 90 | 91 | effect(() => { 92 | fourth(s3.get()); 93 | }); 94 | }); 95 | }); 96 | 97 | s2.set(2); 98 | s2.set(3); 99 | 100 | expect(first).toHaveBeenCalledTimes(1); 101 | expect(second).toHaveBeenCalledTimes(1); 102 | expect(third).toHaveBeenNthCalledWith(1, 1); 103 | expect(third).toHaveBeenNthCalledWith(2, 2); 104 | expect(third).toHaveBeenNthCalledWith(3, 3); 105 | expect(third).toHaveBeenCalledTimes(3); 106 | expect(fourth).toHaveBeenNthCalledWith(1, 1); 107 | expect(fourth).toHaveBeenNthCalledWith(2, 2); 108 | expect(fourth).toHaveBeenNthCalledWith(3, 3); 109 | expect(fourth).toHaveBeenCalledTimes(3); 110 | }); 111 | 112 | it("Signal#once Should not update dependency graph", () => { 113 | const s = new Signal(1); 114 | const fn = vi.fn(); 115 | 116 | effect(() => { 117 | fn(s.once()); 118 | }); 119 | 120 | s.set(2); 121 | 122 | expect(fn).toBeCalledWith(1); 123 | expect(fn).toBeCalledTimes(1); 124 | }); 125 | 126 | it("Should skip re-calculation if the value is same", () => { 127 | const s = new Signal(1); 128 | const fn = vi.fn(); 129 | 130 | effect(() => { 131 | fn(s.get()); 132 | }); 133 | 134 | s.set(1); 135 | s.set(1); 136 | 137 | expect(fn).toBeCalledWith(1); 138 | expect(fn).toBeCalledTimes(1); 139 | }); 140 | 141 | it("Should run cleanup function", () => { 142 | const s1 = new Signal(1); 143 | const s2 = new Signal("foo"); 144 | 145 | const f1 = vi.fn(); 146 | const f2 = vi.fn(); 147 | const f3 = vi.fn(); 148 | 149 | effect(() => { 150 | s1.get(); 151 | 152 | effect(() => { 153 | return () => { 154 | f2(s2.get()); 155 | }; 156 | }); 157 | 158 | effect(() => { 159 | return () => { 160 | f3(); 161 | }; 162 | }); 163 | 164 | return () => { 165 | f1(s1.get()); 166 | }; 167 | }); 168 | 169 | s1.set(2); 170 | s2.set("bar"); 171 | 172 | expect(f1).toHaveBeenCalledOnce(); 173 | expect(f1).toHaveBeenCalledWith(1); 174 | expect(f2).toHaveBeenCalledTimes(1); 175 | expect(f3).toHaveBeenCalledTimes(1); 176 | }); 177 | }); 178 | -------------------------------------------------------------------------------- /src/signal.ts: -------------------------------------------------------------------------------- 1 | const stack: Computation[] = []; 2 | 3 | const DEPENDANTS = Symbol(); 4 | 5 | export class Signal { 6 | [DEPENDANTS] = new Set(); 7 | 8 | constructor(private value: T) {} 9 | 10 | set(value: T) { 11 | if (value === this.value) { 12 | return; 13 | } 14 | 15 | for (const computation of this[DEPENDANTS]) { 16 | computation.runCleanup(); 17 | } 18 | 19 | this.value = value; 20 | 21 | const dependantComputations = Array.from(this[DEPENDANTS]); 22 | this[DEPENDANTS].clear(); 23 | 24 | for (const computation of dependantComputations) { 25 | if (computation.isDestroyed) { 26 | continue; 27 | } 28 | 29 | computation.run(true); 30 | } 31 | } 32 | 33 | /** 34 | * Get a current value of the signal. 35 | * This method updates dependency graph. 36 | */ 37 | get(): T { 38 | if (stack.length > 0) { 39 | this[DEPENDANTS].add(stack[stack.length - 1]); 40 | } 41 | 42 | return this.value; 43 | } 44 | 45 | /** 46 | * Get a current value of the signal. 47 | * This method does not update dependency graph. 48 | */ 49 | once(): T { 50 | return this.value; 51 | } 52 | } 53 | 54 | type ComputationFn = () => void | (() => void); 55 | 56 | class Computation { 57 | isDestroyed: boolean = false; 58 | childComputations = new Set(); 59 | cleanup: ComputationFn | null = null; 60 | 61 | constructor(private fn: ComputationFn) {} 62 | 63 | run(isolated = false): void { 64 | this.runCleanup(); 65 | 66 | this.destroyChildren(); 67 | 68 | if (stack.length > 0 && !isolated) { 69 | stack[stack.length - 1].childComputations.add(this); 70 | } 71 | 72 | stack.push(this); 73 | 74 | try { 75 | this.cleanup = this.fn() ?? null; 76 | } finally { 77 | stack.pop(); 78 | } 79 | } 80 | 81 | runCleanup(): void { 82 | for (const child of this.childComputations) { 83 | child.runCleanup(); 84 | } 85 | 86 | if (!this.cleanup) { 87 | return; 88 | } 89 | 90 | this.cleanup(); 91 | this.cleanup = null; 92 | } 93 | 94 | destroy() { 95 | this.runCleanup(); 96 | 97 | this.isDestroyed = true; 98 | 99 | this.destroyChildren(); 100 | } 101 | 102 | destroyChildren() { 103 | for (const child of this.childComputations) { 104 | child.destroy(); 105 | } 106 | 107 | this.childComputations.clear(); 108 | } 109 | } 110 | 111 | export function compute(f: () => T): Signal { 112 | const signal = new Signal(undefined as any); 113 | 114 | effect(() => { 115 | signal.set(f()); 116 | }); 117 | 118 | return signal; 119 | } 120 | 121 | export function effect(f: ComputationFn): void { 122 | new Computation(f).run(); 123 | } 124 | -------------------------------------------------------------------------------- /src/state.ts: -------------------------------------------------------------------------------- 1 | const enum Types { 2 | Idle, 3 | SetupError, 4 | Loaded, 5 | Canvas, 6 | Info, 7 | Preferences, 8 | } 9 | 10 | interface Idle { 11 | type: Types.Idle; 12 | } 13 | 14 | export const idle: Idle = { 15 | type: Types.Idle, 16 | }; 17 | 18 | export function isIdle(state: State): state is Idle { 19 | return state.type === Types.Idle; 20 | } 21 | 22 | interface SetupError { 23 | type: Types.SetupError; 24 | error: Error; 25 | } 26 | 27 | export function setupError(error: Error): SetupError { 28 | return { 29 | type: Types.SetupError, 30 | error, 31 | }; 32 | } 33 | 34 | export function isSetupError(state: State): state is SetupError { 35 | return state.type === Types.SetupError; 36 | } 37 | 38 | interface Loaded { 39 | type: Types.Loaded; 40 | 41 | data: T; 42 | } 43 | 44 | export function loaded(data: T): Loaded { 45 | return { 46 | type: Types.Loaded, 47 | data, 48 | }; 49 | } 50 | 51 | export function isLoaded(state: State): state is Loaded { 52 | return state.type === Types.Loaded; 53 | } 54 | 55 | export type State = Idle | SetupError | Loaded; 56 | 57 | interface Canvas { 58 | type: Types.Canvas; 59 | } 60 | 61 | export const canvas: Canvas = { 62 | type: Types.Canvas, 63 | }; 64 | 65 | export function isCanvas(state: LoadedState): state is Canvas { 66 | return state.type === Types.Canvas; 67 | } 68 | 69 | interface Info { 70 | type: Types.Info; 71 | } 72 | 73 | export const info: Info = { type: Types.Info }; 74 | 75 | export function isInfo(state: LoadedState): state is Info { 76 | return state.type === Types.Info; 77 | } 78 | 79 | interface Preferences { 80 | type: Types.Preferences; 81 | } 82 | 83 | export const preferences: Preferences = { type: Types.Preferences }; 84 | 85 | export function isPreferences(state: LoadedState): state is Preferences { 86 | return state.type === Types.Preferences; 87 | } 88 | 89 | export type LoadedState = Canvas | Info | Preferences; 90 | -------------------------------------------------------------------------------- /src/styles.ts: -------------------------------------------------------------------------------- 1 | import { styles as uiStyles } from "./ui/styles.js"; 2 | 3 | const commonHostStyles = /* css */ ` 4 | :host { 5 | /* 6 | Palette from: https://yeun.github.io/open-color/ 7 | https://github.com/yeun/open-color/blob/3a716ee1f5ff5456db33cb8a6e964afdca1e7bc3/LICENSE 8 | */ 9 | --color-gray-0: 248 249 250; 10 | --color-gray-1: 241 243 245; 11 | --color-gray-2: 233 236 239; 12 | --color-gray-3: 222 226 230; 13 | --color-gray-5: 173 181 189; 14 | --color-gray-6: 134 142 150; 15 | --color-gray-7: 73 80 87; 16 | --color-gray-8: 52 58 64; 17 | --color-gray-9: 33 37 41; 18 | --color-red-4: 255 135 135; 19 | --color-red-9: 201 42 42; 20 | --color-grape-3: 229 153 247; 21 | --color-grape-8: 156 54 181; 22 | --color-blue-9: 24 100 171; 23 | --color-cyan-3: 102 217 232; 24 | --color-cyan-8: 12 133 153; 25 | --color-green-3: 140 233 154; 26 | --color-green-8: 47 158 68; 27 | --color-yellow-2: 255 236 153; 28 | --color-orange-8: 232 89 12; 29 | --color-orange-9: 217 72 15; 30 | 31 | /* Typography */ 32 | --font-family-sans: var(--figspec-font-family-sans, system-ui, ui-sans-serif, sans-serif); 33 | --font-family-mono: var(--figspec-font-family-mono, monospace); 34 | --font-size: var(--figspec-font-size, 1rem); 35 | 36 | /* Spacing */ 37 | --spacing-base: var(--figspec-spacing-base, 10px); 38 | --spacing-scale: var(--figspec-spacing-scale, 1.25); 39 | 40 | --spacing_0: var(--figspec-spacing_0, var(--spacing-base)); 41 | --spacing_1: var(--figspec_spacing_1, calc(var(--spacing_0) * var(--spacing-scale))); 42 | --spacing_2: var(--figspec_spacing_2, calc(var(--spacing_1) * var(--spacing-scale))); 43 | --spacing_3: var(--figspec_spacing_3, calc(var(--spacing_2) * var(--spacing-scale))); 44 | --spacing_4: var(--figspec_spacing_4, calc(var(--spacing_3) * var(--spacing-scale))); 45 | --spacing_5: var(--figspec_spacing_5, calc(var(--spacing_4) * var(--spacing-scale))); 46 | --spacing_-1: var(--figspec_spacing_-1, calc(var(--spacing_0) / var(--spacing-scale))); 47 | --spacing_-2: var(--figspec_spacing_-2, calc(var(--spacing_-1) / var(--spacing-scale))); 48 | --spacing_-3: var(--figspec_spacing_-3, calc(var(--spacing_-2) / var(--spacing-scale))); 49 | --spacing_-4: var(--figspec_spacing_-4, calc(var(--spacing_-3) / var(--spacing-scale))); 50 | --spacing_-5: var(--figspec_spacing_-5, calc(var(--spacing_-4) / var(--spacing-scale))); 51 | 52 | /* Action */ 53 | --default-action-overlay: rgb(var(--color-gray-8) / 0.1); 54 | --default-action-border: rgb(var(--color-gray-5) / 0.5); 55 | --default-action-horizontal-padding: var(--spacing_-1); 56 | --default-action-vertical-padding: var(--spacing_-2); 57 | 58 | --action-overlay: var(--figspec-action-overlay, var(--default-action-overlay)); 59 | --action-border: var(--figspec-action-border, var(--default-action-border)); 60 | --action-horizontal-padding: var(--figspec-action-horizontal-padding, var(--default-action-horizontal-padding)); 61 | --action-vertical-padding: var(--figspec-action-vertical-padding, var(--default-action-vertical-padding)); 62 | --action-radius: var(--figspec-action-radius, 4px); 63 | 64 | /* Canvas */ 65 | --default-canvas-bg: #e5e5e5; 66 | 67 | --canvas-bg: var(--figspec-canvas-bg, var(--default-canvas-bg)); 68 | 69 | /* Base styles */ 70 | --default-fg: rgb(var(--color-gray-9)); 71 | --default-bg: rgb(var(--color-gray-0)); 72 | --default-subtle-fg: rgb(var(--color-gray-7)); 73 | --default-success-fg: rgb(var(--color-green-8)); 74 | --default-error-fg: rgb(var(--color-red-9)); 75 | 76 | --fg: var(--figspec-fg, var(--default-fg)); 77 | --bg: var(--figspec-bg, var(--default-bg)); 78 | --subtle-fg: var(--figspec-subtle-fg, var(--default-subtle-fg)); 79 | --success-fg: var(--figspec-success-fg, var(--default-success-fg)); 80 | --error-fg: var(--figspec-error-fg, var(--default-error-fg)); 81 | --z-index: var(--figspec-viewer-z-index, 0); 82 | 83 | /* Code, syntax highlighting */ 84 | /* https://yeun.github.io/open-color/ */ 85 | --default-code-bg: rgb(var(--color-gray-2)); 86 | --default-code-text: rgb(var(--color-gray-9)); 87 | --default-code-keyword: rgb(var(--color-grape-8)); 88 | --default-code-string: rgb(var(--color-green-8)); 89 | --default-code-number: rgb(var(--color-cyan-8)); 90 | --default-code-list: rgb(var(--color-gray-6)); 91 | --default-code-comment: rgb(var(--color-gray-5)); 92 | --default-code-literal: var(--default-code-number); 93 | --default-code-function: var(--default-code-keyword); 94 | --default-code-unit: rgb(var(--color-orange-8)); 95 | 96 | --code-bg: var(--figspec-code-bg, var(--default-code-bg)); 97 | --code-text: var(--figspec-code-text, var(--default-code-text)); 98 | --code-keyword: var(--figspec-code-keyword, var(--default-code-keyword)); 99 | --code-string: var(--figspec-code-string, var(--default-code-string)); 100 | --code-number: var(--figspec-code-number, var(--default-code-number)); 101 | --code-list: var(--figspec-code-list, var(--default-code-list)); 102 | --code-comment: var(--figspec-code-comment, var(--default-code-comment)); 103 | --code-literal: var(--figspec-code-literal, var(--default-code-literal)); 104 | --code-function: var(--figspec-code-function, var(--default-code-function)); 105 | --code-unit: var(--figspec-code-unit, var(--default-code-unit)); 106 | 107 | /* Panel */ 108 | --panel-border: 1px solid rgb(var(--color-gray-5) / 0.5); 109 | --panel-radii: 2px; 110 | 111 | /* Snackbar */ 112 | --default-snackbar-bg: rgb(var(--color-gray-8)); 113 | --default-snackbar-fg: rgb(var(--color-gray-0)); 114 | --snackbar-fg: var(--figspec-snackbar-fg, var(--default-snackbar-fg)); 115 | --snackbar-bg: var(--figspec-snackbar-bg, var(--default-snackbar-bg)); 116 | --snackbar-radius: var(--figspec-snackbar-radius, 6px); 117 | --snackbar-font-size: var(--figspec-snackbar-font-size, calc(var(--font-size) * 0.9)); 118 | --snackbar-font-family: var(--figspec-snackbar-font-family, var(--font-family-sans)); 119 | --snackbar-shadow: var(--figspec-snackbar-shadow, 0 1px 3px rgb(0 0 0 / 0.3)); 120 | --snackbar-margin: var(--figspec-snackbar-margin, var(--spacing_-3) var(--spacing_-2)); 121 | --snackbar-padding: var(--figspec-snackbar-padding, var(--spacing_-2) var(--spacing_0)); 122 | --snackbar-border: var(--figspec-snackbar-border, none); 123 | 124 | --guide-thickness: var(--figspec-guide-thickness, 1.5px); 125 | --guide-color: var(--figspec-guide-color, rgb(var(--color-orange-9))); 126 | --guide-selected-color: var( 127 | --figspec-guide-selected-color, 128 | rgb(var(--color-blue-9)) 129 | ); 130 | --guide-tooltip-fg: var(--figspec-guide-tooltip-fg, rgb(var(--color-gray-0))); 131 | --guide-selected-tooltip-fg: var( 132 | --figspec-guide-selected-tooltip-fg, 133 | rgb(var(--color-gray-0)) 134 | ); 135 | --guide-tooltip-bg: var( 136 | --figspec-guide-tooltip-bg, 137 | var(--guide-color) 138 | ); 139 | --guide-selected-tooltip-bg: var( 140 | --figspec-guide-selected-tooltip-bg, 141 | var(--guide-selected-color) 142 | ); 143 | --guide-tooltip-font-size: var( 144 | --figspec-guide-tooltip-font-size, 145 | calc(var(--font-size) * 0.8) 146 | ); 147 | 148 | position: relative; 149 | display: block; 150 | font-size: var(--font-size); 151 | font-family: var(--font-family-sans); 152 | 153 | background-color: var(--bg); 154 | color: var(--fg); 155 | user-select: none; 156 | overflow: hidden; 157 | z-index: var(--z-index); 158 | } 159 | 160 | @media (prefers-color-scheme: dark) { 161 | :host { 162 | --default-action-overlay: rgb(var(--color-gray-0) / 0.15); 163 | 164 | --default-fg: rgb(var(--color-gray-0)); 165 | --default-bg: rgb(var(--color-gray-9)); 166 | --default-subtle-fg: rgb(var(--color-gray-5)); 167 | --default-success-fg: rgb(var(--color-green-3)); 168 | --default-error-fg: rgb(var(--color-red-4)); 169 | 170 | --default-code-bg: rgb(var(--color-gray-8)); 171 | --default-code-text: rgb(var(--color-gray-1)); 172 | --default-code-keyword: rgb(var(--color-grape-3)); 173 | --default-code-string: rgb(var(--color-green-3)); 174 | --default-code-number: rgb(var(--color-cyan-3)); 175 | --default-code-list: rgb(var(--color-gray-3)); 176 | --default-code-comment: rgb(var(--color-gray-6)); 177 | --default-code-unit: rgb(var(--color-yellow-2)); 178 | } 179 | } 180 | 181 | @media (pointer: coarse) { 182 | :host { 183 | --default-action-horizontal-padding: var(--spacing_0); 184 | --default-action-vertical-padding: var(--spacing_-1); 185 | } 186 | } 187 | `; 188 | 189 | export const styles = commonHostStyles + uiStyles; 190 | -------------------------------------------------------------------------------- /src/ui/empty/empty.ts: -------------------------------------------------------------------------------- 1 | import { className, el } from "../../dom.js"; 2 | import { fullscreenPanel } from "../fullscreenPanel/fullscreenPanel.js"; 3 | 4 | export const styles = /* css */ ` 5 | .em-container { 6 | margin: 0 auto; 7 | max-width: calc(var(--font-size) * 30); 8 | margin-top: var(--spacing_3); 9 | display: flex; 10 | flex-direction: column; 11 | align-items: flex-start; 12 | gap: var(--spacing_2); 13 | 14 | user-select: text; 15 | } 16 | 17 | .em-title { 18 | margin: 0; 19 | font-size: calc(var(--font-size) * 1.2); 20 | font-weight: bold; 21 | 22 | color: var(--fg); 23 | } 24 | 25 | .em-body { 26 | margin: 0; 27 | width: 100%; 28 | } 29 | .em-body p { 30 | margin: 0; 31 | margin-bottom: var(--spacing_1); 32 | font-size: calc(var(--font-size) * 1); 33 | 34 | color: var(--subtle-fg); 35 | } 36 | .em-body pre { 37 | align-self: stretch; 38 | display: block; 39 | width: 100%; 40 | font-family: var(--font-family-mono); 41 | font-size: calc(var(--font-size) * 0.9); 42 | padding: var(--spacing_0); 43 | tab-size: 2; 44 | 45 | background-color: var(--code-bg); 46 | border-radius: var(--panel-radii); 47 | color: var(--code-fg); 48 | overflow: auto; 49 | } 50 | `; 51 | 52 | type ElementChildren = NonNullable[2]>; 53 | 54 | interface EmptyProps { 55 | title: ElementChildren; 56 | 57 | body: ElementChildren; 58 | } 59 | 60 | export function empty({ title, body }: EmptyProps): HTMLElement { 61 | return fullscreenPanel({ 62 | body: [ 63 | el( 64 | "div", 65 | [className("em-container")], 66 | [ 67 | el("p", [className("em-title")], title), 68 | el("div", [className("em-body")], body), 69 | ], 70 | ), 71 | ], 72 | }); 73 | } 74 | -------------------------------------------------------------------------------- /src/ui/fullscreenPanel/fullscreenPanel.ts: -------------------------------------------------------------------------------- 1 | import { attr, el, className, svg } from "../../dom.js"; 2 | import { effect } from "../../signal.js"; 3 | 4 | import { iconButton } from "../iconButton/iconButton.js"; 5 | 6 | export const styles = /* css */ ` 7 | .fp-root { 8 | position: absolute; 9 | top: 0; 10 | left: 0; 11 | width: 100%; 12 | height: 100%; 13 | 14 | background-color: var(--bg); 15 | color: var(--fg); 16 | } 17 | 18 | .fp-close { 19 | position: absolute; 20 | right: var(--spacing_-1); 21 | top: var(--spacing_-1); 22 | 23 | background-color: inherit; 24 | border-radius: var(--panel-radii); 25 | z-index: calc(var(--z-index) + 5); 26 | 27 | opacity: 0.5; 28 | } 29 | .fp-close:hover, .fp-close:focus-within { 30 | opacity: 1; 31 | } 32 | 33 | .fp-close-icon { 34 | font-size: calc(var(--font-size) * 1.1); 35 | } 36 | 37 | .fp-body { 38 | max-width: 100%; 39 | max-height: 100%; 40 | padding: var(--spacing_1); 41 | box-sizing: border-box; 42 | 43 | overflow-y: auto; 44 | } 45 | `; 46 | 47 | interface FullscreenPanelProps { 48 | body: Parameters[2]; 49 | 50 | onClose?(): void; 51 | } 52 | 53 | export function fullscreenPanel({ 54 | body, 55 | onClose, 56 | }: FullscreenPanelProps): HTMLElement { 57 | effect(() => { 58 | if (!onClose) { 59 | return; 60 | } 61 | 62 | const onEsc = (ev: KeyboardEvent) => { 63 | if (ev.key !== "Escape") { 64 | return; 65 | } 66 | 67 | ev.preventDefault(); 68 | ev.stopPropagation(); 69 | 70 | onClose(); 71 | }; 72 | 73 | document.addEventListener("keydown", onEsc); 74 | 75 | return () => { 76 | document.removeEventListener("keydown", onEsc); 77 | }; 78 | }); 79 | 80 | return el( 81 | "div", 82 | [className("fp-root")], 83 | [ 84 | onClose 85 | ? el( 86 | "div", 87 | [className("fp-close")], 88 | [ 89 | iconButton({ 90 | title: "Close", 91 | icon: svg( 92 | "svg", 93 | [ 94 | className("fp-close-icon"), 95 | attr("viewBox", "0 0 10 10"), 96 | attr("fill", "none"), 97 | attr("stroke", "currentColor"), 98 | ], 99 | [svg("path", [attr("d", "M2,2 L8,8 M8,2 L2,8")])], 100 | ), 101 | onClick() { 102 | onClose(); 103 | }, 104 | }), 105 | ], 106 | ) 107 | : null, 108 | el("div", [className("fp-body")], body), 109 | ], 110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /src/ui/iconButton/iconButton.ts: -------------------------------------------------------------------------------- 1 | import { attr, className, el, on } from "../../dom.js"; 2 | 3 | export const styles = /* css */ ` 4 | .ib-button { 5 | appearance: none; 6 | display: inline-flex; 7 | border: none; 8 | padding: var(--action-vertical-padding) var(--action-horizontal-padding); 9 | margin: 0; 10 | font-size: calc(var(--font-size) * 0.9); 11 | 12 | background: transparent; 13 | border-radius: var(--action-radius); 14 | color: inherit; 15 | cursor: pointer; 16 | outline: none; 17 | } 18 | .ib-button:hover { 19 | background: var(--action-overlay); 20 | } 21 | .ib-button:focus { 22 | outline: none; 23 | } 24 | .ib-button:focus-visible { 25 | outline: 2px solid SelectedItem; 26 | } 27 | 28 | .ib-button > svg { 29 | width: auto; 30 | height: 1em; 31 | } 32 | `; 33 | 34 | interface IconButtonProps { 35 | title: string; 36 | 37 | icon: SVGElement; 38 | 39 | onClick(): void; 40 | } 41 | 42 | export function iconButton({ 43 | title, 44 | icon, 45 | onClick, 46 | }: IconButtonProps): HTMLElement { 47 | return el( 48 | "button", 49 | [className("ib-button"), attr("title", title), on("click", onClick)], 50 | [icon], 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/ui/infoItems/infoItems.ts: -------------------------------------------------------------------------------- 1 | import { attr, className, el } from "../../dom.js"; 2 | import { compute, Signal } from "../../signal.js"; 3 | 4 | export const styles = /* css */ ` 5 | .ii-list { 6 | padding: 0; 7 | margin: 0; 8 | user-select: text; 9 | } 10 | 11 | .ii-label { 12 | padding: 0; 13 | margin: 0; 14 | margin-bottom: var(--spacing_-5); 15 | font-size: calc(var(--font-size) * 0.8); 16 | font-weight: bold; 17 | 18 | color: var(--subtle-fg); 19 | } 20 | 21 | .ii-content { 22 | padding: 0; 23 | margin: 0; 24 | margin-bottom: var(--spacing_3); 25 | font-size: var(--font-size); 26 | font-weight: normal; 27 | 28 | color: var(--fg); 29 | } 30 | `; 31 | 32 | type Children = NonNullable[2]>; 33 | 34 | interface Item { 35 | label: Children[number]; 36 | 37 | content: Children; 38 | } 39 | 40 | export function infoItems( 41 | items: readonly (Item | Signal | null)[], 42 | ): HTMLElement { 43 | return el( 44 | "dl", 45 | [className("ii-list")], 46 | items 47 | .map((item) => { 48 | if (!item) { 49 | return []; 50 | } 51 | 52 | if (item instanceof Signal) { 53 | return [map(label, item), map(content, item)]; 54 | } 55 | 56 | return [label(item), content(item)]; 57 | }) 58 | .flat(), 59 | ); 60 | } 61 | 62 | function map(f: (v: T) => P, s: Signal): Signal

{ 63 | return compute(() => { 64 | const v = s.get(); 65 | if (v === null) { 66 | return null; 67 | } 68 | 69 | return f(v); 70 | }); 71 | } 72 | 73 | function label(item: Item): HTMLElement { 74 | return el( 75 | "dt", 76 | [className("ii-label")], 77 | [item.label, el("span", [attr("aria-hidden", "true")], [":"])], 78 | ); 79 | } 80 | 81 | function content(item: Item): HTMLElement { 82 | return el("dd", [className("ii-content")], item.content); 83 | } 84 | -------------------------------------------------------------------------------- /src/ui/inspectorPanel/cssCode.ts: -------------------------------------------------------------------------------- 1 | import { attr, className, el, style } from "../../dom.js"; 2 | import { roundTo } from "../../math.js"; 3 | import { type Preferences } from "../../preferences.js"; 4 | 5 | import { 6 | type CSSStyle, 7 | type CSSStyleValue, 8 | CSSStyleValueTypes, 9 | } from "./cssgen/cssgen.js"; 10 | 11 | export const styles = /* css */ ` 12 | .cc-container { 13 | margin: 0; 14 | padding: var(--spacing_-1); 15 | 16 | background: var(--code-bg); 17 | border-radius: var(--panel-radii); 18 | color: var(--code-text); 19 | overflow: auto; 20 | user-select: text; 21 | } 22 | 23 | .cc-code { 24 | font-family: var(--font-family-mono); 25 | font-size: calc(var(--font-size) * 0.8); 26 | } 27 | 28 | .cc-color-preview { 29 | --_size: calc(var(--font-size) * 0.8); 30 | 31 | position: relative; 32 | display: inline-flex; 33 | width: var(--_size); 34 | height: var(--_size); 35 | border-radius: calc(var(--_size) / 6); 36 | margin: 0 var(--spacing_-5); 37 | 38 | background-image: url('data:image/svg+xml,'); 39 | overflow: hidden; 40 | vertical-align: middle; 41 | } 42 | .cc-color-preview::after { 43 | content: ""; 44 | display: block; 45 | position: absolute; 46 | top: 0; 47 | right: 0; 48 | bottom: 0; 49 | left: 0; 50 | border: 1px solid rgb(var(--color-gray-5) / 0.7); 51 | 52 | background: var(--_bg, transparent); 53 | border-radius: inherit; 54 | } 55 | 56 | .cc-token-string { 57 | color: var(--code-string); 58 | } 59 | 60 | .cc-token-number { 61 | color: var(--code-number); 62 | } 63 | 64 | .cc-token-comment { 65 | color: var(--code-comment); 66 | font-style: italic; 67 | } 68 | 69 | .cc-token-keyword { 70 | color: var(--code-keyword); 71 | } 72 | 73 | .cc-token-list { 74 | color: var(--code-list); 75 | } 76 | 77 | .cc-token-function { 78 | color: var(--code-function); 79 | } 80 | 81 | .cc-token-unit { 82 | color: var(--code-unit); 83 | } 84 | `; 85 | 86 | export function cssCode( 87 | styles: readonly CSSStyle[], 88 | preferences: Readonly, 89 | ): HTMLElement { 90 | return el( 91 | "pre", 92 | [className("cc-container")], 93 | [ 94 | el( 95 | "code", 96 | [className("cc-code")], 97 | styles.map((style) => { 98 | return el( 99 | "span", 100 | [], 101 | [ 102 | style.propertyName, 103 | ": ", 104 | cssValue(style.value, preferences), 105 | ";\n", 106 | ], 107 | ); 108 | }), 109 | ), 110 | ], 111 | ); 112 | } 113 | 114 | function cssValue( 115 | value: CSSStyleValue, 116 | preferences: Readonly, 117 | ): HTMLElement { 118 | switch (value.type) { 119 | case CSSStyleValueTypes.Comment: 120 | return el( 121 | "span", 122 | [className("cc-token-comment")], 123 | ["/* ", value.text, " */"], 124 | ); 125 | case CSSStyleValueTypes.Color: 126 | return el( 127 | "span", 128 | [], 129 | [ 130 | el( 131 | "i", 132 | [ 133 | className("cc-color-preview"), 134 | style({ "--_bg": value.color }), 135 | attr("aria-hidden", "true"), 136 | ], 137 | [], 138 | ), 139 | cssValue(value.value, preferences), 140 | ], 141 | ); 142 | case CSSStyleValueTypes.Keyword: 143 | return el("span", [className("cc-token-keyword")], [value.ident]); 144 | case CSSStyleValueTypes.List: 145 | return el( 146 | "span", 147 | [], 148 | [ 149 | [cssValue(value.head, preferences)], 150 | ...value.tail.map((v) => [ 151 | el("span", [className("cc-token-list")], [value.separator]), 152 | cssValue(v, preferences), 153 | ]), 154 | ].flat(), 155 | ); 156 | case CSSStyleValueTypes.Number: 157 | const precision = value.precision ?? preferences.decimalPlaces; 158 | 159 | return el( 160 | "span", 161 | [className("cc-token-number")], 162 | [ 163 | roundTo(value.value, precision).toString(10), 164 | el("span", [className("cc-token-unit")], [value.unit || ""]), 165 | ], 166 | ); 167 | case CSSStyleValueTypes.String: 168 | return el( 169 | "span", 170 | [className("cc-token-string")], 171 | [`"`, value.value.replace(/"/g, `\\"`), `"`], 172 | ); 173 | case CSSStyleValueTypes.Literal: 174 | return el("span", [className("cc-token-literal")], [value.text]); 175 | case CSSStyleValueTypes.FunctionCall: 176 | return el( 177 | "span", 178 | [], 179 | [ 180 | el("span", [className("cc-token-function")], [value.functionName]), 181 | "(", 182 | cssValue(value.args, preferences), 183 | ")", 184 | ], 185 | ); 186 | case CSSStyleValueTypes.Unknown: 187 | return el("span", [className("cc-token-unknown")], [value.text]); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/ui/inspectorPanel/cssgen/CSSStyle.ts: -------------------------------------------------------------------------------- 1 | export enum CSSStyleValueTypes { 2 | Color, 3 | Number, 4 | String, 5 | List, 6 | Keyword, 7 | Comment, 8 | Literal, 9 | FunctionCall, 10 | Unknown, 11 | } 12 | 13 | interface CSSStyleColorValue { 14 | type: CSSStyleValueTypes.Color; 15 | 16 | color: string; 17 | 18 | value: CSSStyleValue; 19 | } 20 | 21 | interface CSSStyleNumberValue { 22 | type: CSSStyleValueTypes.Number; 23 | 24 | value: number; 25 | 26 | precision?: number; 27 | 28 | unit?: string; 29 | } 30 | 31 | interface CSSStyleStringValue { 32 | type: CSSStyleValueTypes.String; 33 | 34 | value: string; 35 | } 36 | 37 | interface CSSStyleKeywordValue { 38 | type: CSSStyleValueTypes.Keyword; 39 | 40 | ident: string; 41 | } 42 | 43 | interface CSSStyleUnknownValue { 44 | type: CSSStyleValueTypes.Unknown; 45 | 46 | text: string; 47 | } 48 | 49 | interface CSSStyleFunctionCallValue { 50 | type: CSSStyleValueTypes.FunctionCall; 51 | 52 | functionName: string; 53 | 54 | args: CSSStyleValue; 55 | } 56 | 57 | interface CSSStyleLiteralValue { 58 | type: CSSStyleValueTypes.Literal; 59 | 60 | text: string; 61 | } 62 | 63 | interface CSSStyleListValue { 64 | type: CSSStyleValueTypes.List; 65 | 66 | head: CSSStyleValue; 67 | tail: CSSStyleValue[]; 68 | 69 | separator: string; 70 | } 71 | 72 | interface CSSStyleCommentInValue { 73 | type: CSSStyleValueTypes.Comment; 74 | 75 | text: string; 76 | } 77 | 78 | export type CSSStyleValue = 79 | | CSSStyleColorValue 80 | | CSSStyleNumberValue 81 | | CSSStyleStringValue 82 | | CSSStyleKeywordValue 83 | | CSSStyleUnknownValue 84 | | CSSStyleLiteralValue 85 | | CSSStyleFunctionCallValue 86 | | CSSStyleListValue 87 | | CSSStyleCommentInValue; 88 | 89 | export interface CSSStyle { 90 | propertyName: string; 91 | 92 | value: CSSStyleValue; 93 | } 94 | -------------------------------------------------------------------------------- /src/ui/inspectorPanel/cssgen/colors.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { isTransparent } from "./colors"; 4 | 5 | describe("#isTransparent", () => { 6 | it("Should return `true` for transparent black", () => { 7 | expect(isTransparent({ r: 0, g: 0, b: 0, a: 0 })).toBe(true); 8 | }); 9 | 10 | it("Should return `false` for non-transparent black", () => { 11 | expect(isTransparent({ r: 0, g: 0, b: 0, a: 0.5 })).toBe(false); 12 | }); 13 | 14 | it("Should return `false` for transparent white", () => { 15 | expect(isTransparent({ r: 1, g: 1, b: 1, a: 0 })).toBe(false); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/ui/inspectorPanel/cssgen/colors.ts: -------------------------------------------------------------------------------- 1 | import type * as figma from "../../../figma.js"; 2 | 3 | /** 4 | * Returns whether the given color is a _transparent black_ (value of the `transparent` keyword). 5 | * 6 | * https://www.w3.org/TR/css-color-3/#transparent 7 | */ 8 | export function isTransparent({ r, g, b, a }: figma.Color): boolean { 9 | return !r && !g && !b && !a; 10 | } 11 | 12 | // https://drafts.csswg.org/css-color-4/#predefined-sRGB 13 | function toLinearLight(c: number): number { 14 | const abs = Math.abs(c); 15 | 16 | return abs < 0.04045 17 | ? c / 12.92 18 | : (c < 0 ? -1 : 1) * Math.pow((abs + 0.055) / 1.055, 2.4); 19 | } 20 | 21 | // https://drafts.csswg.org/css-color-4/#color-conversion-code 22 | function gammaEncode(c: number): number { 23 | const abs = Math.abs(c); 24 | 25 | return abs > 0.0031308 26 | ? (c < 0 ? -1 : 1) * (1.055 * Math.pow(abs, 1 / 2.4) - 0.055) 27 | : 12.92 * c; 28 | } 29 | 30 | type Vec3 = readonly [number, number, number]; 31 | 32 | export function mmul(a: readonly [Vec3, Vec3, Vec3], b: Vec3): Vec3 { 33 | return [ 34 | a[0][0] * b[0] + a[0][1] * b[1] + a[0][2] * b[2], 35 | a[1][0] * b[0] + a[1][1] * b[1] + a[1][2] * b[2], 36 | a[2][0] * b[0] + a[2][1] * b[1] + a[2][2] * b[2], 37 | ]; 38 | } 39 | 40 | // https://drafts.csswg.org/css-color-4/#color-conversion-code 41 | function srgbToXYZ(c: figma.Color): Vec3 { 42 | const r = toLinearLight(c.r); 43 | const g = toLinearLight(c.g); 44 | const b = toLinearLight(c.b); 45 | 46 | return mmul( 47 | [ 48 | [506752 / 1228815, 87881 / 245763, 12673 / 70218], 49 | [87098 / 409605, 175762 / 245763, 12673 / 175545], 50 | [7918 / 409605, 87881 / 737289, 1001167 / 1053270], 51 | ], 52 | [r, g, b], 53 | ); 54 | } 55 | 56 | // https://drafts.csswg.org/css-color-4/#color-conversion-code 57 | function xyzToDisplayP3(xyz: Vec3): figma.Color { 58 | const [r, g, b] = mmul( 59 | [ 60 | [446124 / 178915, -333277 / 357830, -72051 / 178915], 61 | [-14852 / 17905, 63121 / 35810, 423 / 17905], 62 | [11844 / 330415, -50337 / 660830, 316169 / 330415], 63 | ], 64 | xyz, 65 | ); 66 | 67 | return { 68 | r: gammaEncode(r), 69 | g: gammaEncode(g), 70 | b: gammaEncode(b), 71 | a: 1, 72 | }; 73 | } 74 | 75 | export function srgbToDisplayP3(srgb: figma.Color): figma.Color { 76 | return { 77 | ...xyzToDisplayP3(srgbToXYZ(srgb)), 78 | a: srgb.a, 79 | }; 80 | } 81 | -------------------------------------------------------------------------------- /src/ui/inspectorPanel/cssgen/cssgen.ts: -------------------------------------------------------------------------------- 1 | export * from "./CSSStyle.js"; 2 | export { fromNode } from "./fromNode.js"; 3 | export { serializeStyle, serializeValue } from "./serialize.js"; 4 | -------------------------------------------------------------------------------- /src/ui/inspectorPanel/cssgen/fromNode.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import type * as figma from "../../../figma"; 4 | import { defaultPreferenecs, type Preferences } from "../../../preferences"; 5 | 6 | import { fromNode } from "./fromNode"; 7 | import { serializeStyle } from "./serialize"; 8 | 9 | describe("fromNode", () => { 10 | describe("padding", () => { 11 | it("Should have v/h padding rule for v/h paddings", () => { 12 | const input: figma.Node & figma.HasLegacyPadding = { 13 | id: "", 14 | name: "", 15 | type: "", 16 | horizontalPadding: 1, 17 | verticalPadding: 2, 18 | }; 19 | 20 | expect( 21 | fromNode(input, defaultPreferenecs).map((s) => 22 | serializeStyle(s, defaultPreferenecs), 23 | ), 24 | ).toContain("padding: 2px 1px;"); 25 | }); 26 | 27 | it("Should have single-value padding rule for v/h paddings are the same value", () => { 28 | const input: figma.Node & figma.HasLegacyPadding = { 29 | id: "", 30 | name: "", 31 | type: "", 32 | horizontalPadding: 5, 33 | verticalPadding: 5, 34 | }; 35 | 36 | expect( 37 | fromNode(input, defaultPreferenecs).map((s) => 38 | serializeStyle(s, defaultPreferenecs), 39 | ), 40 | ).toContain("padding: 5px;"); 41 | }); 42 | 43 | it("Should have four-value padding", () => { 44 | const input: figma.Node & figma.HasPadding = { 45 | id: "", 46 | name: "", 47 | type: "", 48 | paddingTop: 1, 49 | paddingRight: 2, 50 | paddingBottom: 3, 51 | paddingLeft: 4, 52 | }; 53 | 54 | expect( 55 | fromNode(input, defaultPreferenecs).map((s) => 56 | serializeStyle(s, defaultPreferenecs), 57 | ), 58 | ).toContain("padding: 1px 2px 3px 4px;"); 59 | }); 60 | }); 61 | 62 | describe("effects", () => { 63 | it("Should convert inner shadow effect", () => { 64 | const input: figma.Node & figma.HasEffects = { 65 | id: "", 66 | name: "", 67 | type: "", 68 | effects: [ 69 | { 70 | type: "INNER_SHADOW", 71 | radius: 5, 72 | spread: 1, 73 | visible: true, 74 | offset: { x: 3, y: 4 }, 75 | color: { r: 1, g: 1, b: 1, a: 1 }, 76 | blendMode: "NORMAL", 77 | } satisfies figma.ShadowEffect, 78 | ], 79 | }; 80 | 81 | const preferences: Preferences = { 82 | ...defaultPreferenecs, 83 | cssColorNotation: "hex", 84 | }; 85 | 86 | expect( 87 | fromNode(input, preferences).map((s) => serializeStyle(s, preferences)), 88 | ).toContain("box-shadow: 3px 4px 5px 1px #ffffff inset;"); 89 | }); 90 | 91 | it("Should skip invisible effects", () => { 92 | const input: figma.Node & figma.HasEffects = { 93 | id: "", 94 | name: "", 95 | type: "", 96 | effects: [ 97 | { 98 | type: "INNER_SHADOW", 99 | radius: 5, 100 | spread: 1, 101 | visible: false, 102 | offset: { x: 3, y: 4 }, 103 | color: { r: 1, g: 1, b: 1, a: 1 }, 104 | blendMode: "NORMAL", 105 | } satisfies figma.ShadowEffect, 106 | ], 107 | }; 108 | 109 | const preferences: Preferences = { 110 | ...defaultPreferenecs, 111 | cssColorNotation: "hex", 112 | }; 113 | 114 | expect( 115 | fromNode(input, preferences).map((s) => serializeStyle(s, preferences)), 116 | ).not.toContain("box-shadow: 3px 4px 5px 1px #ffffff inset;"); 117 | }); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /src/ui/inspectorPanel/cssgen/gradient.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { getLinearGradientAngle, radToDeg } from "./gradient"; 4 | 5 | describe("radToDeg", () => { 6 | it("πrad = 180deg", () => { 7 | expect(radToDeg(Math.PI)).toBeCloseTo(180); 8 | }); 9 | 10 | it("π/2rad = 90deg", () => { 11 | expect(radToDeg(Math.PI / 2)).toBeCloseTo(90); 12 | }); 13 | }); 14 | 15 | describe("getLinearGradientAngle", () => { 16 | it("(0.5,0) to (0.5,1) = 270deg", () => { 17 | expect(getLinearGradientAngle({ x: 0.5, y: 0 }, { x: 0.5, y: 1 })).toBe( 18 | 270, 19 | ); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/ui/inspectorPanel/cssgen/gradient.ts: -------------------------------------------------------------------------------- 1 | import type * as figma from "../../../figma.js"; 2 | 3 | /** 4 | * @returns Angle in degrees 5 | */ 6 | export function getLinearGradientAngle( 7 | start: figma.Vector, 8 | end: figma.Vector, 9 | ): number { 10 | return radToDeg(getAngle(start, end)); 11 | } 12 | 13 | /** 14 | * Get angle of the vector in radian 15 | * @returns Angle in radian 16 | */ 17 | function getAngle(start: figma.Vector, end: figma.Vector): number { 18 | return Math.atan(((end.y - start.y) / (end.x - start.x)) * -1); 19 | } 20 | 21 | export function radToDeg(rad: number): number { 22 | const deg = ((180 * rad) / Math.PI) | 0; 23 | 24 | return deg < 0 ? 360 + deg : deg; 25 | } 26 | -------------------------------------------------------------------------------- /src/ui/inspectorPanel/cssgen/serialize.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { defaultPreferenecs } from "../../../preferences"; 4 | 5 | import { CSSStyleValueTypes } from "./CSSStyle"; 6 | import { serializeStyle, serializeValue } from "./serialize"; 7 | 8 | describe("serializeValue", () => { 9 | it("Should output literals/unknowns as-is", () => { 10 | expect( 11 | serializeValue( 12 | { 13 | type: CSSStyleValueTypes.Unknown, 14 | text: "unknown-value", 15 | }, 16 | defaultPreferenecs, 17 | ), 18 | ).toBe("unknown-value"); 19 | 20 | expect( 21 | serializeValue( 22 | { 23 | type: CSSStyleValueTypes.Literal, 24 | text: "literal-value", 25 | }, 26 | defaultPreferenecs, 27 | ), 28 | ).toBe("literal-value"); 29 | }); 30 | 31 | it("Should quote string", () => { 32 | expect( 33 | serializeValue( 34 | { 35 | type: CSSStyleValueTypes.String, 36 | value: "Foo Bar", 37 | }, 38 | defaultPreferenecs, 39 | ), 40 | ).toBe(`"Foo Bar"`); 41 | }); 42 | 43 | it("Should escape double-quotes in string", () => { 44 | expect( 45 | serializeValue( 46 | { 47 | type: CSSStyleValueTypes.String, 48 | value: `"Foo Bar"`, 49 | }, 50 | defaultPreferenecs, 51 | ), 52 | ).toBe(`"\\"Foo Bar\\""`); 53 | }); 54 | 55 | it("Should output comment", () => { 56 | expect( 57 | serializeValue( 58 | { 59 | type: CSSStyleValueTypes.Comment, 60 | text: "Comment String", 61 | }, 62 | defaultPreferenecs, 63 | ), 64 | ).toBe("/* Comment String */"); 65 | }); 66 | 67 | it("Should ignore color wrapper and uses its contents only", () => { 68 | expect( 69 | serializeValue( 70 | { 71 | type: CSSStyleValueTypes.Color, 72 | color: "????", 73 | value: { 74 | type: CSSStyleValueTypes.Literal, 75 | text: "--bar", 76 | }, 77 | }, 78 | defaultPreferenecs, 79 | ), 80 | ).toBe("--bar"); 81 | }); 82 | 83 | it("Should join list items with the separator", () => { 84 | expect( 85 | serializeValue( 86 | { 87 | type: CSSStyleValueTypes.List, 88 | separator: " / ", 89 | head: { 90 | type: CSSStyleValueTypes.List, 91 | separator: ", ", 92 | head: { type: CSSStyleValueTypes.Number, value: 1 }, 93 | tail: [{ type: CSSStyleValueTypes.Literal, text: "foo" }], 94 | }, 95 | tail: [ 96 | { 97 | type: CSSStyleValueTypes.List, 98 | separator: " |> ", 99 | head: { 100 | type: CSSStyleValueTypes.Literal, 101 | text: "bar", 102 | }, 103 | tail: [{ type: CSSStyleValueTypes.Literal, text: "baz" }], 104 | }, 105 | ], 106 | }, 107 | defaultPreferenecs, 108 | ), 109 | ).toBe("1, foo / bar |> baz"); 110 | }); 111 | 112 | it("Should construct function call", () => { 113 | expect( 114 | serializeValue( 115 | { 116 | type: CSSStyleValueTypes.FunctionCall, 117 | functionName: "var", 118 | args: { 119 | type: CSSStyleValueTypes.Literal, 120 | text: "--_foo", 121 | }, 122 | }, 123 | defaultPreferenecs, 124 | ), 125 | ).toBe("var(--_foo)"); 126 | }); 127 | }); 128 | 129 | describe("serializeStyle", () => { 130 | it("Should serialize a style object into valid CSS rule", () => { 131 | const str = serializeStyle( 132 | { 133 | propertyName: "foo", 134 | value: { 135 | type: CSSStyleValueTypes.Literal, 136 | text: "#fff", 137 | }, 138 | }, 139 | defaultPreferenecs, 140 | ); 141 | 142 | expect(str).toBe("foo: #fff;"); 143 | }); 144 | }); 145 | -------------------------------------------------------------------------------- /src/ui/inspectorPanel/cssgen/serialize.ts: -------------------------------------------------------------------------------- 1 | import { roundTo } from "../../../math.js"; 2 | import { type Preferences } from "../../../preferences.js"; 3 | 4 | import { CSSStyle, CSSStyleValue, CSSStyleValueTypes } from "./CSSStyle.js"; 5 | 6 | export function serializeValue( 7 | value: CSSStyleValue, 8 | preferences: Readonly, 9 | ): string { 10 | switch (value.type) { 11 | case CSSStyleValueTypes.Color: 12 | return serializeValue(value.value, preferences); 13 | case CSSStyleValueTypes.Comment: 14 | return `/* ${value.text} */`; 15 | case CSSStyleValueTypes.FunctionCall: 16 | return ( 17 | value.functionName + "(" + serializeValue(value.args, preferences) + ")" 18 | ); 19 | case CSSStyleValueTypes.Keyword: 20 | return value.ident; 21 | case CSSStyleValueTypes.List: 22 | return [value.head, ...value.tail] 23 | .map((v) => serializeValue(v, preferences)) 24 | .join(value.separator); 25 | case CSSStyleValueTypes.Literal: 26 | return value.text; 27 | case CSSStyleValueTypes.Number: 28 | return ( 29 | roundTo(value.value, value.precision ?? preferences.decimalPlaces) + 30 | (value.unit || "") 31 | ); 32 | case CSSStyleValueTypes.String: 33 | return `"${value.value.replace(/"/g, `\\"`)}"`; 34 | case CSSStyleValueTypes.Unknown: 35 | return value.text; 36 | } 37 | } 38 | 39 | export function serializeStyle( 40 | style: CSSStyle, 41 | preferences: Readonly, 42 | ): string { 43 | return ( 44 | style.propertyName + ": " + serializeValue(style.value, preferences) + ";" 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/ui/inspectorPanel/icons.ts: -------------------------------------------------------------------------------- 1 | import { attr, svg, type ElementFn } from "../../dom.js"; 2 | 3 | export function close( 4 | attrs: readonly ElementFn[] = [], 5 | ): SVGSVGElement { 6 | return svg( 7 | "svg", 8 | [...attrs, attr("viewBox", "0 0 20 20"), attr("aria-label", "Close icon")], 9 | [ 10 | svg( 11 | "path", 12 | [ 13 | attr("d", "M1 19L19 1M19 19L1 1"), 14 | attr("stroke-width", "2"), 15 | attr("stroke", "currentColor"), 16 | ], 17 | [], 18 | ), 19 | ], 20 | ); 21 | } 22 | 23 | export function copy( 24 | attrs: readonly ElementFn[] = [], 25 | ): SVGSVGElement { 26 | return svg( 27 | "svg", 28 | [...attrs, attr("viewBox", "0 0 30 30"), attr("aria-label", "Copy icon")], 29 | [ 30 | svg( 31 | "path", 32 | [ 33 | attr( 34 | "d", 35 | "M21 25.5C21 24.9477 20.5523 24.5 20 24.5C19.4477 24.5 19 24.9477 19 25.5H21ZM13 2H25V0H13V2ZM28 5V21H30V5H28ZM25 24H13V26H25V24ZM10 21V5H8V21H10ZM13 24C11.3431 24 10 22.6569 10 21H8C8 23.7614 10.2386 26 13 26V24ZM28 21C28 22.6569 26.6569 24 25 24V26C27.7614 26 30 23.7614 30 21H28ZM25 2C26.6569 2 28 3.34315 28 5H30C30 2.23858 27.7614 0 25 0V2ZM13 0C10.2386 0 8 2.23858 8 5H10C10 3.34315 11.3431 2 13 2V0ZM16.5 28H5V30H16.5V28ZM2 25V10H0V25H2ZM5 28C3.34315 28 2 26.6569 2 25H0C0 27.7614 2.23858 30 5 30V28ZM5 7H8V5H5V7ZM2 10C2 8.34315 3.34315 7 5 7V5C2.23858 5 0 7.23858 0 10H2ZM16.5 30C18.9853 30 21 27.9853 21 25.5H19C19 26.8807 17.8807 28 16.5 28V30Z", 36 | ), 37 | attr("fill", "currentColor"), 38 | ], 39 | [], 40 | ), 41 | ], 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/ui/inspectorPanel/inspectorPanel.ts: -------------------------------------------------------------------------------- 1 | import { className, el, on } from "../../dom.js"; 2 | import * as figma from "../../figma.js"; 3 | import { roundTo } from "../../math.js"; 4 | import { type Preferences } from "../../preferences.js"; 5 | import { compute, effect, Signal } from "../../signal.js"; 6 | 7 | import { type SnackbarContent } from "../snackbar/snackbar.js"; 8 | 9 | import { cssCode, styles as cssCodeStyles } from "./cssCode.js"; 10 | import * as cssgen from "./cssgen/cssgen.js"; 11 | import { section } from "./section.js"; 12 | 13 | export const styles = 14 | /* css */ ` 15 | .ip-root { 16 | position: absolute; 17 | height: 100%; 18 | width: 300px; 19 | right: 0; 20 | border-left: var(--panel-border); 21 | 22 | background: var(--bg); 23 | color: var(--fg); 24 | overflow-y: auto; 25 | z-index: calc(var(--z-index) + 10); 26 | } 27 | .ip-root:focus-visible { 28 | box-shadow: inset 0 0 0 2px SelectedItem; 29 | outline: none; 30 | } 31 | 32 | .ip-section { 33 | padding: var(--spacing_1); 34 | border-bottom: var(--panel-border); 35 | } 36 | 37 | .ip-section-heading { 38 | display: flex; 39 | align-items: center; 40 | margin: 0; 41 | margin-bottom: var(--spacing_0); 42 | } 43 | 44 | .ip-section-heading-title { 45 | flex-grow: 1; 46 | flex-shrink: 1; 47 | font-size: calc(var(--font-size) * 1); 48 | margin: 0; 49 | } 50 | 51 | .ip-style-section { 52 | margin-bottom: var(--spacing_0); 53 | } 54 | 55 | .ip-overview { 56 | display: flex; 57 | flex-wrap: wrap; 58 | align-items: center; 59 | gap: var(--spacing_1) var(--spacing_2); 60 | margin: 0; 61 | margin-top: var(--spacing_2); 62 | } 63 | 64 | .ip-prop { 65 | display: flex; 66 | flex-direction: column; 67 | align-items: flex-start; 68 | justify-content: flex-start; 69 | margin: 0; 70 | gap: var(--spacing_-4); 71 | } 72 | 73 | .ip-prop-label { 74 | font-weight: bold; 75 | font-size: calc(var(--font-size) * 0.7); 76 | 77 | color: var(--subtle-fg); 78 | } 79 | .ip-prop-value { 80 | font-size: calc(var(--font-size) * 0.9); 81 | 82 | color: var(--fg); 83 | user-select: text; 84 | } 85 | 86 | .ip-text-content { 87 | display: block; 88 | width: 100%; 89 | padding: var(--spacing_-1); 90 | box-sizing: border-box; 91 | font-family: var(--font-family-mono); 92 | font-size: calc(var(--font-size) * 0.8); 93 | 94 | background: var(--code-bg); 95 | border-radius: var(--panel-radii); 96 | color: var(--code-text); 97 | user-select: text; 98 | } 99 | 100 | .ip-options { 101 | display: flex; 102 | justify-content: flex-end; 103 | margin-top: var(--spacing_-1); 104 | } 105 | 106 | .ip-pref-action { 107 | appearance: none; 108 | border: 1px solid var(--action-border); 109 | padding: var(--action-vertical-padding) var(--action-horizontal-padding); 110 | 111 | background: transparent; 112 | border-radius: var(--action-radius); 113 | color: var(--fg); 114 | cursor: pointer; 115 | } 116 | .ip-pref-action:hover { 117 | background-color: var(--action-overlay); 118 | } 119 | .ip-pref-action:focus { 120 | outline: none; 121 | } 122 | .ip-pref-action:focus-visible { 123 | border-color: SelectedItem; 124 | outline: 1px solid SelectedItem; 125 | } 126 | ` + cssCodeStyles; 127 | 128 | interface InspectorPanelProps { 129 | snackbar: Signal; 130 | 131 | preferences: Signal>; 132 | 133 | selected: Signal; 134 | 135 | onOpenPreferencesPanel(): void; 136 | } 137 | 138 | export function inspectorPanel({ 139 | snackbar: $snackbar, 140 | preferences: $preferences, 141 | selected: $selected, 142 | onOpenPreferencesPanel, 143 | }: InspectorPanelProps): Signal { 144 | effect(() => { 145 | // No need to rerun this effect on node-to-node changes 146 | if (!compute(() => !!$selected.get()).get()) { 147 | return; 148 | } 149 | 150 | const onEsc = (ev: KeyboardEvent) => { 151 | if (ev.key !== "Escape" || ev.isComposing) { 152 | return; 153 | } 154 | 155 | ev.preventDefault(); 156 | ev.stopPropagation(); 157 | 158 | $selected.set(null); 159 | }; 160 | 161 | document.addEventListener("keydown", onEsc); 162 | 163 | return () => { 164 | document.removeEventListener("keydown", onEsc); 165 | }; 166 | }); 167 | 168 | return compute(() => { 169 | const node = $selected.get(); 170 | if (!node) { 171 | return null; 172 | } 173 | 174 | return el( 175 | "div", 176 | [className("ip-root")], 177 | [ 178 | section({ 179 | title: node.name, 180 | body: [ 181 | el( 182 | "div", 183 | [className("ip-overview")], 184 | [ 185 | el( 186 | "p", 187 | [className("ip-prop")], 188 | [ 189 | el("span", [className("ip-prop-label")], ["Type:"]), 190 | el("span", [className("ip-prop-value")], [node.type]), 191 | ], 192 | ), 193 | ], 194 | ), 195 | figma.hasBoundingBox(node) 196 | ? el( 197 | "div", 198 | [className("ip-overview")], 199 | [ 200 | el( 201 | "p", 202 | [className("ip-prop")], 203 | [ 204 | el("span", [className("ip-prop-label")], ["Width:"]), 205 | el( 206 | "span", 207 | [className("ip-prop-value")], 208 | [ 209 | compute( 210 | () => 211 | roundTo( 212 | node.absoluteBoundingBox.width, 213 | $preferences.get().decimalPlaces, 214 | ) + "px", 215 | ), 216 | ], 217 | ), 218 | ], 219 | ), 220 | el( 221 | "p", 222 | [className("ip-prop")], 223 | [ 224 | el("span", [className("ip-prop-label")], ["Height:"]), 225 | el( 226 | "span", 227 | [className("ip-prop-value")], 228 | [ 229 | compute( 230 | () => 231 | roundTo( 232 | node.absoluteBoundingBox.height, 233 | $preferences.get().decimalPlaces, 234 | ) + "px", 235 | ), 236 | ], 237 | ), 238 | ], 239 | ), 240 | ], 241 | ) 242 | : null, 243 | figma.hasTypeStyle(node) 244 | ? el( 245 | "div", 246 | [className("ip-overview")], 247 | [ 248 | el( 249 | "p", 250 | [className("ip-prop")], 251 | [ 252 | el("span", [className("ip-prop-label")], ["Font:"]), 253 | el( 254 | "span", 255 | [className("ip-prop-value")], 256 | [ 257 | node.style.fontPostScriptName || 258 | node.style.fontFamily, 259 | ], 260 | ), 261 | ], 262 | ), 263 | ], 264 | ) 265 | : null, 266 | ], 267 | icon: "close", 268 | onIconClick: () => { 269 | $selected.set(null); 270 | }, 271 | }), 272 | figma.hasPadding(node) && 273 | (node.paddingTop > 0 || 274 | node.paddingRight > 0 || 275 | node.paddingBottom > 0 || 276 | node.paddingLeft > 0) 277 | ? section({ 278 | title: "Padding", 279 | body: [ 280 | el( 281 | "p", 282 | [className("ip-prop")], 283 | [ 284 | el("span", [className("ip-prop-label")], ["Top:"]), 285 | el( 286 | "span", 287 | [className("ip-prop-value")], 288 | [node.paddingTop.toString(10)], 289 | ), 290 | ], 291 | ), 292 | el( 293 | "p", 294 | [className("ip-prop")], 295 | [ 296 | el("span", [className("ip-prop-label")], ["Right:"]), 297 | el( 298 | "span", 299 | [className("ip-prop-value")], 300 | [node.paddingRight.toString(10)], 301 | ), 302 | ], 303 | ), 304 | el( 305 | "p", 306 | [className("ip-prop")], 307 | [ 308 | el("span", [className("ip-prop-label")], ["Bottom:"]), 309 | el( 310 | "span", 311 | [className("ip-prop-value")], 312 | [node.paddingBottom.toString(10)], 313 | ), 314 | ], 315 | ), 316 | el( 317 | "p", 318 | [className("ip-prop")], 319 | [ 320 | el("span", [className("ip-prop-label")], ["Left:"]), 321 | el( 322 | "span", 323 | [className("ip-prop-value")], 324 | [node.paddingLeft.toString(10)], 325 | ), 326 | ], 327 | ), 328 | ], 329 | }) 330 | : figma.hasLegacyPadding(node) && 331 | (node.horizontalPadding > 0 || node.verticalPadding > 0) 332 | ? section({ 333 | title: "Layout", 334 | body: [ 335 | node.horizontalPadding > 0 336 | ? el( 337 | "p", 338 | [className("ip-prop")], 339 | [ 340 | el("span", [], ["Padding(H): "]), 341 | node.horizontalPadding.toString(10), 342 | ], 343 | ) 344 | : null, 345 | node.verticalPadding > 0 346 | ? el( 347 | "p", 348 | [className("ip-prop")], 349 | [ 350 | el("span", [], ["Padding(V): "]), 351 | node.verticalPadding.toString(10), 352 | ], 353 | ) 354 | : null, 355 | ], 356 | }) 357 | : null, 358 | figma.hasCharacters(node) 359 | ? section({ 360 | title: "Content", 361 | body: [ 362 | el("code", [className("ip-text-content")], [node.characters]), 363 | ], 364 | icon: "copy", 365 | async onIconClick() { 366 | try { 367 | await navigator.clipboard.writeText(node.characters); 368 | 369 | $snackbar.set(["Copied text content to clipboard"]); 370 | } catch (error) { 371 | console.error("Failed to copy text content", error); 372 | $snackbar.set([ 373 | "Failed to copy text content: ", 374 | error instanceof Error ? error.message : String(error), 375 | ]); 376 | } 377 | }, 378 | }) 379 | : null, 380 | section({ 381 | title: "CSS", 382 | body: [ 383 | compute(() => { 384 | const preferences = $preferences.get(); 385 | const css = cssgen.fromNode(node, preferences); 386 | 387 | return cssCode(css, preferences); 388 | }), 389 | el( 390 | "div", 391 | [className("ip-options")], 392 | [ 393 | el( 394 | "button", 395 | [ 396 | className("ip-pref-action"), 397 | on("click", () => { 398 | onOpenPreferencesPanel(); 399 | }), 400 | ], 401 | ["Customize"], 402 | ), 403 | ], 404 | ), 405 | ], 406 | icon: "copy", 407 | onIconClick: async () => { 408 | const preferences = $preferences.once(); 409 | 410 | const css = cssgen.fromNode(node, preferences); 411 | 412 | const code = css 413 | .map((style) => cssgen.serializeStyle(style, preferences)) 414 | .join("\n"); 415 | 416 | try { 417 | await navigator.clipboard.writeText(code); 418 | 419 | $snackbar.set(["Copied CSS code to clipboard"]); 420 | } catch (error) { 421 | console.error("Failed to copy CSS code", error); 422 | $snackbar.set([ 423 | "Failed to copy CSS code: ", 424 | error instanceof Error ? error.message : String(error), 425 | ]); 426 | } 427 | }, 428 | }), 429 | ], 430 | ); 431 | }); 432 | } 433 | -------------------------------------------------------------------------------- /src/ui/inspectorPanel/section.ts: -------------------------------------------------------------------------------- 1 | import { attr, className, el } from "../../dom.js"; 2 | 3 | import { iconButton } from "../iconButton/iconButton.js"; 4 | 5 | import * as icons from "./icons.js"; 6 | 7 | interface SectionProps { 8 | title: string; 9 | 10 | body: NonNullable[2]>; 11 | 12 | icon?: "close" | "copy"; 13 | 14 | onIconClick?(): void; 15 | } 16 | 17 | export function section({ 18 | title, 19 | body, 20 | icon: iconType, 21 | onIconClick, 22 | }: SectionProps): HTMLElement { 23 | const iconAttrs = [ 24 | attr("role", "img"), 25 | className("ip-section-heading-button-icon"), 26 | ]; 27 | 28 | const icon = 29 | iconType === "close" 30 | ? icons.close(iconAttrs) 31 | : iconType === "copy" 32 | ? icons.copy(iconAttrs) 33 | : null; 34 | 35 | return el( 36 | "div", 37 | [className("ip-section")], 38 | [ 39 | el( 40 | "div", 41 | [className("ip-section-heading")], 42 | [ 43 | el("h4", [className("ip-section-heading-title")], [title]), 44 | icon && 45 | onIconClick && 46 | iconButton({ 47 | title: iconType === "close" ? "Close" : "Copy", 48 | icon, 49 | onClick: onIconClick, 50 | }), 51 | ], 52 | ), 53 | ...body, 54 | ], 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/ui/menuBar/icons.ts: -------------------------------------------------------------------------------- 1 | import { attr, className, svg, style } from "../../dom.js"; 2 | 3 | export const icons = { 4 | info: () => 5 | svg( 6 | "svg", 7 | [ 8 | attr("viewBox", "0 0 100 100"), 9 | className("mb-icon"), 10 | attr("fill", "none"), 11 | attr("stroke-width", "8"), 12 | ], 13 | [ 14 | svg("circle", [ 15 | attr("cx", "50"), 16 | attr("cy", "50"), 17 | attr("r", "42"), 18 | attr("stroke", "currentColor"), 19 | ]), 20 | svg("circle", [ 21 | attr("cx", "50"), 22 | attr("cy", "30"), 23 | attr("r", "9"), 24 | attr("fill", "currentColor"), 25 | ]), 26 | svg("rect", [ 27 | attr("x", "44"), 28 | attr("y", "45"), 29 | attr("width", "12"), 30 | attr("height", "35"), 31 | attr("fill", "currentColor"), 32 | ]), 33 | ], 34 | ), 35 | preferences: () => 36 | svg( 37 | "svg", 38 | [ 39 | attr("viewBox", "0 0 100 100"), 40 | className("mb-icon"), 41 | attr("fill", "none"), 42 | attr("stroke-width", "10"), 43 | ], 44 | [ 45 | svg("circle", [ 46 | attr("cx", "50"), 47 | attr("cy", "50"), 48 | attr("r", "30"), 49 | attr("stroke", "currentColor"), 50 | ]), 51 | ...Array.from({ length: 8 }).map((_, i) => { 52 | const deg = 45 * i; 53 | 54 | return svg("path", [ 55 | attr("d", "M45,2 l10,0 l5,15 l-20,0 Z"), 56 | attr("fill", "currentColor"), 57 | style({ 58 | transform: `rotate(${deg}deg)`, 59 | "transform-origin": "50px 50px", 60 | }), 61 | ]); 62 | }), 63 | ], 64 | ), 65 | } as const; 66 | -------------------------------------------------------------------------------- /src/ui/menuBar/menuBar.ts: -------------------------------------------------------------------------------- 1 | import { attr, className, el } from "../../dom.js"; 2 | 3 | import { iconButton } from "../iconButton/iconButton.js"; 4 | import { icons } from "./icons.js"; 5 | 6 | export const styles = /* css */ ` 7 | .mb-root { 8 | position: absolute; 9 | top: 0; 10 | width: 100%; 11 | } 12 | 13 | .mb-root:hover > .mb-menubar[data-autohide] { 14 | transition-delay: 0s; 15 | transform: translateY(0px); 16 | } 17 | 18 | .mb-menubar { 19 | padding: var(--spacing_-3); 20 | display: flex; 21 | 22 | background-color: var(--bg); 23 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); 24 | color: var(--fg); 25 | z-index: 1; 26 | } 27 | .mb-menubar[data-autohide] { 28 | transition: transform 0.15s 0.5s ease-out; 29 | transform: translateY(-100%); 30 | } 31 | 32 | .mb-menubar .sl-select { 33 | --action-border: transparent; 34 | } 35 | 36 | .mb-slots { 37 | flex: 1; 38 | } 39 | 40 | .mb-actions { 41 | flex-grow: 0; 42 | flex-shrink: 0; 43 | display: flex; 44 | gap: var(--spacing_-2); 45 | } 46 | `; 47 | 48 | interface MenuBarProps { 49 | slot?: Parameters[2]; 50 | 51 | onOpenInfo(): void; 52 | onOpenPreferences(): void; 53 | } 54 | 55 | export function menuBar({ 56 | slot, 57 | onOpenInfo, 58 | onOpenPreferences, 59 | }: MenuBarProps): HTMLElement { 60 | return el( 61 | "div", 62 | [className("mb-root")], 63 | [ 64 | el( 65 | "div", 66 | [className("mb-menubar"), attr("data-autohide", false)], 67 | [ 68 | el("div", [className("mb-slots")], slot), 69 | el( 70 | "div", 71 | [className("mb-actions")], 72 | [ 73 | iconButton({ 74 | title: "File info", 75 | icon: icons.info(), 76 | onClick: () => { 77 | onOpenInfo(); 78 | }, 79 | }), 80 | iconButton({ 81 | title: "Preferences", 82 | icon: icons.preferences(), 83 | onClick: () => { 84 | onOpenPreferences(); 85 | }, 86 | }), 87 | ], 88 | ), 89 | ], 90 | ), 91 | ], 92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /src/ui/preferencesPanel/choice.ts: -------------------------------------------------------------------------------- 1 | import { attr, className, el, prop, on } from "../../dom.js"; 2 | import { compute, Signal } from "../../signal.js"; 3 | 4 | import { check } from "./icons.js"; 5 | 6 | export const styles = /* css */ ` 7 | .pp-choice-container { 8 | position: relative; 9 | } 10 | 11 | .pp-choice-input { 12 | position: absolute; 13 | top: 0; 14 | left: 0; 15 | width: 1px; 16 | height: 1px; 17 | 18 | opacity: 0; 19 | } 20 | 21 | .pp-choice-box { 22 | padding: var(--action-vertical-padding) var(--action-horizontal-padding); 23 | display: grid; 24 | grid-template-columns: min-content minmax(0, 1fr); 25 | grid-template-rows: max-content max-content; 26 | align-items: center; 27 | gap: var(--spacing_-2) var(--spacing_-1); 28 | border: 1px solid var(--action-border); 29 | border: 1px solid transparent; 30 | 31 | border-radius: var(--action-radius); 32 | cursor: pointer; 33 | } 34 | .pp-choice-input:checked + .pp-choice-box { 35 | border-color: var(--action-border); 36 | } 37 | .pp-choice-input:checked + .pp-choice-box > .pp-choice-check { 38 | color: var(--success-fg); 39 | 40 | opacity: 1; 41 | } 42 | .pp-choice-input:focus-visible + .pp-choice-box { 43 | box-shadow: 0 0 0 1px inset SelectedItem; 44 | border-color: SelectedItem; 45 | } 46 | .pp-choice-box:hover { 47 | background-color: var(--action-overlay); 48 | } 49 | 50 | .pp-choice-check { 51 | height: calc(var(--font-size) * 0.8); 52 | width: auto; 53 | 54 | color: var(--subtle-fg); 55 | 56 | opacity: 0.15; 57 | } 58 | 59 | .pp-choice-label { 60 | font-size: calc(var(--font-size) * 1); 61 | 62 | color: var(--fg); 63 | } 64 | 65 | .pp-choice-desc { 66 | grid-column: 1 / 3; 67 | margin: 0; 68 | font-size: calc(var(--font-size) * 0.8); 69 | 70 | color: var(--subtle-fg); 71 | } 72 | `; 73 | 74 | type ElementChildren = NonNullable[2]>; 75 | 76 | interface ChoiceProps { 77 | value: T; 78 | 79 | label: ElementChildren[number]; 80 | 81 | description?: ElementChildren; 82 | 83 | selected: Signal; 84 | 85 | group: string; 86 | 87 | onChange(value: T): void; 88 | } 89 | 90 | export function choice({ 91 | value, 92 | label, 93 | description, 94 | selected, 95 | group, 96 | onChange, 97 | }: ChoiceProps): HTMLElement { 98 | const id = group + "_" + value; 99 | const labelId = id + "_label"; 100 | const descriptionId = id + "_description"; 101 | 102 | const input = el("input", [ 103 | attr("id", id), 104 | className("pp-choice-input"), 105 | attr("aria-labelledby", labelId), 106 | attr("aria-describedby", description ? descriptionId : false), 107 | attr("type", "radio"), 108 | attr("name", group), 109 | prop( 110 | "checked", 111 | compute(() => selected.get() === value), 112 | ), 113 | on("change", (ev) => { 114 | ev.preventDefault(); 115 | 116 | onChange(value); 117 | }), 118 | ]); 119 | 120 | return el( 121 | "div", 122 | [className("pp-choice-container")], 123 | [ 124 | input, 125 | el( 126 | "div", 127 | [ 128 | className("pp-choice-box"), 129 | on("click", (ev) => { 130 | if (ev.target instanceof HTMLAnchorElement) { 131 | return; 132 | } 133 | 134 | ev.preventDefault(); 135 | 136 | input.click(); 137 | }), 138 | ], 139 | [ 140 | check([className("pp-choice-check")]), 141 | el( 142 | "span", 143 | [attr("id", labelId), className("pp-choice-label")], 144 | [label], 145 | ), 146 | description && 147 | el( 148 | "p", 149 | [attr("id", descriptionId), className("pp-choice-desc")], 150 | description, 151 | ), 152 | ], 153 | ), 154 | ], 155 | ); 156 | } 157 | -------------------------------------------------------------------------------- /src/ui/preferencesPanel/icons.ts: -------------------------------------------------------------------------------- 1 | import { attr, svg, type ElementFn } from "../../dom.js"; 2 | 3 | export function check( 4 | attrs: readonly ElementFn[] = [], 5 | ): SVGSVGElement { 6 | return svg( 7 | "svg", 8 | [ 9 | ...attrs, 10 | attr("viewBox", "0 0 100 100"), 11 | attr("aria-label", "Check mark"), 12 | ], 13 | [ 14 | svg("path", [ 15 | attr("d", "M10,50 L40,80 L90,20"), 16 | attr("fill", "none"), 17 | attr("stroke", "currentColor"), 18 | attr("stroke-width", "15"), 19 | ]), 20 | ], 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/ui/preferencesPanel/preferencesPanel.ts: -------------------------------------------------------------------------------- 1 | import { attr, className, el, type ElementFn, on, prop } from "../../dom.js"; 2 | import { roundTo } from "../../math.js"; 3 | import { type Preferences } from "../../preferences.js"; 4 | import { compute, Signal } from "../../signal.js"; 5 | 6 | import { choice, styles as choiceStyles } from "./choice.js"; 7 | 8 | export const styles = 9 | /* css */ ` 10 | .pp-root { 11 | overflow-y: auto; 12 | } 13 | 14 | .pp-section-header { 15 | display: block; 16 | margin: 0; 17 | margin-top: var(--spacing_5); 18 | margin-bottom: var(--spacing_1); 19 | font-size: calc(var(--font-size) * 1.1); 20 | font-weight: bold; 21 | } 22 | 23 | .pp-choice-list { 24 | display: flex; 25 | flex-direction: column; 26 | align-items: stretch; 27 | justify-content: flex-start; 28 | gap: var(--spacing_-2); 29 | } 30 | 31 | .pp-input { 32 | appearance: none; 33 | border: 1px solid var(--action-border); 34 | padding: var(--action-vertical-padding) var(--action-horizontal-padding); 35 | display: inline-block; 36 | font-size: calc(var(--font-size) * 0.9); 37 | min-width: 6em; 38 | 39 | background: transparent; 40 | border-radius: var(--action-radius); 41 | color: var(--fg); 42 | } 43 | .pp-input:focus { 44 | outline: none; 45 | } 46 | .pp-input:focus-visible { 47 | border-color: SelectedItem; 48 | outline: 1px solid SelectedItem; 49 | } 50 | 51 | .pp-description, .pp-error { 52 | margin: 0; 53 | margin-top: var(--spacing_0); 54 | font-size: calc(var(--font-size) * 0.8); 55 | 56 | color: var(--subtle-fg); 57 | } 58 | 59 | .pp-error { 60 | color: var(--error-fg); 61 | } 62 | ` + choiceStyles; 63 | 64 | interface PreferencesPanelProps { 65 | preferences: Signal>; 66 | } 67 | 68 | export function preferencesPanel({ 69 | preferences: $preferences, 70 | }: PreferencesPanelProps): HTMLElement { 71 | return el( 72 | "div", 73 | [className("pp-root")], 74 | [ 75 | cssColorNotation($preferences), 76 | lengthUnit($preferences), 77 | rootFontSizeInPx($preferences), 78 | enableColorPreview($preferences), 79 | decimalPlaces($preferences), 80 | viewportPanSpeed($preferences), 81 | viewportZoomSpeed($preferences), 82 | ], 83 | ); 84 | } 85 | 86 | interface NumberInputProps { 87 | $error: Signal; 88 | initialValue: number; 89 | min: number; 90 | max: number; 91 | step?: number; 92 | attrs?: readonly ElementFn[]; 93 | onChange(value: number): void; 94 | } 95 | 96 | function numberInput({ 97 | $error, 98 | initialValue, 99 | min, 100 | max, 101 | step = 1, 102 | attrs = [], 103 | onChange, 104 | }: NumberInputProps): HTMLInputElement { 105 | return el("input", [ 106 | ...attrs, 107 | className("pp-input"), 108 | attr("type", "number"), 109 | prop("value", initialValue.toString(10)), 110 | attr("min", min.toString(10)), 111 | attr("max", max.toString(10)), 112 | attr("step", step.toString(10)), 113 | attr( 114 | "aria-invalid", 115 | compute(() => ($error.get() ? "true" : false)), 116 | ), 117 | on("change", (ev) => { 118 | ev.preventDefault(); 119 | 120 | const value = parseInt((ev.currentTarget as HTMLInputElement).value, 10); 121 | 122 | if (!Number.isFinite(value)) { 123 | $error.set("Please input a valid number."); 124 | return; 125 | } 126 | 127 | if (value < min) { 128 | $error.set( 129 | "Input must be greater than or equal to " + min.toString(10) + ".", 130 | ); 131 | return; 132 | } 133 | 134 | if (value > max) { 135 | $error.set( 136 | "Input must be less than or equal to " + max.toString(10) + ".", 137 | ); 138 | return; 139 | } 140 | 141 | $error.set(null); 142 | onChange(value); 143 | }), 144 | ]); 145 | } 146 | 147 | function cssColorNotation( 148 | $preferences: Signal>, 149 | ): HTMLElement { 150 | const selected = compute(() => $preferences.get().cssColorNotation); 151 | 152 | const onChange = (cssColorNotation: Preferences["cssColorNotation"]) => { 153 | $preferences.set({ 154 | ...$preferences.once(), 155 | cssColorNotation, 156 | }); 157 | }; 158 | 159 | return el( 160 | "div", 161 | [], 162 | [ 163 | el("span", [className("pp-section-header")], ["CSS color notation"]), 164 | el( 165 | "div", 166 | [className("pp-choice-list")], 167 | [ 168 | choice({ 169 | value: "hex", 170 | label: "RGB (Hex)", 171 | selected, 172 | group: "color_notation", 173 | onChange, 174 | }), 175 | choice({ 176 | value: "rgb", 177 | label: "RGB", 178 | selected, 179 | group: "color_notation", 180 | onChange, 181 | }), 182 | choice({ 183 | value: "hsl", 184 | label: "HSL", 185 | selected, 186 | group: "color_notation", 187 | onChange, 188 | }), 189 | choice({ 190 | value: "color-srgb", 191 | label: "sRGB", 192 | selected, 193 | description: [ 194 | "Display colors with ", 195 | el("code", [], ["color"]), 196 | " function in sRGB color space. When the Figma file is set to use Display P3 color space, ", 197 | "color gamut would be inaccurate.", 198 | ], 199 | group: "color_notation", 200 | onChange, 201 | }), 202 | choice({ 203 | value: "display-p3", 204 | label: "Display P3", 205 | selected, 206 | description: [ 207 | "Display colors in Display P3 color space. ", 208 | "Suitable for Figma files set to use Display P3 color space. ", 209 | "When the Figma file is set to use sRGB color space (default), resulting colors may be oversaturated. ", 210 | "If the user environment does not support Display P3 color space, out-of-gamut colors are clamped (CSS Gamut Mapping).", 211 | ], 212 | group: "color_notation", 213 | onChange, 214 | }), 215 | choice({ 216 | value: "srgb-to-display-p3", 217 | label: "Display P3 (sRGB range)", 218 | selected, 219 | description: [ 220 | "Display colors in Display P3 color space. ", 221 | "This mode treats original color as sRGB and converts it to Display P3 color using ", 222 | el( 223 | "a", 224 | [ 225 | attr( 226 | "href", 227 | "https://drafts.csswg.org/css-color-4/#predefined-to-predefined", 228 | ), 229 | attr("target", "_blank"), 230 | ], 231 | ["a method described in CSS Color Module 4 draft spec"], 232 | ), 233 | ". ", 234 | "When the Figma file is set to use Display P3 color space, resulting colors may be undersaturated. ", 235 | "The colors generated by this mode look same regardless of whether the user environment supports Display P3 color space or not. ", 236 | ], 237 | group: "color_notation", 238 | onChange, 239 | }), 240 | ], 241 | ), 242 | ], 243 | ); 244 | } 245 | 246 | function lengthUnit($preferences: Signal>): HTMLElement { 247 | const selected = compute(() => $preferences.get().lengthUnit); 248 | 249 | const onChange = (lengthUnit: Preferences["lengthUnit"]) => { 250 | $preferences.set({ 251 | ...$preferences.once(), 252 | lengthUnit, 253 | }); 254 | }; 255 | 256 | return el( 257 | "div", 258 | [], 259 | [ 260 | el("span", [className("pp-section-header")], ["CSS length unit"]), 261 | el( 262 | "div", 263 | [className("pp-choice-list")], 264 | [ 265 | choice({ 266 | value: "px", 267 | label: "px", 268 | selected, 269 | group: "length_unit", 270 | onChange, 271 | }), 272 | choice({ 273 | value: "rem", 274 | label: "rem", 275 | selected, 276 | description: [ 277 | "Showing rem sizes to match the px sizes, assuming the ", 278 | el("code", [], [":root"]), 279 | " is set to ", 280 | compute(() => { 281 | const { rootFontSizeInPx, decimalPlaces } = $preferences.get(); 282 | 283 | return roundTo(rootFontSizeInPx, decimalPlaces).toString(10); 284 | }), 285 | "px. ", 286 | "This option changes ", 287 | el("b", [], ["every"]), 288 | " length unit to rem, even where the use of px is preferable.", 289 | ], 290 | group: "length_unit", 291 | onChange, 292 | }), 293 | ], 294 | ), 295 | ], 296 | ); 297 | } 298 | 299 | function rootFontSizeInPx( 300 | $preferences: Signal>, 301 | ): Signal { 302 | const $isUsingRem = compute(() => $preferences.get().lengthUnit === "rem"); 303 | const $error = new Signal(null); 304 | 305 | return compute(() => { 306 | if (!$isUsingRem.get()) { 307 | return null; 308 | } 309 | 310 | return el( 311 | "div", 312 | [], 313 | [ 314 | el( 315 | "span", 316 | [className("pp-section-header")], 317 | ["Root font size for rem calculation"], 318 | ), 319 | numberInput({ 320 | $error, 321 | initialValue: $preferences.once().rootFontSizeInPx, 322 | min: 1, 323 | max: 100, 324 | attrs: [attr("aria-describedby", "root_font_size_desc")], 325 | onChange(rootFontSizeInPx) { 326 | $preferences.set({ 327 | ...$preferences.once(), 328 | rootFontSizeInPx, 329 | }); 330 | }, 331 | }), 332 | compute(() => { 333 | const error = $error.get(); 334 | if (!error) { 335 | return null; 336 | } 337 | 338 | return el("p", [className("pp-error")], [error]); 339 | }), 340 | el( 341 | "p", 342 | [attr("id", "root_font_size_desc"), className("pp-description")], 343 | [ 344 | "Font size set to your page's ", 345 | el("code", [], [":root"]), 346 | " in px. ", 347 | "When unset, 16px (default value) is the recommended value as it is the default value most browser/platform uses. ", 348 | "When you set 62.5% (or similar) to make 1rem to match 10px, input 10px. ", 349 | "With the current setting, 1px = ", 350 | compute(() => { 351 | const { rootFontSizeInPx, decimalPlaces } = $preferences.get(); 352 | 353 | return roundTo(1 / rootFontSizeInPx, decimalPlaces + 2).toString( 354 | 10, 355 | ); 356 | }), 357 | "rem. ", 358 | ], 359 | ), 360 | ], 361 | ); 362 | }); 363 | } 364 | 365 | function enableColorPreview( 366 | $preferences: Signal>, 367 | ): HTMLElement { 368 | const selected = compute(() => 369 | $preferences.get().enableColorPreview ? "true" : "false", 370 | ); 371 | 372 | const onChange = (enableColorPreview: "true" | "false") => { 373 | $preferences.set({ 374 | ...$preferences.once(), 375 | enableColorPreview: enableColorPreview === "true", 376 | }); 377 | }; 378 | 379 | return el( 380 | "div", 381 | [], 382 | [ 383 | el("span", [className("pp-section-header")], ["CSS color preview"]), 384 | el( 385 | "div", 386 | [className("pp-choice-list")], 387 | [ 388 | choice({ 389 | value: "true", 390 | label: "Enabled", 391 | selected, 392 | description: [ 393 | "Displays a color preview next to a CSS color value.", 394 | ], 395 | group: "color_preview", 396 | onChange, 397 | }), 398 | choice({ 399 | value: "false", 400 | label: "Disabled", 401 | selected, 402 | description: ["Do not display color previews inside CSS code."], 403 | group: "color_preview", 404 | onChange, 405 | }), 406 | ], 407 | ), 408 | ], 409 | ); 410 | } 411 | 412 | const ROUND_TEST_VALUE = 1.23456789123; 413 | 414 | function decimalPlaces( 415 | $preferences: Signal>, 416 | ): HTMLElement { 417 | const $error = new Signal(null); 418 | 419 | return el( 420 | "div", 421 | [], 422 | [ 423 | el("span", [className("pp-section-header")], ["Decimal places"]), 424 | numberInput({ 425 | $error, 426 | initialValue: $preferences.once().decimalPlaces, 427 | min: 0, 428 | max: 10, 429 | attrs: [attr("aria-describedby", "decimal_places_desc")], 430 | onChange(decimalPlaces) { 431 | $preferences.set({ 432 | ...$preferences.once(), 433 | decimalPlaces, 434 | }); 435 | }, 436 | }), 437 | compute(() => { 438 | const error = $error.get(); 439 | if (!error) { 440 | return null; 441 | } 442 | 443 | return el("p", [className("pp-error")], [error]); 444 | }), 445 | el( 446 | "p", 447 | [attr("id", "decimal_places_desc"), className("pp-description")], 448 | [ 449 | "The number of decimal places to show in UI and CSS code. Some parts ignore, add to, or subtract to this number. ", 450 | "With the current setting, ", 451 | ROUND_TEST_VALUE.toString(10), 452 | " would be rounded to ", 453 | compute(() => { 454 | const { decimalPlaces } = $preferences.get(); 455 | 456 | return ( 457 | roundTo( 458 | ROUND_TEST_VALUE, 459 | $preferences.get().decimalPlaces, 460 | ).toString(10) + (decimalPlaces === 0 ? " (integer)" : "") 461 | ); 462 | }), 463 | ". ", 464 | ], 465 | ), 466 | ], 467 | ); 468 | } 469 | 470 | function viewportZoomSpeed( 471 | $preferences: Signal>, 472 | ): HTMLElement { 473 | const $error = new Signal(null); 474 | 475 | return el( 476 | "div", 477 | [], 478 | [ 479 | el("span", [className("pp-section-header")], ["Viewport zoom speed"]), 480 | numberInput({ 481 | $error, 482 | initialValue: $preferences.once().viewportZoomSpeed, 483 | min: 0, 484 | max: 999, 485 | attrs: [attr("aria-describedby", "zoom_speed_desc")], 486 | onChange(viewportZoomSpeed) { 487 | $preferences.set({ 488 | ...$preferences.once(), 489 | viewportZoomSpeed, 490 | }); 491 | }, 492 | }), 493 | compute(() => { 494 | const error = $error.get(); 495 | if (!error) { 496 | return null; 497 | } 498 | 499 | return el("p", [className("pp-error")], [error]); 500 | }), 501 | el( 502 | "p", 503 | [attr("id", "zoom_speed_desc"), className("pp-description")], 504 | ["The speed of viewport scaling action."], 505 | ), 506 | ], 507 | ); 508 | } 509 | 510 | function viewportPanSpeed( 511 | $preferences: Signal>, 512 | ): HTMLElement { 513 | const $error = new Signal(null); 514 | 515 | return el( 516 | "div", 517 | [], 518 | [ 519 | el("span", [className("pp-section-header")], ["Viewport pan speed"]), 520 | numberInput({ 521 | $error, 522 | initialValue: $preferences.once().viewportPanSpeed, 523 | min: 0, 524 | max: 999, 525 | attrs: [attr("aria-describedby", "pan_speed_desc")], 526 | onChange(viewportPanSpeed) { 527 | $preferences.set({ 528 | ...$preferences.once(), 529 | viewportPanSpeed, 530 | }); 531 | }, 532 | }), 533 | compute(() => { 534 | const error = $error.get(); 535 | if (!error) { 536 | return null; 537 | } 538 | 539 | return el("p", [className("pp-error")], [error]); 540 | }), 541 | el( 542 | "p", 543 | [attr("id", "pan_speed_desc"), className("pp-description")], 544 | ["The speed of viewport pan/move action."], 545 | ), 546 | ], 547 | ); 548 | } 549 | -------------------------------------------------------------------------------- /src/ui/selectBox/selectBox.ts: -------------------------------------------------------------------------------- 1 | import { 2 | attr, 3 | className, 4 | el, 5 | on, 6 | raf, 7 | svg, 8 | type ElementFn, 9 | } from "../../dom.js"; 10 | import { compute, Signal } from "../../signal.js"; 11 | 12 | export const styles = /* css */ ` 13 | .sl-wrapper { 14 | --_caret-size: calc(var(--font-size) * 0.625); 15 | --_caret-width: calc(var(--_caret-size) * 0.8); 16 | 17 | position: relative; 18 | display: inline-flex; 19 | box-sizing: border-box; 20 | } 21 | 22 | .sl-select { 23 | appearance: none; 24 | padding: var(--action-vertical-padding) var(--action-horizontal-padding); 25 | padding-right: calc(var(--action-horizontal-padding) * 2 + var(--_caret-size)); 26 | margin: 0; 27 | border: 1px solid var(--action-border); 28 | border: none; 29 | box-sizing: border-box; 30 | font-size: calc(var(--font-size) * 0.8); 31 | width: 100%; 32 | 33 | background: transparent; 34 | border-radius: var(--action-radius); 35 | color: inherit; 36 | cursor: pointer; 37 | outline: none; 38 | } 39 | .sl-select:hover { 40 | background-color: var(--action-overlay); 41 | } 42 | .sl-select:focus { 43 | outline: none; 44 | } 45 | .sl-select:focus-visible { 46 | outline: 2px solid SelectedItem; 47 | } 48 | 49 | .sl-caret { 50 | position: absolute; 51 | right: var(--action-horizontal-padding); 52 | width: var(--_caret-size); 53 | height: var(--_caret-size); 54 | top: 0; 55 | bottom: 0; 56 | margin: auto 0; 57 | 58 | pointer-events: none; 59 | stroke: currentColor; 60 | stroke-width: var(--_caret-width); 61 | fill: none; 62 | } 63 | `; 64 | 65 | interface SelectboxProps { 66 | attrs?: readonly ElementFn[]; 67 | 68 | options: readonly HTMLOptionElement[]; 69 | 70 | wrapperAttrs?: readonly ElementFn[]; 71 | 72 | value: string | undefined | Signal; 73 | 74 | onChange?(value: string): void; 75 | } 76 | 77 | export function selectBox({ 78 | options, 79 | attrs = [], 80 | wrapperAttrs = [], 81 | value, 82 | onChange, 83 | }: SelectboxProps): HTMLElement { 84 | return el( 85 | "div", 86 | [className("sl-wrapper"), ...wrapperAttrs], 87 | [ 88 | el( 89 | "select", 90 | [ 91 | className("sl-select"), 92 | raf( 93 | compute(() => (el) => { 94 | el.value = (value instanceof Signal ? value.get() : value) || ""; 95 | }), 96 | ), 97 | on("change", (ev) => { 98 | if (!(ev.currentTarget instanceof HTMLSelectElement)) { 99 | return; 100 | } 101 | 102 | onChange?.(ev.currentTarget.value); 103 | }), 104 | ...attrs, 105 | ], 106 | options, 107 | ), 108 | svg( 109 | "svg", 110 | [ 111 | attr("viewBox", "0 0 100 100"), 112 | className("sl-caret"), 113 | attr("aria-hidden", "true"), 114 | ], 115 | [svg("path", [attr("d", "M0,25 l50,50 l50,-50")])], 116 | ), 117 | ], 118 | ); 119 | } 120 | -------------------------------------------------------------------------------- /src/ui/snackbar/snackbar.ts: -------------------------------------------------------------------------------- 1 | import { attr, className, el, type ElementFn } from "../../dom.js"; 2 | import { compute, effect, type Signal } from "../../signal.js"; 3 | 4 | export const styles = /* css */ ` 5 | .sn-container { 6 | position: absolute; 7 | top: 0; 8 | left: 0; 9 | width: 100%; 10 | height: 100%; 11 | 12 | overflow: hidden; 13 | pointer-events: none; 14 | } 15 | 16 | @keyframes slidein { 17 | 0% { 18 | opacity: 0; 19 | transform: translateY(30%); 20 | } 21 | 22 | 70% { 23 | opacity: 1; 24 | } 25 | 26 | 100% { 27 | transform: translateY(0px); 28 | } 29 | } 30 | 31 | @keyframes fadein { 32 | from { 33 | opacity: 0; 34 | } 35 | 36 | to { 37 | opacity: 1; 38 | } 39 | } 40 | 41 | .sn-wrapper { 42 | position: absolute; 43 | right: 0; 44 | bottom: 0; 45 | left: 0; 46 | padding: var(--snackbar-margin); 47 | min-width: 0; 48 | width: 100%; 49 | display: inline-flex; 50 | justify-content: center; 51 | align-items: center; 52 | } 53 | 54 | .sn-bar { 55 | margin: 0; 56 | min-width: 0; 57 | display: inline-block; 58 | padding: var(--snackbar-padding); 59 | font-family: var(--snackbar-font-family); 60 | font-size: var(--snackbar-font-size); 61 | border: var(--snackbar-border); 62 | box-sizing: border-box; 63 | 64 | background-color: var(--snackbar-bg); 65 | border-radius: var(--snackbar-radius); 66 | box-shadow: var(--snackbar-shadow); 67 | color: var(--snackbar-fg); 68 | pointer-events: all; 69 | z-index: calc(var(--z-index) + 11); 70 | 71 | animation: 0.15s ease-in 0s 1 forwards slidein; 72 | } 73 | 74 | @media (prefers-reduced-motion: reduce) { 75 | .sn-bar { 76 | animation-name: fadein; 77 | } 78 | } 79 | `; 80 | 81 | export type SnackbarContent = NonNullable[2]> | null; 82 | 83 | interface SnackbarProps { 84 | signal: Signal; 85 | 86 | lifetimeMs: number; 87 | 88 | attrs?: readonly ElementFn[]; 89 | } 90 | 91 | export function snackbar({ 92 | signal, 93 | lifetimeMs, 94 | attrs = [], 95 | }: SnackbarProps): HTMLDivElement { 96 | effect(() => { 97 | if (!signal.get()) { 98 | return; 99 | } 100 | 101 | const timerId = setTimeout(() => { 102 | signal.set(null); 103 | }, lifetimeMs); 104 | 105 | return () => { 106 | clearTimeout(timerId); 107 | }; 108 | }); 109 | 110 | return el( 111 | "div", 112 | [...attrs, className("sn-container"), attr("aria-live", "polite")], 113 | [ 114 | compute(() => { 115 | const value = signal.get(); 116 | if (!value) { 117 | return null; 118 | } 119 | 120 | return el( 121 | "div", 122 | [className("sn-wrapper")], 123 | [el("p", [className("sn-bar")], value)], 124 | ); 125 | }), 126 | ], 127 | ); 128 | } 129 | -------------------------------------------------------------------------------- /src/ui/styles.ts: -------------------------------------------------------------------------------- 1 | import { styles as empty } from "./empty/empty.js"; 2 | import { styles as fullscreenPanel } from "./fullscreenPanel/fullscreenPanel.js"; 3 | import { styles as iconButton } from "./iconButton/iconButton.js"; 4 | import { styles as infoItems } from "./infoItems/infoItems.js"; 5 | import { styles as inspectorPanel } from "./inspectorPanel/inspectorPanel.js"; 6 | import { styles as menuBar } from "./menuBar/menuBar.js"; 7 | import { styles as preferencesPanel } from "./preferencesPanel/preferencesPanel.js"; 8 | import { styles as selectBox } from "./selectBox/selectBox.js"; 9 | import { styles as snackbar } from "./snackbar/snackbar.js"; 10 | 11 | export const styles: string = 12 | inspectorPanel + 13 | selectBox + 14 | iconButton + 15 | fullscreenPanel + 16 | menuBar + 17 | infoItems + 18 | empty + 19 | preferencesPanel + 20 | snackbar; 21 | -------------------------------------------------------------------------------- /src/ui/ui.ts: -------------------------------------------------------------------------------- 1 | import { el } from "../dom.js"; 2 | import type * as figma from "../figma.js"; 3 | import { type Preferences } from "../preferences.js"; 4 | import { compute, Signal } from "../signal.js"; 5 | import { 6 | canvas, 7 | info, 8 | preferences, 9 | isIdle, 10 | isSetupError, 11 | isInfo, 12 | isPreferences, 13 | type LoadedState, 14 | type State, 15 | } from "../state.js"; 16 | 17 | import { empty } from "./empty/empty.js"; 18 | import { fullscreenPanel } from "./fullscreenPanel/fullscreenPanel.js"; 19 | import { inspectorPanel } from "./inspectorPanel/inspectorPanel.js"; 20 | import { menuBar } from "./menuBar/menuBar.js"; 21 | import { preferencesPanel } from "./preferencesPanel/preferencesPanel.js"; 22 | import { snackbar, type SnackbarContent } from "./snackbar/snackbar.js"; 23 | 24 | const SNACKBAR_LIFETIME = 3000; 25 | 26 | type ElementChild = NonNullable[2]>[number]; 27 | 28 | interface UIProps { 29 | state: Signal>; 30 | preferences: Signal>; 31 | 32 | infoContents: (data: T) => ElementChild; 33 | frameCanvas: ( 34 | data: T, 35 | selected: Signal, 36 | loadedState: Signal, 37 | ) => ElementChild; 38 | menuSlot?: (data: T) => ElementChild; 39 | 40 | caller: "frame" | "file"; 41 | } 42 | 43 | export function ui({ 44 | state: $state, 45 | preferences: $preferences, 46 | infoContents: createinfoContents, 47 | frameCanvas: createFrameCanvas, 48 | menuSlot: createMenuSlot, 49 | caller, 50 | }: UIProps): Signal { 51 | const $snackbar = new Signal(null); 52 | 53 | return compute(() => { 54 | const s = $state.get(); 55 | 56 | if (isIdle(s)) { 57 | if (caller === "file") { 58 | return empty({ 59 | title: ["No Figma file"], 60 | body: [ 61 | el( 62 | "p", 63 | [], 64 | [ 65 | "Both Figma file data and rendered images are missing. ", 66 | "Please provide those in order to start Figspec File Viewer. ", 67 | ], 68 | ), 69 | ], 70 | }); 71 | } 72 | 73 | return empty({ 74 | title: ["No Figma frame"], 75 | body: [ 76 | el( 77 | "p", 78 | [], 79 | [ 80 | "Both frame data and rendered image are missing. ", 81 | "Please provide those in order to start Figspec Frame Viewer. ", 82 | ], 83 | ), 84 | ], 85 | }); 86 | } 87 | 88 | if (isSetupError(s)) { 89 | return empty({ 90 | title: ["Failed to render Figma ", caller], 91 | body: [ 92 | el( 93 | "p", 94 | [], 95 | ["Couldn't render the Figma ", caller, " due to an error."], 96 | ), 97 | el("pre", [], [s.error.message, "\n\n", s.error.stack]), 98 | ], 99 | }); 100 | } 101 | 102 | const $loadedState = new Signal(canvas); 103 | const $selected = new Signal(null); 104 | 105 | const frameCanvas = createFrameCanvas(s.data, $selected, $loadedState); 106 | 107 | const perState = compute(() => { 108 | const loadedState = $loadedState.get(); 109 | 110 | if (isInfo(loadedState)) { 111 | return fullscreenPanel({ 112 | body: [createinfoContents(s.data)], 113 | onClose() { 114 | $loadedState.set(canvas); 115 | }, 116 | }); 117 | } 118 | 119 | if (isPreferences(loadedState)) { 120 | return fullscreenPanel({ 121 | body: [preferencesPanel({ preferences: $preferences })], 122 | onClose() { 123 | $loadedState.set(canvas); 124 | }, 125 | }); 126 | } 127 | 128 | return el( 129 | "div", 130 | [], 131 | [ 132 | menuBar({ 133 | slot: [createMenuSlot?.(s.data)], 134 | onOpenInfo() { 135 | $loadedState.set(info); 136 | }, 137 | onOpenPreferences() { 138 | $loadedState.set(preferences); 139 | }, 140 | }), 141 | inspectorPanel({ 142 | selected: $selected, 143 | preferences: $preferences, 144 | snackbar: $snackbar, 145 | onOpenPreferencesPanel() { 146 | $loadedState.set(preferences); 147 | }, 148 | }), 149 | ], 150 | ); 151 | }); 152 | 153 | const layer = el( 154 | "div", 155 | [], 156 | [ 157 | frameCanvas, 158 | perState, 159 | snackbar({ signal: $snackbar, lifetimeMs: SNACKBAR_LIFETIME }), 160 | ], 161 | ); 162 | 163 | return layer; 164 | }); 165 | } 166 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false 5 | }, 6 | "exclude": ["**/*.spec.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2019", 4 | "module": "ESNext", 5 | "declaration": true, 6 | "lib": ["ESNext", "DOM"], 7 | "strict": true, 8 | "noUnusedLocals": true, 9 | "moduleResolution": "Node", 10 | "noEmit": true, 11 | "outDir": "esm/es2019" 12 | }, 13 | "include": ["./src/**/*.ts"] 14 | } 15 | -------------------------------------------------------------------------------- /website/.gitignore: -------------------------------------------------------------------------------- 1 | # Build output 2 | /dist 3 | -------------------------------------------------------------------------------- /website/examples/demo-data/Klm6pxIZSaJFiOMX5FpTul9F/2:13.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /website/examples/demo-data/Klm6pxIZSaJFiOMX5FpTul9F/2:5.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /website/examples/demo-data/Klm6pxIZSaJFiOMX5FpTul9F/2:9.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /website/examples/demo-data/Klm6pxIZSaJFiOMX5FpTul9F/93:14.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /website/examples/demo-data/Klm6pxIZSaJFiOMX5FpTul9F/93:32.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /website/examples/events.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Events example | Figspec 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /website/examples/events.ts: -------------------------------------------------------------------------------- 1 | import { FigspecFrameViewer } from "../../src"; 2 | 3 | import * as demoFrame from "./demo-data/Klm6pxIZSaJFiOMX5FpTul9F/64:1.json"; 4 | import demoImage from "./demo-data/Klm6pxIZSaJFiOMX5FpTul9F/64:1.svg"; 5 | 6 | const el = document.getElementById("demo"); 7 | 8 | if (el && el instanceof FigspecFrameViewer) { 9 | el.apiResponse = demoFrame; 10 | el.renderedImage = demoImage; 11 | 12 | el.addEventListener("nodeselect", (ev) => { 13 | console.log(ev); 14 | }); 15 | 16 | el.addEventListener("preferencesupdate", (ev) => { 17 | console.log(ev); 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /website/examples/file.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | File viewer example | Figspec 7 | 8 | 9 | 10 | 11 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /website/examples/file.ts: -------------------------------------------------------------------------------- 1 | import { FigspecFileViewer } from "../../src"; 2 | 3 | import * as demoJson from "./demo-data/Klm6pxIZSaJFiOMX5FpTul9F/file.json"; 4 | import image2_5 from "./demo-data/Klm6pxIZSaJFiOMX5FpTul9F/2:5.svg"; 5 | import image2_9 from "./demo-data/Klm6pxIZSaJFiOMX5FpTul9F/2:9.svg"; 6 | import image2_13 from "./demo-data/Klm6pxIZSaJFiOMX5FpTul9F/2:13.svg"; 7 | import image64_1 from "./demo-data/Klm6pxIZSaJFiOMX5FpTul9F/64:1.svg"; 8 | import image93_14 from "./demo-data/Klm6pxIZSaJFiOMX5FpTul9F/93:14.svg"; 9 | import image93_32 from "./demo-data/Klm6pxIZSaJFiOMX5FpTul9F/93:32.svg"; 10 | 11 | const el = document.getElementById("demo"); 12 | 13 | if (el && el instanceof FigspecFileViewer) { 14 | el.apiResponse = demoJson; 15 | el.renderedImages = { 16 | "2:5": image2_5, 17 | "2:9": image2_9, 18 | "2:13": image2_13, 19 | "64:1": image64_1, 20 | "93:14": image93_14, 21 | "93:32": image93_32, 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /website/examples/many-nodes.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Many nodes example | Figspec 7 | 8 | 9 | 10 | 11 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /website/examples/many-nodes.ts: -------------------------------------------------------------------------------- 1 | import { FigspecFrameViewer } from "../../src"; 2 | 3 | import * as demoFrame from "./demo-data/BXLAHpnTWaZL7Xcnp3aq3g/7-29212.json"; 4 | import demoImage from "./demo-data/BXLAHpnTWaZL7Xcnp3aq3g/7-29212.svg"; 5 | 6 | const el = document.getElementById("demo"); 7 | 8 | if (el && el instanceof FigspecFrameViewer) { 9 | // @ts-ignore: TS can't handle large file 10 | el.apiResponse = demoFrame; 11 | el.renderedImage = demoImage; 12 | } 13 | -------------------------------------------------------------------------------- /website/examples/missing-image.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Image missing error example | Figspec 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /website/examples/missing-image.ts: -------------------------------------------------------------------------------- 1 | import { FigspecFrameViewer } from "../../src"; 2 | 3 | import * as demoFrame from "./demo-data/Klm6pxIZSaJFiOMX5FpTul9F/64:1.json"; 4 | 5 | const el = document.getElementById("demo"); 6 | 7 | if (el && el instanceof FigspecFrameViewer) { 8 | el.apiResponse = demoFrame; 9 | } 10 | -------------------------------------------------------------------------------- /website/examples/parameter-missing-error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Parameter missing error example | Figspec 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /website/examples/parameter-missing-error.ts: -------------------------------------------------------------------------------- 1 | import "../../src"; 2 | 3 | // This is obviously no-op but required: without these kind of side-effect-able 4 | // statement, Vite (or Rollup, or code minifier library, or something idk) 5 | // screws module deduplication or something then emits completely broken 6 | // JS for **other example pages**. 7 | // It inserts `import "/assets/.js"` to every other 8 | // JS files except this and top-level one. (e.g. `examples/file.ts`) 9 | // I haven't dug because I know from my experience Vite's MPA support is 10 | // totally incomplete afterthought marketing bs. So much bugs, pitfalls, 11 | // DX problems and design problems. Maybe their tooling stack is complicated 12 | // or fragile but the final quality of the tool is for overall experience. 13 | // As a user of the tool, I gave up. Hence, this ugly workaround. 14 | document.getElementById("demo"); 15 | -------------------------------------------------------------------------------- /website/examples/style.css: -------------------------------------------------------------------------------- 1 | figspec-frame-viewer, 2 | figspec-frame-viewer-next, 3 | figspec-file-viewer { 4 | position: absolute; 5 | top: 0; 6 | left: 0; 7 | width: 100%; 8 | height: 100%; 9 | } 10 | -------------------------------------------------------------------------------- /website/index.ts: -------------------------------------------------------------------------------- 1 | import { FigspecFrameViewer } from "../src"; 2 | 3 | import * as demoFrame from "./examples/demo-data/Klm6pxIZSaJFiOMX5FpTul9F/64:1.json"; 4 | import demoImage from "./examples/demo-data/Klm6pxIZSaJFiOMX5FpTul9F/64:1.svg"; 5 | 6 | const PREFERENCES_KEY = "figspec_preferences_v1"; 7 | 8 | const savedPreferences = localStorage.getItem(PREFERENCES_KEY); 9 | 10 | const demo = document.getElementById("frame_demo"); 11 | 12 | if (demo && demo instanceof FigspecFrameViewer) { 13 | try { 14 | if (savedPreferences) { 15 | const value = JSON.parse(savedPreferences); 16 | 17 | demo.preferences = value; 18 | } 19 | } catch (error) { 20 | console.error("Failed to restore saved preferences"); 21 | } 22 | 23 | demo.apiResponse = demoFrame; 24 | demo.renderedImage = demoImage; 25 | 26 | demo.addEventListener("preferencesupdate", (ev) => { 27 | const { preferences } = (ev as CustomEvent).detail; 28 | 29 | localStorage.setItem(PREFERENCES_KEY, JSON.stringify(preferences)); 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /website/style.css: -------------------------------------------------------------------------------- 1 | @import "@picocss/pico/css/pico.classless.min.css"; 2 | 3 | figspec-frame-viewer, 4 | figspec-file-viewer { 5 | --figspec-font-size: 0.8rem; 6 | width: 100%; 7 | /* 50vh is for shallow viewports */ 8 | min-height: min(20rem, 50vh); 9 | box-shadow: var(--card-box-shadow); 10 | 11 | border-radius: var(--border-radius); 12 | } 13 | 14 | iframe[name="example_frame"] { 15 | width: 100%; 16 | min-height: min(20rem, 50vh); 17 | 18 | box-shadow: var(--card-box-shadow); 19 | border-radius: var(--border-radius); 20 | } 21 | 22 | table { 23 | width: 100%; 24 | table-layout: fixed; 25 | } 26 | -------------------------------------------------------------------------------- /website/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["vite/client"], 5 | "resolveJsonModule": true 6 | }, 7 | "include": ["../src/**/*.ts", "./**/*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /website/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { glob } from "glob"; 2 | import { defineConfig } from "vite"; 3 | 4 | export default defineConfig(async () => { 5 | // In Vite you need to explicitly enumerate every HTML files. 6 | // Otherwise it won't be built, although it works on dev server. 7 | const htmlFiles = await glob("**/*.html", { 8 | ignore: ["dist/**"], 9 | // @ts-ignore: ESM-Node.js thing 10 | cwd: __dirname, 11 | withFileTypes: true, 12 | }); 13 | 14 | return { 15 | // Use relative paths for output files. At least this docs site does not 16 | // rely on absolute paths so there is no advantage for absolute paths, 17 | // which Vite defaults to. 18 | base: "", 19 | build: { 20 | rollupOptions: { 21 | input: Object.fromEntries( 22 | htmlFiles.map((path, i) => [i, path.fullpath()]) 23 | ), 24 | output: { 25 | // With [name] in it, Vite (or Rollup idk) uses a file name of the first HTML file 26 | // access the asset. For example, examples/style.css became dist/parameter-missing-error-[hash].css 27 | // in my testing and dist/examples/file.html imports dist/parameter-missing-error-[hash].css. 28 | // This is not directly visible to users but confusing enough for whom examined raw HTML for 29 | // markup usage. 30 | assetFileNames: "[hash:12][extname]", 31 | }, 32 | }, 33 | }, 34 | }; 35 | }); 36 | --------------------------------------------------------------------------------