Andrés Moya 3cd9bfa9de
✨ Add sync with design tokens in external libraries (#10293)
* ✨ Auto link tokens when adding external libraries (provisional)

* 🔧 Refactor tokens-lib initialization

* 🔧 Add separated TokenStatus to store status apart of TokensLib

* 🔧 Make all status operations use the new data structure

* 🔧 Normalize status helper functions and access token sets by id

* 🔧 Rename :tokens-file to :tokens-source

* 🎉 Allow the user to choose the tokens-source of a file

* 🎉 Make tokens library readonly when it's in an external file

* 🎉 Show tokens in library summaries

* 🎉 Show source info in sidebar

* 🔧 Fix integration tests

* 🐛 Propagate changes of token values in external library

* 🎉 Layout updates

* 🔧 Refactor tokens source calculations

* 🔧 Add harder checks for nil or empty values in everything

* 🐛 Fix some integration tests

* 🔧 Add integration tests for tokens in external libs

* 🔧 Validate and repair missing tokens status

* 🎉 Make ui changes optional with config flag

* 🐛 Propagate tokens after synchronizing components in ext library

* 🐛 Propagate tokens after creating new instances

* 🐛 Propagate tokens after synchronizing tokens in ext library

* 🐛 Add a tokens source icon to libraries section (#11439)

* 🐛 Add a tokens source icon to libraries section

* 🐛 Fix ellipsis on library names

* ♻️ Remove code under flag on legacy component

* 🐛 Fix token theme name on inspect tab

* 🎉 Add changes notification (#11476)

* 🎉 Add changes notification

* ♻️ Change fn names

* 🐛 Fix tokens source label truncation and missing translations (#11533)

* 🐛 Fix tokens source label truncation and missing translations

The tokens source file name always showed, even for the current file,
and long names wrapped onto a second line instead of truncating
because the header used flex-wrap and overflow-wrap: break-word
instead of single-line ellipsis.

Show the source row unconditionally (it now displays "This file" when
the source is the current file, matching the connected-library case),
truncate the file name to one line with an ellipsis, and only attach a
tooltip with the full name when the text is actually truncated.

Replace the hardcoded UI strings with translated ones and add their
English and Spanish entries.

AI-assisted-by: claude-sonnet-5

* 🐛 Remove redundant effect dependency in tokens source

file-name-truncated? was listed as a dependency of the with-effect
that checks and observes label truncation, even though it isn't
read inside the effect body. Since the effect itself flips that
state via check-file-name-truncated, including it as a dependency
caused the ResizeObserver to be needlessly disconnected and
reconnected on every truncation change.

AI-assisted-by: claude-sonnet-5

* 🐛 Fix small visual error

* 🐛 Fix problem with plugins

* 🐛 Fix playwright tests

---------

Co-authored-by: Eva Marco <evamarcod@gmail.com>
Co-authored-by: Eva Marco <eva.marco@kaleidos.net>
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
2026-09-21 15:37:45 +02:00

282 lines
7.0 KiB
TypeScript

import type { PluginMessageEvent, PluginUIEvent } from './model.js';
import {
TokenType,
TokenProperty,
TokenValueString,
} 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<PluginUIEvent>(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.properties);
}
});
function sendMessage(message: PluginMessageEvent) {
penpot.ui.sendMessage(message);
}
function loadLibrary() {
const tokensCatalog = penpot.library.local.tokens;
const isEditableTokens = tokensCatalog.isEditableTokens;
penpot.ui.sendMessage({
source: 'penpot',
type: 'set-is-editable-tokens',
isEditableTokens,
});
const themes = tokensCatalog.themes;
const themesData = themes.map((theme) => {
const activeSets = theme.activeSets.map((set) => set.name).join(', ');
return {
id: theme.id,
group: theme.group,
name: theme.name,
active: theme.active,
activeSets: activeSets,
};
});
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,
valueString: JSON.stringify(token.value),
resolvedValueString: token.resolvedValueString,
};
}),
]);
}
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({ group: themeGroup, name: themeName });
if (theme) {
loadLibrary();
}
}
function addSet(setName: string) {
const tokensCatalog = penpot.library.local.tokens;
const set = tokensCatalog?.addSet({ name: 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({
type: tokenType as TokenType,
name: tokenName,
value: tokenValue as TokenValueString,
});
if (token) {
// TODO: remove this timeout when styleDictionary is replaced
// with tokenScript and the token validation is syncrhronous.
setTimeout(() => {
loadTokens(setId);
}, 0);
}
}
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;
// TODO: remove this timeout when styleDictionary is replaced
// with tokenScript and the token validation is syncrhronous.
setTimeout(() => {
loadTokens(setId);
}, 0);
}
}
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,
properties: TokenProperty[] | undefined,
) {
const tokensCatalog = penpot.library.local.tokens;
const set = tokensCatalog?.getSetById(setId);
const token = set?.getTokenById(tokenId);
if (token) {
token.applyToSelected(properties);
}
// Alternative way
//
// const selection = penpot.selection;
// if (token && selection) {
// token.applyToShapes(selection, properties);
// }
// Other alternative way
//
// const selection = penpot.selection;
// if (token && selection) {
// for (const shape of selection) {
// shape.applyToken(token, properties);
// }
// }
}