diff --git a/plugins/README.md b/plugins/README.md index 399aa37943..22f4556166 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -19,7 +19,7 @@ In the `apps` folder you'll find some examples that use the libraries mentioned - example-styles: to run this example you should run ``` -npm run start:styles-example +pnpm run start:styles-example ``` Open in your browser: `http://localhost:4202/` @@ -28,8 +28,8 @@ Open in your browser: `http://localhost:4202/` This guide will help you launch a Penpot plugin from the penpot-plugins repository. Before proceeding, ensure that you have Penpot running locally by following the [setup instructions](https://help.penpot.app/technical-guide/developer/devenv/). -In the terminal, navigate to the **penpot-plugins** repository and run `npm install` to install the required dependencies. -Then, run `npm start` to launch the plugins wrapper. +In the terminal, navigate to the **penpot-plugins** repository and run `pnpm install` to install the required dependencies. +Then, run `pnpm run start` to launch the plugins wrapper. After installing the dependencies, choose a plugin to launch. You can either run one of the provided examples or create your own (see "Creating a plugin from scratch" below). To launch a plugin, Open a new terminal tab and run the appropriate startup script for the chosen plugin. @@ -38,7 +38,7 @@ For instance, to launch the Contrast plugin, use the following command: ``` // for the contrast plugin -npm run start:plugin:contrast +pnpm run start:plugin:contrast ``` Finally, open in your browser the specific port. In this specific example would be `http://localhost:4302` @@ -49,21 +49,22 @@ A table listing the available plugins and their corresponding startup commands i | Plugin | Description | PORT | Start command | Manifest URL | | ----------------------- | ----------------------------------------------------------- | ---- | ------------------------------------- | ------------------------------------------ | -| poc-state-plugin | Sandbox plugin to test new plugins api functionality | 4301 | npm run start:plugin:poc-state | http://localhost:4301/assets/manifest.json | -| contrast-plugin | Sample plugin that gives you color contrast information | 4302 | npm run start:plugin:contrast | http://localhost:4302/assets/manifest.json | -| icons-plugin | Tool to add icons from [Feather](https://feathericons.com/) | 4303 | npm run start:plugin:icons | http://localhost:4303/assets/manifest.json | -| lorem-ipsum-plugin | Generate Lorem ipsum text | 4304 | npm run start:plugin:loremipsum | http://localhost:4304/assets/manifest.json | -| create-palette-plugin | Creates a board with all the palette colors | 4305 | npm run start:plugin:palette | http://localhost:4305/assets/manifest.json | -| table-plugin | Create or import table | 4306 | npm run start:table-plugin | http://localhost:4306/assets/manifest.json | -| rename-layers-plugin | Rename layers in bulk | 4307 | npm run start:plugin:renamelayers | http://localhost:4307/assets/manifest.json | -| colors-to-tokens-plugin | Generate tokens JSON file | 4308 | npm run start:plugin:colors-to-tokens | http://localhost:4308/assets/manifest.json | +| poc-state-plugin | Sandbox plugin to test new plugins api functionality | 4301 | pnpm run start:plugin:poc-state | http://localhost:4301/assets/manifest.json | +| contrast-plugin | Sample plugin that gives you color contrast information | 4302 | pnpm run start:plugin:contrast | http://localhost:4302/assets/manifest.json | +| icons-plugin | Tool to add icons from [Feather](https://feathericons.com/) | 4303 | pnpm run start:plugin:icons | http://localhost:4303/assets/manifest.json | +| lorem-ipsum-plugin | Generate Lorem ipsum text | 4304 | pnpm run start:plugin:loremipsum | http://localhost:4304/assets/manifest.json | +| create-palette-plugin | Creates a board with all the palette colors | 4305 | pnpm run start:plugin:palette | http://localhost:4305/assets/manifest.json | +| table-plugin | Create or import table | 4306 | pnpm run start:table-plugin | http://localhost:4306/assets/manifest.json | +| rename-layers-plugin | Rename layers in bulk | 4307 | pnpm run start:plugin:renamelayers | http://localhost:4307/assets/manifest.json | +| colors-to-tokens-plugin | Generate tokens JSON file | 4308 | pnpm run start:plugin:colors-to-tokens | http://localhost:4308/assets/manifest.json | +| poc-tokens-plugin | Sandbox plugin to test tokens functionality | 4309 | pnpm run start:plugin:poc-tokens | http://localhost:4309/assets/manifest.json | ## Web Apps | App | Description | PORT | Start command | URL | | --------------- | ----------------------------------------------------------------- | ---- | -------------------------------- | ---------------------- | -| plugins-runtime | Runtime for the plugins subsystem | 4200 | npm run start:app:runtime | | -| example-styles | Showcase of some of the Penpot styles that can be used in plugins | 4201 | npm run start:app:styles-example | http://localhost:4201/ | +| plugins-runtime | Runtime for the plugins subsystem | 4200 | pnpm run start:app:runtime | | +| example-styles | Showcase of some of the Penpot styles that can be used in plugins | 4201 | pnpm run start:app:styles-example | http://localhost:4201/ | ## Creating a plugin from scratch diff --git a/plugins/apps/poc-tokens-plugin/eslint.config.js b/plugins/apps/poc-tokens-plugin/eslint.config.js new file mode 100644 index 0000000000..7aa90c2ab0 --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/eslint.config.js @@ -0,0 +1,51 @@ +import baseConfig from '../../eslint.config.js'; +import { compat } from '../../eslint.base.config.js'; + +export default [ + ...baseConfig, + ...compat + .config({ + extends: [ + 'plugin:@nx/angular', + 'plugin:@angular-eslint/template/process-inline-templates', + ], + }) + .map((config) => ({ + ...config, + files: ['**/*.ts'], + rules: { + '@angular-eslint/directive-selector': [ + 'error', + { + type: 'attribute', + prefix: 'app', + style: 'camelCase', + }, + ], + '@angular-eslint/component-selector': [ + 'error', + { + type: 'element', + prefix: 'app', + style: 'kebab-case', + }, + ], + }, + })), + ...compat + .config({ extends: ['plugin:@nx/angular-template'] }) + .map((config) => ({ + ...config, + files: ['**/*.html'], + rules: {}, + })), + { ignores: ['**/assets/*.js'] }, + { + languageOptions: { + parserOptions: { + project: './tsconfig.*?.json', + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +]; diff --git a/plugins/apps/poc-tokens-plugin/project.json b/plugins/apps/poc-tokens-plugin/project.json new file mode 100644 index 0000000000..c2bc7cad81 --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/project.json @@ -0,0 +1,79 @@ +{ + "name": "poc-tokens-plugin", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "prefix": "app", + "sourceRoot": "apps/poc-tokens-plugin/src", + "tags": ["type:plugin"], + "targets": { + "build": { + "executor": "@angular-devkit/build-angular:application", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/apps/poc-tokens-plugin", + "index": "apps/poc-tokens-plugin/src/index.html", + "browser": "apps/poc-tokens-plugin/src/main.ts", + "polyfills": ["zone.js"], + "tsConfig": "apps/poc-tokens-plugin/tsconfig.app.json", + "assets": [ + "apps/poc-tokens-plugin/src/favicon.ico", + "apps/poc-tokens-plugin/src/assets" + ], + "styles": [ + "libs/plugins-styles/src/lib/styles.css", + "apps/poc-tokens-plugin/src/styles.css" + ], + "scripts": [], + "optimization": { + "scripts": true, + "styles": true, + "fonts": false + } + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kb", + "maximumError": "1mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "2kb", + "maximumError": "4kb" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production", + "dependsOn": ["buildPlugin"] + }, + "serve": { + "executor": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { + "buildTarget": "poc-tokens-plugin:build:production" + }, + "development": { + "buildTarget": "poc-tokens-plugin:build:development", + "port": 4309, + "host": "0.0.0.0" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "executor": "@angular-devkit/build-angular:extract-i18n", + "options": { + "buildTarget": "poc-tokens-plugin:build" + } + } + } +} diff --git a/plugins/apps/poc-tokens-plugin/src/app/app.component.css b/plugins/apps/poc-tokens-plugin/src/app/app.component.css new file mode 100644 index 0000000000..9a64100273 --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/src/app/app.component.css @@ -0,0 +1,127 @@ +/* @import "@penpot/plugin-styles/styles.css"; */ + +.container { + display: flex; + flex-direction: column; + height: 100%; +} + +.title-l { + margin: var(--spacing-16) 0; +} + +.columns { + display: grid; + grid-template-columns: 50% 50%; + flex-grow: 1; + margin-block-end: var(--spacing-16); +} + +.panels { + display: flex; + flex-direction: column; + flex-grow: 1; + padding: 0 var(--spacing-8); +} + +.panel { + padding: var(--spacing-8); + display: flex; + flex-basis: 0; + flex-grow: 1; + flex-direction: column; + overflow: auto; +} + +.panel:not(:first-child) { + border-block-start: 1px solid var(--df-secondary); + padding-block-start: var(--spacing-16); +} + +.panel-heading, +.token-group { + display: flex; + flex-direction: row; + padding-inline-end: var(--spacing-8); +} + +.panel-heading p, +.token-group span { + flex-grow: 1; +} + +.panel-heading button, +.token-group button { + background: none; + padding: var(--spacing-4) calc(var(--spacing-12) / 2); +} + +.panel-heading button:focus, +.token-group button:focus { + padding: calc(var(--spacing-4) - 2px) calc(var(--spacing-12) / 2 - 2px); +} + +.panel-item button { + opacity: 0; + margin-inline-end: var(--spacing-8); + padding: var(--spacing-4) calc(var(--spacing-12) / 2); +} + +.panel-item button:hover { + opacity: 1; +} + +.panel-item button:focus { + opacity: 1; + padding: calc(var(--spacing-4) - 2px) calc(var(--spacing-12) / 2 - 2px); +} + +.panel ul { + /* flex-grow: 1; */ + overflow-y: auto; + padding-inline-end: var(--spacing-8); +} + +.panel-item { + display: flex; + flex-direction: row; +} + +.panel-item span { + flex-grow: 1; +} + +.set-item { + cursor: pointer; +} + +.set-item.selected { + background-color: var(--db-quaternary); +} + +.set-item:hover { + color: var(--da-primary); + background-color: var(--db-secondary); +} + +.token-group:not(:first-child) { + margin-top: var(--spacing-8); +} + +.token-group { + border-block-end: 1px solid var(--df-secondary); + text-transform: capitalize; +} + +.token-item { + cursor: pointer; +} + +.token-item:hover { + color: var(--da-primary); +} + +.buttons { + display: flex; + flex-direction: row-reverse; +} diff --git a/plugins/apps/poc-tokens-plugin/src/app/app.component.html b/plugins/apps/poc-tokens-plugin/src/app/app.component.html new file mode 100644 index 0000000000..5be709cb57 --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/src/app/app.component.html @@ -0,0 +1,144 @@ +
+

Design tokens plugin POC

+ +
+
+
+
+

THEMES

+ +
+ +
    + @for (theme of themes; track theme.id) { +
  • + {{ theme.group }} / {{ theme.name }} + + +
    + +
    +
  • + } +
+
+ +
+
+

SETS

+ +
+ +
    + @for (set of sets; track set.id) { +
  • + + {{ set.name }} + + + +
    + +
    +
  • + } +
+
+
+
+
+

TOKENS

+ +
    + @for (group of tokenGroups; track group[0]) { +
  • + {{ group[0] }} + +
  • + @for (token of group[1]; track token.id) { +
  • + {{ token.name }} + + +
  • + } + } +
+
+
+
+ +
+ +
+
diff --git a/plugins/apps/poc-tokens-plugin/src/app/app.component.ts b/plugins/apps/poc-tokens-plugin/src/app/app.component.ts new file mode 100644 index 0000000000..63995292f6 --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/src/app/app.component.ts @@ -0,0 +1,290 @@ +import { Component, inject } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute } from '@angular/router'; +import { fromEvent, map, filter, take, merge } from 'rxjs'; +import { PluginMessageEvent, PluginUIEvent } from '../model'; + +type TokenTheme = { + id: string; + name: string; + group: string; + description: string; + active: boolean; +}; + +type TokenSet = { + id: string; + name: string; + description: string; + active: boolean; +}; + +type Token = { + id: string; + name: string; + description: string; +}; + +type TokensGroup = [string, Token[]]; + +@Component({ + selector: 'app-root', + templateUrl: './app.component.html', + styleUrl: './app.component.css', + host: { + '[attr.data-theme]': 'theme()', + }, +}) +export class AppComponent { + public route = inject(ActivatedRoute); + + public messages$ = fromEvent>( + window, + 'message', + ); + + public initialTheme$ = this.route.queryParamMap.pipe( + map((params) => params.get('theme')), + filter((theme) => !!theme), + take(1), + ); + + public theme = toSignal( + merge( + this.initialTheme$, + this.messages$.pipe( + filter((event) => event.data.type === 'theme'), + map((event) => { + return event.data.content; + }), + ), + ), + ); + + public themes: TokenTheme[] = []; + public sets: TokenSet[] = []; + public tokenGroups: TokensGroup[] = []; + public currentSetId: string | undefined = undefined; + + constructor() { + window.addEventListener('message', (event) => { + if (event.data.type === 'set-themes') { + this.#setThemes(event.data.themesData); + } else if (event.data.type === 'set-sets') { + this.#setSets(event.data.setsData); + } else if (event.data.type === 'set-tokens') { + this.#setTokens(event.data.tokenGroupsData); + } + }); + } + + loadLibrary() { + this.#sendMessage({ type: 'load-library' }); + } + + loadTokens(setId: string) { + this.currentSetId = setId; + this.#sendMessage({ type: 'load-tokens', setId }); + } + + addTheme() { + this.#sendMessage({ + type: 'add-theme', + themeGroup: this.#randomString(), + themeName: this.#randomString(), + }); + } + + addSet() { + this.#sendMessage({ type: 'add-set', setName: this.#randomString() }); + } + + addToken(tokenType: string) { + let tokenValue; + switch (tokenType) { + case 'borderRadius': + tokenValue = 25; + break; + case 'shadow': + tokenValue = [ + { + color: '#123456', + inset: 'false', + offsetX: '6', + offsetY: '6', + spread: '0', + blur: '4', + }, + ]; + break; + case 'color': + tokenValue = '#fabada'; + break; + case 'dimension': + tokenValue = 100; + break; + case 'fontFamilies': + tokenValue = ['Source Sans Pro', 'Sans serif']; + break; + case 'fontSizes': + tokenValue = 24; + break; + case 'fontWeights': + tokenValue = 'bold'; + break; + case 'letterSpacing': + tokenValue = 0.5; + break; + case 'number': + tokenValue = 33; + break; + case 'opacity': + tokenValue = 0.6; + break; + case 'rotation': + tokenValue = 45; + break; + case 'sizing': + tokenValue = 200; + break; + case 'spacing': + tokenValue = 16; + break; + case 'borderWidth': + tokenValue = 3; + break; + case 'textCase': + tokenValue = 'lowercase'; + break; + case 'textDecoration': + tokenValue = 'underline'; + break; + case 'typography': + tokenValue = { + fontFamilies: ['Acme', 'Arial', 'Sans Serif'], + fontSizes: '36', + letterSpacing: '0.8', + textCase: 'uppercase', + textDecoration: 'none', + fontWeights: '600', + lineHeight: '1.5', + }; + break; + } + + if (this.currentSetId && tokenValue) { + this.#sendMessage({ + type: 'add-token', + setId: this.currentSetId, + tokenType, + tokenName: this.#randomString(), + tokenValue, + }); + } else { + console.log('Invalid token type'); + } + } + + renameTheme(themeId: string, themeName: string) { + const newName = prompt('Rename theme', themeName); + if (newName && newName !== '') { + this.#sendMessage({ type: 'rename-theme', themeId, newName }); + } + } + + renameSet(setId: string, setName: string) { + const newName = prompt('Rename set', setName); + if (newName && newName !== '') { + this.#sendMessage({ type: 'rename-set', setId, newName }); + } + } + + renameToken(tokenId: string, tokenName: string) { + const newName = prompt('Rename token', tokenName); + if (this.currentSetId && newName && newName !== '') { + this.#sendMessage({ + type: 'rename-token', + setId: this.currentSetId, + tokenId, + newName, + }); + } + } + + deleteTheme(themeId: string) { + this.#sendMessage({ type: 'delete-theme', themeId }); + } + + deleteSet(setId: string) { + this.#sendMessage({ type: 'delete-set', setId }); + } + + deleteToken(tokenId: string) { + if (this.currentSetId) { + this.#sendMessage({ + type: 'delete-token', + setId: this.currentSetId, + tokenId, + }); + } + } + + isThemeActive(themeId: string) { + for (const theme of this.themes) { + if (theme.id === themeId) { + return theme.active; + } + } + return false; + } + + toggleTheme(themeId: string) { + this.#sendMessage({ type: 'toggle-theme', themeId }); + } + + isSetActive(setId: string) { + for (const set of this.sets) { + if (set.id === setId) { + return set.active; + } + } + return false; + } + + toggleSet(setId: string) { + this.#sendMessage({ type: 'toggle-set', setId }); + } + + applyToken(tokenId: string) { + if (this.currentSetId) { + this.#sendMessage({ + type: 'apply-token', + setId: this.currentSetId, + tokenId, + // attributes: ['stroke-color'] // Uncomment to choose attribute to apply + }); // (incompatible attributes will have no effect) + } + } + + #sendMessage(message: PluginUIEvent) { + parent.postMessage(message, '*'); + } + + #setThemes(themes: TokenTheme[]) { + this.themes = themes; + } + + #setSets(sets: TokenSet[]) { + this.sets = sets; + } + + #setTokens(tokenGroups: TokensGroup[]) { + this.tokenGroups = tokenGroups; + } + + #randomString() { + // Generate a big random number and convert it to string using base 36 + // (the number of letters in the ascii alphabet) + return Math.floor(Math.random() * Date.now()).toString(36); + } +} diff --git a/plugins/apps/poc-tokens-plugin/src/app/app.config.ts b/plugins/apps/poc-tokens-plugin/src/app/app.config.ts new file mode 100644 index 0000000000..fb93f472fd --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/src/app/app.config.ts @@ -0,0 +1,11 @@ +import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; +import { provideRouter } from '@angular/router'; + +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideZoneChangeDetection({ eventCoalescing: true }), + provideRouter(routes), + ], +}; diff --git a/plugins/apps/poc-tokens-plugin/src/app/app.routes.ts b/plugins/apps/poc-tokens-plugin/src/app/app.routes.ts new file mode 100644 index 0000000000..dc39edb5f2 --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/src/app/app.routes.ts @@ -0,0 +1,3 @@ +import { Routes } from '@angular/router'; + +export const routes: Routes = []; diff --git a/plugins/apps/poc-tokens-plugin/src/assets/CORS b/plugins/apps/poc-tokens-plugin/src/assets/CORS new file mode 100644 index 0000000000..72e8ffc0db --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/src/assets/CORS @@ -0,0 +1 @@ +* diff --git a/plugins/apps/poc-tokens-plugin/src/assets/_headers b/plugins/apps/poc-tokens-plugin/src/assets/_headers new file mode 100644 index 0000000000..c776a4792b --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/src/assets/_headers @@ -0,0 +1,2 @@ +/* + Access-Control-Allow-Origin: * diff --git a/plugins/apps/poc-tokens-plugin/src/assets/favicon.ico b/plugins/apps/poc-tokens-plugin/src/assets/favicon.ico new file mode 100644 index 0000000000..fc5e208af4 Binary files /dev/null and b/plugins/apps/poc-tokens-plugin/src/assets/favicon.ico differ diff --git a/plugins/apps/poc-tokens-plugin/src/assets/icon.png b/plugins/apps/poc-tokens-plugin/src/assets/icon.png new file mode 100644 index 0000000000..cf045fb5e6 Binary files /dev/null and b/plugins/apps/poc-tokens-plugin/src/assets/icon.png differ diff --git a/plugins/apps/poc-tokens-plugin/src/assets/manifest.json b/plugins/apps/poc-tokens-plugin/src/assets/manifest.json new file mode 100644 index 0000000000..540167f2f9 --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/src/assets/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "Design tokens plugin POC", + "description": "This is a plugin to try Design Tokens in Penpot API", + "code": "/assets/plugin.js", + "permissions": [ + "page:read", + "content:read", + "file:read", + "selection:read", + "content:write", + "library:read", + "library:write" + ] +} diff --git a/plugins/apps/poc-tokens-plugin/src/index.html b/plugins/apps/poc-tokens-plugin/src/index.html new file mode 100644 index 0000000000..c285210e33 --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/src/index.html @@ -0,0 +1,13 @@ + + + + + Angular example plugin + + + + + + + + diff --git a/plugins/apps/poc-tokens-plugin/src/main.ts b/plugins/apps/poc-tokens-plugin/src/main.ts new file mode 100644 index 0000000000..8882c4517f --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/src/main.ts @@ -0,0 +1,7 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { AppComponent } from './app/app.component'; + +bootstrapApplication(AppComponent, appConfig).catch((err) => + console.error(err), +); diff --git a/plugins/apps/poc-tokens-plugin/src/model.ts b/plugins/apps/poc-tokens-plugin/src/model.ts new file mode 100644 index 0000000000..8f76935892 --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/src/model.ts @@ -0,0 +1,112 @@ +import { TokenProperty } from '@penpot/plugin-types'; + +/** + * This file contains the typescript interfaces for the plugin events. + */ + +// Events sent from the ui to the plugin + +export interface LoadLibraryEvent { + type: 'load-library'; +} + +export interface LoadTokensEvent { + type: 'load-tokens'; + setId: string; +} + +export interface AddThemeEvent { + type: 'add-theme'; + themeGroup: string; + themeName: string; +} + +export interface AddSetEvent { + type: 'add-set'; + setName: string; +} + +export interface AddTokenEvent { + type: 'add-token'; + setId: string; + tokenType: string; + tokenName: string; + tokenValue: unknown; +} + +export interface RenameThemeEvent { + type: 'rename-theme'; + themeId: string; + newName: string; +} + +export interface RenameSetEvent { + type: 'rename-set'; + setId: string; + newName: string; +} + +export interface RenameTokenEvent { + type: 'rename-token'; + setId: string; + tokenId: string; + newName: string; +} + +export interface DeleteThemeEvent { + type: 'delete-theme'; + themeId: string; +} + +export interface DeleteSetEvent { + type: 'delete-set'; + setId: string; +} + +export interface DeleteTokenEvent { + type: 'delete-token'; + setId: string; + tokenId: string; +} + +export interface ToggleThemeEvent { + type: 'toggle-theme'; + themeId: string; +} + +export interface ToggleSetEvent { + type: 'toggle-set'; + setId: string; +} + +export interface ApplyTokenEvent { + type: 'apply-token'; + setId: string; + tokenId: string; + attributes?: TokenProperty[]; +} + +export type PluginUIEvent = + | LoadLibraryEvent + | LoadTokensEvent + | AddThemeEvent + | AddSetEvent + | AddTokenEvent + | RenameThemeEvent + | RenameSetEvent + | RenameTokenEvent + | DeleteThemeEvent + | DeleteSetEvent + | DeleteTokenEvent + | ToggleThemeEvent + | ToggleSetEvent + | ApplyTokenEvent; + +// Events sent from the plugin to the ui + +export interface ThemePluginEvent { + type: 'theme'; + content: string; +} + +export type PluginMessageEvent = ThemePluginEvent; diff --git a/plugins/apps/poc-tokens-plugin/src/plugin.ts b/plugins/apps/poc-tokens-plugin/src/plugin.ts new file mode 100644 index 0000000000..cccf6f678e --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/src/plugin.ts @@ -0,0 +1,246 @@ +import type { PluginMessageEvent, PluginUIEvent } from './model.js'; +import { TokenType, TokenProperty } from '@penpot/plugin-types'; + +penpot.ui.open('Design Tokens test', `?theme=${penpot.theme}`, { + width: 1000, + height: 800, +}); + +penpot.on('themechange', (theme) => { + sendMessage({ type: 'theme', content: theme }); +}); + +penpot.ui.onMessage(async (message) => { + if (message.type === 'load-library') { + loadLibrary(); + } else if (message.type === 'load-tokens') { + loadTokens(message.setId); + } else if (message.type === 'add-theme') { + addTheme(message.themeGroup, message.themeName); + } else if (message.type === 'add-set') { + addSet(message.setName); + } else if (message.type === 'add-token') { + addToken( + message.setId, + message.tokenType, + message.tokenName, + message.tokenValue, + ); + } else if (message.type === 'rename-theme') { + renameTheme(message.themeId, message.newName); + } else if (message.type === 'rename-set') { + renameSet(message.setId, message.newName); + } else if (message.type === 'rename-token') { + renameToken(message.setId, message.tokenId, message.newName); + } else if (message.type === 'delete-theme') { + deleteTheme(message.themeId); + } else if (message.type === 'delete-set') { + deleteSet(message.setId); + } else if (message.type === 'delete-token') { + deleteToken(message.setId, message.tokenId); + } else if (message.type === 'toggle-theme') { + toggleTheme(message.themeId); + } else if (message.type === 'toggle-set') { + toggleSet(message.setId); + } else if (message.type === 'apply-token') { + applyToken(message.setId, message.tokenId, message.attributes); + } +}); + +function sendMessage(message: PluginMessageEvent) { + penpot.ui.sendMessage(message); +} + +function loadLibrary() { + const tokensCatalog = penpot.library.local.tokens; + + const themes = tokensCatalog.themes; + + const themesData = themes.map((theme) => { + return { + id: theme.id, + group: theme.group, + name: theme.name, + active: theme.active, + }; + }); + + penpot.ui.sendMessage({ + source: 'penpot', + type: 'set-themes', + themesData, + }); + + const sets = tokensCatalog.sets; + + const setsData = sets.map((set) => { + return { + id: set.id, + name: set.name, + active: set.active, + }; + }); + + penpot.ui.sendMessage({ + source: 'penpot', + type: 'set-sets', + setsData, + }); +} + +function loadTokens(setId: string) { + const tokensCatalog = penpot.library.local.tokens; + const set = tokensCatalog?.getSetById(setId); + const tokensByType = set?.tokensByType; + + const tokenGroupsData = []; + if (tokensByType) { + for (const group of tokensByType) { + const type = group[0]; + const tokens = group[1]; + tokenGroupsData.push([ + type, + tokens.map((token) => { + return { + id: token.id, + name: token.name, + description: token.description, + }; + }), + ]); + } + + penpot.ui.sendMessage({ + source: 'penpot', + type: 'set-tokens', + tokenGroupsData, + }); + } +} + +function addTheme(themeGroup: string, themeName: string) { + const tokensCatalog = penpot.library.local.tokens; + const theme = tokensCatalog?.addTheme(themeGroup, themeName); + if (theme) { + loadLibrary(); + } +} + +function addSet(setName: string) { + const tokensCatalog = penpot.library.local.tokens; + const set = tokensCatalog?.addSet(setName); + if (set) { + loadLibrary(); + } +} + +function addToken( + setId: string, + tokenType: string, + tokenName: string, + tokenValue: unknown, +) { + const tokensCatalog = penpot.library.local.tokens; + const set = tokensCatalog?.getSetById(setId); + const token = set?.addToken(tokenType as TokenType, tokenName, tokenValue); + if (token) { + loadTokens(setId); + } +} + +function renameTheme(themeId: string, newName: string) { + const tokensCatalog = penpot.library.local.tokens; + const theme = tokensCatalog?.getThemeById(themeId); + if (theme) { + theme.name = newName; + loadLibrary(); + } +} + +function renameSet(setId: string, newName: string) { + const tokensCatalog = penpot.library.local.tokens; + const set = tokensCatalog?.getSetById(setId); + if (set) { + set.name = newName; + loadLibrary(); + } +} + +function renameToken(setId: string, tokenId: string, newName: string) { + const tokensCatalog = penpot.library.local.tokens; + const set = tokensCatalog?.getSetById(setId); + const token = set?.getTokenById(tokenId); + if (token) { + token.name = newName; + loadTokens(setId); + } +} + +function deleteTheme(themeId: string) { + const tokensCatalog = penpot.library.local.tokens; + const theme = tokensCatalog?.getThemeById(themeId); + if (theme) { + theme.remove(); + loadLibrary(); + } +} + +function deleteSet(setId: string) { + const tokensCatalog = penpot.library.local.tokens; + const set = tokensCatalog?.getSetById(setId); + if (set) { + set.remove(); + loadLibrary(); + } +} + +function deleteToken(setId: string, tokenId: string) { + const tokensCatalog = penpot.library.local.tokens; + const set = tokensCatalog?.getSetById(setId); + const token = set?.getTokenById(tokenId); + if (token) { + token.remove(); + loadTokens(setId); + } +} + +function toggleTheme(themeId: string) { + const tokensCatalog = penpot.library.local.tokens; + const theme = tokensCatalog?.getThemeById(themeId); + if (theme) { + theme.toggleActive(); + loadLibrary(); + } +} + +function toggleSet(setId: string) { + const tokensCatalog = penpot.library.local.tokens; + const set = tokensCatalog?.getSetById(setId); + if (set) { + set.toggleActive(); + loadLibrary(); + } +} + +function applyToken( + setId: string, + tokenId: string, + attributes: TokenProperty[] | undefined, +) { + const tokensCatalog = penpot.library.local.tokens; + const set = tokensCatalog?.getSetById(setId); + const token = set?.getTokenById(tokenId); + + if (token) { + token.applyToSelected(attributes); + } + + // Alternatve way + // + // const selection = penpot.selection; + // if (token && selection) { + // for (const shape of selection) { + // shape.applyToken(token, attributes); + // } + // } +} diff --git a/plugins/apps/poc-tokens-plugin/src/styles.css b/plugins/apps/poc-tokens-plugin/src/styles.css new file mode 100644 index 0000000000..007341e2f7 --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/src/styles.css @@ -0,0 +1,23 @@ +/* @import "@penpot/plugin-styles/styles.css"; */ + +html { + height: 100%; +} + +body { + height: 100%; + line-height: 1.5; + padding: 10px; +} + +ul { + margin-block-start: var(--spacing-12); +} + +.title-l { + text-align: center; +} + +.headline-l { + margin-block-start: var(--spacing-8); +} diff --git a/plugins/apps/poc-tokens-plugin/tsconfig.app.json b/plugins/apps/poc-tokens-plugin/tsconfig.app.json new file mode 100644 index 0000000000..936913d9af --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/tsconfig.app.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [] + }, + "files": ["src/main.ts"], + "include": ["src/**/*.d.ts"], + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"] +} diff --git a/plugins/apps/poc-tokens-plugin/tsconfig.editor.json b/plugins/apps/poc-tokens-plugin/tsconfig.editor.json new file mode 100644 index 0000000000..b927bb69fc --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/tsconfig.editor.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts"], + "compilerOptions": { + "types": ["node"] + } +} diff --git a/plugins/apps/poc-tokens-plugin/tsconfig.json b/plugins/apps/poc-tokens-plugin/tsconfig.json new file mode 100644 index 0000000000..4c48587cfe --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.editor.json" + }, + { + "path": "./tsconfig.plugin.json" + } + ], + "extends": "../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/plugins/apps/poc-tokens-plugin/tsconfig.plugin.json b/plugins/apps/poc-tokens-plugin/tsconfig.plugin.json new file mode 100644 index 0000000000..961987f7a1 --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/tsconfig.plugin.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": [] + }, + "files": ["src/plugin.ts"], + "include": ["../../libs/plugin-types/index.d.ts"] +} diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index ff56cb2490..57b29ff591 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -2,10 +2,8 @@ * These are methods and properties available on the `penpot` global object. * */ -export interface Penpot extends Omit< - Context, - 'addListener' | 'removeListener' -> { +export interface Penpot + extends Omit { ui: { /** * Opens the plugin UI. It is possible to develop a plugin without interface (see Palette color example) but if you need, the way to open this UI is using `penpot.ui.open`. @@ -2533,6 +2531,13 @@ export interface Library extends PluginData { */ readonly components: LibraryComponent[]; + /** + * A catalog of Design Tokens in the library. + * + * See `TokenCatalog` type to see usage. + */ + readonly tokens: TokenCatalog; + /** * Creates a new color element in the library. * @return Returns a new `LibraryColor` object representing the created color element. @@ -2800,9 +2805,9 @@ export interface LibraryTypography extends LibraryElement { fontId: string; /** - * The font family of the typography element. + * The font families of the typography element. */ - fontFamily: string; + fontFamilies: string; /** * The unique identifier of the font variant used in the typography element. @@ -3728,6 +3733,17 @@ export interface ShapeBase extends PluginData { */ setParentIndex(index: number): void; + /** + * The design tokens applied to this shape. + * It's a map property name -> token name. + * + * NOTE that the tokens application is by name and not by id. If there exist + * several tokens with the same name in different sets, the actual token applied + * and the value set to the attributes will depend on which sets are active + * (and will change if different sets or themes are activated later). + */ + readonly tokens: { [property: string]: string }; + /** * @return Returns true if the current shape is inside a component instance */ @@ -3892,6 +3908,19 @@ export interface ShapeBase extends PluginData { */ removeInteraction(interaction: Interaction): void; + /** + * Applies one design token to one or more properties of the shape. + * @param token is the Token to apply + * @param properties an optional list of property names. If omitted, the + * default properties will be applied. + * + * NOTE that the tokens application is by name and not by id. If there exist + * several tokens with the same name in different sets, the actual token applied + * and the value set to the attributes will depend on which sets are active + * (and will change if different sets or themes are activated later). + */ + applyToken(token: Token, properties: TokenProperty[] | undefined): void; + /** * Creates a clone of the shape. * @return Returns a new instance of the shape with identical properties. @@ -4279,6 +4308,1033 @@ export type TrackType = 'flex' | 'fixed' | 'percent' | 'auto'; */ export type Trigger = 'click' | 'mouse-enter' | 'mouse-leave' | 'after-delay'; +/** + * Represents the base properties and methods of a Design Token in Penpot, shared by + * all token types. + */ +export interface TokenBase { + /** + * The unique identifier for this token, used only internally inside Penpot. + * This one is not exported or synced with external Design Token sources. + */ + readonly id: string; + + /** + * The name of the token. It may include a group path separated by `.`. + */ + name: string; + + /** + * An optional description text. + */ + description: string; + + /** + * Adds to the set that contains this Token a new one equal to this one + * but with a new id. + */ + duplicate(): Token; + + /** + * Removes this token from the catalog. + * + * It will NOT be unapplied from any shape, since there may be other tokens + * with the same name. + */ + remove(): void; + + /** + * Applies this token to one or more properties of the given shapes. + * @param shapes is an array of shapes to apply it. + * @param properties an optional list of property names. If omitted, the + * default properties will be applied. + * + * NOTE that the tokens application is by name and not by id. If there exist + * several tokens with the same name in different sets, the actual token applied + * and the value set to the attributes will depend on which sets are active + * (and will change if different sets or themes are activated later). + */ + applyToShapes(shapes: Shape[], properties: TokenProperty[] | undefined): void; + + /** + * Applies this token to the currently selected shapes. + * + * Parameters and warnings are the same as above. + */ + applyToSelected(properties: TokenProperty[] | undefined): void; +} + +/** + * Represents a token of type BorderRadius. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenBorderRadius extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'borderRadius'; + + /** + * The value as defined in the token itself. + * It's a positive number or a reference. + */ + value: string; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's a positive number, or undefined if no value has been found in active sets. + */ + readonly resolvedValue: number | undefined; +} + +/* + * The value of a TokenShadow in its composite form. + */ +export interface TokenShadowValue { + /** + * The color as a string (e.g. "#FF5733"). + */ + color: string; + + /** + * If the shadow is inset or drop. + */ + inset: boolean; + + /** + * The horizontal offset of the shadow in pixels. + */ + offsetX: number; + + /** + * The vertical offset of the shadow in pixels. + */ + offsetY: number; + + /** + * The spread distance of the shadow in pixels. + */ + spread: number; + + /** + * The amount of blur to apply to the shadow. + */ + blur: number; +} + +/* + * The value of a TokenShadow in its composite of strings form. + */ +export interface TokenShadowValueString { + /** + * The color as a string (e.g. "#FF5733"), or a reference + * to a color token. + */ + color: string; + + /** + * If the shadow is inset or drop, or a reference of a + * boolean token. + */ + inset: string; + + /** + * The horizontal offset of the shadow in pixels, or a reference + * to a number token. + */ + offsetX: string; + + /** + * The vertical offset of the shadow in pixels, or a reference + * to a number token. + */ + offsetY: string; + + /** + * The spread distance of the shadow in pixels, or a reference + * to a number token. + */ + spread: string; + + /** + * The amount of blur to apply to the shadow, or a reference + * to a number token. + */ + blur: string; +} + +/** + * Represents a token of type Shadow. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenShadow extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'shadow'; + + /** + * The value as defined in the token itself. + * It may be a string with a reference to other token, or else + * an array of TokenShadowValueString. + */ + value: string | TokenShadowValueString[]; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's an array of TokenShadowValue, or undefined if no value has been found + * in active sets. + */ + readonly resolvedValue: TokenShadowValue[] | undefined; +} + +/** + * Represents a token of type Color. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenColor extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'color'; + + /** + * The value as defined in the token itself. + * It's a rgb color or a reference. + */ + value: string; + + /** + * The value as defined in the token itself. + * It's a rgb color or a reference. + */ + readonly resolvedValue: string | undefined; +} + +/** + * Represents a token of type Dimension. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenDimension extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'dimension'; + + /** + * The value as defined in the token itself. + * It's a positive number or a reference. + */ + value: string; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's a positive number, or undefined if no value has been found in active sets. + */ + readonly resolvedValue: number | undefined; +} + +/** + * Represents a token of type FontFamilies. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenFontFamilies extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'fontFamilies'; + + /** + * The value as defined in the token itself. + * It may be a string with a reference to other token, or else + * an array of strings with one or more font families (each family + * is an item in the array). + */ + value: string | string[]; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's an array of strings with one or more font families, + * or undefined if no value has been found in active sets. + */ + readonly resolvedValue: string[] | undefined; +} + +/** + * Represents a token of type FontSizes. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenFontSizes extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'fontSizes'; + + /** + * The value as defined in the token itself. + * It's a positive number or a reference. + */ + value: string; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's a positive number, or undefined if no value has been found in active sets. + */ + readonly resolvedValue: number | undefined; +} + +/** + * Represents a token of type FontWeights. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenFontWeights extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'fontWeights'; + + /** + * The value as defined in the token itself. + * It's a weight string or a reference. + */ + value: string; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's a weight string ("bold", "strong", etc.), or undefined if no value has + * been found in active sets. + */ + readonly resolvedValue: string | undefined; +} + +/** + * Represents a token of type LetterSpacing. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenLetterSpacing extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'letterSpacing'; + + /** + * The value as defined in the token itself. + * It's a number or a reference. + */ + value: string; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's a number, or undefined if no value has been found in active sets. + */ + readonly resolvedValue: number | undefined; +} + +/** + * Represents a token of type Number. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenNumber extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'number'; + + /** + * The value as defined in the token itself. + * It's a number or a reference. + */ + value: string; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's a number, or undefined if no value has been found in active sets. + */ + readonly resolvedValue: number | undefined; +} + +/** + * Represents a token of type Opacity. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenOpacity extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'opacity'; + + /** + * The value as defined in the token itself. + * It's a number between 0 and 1 or a reference. + */ + value: string; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's a number between 0 and 1, or undefined if no value has been found + * in active sets. + */ + readonly resolvedValue: number | undefined; +} + +/** + * Represents a token of type Rotation. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenRotation extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'rotation'; + + /** + * The value as defined in the token itself. + * It's a number in degrees or a reference. + */ + value: string; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's a number in degrees, or undefined if no value has been found + * in active sets. + */ + readonly resolvedValue: number | undefined; +} + +/** + * Represents a token of type Sizing. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenSizing extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'sizing'; + + /** + * The value as defined in the token itself. + * It's a number or a reference. + */ + value: string; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's a number, or undefined if no value has been found in active sets. + */ + readonly resolvedValue: number | undefined; +} + +/** + * Represents a token of type Spacing. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenSpacing extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'spacing'; + + /** + * The value as defined in the token itself. + * It's a number or a reference. + */ + value: string; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's a number, or undefined if no value has been found in active sets. + */ + readonly resolvedValue: number | undefined; +} + +/** + * Represents a token of type BorderWidth. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenBorderWidth extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'borderWidth'; + + /** + * The value as defined in the token itself. + * It's a positive number or a reference. + */ + value: string; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's a positive number, or undefined if no value has been found in active sets. + */ + readonly resolvedValue: number | undefined; +} + +/** + * Represents a token of type TextCase. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenTextCase extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'textCase'; + + /** + * The value as defined in the token itself. + * It's a case string or a reference. + */ + value: string; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's a case string ("none", "uppercase", "lowercase", "capitalize"), or + * undefined if no value has been found in active sets. + */ + readonly resolvedValue: string | undefined; +} + +/** + * Represents a token of type Decoration. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenTextDecoration extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'textDecoration'; + + /** + * The value as defined in the token itself. + * It's a decoration string or a reference. + */ + value: string; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's a decoration string, or undefined if no value has been found + * in active sets. + */ + readonly resolvedValue: string | undefined; +} + +/* + * The value of a TokenTypography in its composite form. + */ +export interface TokenTypographyValue { + /** + * The letter spacing, as a number. + */ + letterSpacing: number; + + /** + * The list of font families. + */ + fontFamilies: string[]; + + /** + * The font size, as a positive number. + */ + fontSizes: number; + + /** + * The font weight, as a weight string ("bold", "strong", etc.). + */ + fontWeights: string; + + /** + * The line height, as a number. + */ + lineHeight: number; + + /** + * The text case as a string ("none", "uppercase", "lowercase" "capitalize"). + */ + textCase: string; + + /** + * The text decoration as a string ("none", "underline", "strike-through"). + */ + textDecoration: string; +} + +/* + * The value of a TokenTypography in its composite of strings form. + */ +export interface TokenTypographyValueString { + /** + * The letter spacing, as a number, or a reference to a TokenLetterSpacing. + */ + letterSpacing: string; + + /** + * The list of font families, or a reference to a TokenFontFamilies. + */ + fontFamilies: string | string[]; + + /** + * The font size, as a positive number, or a reference to a TokenFontSizes. + */ + fontSizes: string; + + /** + * The font weight, as a weight string ("bold", "strong", etc.), or a + * reference to a TokenFontWeights. + */ + fontWeight: string; + + /** + * The line height, as a number. Note that there not exists an individual + * token type line height, only part of a Typography token. If you need to + * put here a reference, use a NumberToken. + */ + lineHeight: string; + + /** + * The text case as a string ("none", "uppercase", "lowercase" "capitalize"), + * or a reference to a TokenTextCase. + */ + textCase: string; + + /** + * The text decoration as a string ("none", "underline", "strike-through"), + * or a reference to a TokenTextDecoration. + */ + textDecoration: string; +} + +/** + * Represents a token of type Typography. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenTypography extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'typography'; + + /** + * The value as defined in the token itself. + * It may be a string with a reference to other token, or a + * TokenTypographyValueString. + */ + value: string | TokenTypographyValueString; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's a TokenTypographyValue, or undefined if no value has been found + * in active sets. + */ + readonly resolvedValue: TokenTypographyValue[] | undefined; +} + +/** + * The supported Design Tokens in Penpot. + */ +export type Token = + | TokenBorderRadius + | TokenShadow + | TokenColor + | TokenDimension + | TokenFontFamilies + | TokenFontSizes + | TokenFontWeights + | TokenLetterSpacing + | TokenNumber + | TokenOpacity + | TokenRotation + | TokenSizing + | TokenSpacing + | TokenBorderWidth + | TokenTextCase + | TokenTextDecoration + | TokenTypography; + +/** + * The collection of all tokens in a Penpot file's library. + * + * Tokens are contained in sets, that can be marked as active + * or inactive to control the resolved value of the tokens. + * + * The active status of sets can be handled by presets named + * Themes. + */ +export interface TokenCatalog { + /** + * The list of themes in this catalog, in creation order. + */ + readonly themes: TokenTheme[]; + + /** + * The list of sets in this catalog, in the order defined + * by the user. The order is important because then same token name + * exists in several active sets, the latter has precedence. + */ + readonly sets: TokenSet[]; + + /** + * Creates a new TokenTheme and adds it to the catalog. + * @param group The group name of the theme (can be empty string). + * @param name The name of the theme (required) + * @return Returns the created TokenTheme. + */ + addTheme(group: string, name: string): TokenTheme; + + /** + * Creates a new TokenSet and adds it to the catalog. + * @param name The name of the set (required). It may contain + * a group path, separated by `/`. + * @return Returns the created TokenSet. + */ + addSet(name: string): TokenSet; + + /** + * Retrieves a theme. + * @param id the id of the theme. + * @returns Returns the theme or undefined if not found. + */ + getThemeById(id: string): TokenTheme | undefined; + + /** + * Retrieves a set. + * @param id the id of the set. + * @returns Returns the set or undefined if not found. + */ + getSetById(id: string): TokenSet | undefined; +} + +/** + * A collection of Design Tokens. + * + * Inside a set, tokens have an unique name, that will designate + * what token to use if the name is applied to a shape and this + * set is active. + */ +export interface TokenSet { + /** + * The unique identifier for this set, used only internally inside Penpot. + * This one is not exported or synced with external Design Token sources. + */ + readonly id: string; + + /** + * The name of the set. It may include a group path separated by `/`. + */ + name: string; + + /** + * Indicates if the set is currently active. + */ + active: boolean; + + /** + * The tokens contained in this set, in alphabetical order. + */ + readonly tokens: Token[]; + + /** + * The tokens contained in this set, grouped by type. + */ + readonly tokensByType: [string, Token[]][]; + + /** + * Toggles the active status of this set. + */ + toggleActive(): void; + + /** + * Retrieves a token. + * @param id the id of the token. + * @returns Returns the token or undefined if not found. + */ + getTokenById(id: string): Token | undefined; + + /** + * Creates a new Token and adds it to the set. + * @param type Thetype of token. + * @param name The name of the token (required). It may contain + * a group path, separated by `.`. + * @return Returns the created Token. + */ + addToken(type: TokenType, name: string, value: unknown): Token; + + /** + * Adds to the catalog a new TokenSet equal to this one but with a new id. + */ + duplicate(): TokenSet; + + /** + * Removes this set from the catalog. + */ + remove(): void; +} + +/** + * A preset of active TokenSets. + * + * A theme contains a list of references to TokenSets. When the theme + * is activated, it sets are activated too. This will not deactivate + * sets that are _not_ in this theme, because they may have been + * activated by other themes. + * + * Themes may be gruped. At any time only one of the themes in a group + * may be active. But there may be active themes in other groups. This + * allows to define multiple "axis" for theming (e.g. color scheme, + * density or brand). + * + * When a TokenSet is activated or deactivated directly, all themes + * are disabled (indicating that now there is a "custom" manual theme + * active). + */ +export interface TokenTheme { + /** + * The unique identifier for this theme, used only internally inside Penpot. + * This one is not exported or synced with external Design Token sources. + */ + readonly id: string; + + /** + * Optional identifier that may exists if the theme was imported from an + * external tool that uses ids in the json file. + */ + readonly externalId: string | undefined; + + /** + * The group name of the theme. Can be empt string. + */ + group: string; + + /** + * The name of the theme. + */ + name: string; + + /** + * Indicates if the theme is currently active. + */ + active: boolean; + + /** + * Toggles the active status of this theme. + */ + toggleActive(): void; + + /** + * The sets that will be activated if this theme is activated. + */ + activeSets: TokenSet[]; + + /** + * Adds a set to the list of the theme. + */ + addSet(tokenSet: TokenSet): void; + + /** + * Removes a set from the list of the theme. + */ + removeSet(tokenSet: TokenSet): void; + + /** + * Adds to the catalog a new TokenTheme equal to this one but with a new id. + */ + duplicate(): TokenTheme; + + /** + * Removes this theme from the catalog. + */ + remove(): void; +} + +/** + * The properties that a BorderRadius token can be applied to. + */ +type TokenBorderRadiusProps = 'r1' | 'r2' | 'r3' | 'r4'; + +/** + * The properties that a Shadow token can be applied to. + */ +type TokenShadowProps = 'shadow'; + +/** + * The properties that a Color token can be applied to. + */ +type TokenColorProps = 'fill' | 'stroke'; + +/** + * The properties that a Dimension token can be applied to. + */ +type TokenDimensionProps = + // Axis + | 'x' + | 'y' + + // Stroke width + | 'stroke-width'; + +/** + * The properties that a FontFamilies token can be applied to. + */ +type TokenFontFamiliesProps = 'font-families'; + +/** + * The properties that a FontSizes token can be applied to. + */ +type TokenFontSizesProps = 'font-size'; + +/** + * The properties that a FontWeight token can be applied to. + */ +type TokenFontWeightProps = 'font-weight'; + +/** + * The properties that a LetterSpacing token can be applied to. + */ +type TokenLetterSpacingProps = 'letter-spacing'; + +/** + * The properties that a Number token can be applied to. + */ +type TokenNumberProps = 'rotation' | 'line-height'; + +/** + * The properties that an Opacity token can be applied to. + */ +type TokenOpacityProps = 'opacity'; + +/** + * The properties that a Sizing token can be applied to. + */ +type TokenSizingProps = + // Size + | 'width' + | 'height' + + // Layout + | 'layout-item-min-w' + | 'layout-item-max-w' + | 'layout-item-min-h' + | 'layout-item-max-h'; + +/** + * The properties that a Spacing token can be applied to. + */ +type TokenSpacingProps = + // Spacing / Gap + | 'row-gap' + | 'column-gap' + + // Spacing / Padding + | 'p1' + | 'p2' + | 'p3' + | 'p4' + + // Spacing / Margin + | 'm1' + | 'm2' + | 'm3' + | 'm4'; + +/** + * The properties that a BorderWidth token can be applied to. + */ +type TokenBorderWidthProps = 'stroke-width'; + +/** + * The properties that a TextCase token can be applied to. + */ +type TokenTextCaseProps = 'text-case'; + +/** + * The properties that a TextDecoration token can be applied to. + */ +type TokenTextDecorationProps = 'text-decoration'; + +/** + * The properties that a Typography token can be applied to. + */ +type TokenTypographyProps = 'typography'; + +/** + * All the properties that a token can be applied to. + * Not always correspond to Shape properties. For example, + * `fill` property applies to `fillColor` of the first fill + * of the shape. + * + */ +export type TokenProperty = + | 'all' + | TokenBorderRadiusProps + | TokenShadowProps + | TokenColorProps + | TokenDimensionProps + | TokenFontFamiliesProps + | TokenFontSizesProps + | TokenFontWeightProps + | TokenLetterSpacingProps + | TokenNumberProps + | TokenOpacityProps + | TokenSizingProps + | TokenSpacingProps + | TokenBorderWidthProps + | TokenTextCaseProps + | TokenTextDecorationProps + | TokenTypographyProps; + +/** + * The supported types of Design Tokens in Penpot. + */ +export type TokenType = + | 'borderRadius' + | 'shadow' + | 'color' + | 'dimension' + | 'fontFamilies' + | 'fontSizes' + | 'fontWeights' + | 'letterSpacing' + | 'number' + | 'opacity' + | 'rotation' + | 'sizing' + | 'spacing' + | 'borderWidth' + | 'textCase' + | 'textDecoration' + | 'typography'; + /** * Represents a user in Penpot. */ diff --git a/plugins/package.json b/plugins/package.json index 722feae2a5..50a9e29f7b 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -16,6 +16,7 @@ "start:plugin:table": "nx run table-plugin:init", "start:plugin:renamelayers": "nx run rename-layers-plugin:init", "start:plugin:colors-to-tokens": "nx run colors-to-tokens-plugin:init", + "start:plugin:poc-tokens": "nx run poc-tokens-plugin:init", "build": "nx build plugins-runtime --emptyOutDir=true", "build:plugins": "nx run-many -t build --parallel -p tag:type:plugin --exclude=poc-state-plugin", "build:styles-example": "nx run example-styles:build",