Compare commits

..

25 Commits

Author SHA1 Message Date
Andrey Antukh
24af82752c 🐛 Prevent exception on open-new-window when no window is returned
Fixes https://github.com/penpot/penpot/issues/7787
2026-01-21 09:29:24 +01:00
Luis de Dios
079b3fbfad ♻️ Extract and create panel title component (#8090) 2026-01-20 18:56:25 +01:00
Andrey Antukh
299f628951 Merge pull request #8123 from penpot/GlobalStar117-fix/token-validation-crash
🐛 Fix Penpot crash when setting some name in Design tokens
2026-01-20 18:53:05 +01:00
David Barragán Merino
32d0fe6463 🔧 Use selfhosted runner 01 to generate the bundle 2026-01-20 18:09:18 +01:00
Andrey Antukh
cecd3d4a90 📎 Update changelog 2026-01-20 16:00:57 +01:00
Eva Marco
1c2c0987f5 🐛 Fix schema validation for references from other sets 2026-01-20 15:51:43 +01:00
Globalstar117
0418147e74 🐛 Add error handler to token form validation to prevent crash
When creating a token with a name that conflicts with existing
hierarchical token names (e.g., 'accent-color' when 'accent-color.blue.dark'
exists), the validation throws an error via rx/throw. However, the
rx/subs! subscriber in generic_form.cljs had no error handler, causing
an unhandled exception that resulted in an 'Internal Error' crash.

This fix adds an error handler that:
1. Catches validation errors from the reactive stream
2. Uses humanize-errors to convert them to user-friendly messages
3. Displays the error in the form's extra-errors field

Before: Crash with 'Internal Error' dialog
After: Form shows validation error message

Fixes #8110

---
This is a Gittensor contribution.
gittensor:user:GlobalStar117
2026-01-20 15:51:25 +01:00
Eva Marco
7499a5bca6 ♻️ Replace opacity input (#8072)
*  Replace opacity numeric input

*  Add test
2026-01-20 14:30:35 +01:00
Xaviju
6cd5bc76d7 💄 Replace current themes switch with DS switch (#8131) 2026-01-20 14:17:25 +01:00
David Barragán Merino
bbe6ee2e19 🔧 Define a different temporary config file for each execution 2026-01-20 12:59:56 +01:00
David Barragán Merino
fb6d8309b6 🔧 Prevent error 429 downloading docker images from dockerhub 2026-01-20 12:59:56 +01:00
Andrey Antukh
689467bcf9 📎 Update changelog 2026-01-20 12:25:43 +01:00
Andrey Antukh
7724450037 Merge remote-tracking branch 'origin/staging-render' into develop 2026-01-20 12:18:04 +01:00
Xaviju
368fa954ce Remove tokens tree node (#8042) 2026-01-20 11:00:13 +01:00
Andrey Antukh
6fd0f5377c Merge remote-tracking branch 'origin/staging' into staging-render 2026-01-20 10:08:58 +01:00
Elena Torró
eb54bc485e Merge pull request #8120 from penpot/alotor-fix-flex-layout
🐛 Fix problems with layout
2026-01-20 10:00:24 +01:00
Elena Torró
12c24a36b4 Merge pull request #8122 from penpot/fix-thumbnail-generation
🐛 Fix problem with thumbnail generation
2026-01-20 09:59:34 +01:00
Alejandro Alonso
324d54ad28 🐛 Fix set all rounded corners to 0 2026-01-20 09:34:06 +01:00
alonso.torres
f42ff27f3d 🐛 Fix problem with bools 2026-01-19 17:05:04 +01:00
alonso.torres
2c1cc89f53 🐛 Fix problem with thumbnail generation 2026-01-19 12:54:15 +01:00
alonso.torres
498b0b30fe 🐛 Fix problems with layout 2026-01-19 12:17:58 +01:00
Elena Torró
89f40dcda2 🔧 Move WebGL context error message to 'errors' namespace (#8117) 2026-01-19 11:24:19 +01:00
Elena Torró
ccac7bd510 Merge pull request #8108 from penpot/ladybenko-13022-blur-page
🎉 Apply blur effect when switching pages
2026-01-19 11:04:31 +01:00
Belén Albeza
43d1d127dc 🎉 Apply blur effect to previous canvas pixels while setting wasm objects 2026-01-16 13:04:59 +01:00
Belén Albeza
8bd3ef717c 🎉 Apply blur to canvas when switching pages 2026-01-16 13:04:59 +01:00
85 changed files with 28409 additions and 2814 deletions

View File

@@ -40,7 +40,7 @@ on:
jobs:
build-bundle:
name: Build and Upload Penpot Bundle
runs-on: ubuntu-24.04
runs-on: penpot-runner-01
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

View File

@@ -10,6 +10,11 @@ jobs:
runs-on: penpot-runner-02
steps:
- name: Set common environment variables
run: |
# Each job execution will use its own docker configuration.
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
- name: Checkout code
uses: actions/checkout@v4

View File

@@ -22,6 +22,11 @@ jobs:
runs-on: penpot-runner-02
steps:
- name: Set common environment variables
run: |
# Each job execution will use its own docker configuration.
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
- name: Checkout code
uses: actions/checkout@v4
with:
@@ -66,6 +71,15 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
# To avoid the “429 Too Many Requests” error when downloading
# images from DockerHub for unregistered users.
# https://docs.docker.com/docker-hub/usage/
- name: Login to DockerHub Registry
uses: docker/login-action@v3
with:
username: ${{ secrets.PUB_DOCKER_USERNAME }}
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5

View File

@@ -14,7 +14,8 @@
- Tokens panel nested path view [Taiga #9966](https://tree.taiga.io/project/penpot/us/9966)
- Improve usability of lock and hide buttons in the layer panel. [Taiga #12916](https://tree.taiga.io/project/penpot/issue/12916)
- Optimize sidebar performance for deeply nested shapes [Taiga #13017](https://tree.taiga.io/project/penpot/task/13017)
- Change the default option on tokens import dialog [Github #8051](https://github.com/penpot/penpot/pull/8051)
- Remove tokens path node and bulk remove tokens [Taiga #13007](https://tree.taiga.io/project/penpot/us/13007)
- Replace themes management modal radio buttons for switches [Taiga #9215](https://tree.taiga.io/project/penpot/us/9215)
### :bug: Bugs fixed
@@ -22,6 +23,9 @@
- Fix wrong image in the onboarding invitation block [Taiga #13040](https://tree.taiga.io/project/penpot/issue/13040)
- Fix wrong register image [Taiga #12955](https://tree.taiga.io/project/penpot/task/12955)
- Fix error message on components doesn't close automatically [Taiga #12012](https://tree.taiga.io/project/penpot/issue/12012)
- Fix incorrect default option on tokens import dialog [Github #8051](https://github.com/penpot/penpot/pull/8051)
- Fix unhandled exception tokens creation dialog [Github #8110](https://github.com/penpot/penpot/issues/8110)
- Fix unhandled exception on open-new-window helper [Github #7787](https://github.com/penpot/penpot/issues/7787)
## 2.13.0 (Unreleased)
@@ -169,7 +173,6 @@ example. It's still usable as before, we just removed the example.
- Deprecated configuration variables with the prefix `PENPOT_ASSETS_*`, and will be
removed in future versions:
- The `PENPOT_ASSETS_STORAGE_BACKEND` becomes `PENPOT_OBJECTS_STORAGE_BACKEND` and its
values passes from (`assets-fs` or `assets-s3`) to (`fs` or `s3`)
- The `PENPOT_STORAGE_ASSETS_FS_DIRECTORY` becomes `PENPOT_OBJECTS_STORAGE_FS_DIRECTORY`

View File

@@ -487,6 +487,7 @@
:vertical-margin #{:spacing :dimensions}
:sided-margins #{:spacing :dimensions}
:line-height #{:line-height :number}
:opacity #{:opacity}
:font-size #{:font-size}
:letter-spacing #{:letter-spacing}
:fill #{:color}

View File

@@ -101,6 +101,70 @@ test.describe("Tokens: Apply token", () => {
await expect(brTokenPillXL).not.toBeVisible();
});
test("User applies opacity token to a shape from sidebar", async ({
page,
}) => {
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
await page.getByRole("tab", { name: "Layers" }).click();
await workspacePage.layers.getByTestId("layer-row").nth(1).click();
// Open tokens sections on left sidebar
const tokensTabButton = page.getByRole("tab", { name: "Tokens" });
await tokensTabButton.click();
// Unfold opacity tokens
await page.getByRole("button", { name: "Opacity 3" }).click();
await expect(
tokensSidebar.getByRole("button", { name: "opacity", exact: true }),
).toBeVisible();
await tokensSidebar
.getByRole("button", { name: "opacity", exact: true })
.click();
await expect(
tokensSidebar.getByRole("button", { name: "opacity.high" }),
).toBeVisible();
// Apply opacity token from token panels
await tokensSidebar.getByRole("button", { name: "opacity.high" }).click();
// Check if opacity sections is visible on right sidebar
const layerMenuSection = page.getByRole("region", {
name: "layer-menu-section",
});
await expect(layerMenuSection).toBeVisible();
// Check if token pill is visible on design tab on right sidebar
const opacityHighPill = layerMenuSection.getByRole("button", {
name: "opacity.high",
});
await expect(opacityHighPill).toBeVisible();
// Detach token from design tab on right sidebar
const detachButton = layerMenuSection.getByRole("button", {
name: "Detach token",
});
await detachButton.click();
// Open dropdown from input
const dropdownBtn = layerMenuSection.getByLabel('Open token list');
await expect(dropdownBtn).toBeVisible();
await dropdownBtn.click();
// Change token from dropdown
const opacityLowOption = layerMenuSection.getByRole('option', { name: 'opacity.low' });
await expect(opacityLowOption).toBeVisible();
await opacityLowOption.click();
await expect(opacityHighPill).not.toBeVisible();
const opacityLowPill = layerMenuSection.getByRole("button", {
name: "opacity.low",
});
await expect(opacityLowPill).toBeVisible();
});
test("User applies typography token to a text shape", async ({ page }) => {
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTypographyTokensFile(page);
@@ -129,189 +193,6 @@ test.describe("Tokens: Apply token", () => {
await expect(fontSizeInput).toHaveValue("100");
});
test("User edits typography token and all fields are valid", async ({
page,
}) => {
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
await setupTypographyTokensFile(page);
await tokensSidebar
.getByRole("button")
.filter({ hasText: "Typography" })
.click();
// Open edit modal for "Full" typography token
const token = tokensSidebar.getByRole("button", { name: "Full" });
await token.click({ button: "right" });
await page.getByText("Edit token").click();
// Modal opens
await expect(tokensUpdateCreateModal).toBeVisible();
const saveButton = tokensUpdateCreateModal.getByRole("button", {
name: /save/i,
});
// Fill font-family to verify to verify that input value doesn't get split into list of characters
const fontFamilyField = tokensUpdateCreateModal
.getByLabel("Font family")
.first();
await fontFamilyField.fill("OneWord");
// Invalidate incorrect values for font size
const fontSizeField = tokensUpdateCreateModal.getByLabel(/Font Size/i);
await fontSizeField.fill("invalid");
await expect(
tokensUpdateCreateModal.getByText(/Invalid token value:/),
).toBeVisible();
await expect(saveButton).toBeDisabled();
// Show error with line-height depending on invalid font-size
await fontSizeField.fill("");
await expect(saveButton).toBeDisabled();
// Fill in values for all fields and verify they persist when switching tabs
await fontSizeField.fill("16");
await expect(saveButton).toBeEnabled();
const fontWeightField = tokensUpdateCreateModal.getByLabel(/Font Weight/i);
const letterSpacingField =
tokensUpdateCreateModal.getByLabel(/Letter Spacing/i);
const lineHeightField = tokensUpdateCreateModal.getByLabel(/Line Height/i);
const textCaseField = tokensUpdateCreateModal.getByLabel(/Text Case/i);
const textDecorationField =
tokensUpdateCreateModal.getByLabel(/Text Decoration/i);
// Capture all values before switching tabs
const originalValues = {
fontSize: await fontSizeField.inputValue(),
fontFamily: await fontFamilyField.inputValue(),
fontWeight: await fontWeightField.inputValue(),
letterSpacing: await letterSpacingField.inputValue(),
lineHeight: await lineHeightField.inputValue(),
textCase: await textCaseField.inputValue(),
textDecoration: await textDecorationField.inputValue(),
};
// Switch to reference tab and back to composite tab
const referenceTabButton =
tokensUpdateCreateModal.getByTestId("reference-opt");
await referenceTabButton.click();
// Empty reference tab should be disabled
await expect(saveButton).toBeDisabled();
const compositeTabButton =
tokensUpdateCreateModal.getByTestId("composite-opt");
await compositeTabButton.click();
// Filled composite tab should be enabled
await expect(saveButton).toBeEnabled();
// Verify all values are preserved after switching tabs
await expect(fontSizeField).toHaveValue(originalValues.fontSize);
await expect(fontFamilyField).toHaveValue(originalValues.fontFamily);
await expect(fontWeightField).toHaveValue(originalValues.fontWeight);
await expect(letterSpacingField).toHaveValue(originalValues.letterSpacing);
await expect(lineHeightField).toHaveValue(originalValues.lineHeight);
await expect(textCaseField).toHaveValue(originalValues.textCase);
await expect(textDecorationField).toHaveValue(
originalValues.textDecoration,
);
await saveButton.click();
// Modal should close, token should be visible (with new name) in sidebar
await expect(tokensUpdateCreateModal).not.toBeVisible();
});
test("User cant submit empty typography token or reference", async ({
page,
}) => {
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
await setupTypographyTokensFile(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
await tokensTabPanel
.getByRole("button", { name: "Add Token: Typography" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill("typography.empty");
const valueField = tokensUpdateCreateModal.getByLabel("Font Size");
// Insert a value and then delete it
await valueField.fill("1");
await valueField.fill("");
// Submit button should be disabled when field is empty
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await expect(submitButton).toBeDisabled();
// Switch to reference tab, should not be submittable either
const referenceTabButton =
tokensUpdateCreateModal.getByTestId("reference-opt");
await referenceTabButton.click();
await expect(submitButton).toBeDisabled();
});
test("User adds typography token with reference", async ({ page }) => {
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
await setupTypographyTokensFile(page);
const newTokenTitle = "NewReference";
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
await tokensTabPanel
.getByRole("button", { name: "Add Token: Typography" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill(newTokenTitle);
const referenceTabButton = tokensUpdateCreateModal.getByRole("button", {
name: "Use a reference",
});
referenceTabButton.click();
const referenceField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Reference",
});
await referenceField.fill("{Full}");
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
const resolvedValue =
await tokensUpdateCreateModal.getByText("Resolved value:");
await expect(resolvedValue).toBeVisible();
await expect(resolvedValue).toContainText("Font Family: 42dot Sans");
await expect(resolvedValue).toContainText("Font Size: 100");
await expect(resolvedValue).toContainText("Font Weight: 300");
await expect(resolvedValue).toContainText("Letter Spacing: 2");
await expect(resolvedValue).toContainText("Text Case: uppercase");
await expect(resolvedValue).toContainText("Text Decoration: underline");
await expect(submitButton).toBeEnabled();
await submitButton.click();
await expect(tokensUpdateCreateModal).not.toBeVisible();
const newToken = tokensSidebar.getByRole("button", {
name: newTokenTitle,
});
await expect(newToken).toBeVisible();
});
test("User adds shadow token with multiple shadows and applies it to shape", async ({
page,
}) => {

View File

@@ -14,7 +14,7 @@ test.beforeEach(async ({ page }) => {
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-tokens.json");
});
test.describe("Tokens - CRUD", () => {
test.describe("Tokens - creation", () => {
test("User creates border radius token", async ({ page }) => {
await testTokenCreationFlow(page, {
tokenLabel: "Border Radius",
@@ -1008,6 +1008,41 @@ test.describe("Tokens - CRUD", () => {
).toBeEnabled();
});
test("User cant submit empty typography token or reference", async ({
page,
}) => {
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
await setupTypographyTokensFile(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
await tokensTabPanel
.getByRole("button", { name: "Add Token: Typography" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill("typography.empty");
const valueField = tokensUpdateCreateModal.getByLabel("Font Size");
// Insert a value and then delete it
await valueField.fill("1");
await valueField.fill("");
// Submit button should be disabled when field is empty
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await expect(submitButton).toBeDisabled();
// Switch to reference tab, should not be submittable either
const referenceTabButton =
tokensUpdateCreateModal.getByTestId("reference-opt");
await referenceTabButton.click();
await expect(submitButton).toBeDisabled();
});
test("User creates typography token", async ({ page }) => {
const emptyNameError = "Name should be at least 1 character";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
@@ -1256,6 +1291,228 @@ test.describe("Tokens - CRUD", () => {
).toBeEnabled();
});
test("User adds typography token with reference", async ({ page }) => {
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
await setupTypographyTokensFile(page);
const newTokenTitle = "NewReference";
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
await tokensTabPanel
.getByRole("button", { name: "Add Token: Typography" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill(newTokenTitle);
const referenceTabButton = tokensUpdateCreateModal.getByRole("button", {
name: "Use a reference",
});
referenceTabButton.click();
const referenceField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Reference",
});
await referenceField.fill("{Full}");
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
const resolvedValue =
await tokensUpdateCreateModal.getByText("Resolved value:");
await expect(resolvedValue).toBeVisible();
await expect(resolvedValue).toContainText("Font Family: 42dot Sans");
await expect(resolvedValue).toContainText("Font Size: 100");
await expect(resolvedValue).toContainText("Font Weight: 300");
await expect(resolvedValue).toContainText("Letter Spacing: 2");
await expect(resolvedValue).toContainText("Text Case: uppercase");
await expect(resolvedValue).toContainText("Text Decoration: underline");
await expect(submitButton).toBeEnabled();
await submitButton.click();
await expect(tokensUpdateCreateModal).not.toBeVisible();
const newToken = tokensSidebar.getByRole("button", {
name: newTokenTitle,
});
await expect(newToken).toBeVisible();
});
test("User creates grouped color token", async ({ page }) => {
const { workspacePage, tokensUpdateCreateModal, tokensSidebar } =
await setupEmptyTokensFile(page);
await tokensSidebar
.getByRole("button", { name: "Add Token: Color" })
.click();
// Create grouped color token with mouse
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
const valueField = tokensUpdateCreateModal.getByLabel("Value");
await nameField.click();
await nameField.fill("dark.primary");
await valueField.click();
await valueField.fill("red");
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await expect(submitButton).toBeEnabled();
await submitButton.click();
await unfoldTokenTree(tokensSidebar, "color", "dark.primary");
await expect(tokensSidebar.getByLabel("primary")).toBeEnabled();
});
test("User cant create regular token with value missing", async ({
page,
}) => {
const { tokensUpdateCreateModal } = await setupEmptyTokensFile(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
await tokensTabPanel
.getByRole("button", { name: "Add Token: Color" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
// Initially submit button should be disabled
await expect(submitButton).toBeDisabled();
// Fill in name but leave value empty
await nameField.click();
await nameField.fill("primary");
// Submit button should remain disabled when value is empty
await expect(submitButton).toBeDisabled();
});
test("User duplicate color token", async ({ page }) => {
const { tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
await expect(tokensSidebar).toBeVisible();
unfoldTokenTree(tokensSidebar, "color", "colors.blue.100");
const colorToken = tokensSidebar.getByRole("button", {
name: "100",
});
await colorToken.click({ button: "right" });
await expect(tokenContextMenuForToken).toBeVisible();
await tokenContextMenuForToken.getByText("Duplicate token").click();
await expect(tokenContextMenuForToken).not.toBeVisible();
await expect(
tokensSidebar.getByRole("button", { name: "colors.blue.100-copy" }),
).toBeVisible();
});
});
test("User creates grouped color token", async ({ page }) => {
const { workspacePage, tokensUpdateCreateModal, tokensSidebar } =
await setupEmptyTokensFile(page);
await tokensSidebar
.getByRole("button", { name: "Add Token: Color" })
.click();
// Create grouped color token with mouse
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
const valueField = tokensUpdateCreateModal.getByLabel("Value");
await nameField.click();
await nameField.fill("dark.primary");
await valueField.click();
await valueField.fill("red");
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await expect(submitButton).toBeEnabled();
await submitButton.click();
await unfoldTokenTree(tokensSidebar, "color", "dark.primary");
await expect(tokensSidebar.getByLabel("primary")).toBeEnabled();
});
test("User cant create regular token with value missing", async ({
page,
}) => {
const { tokensUpdateCreateModal } = await setupEmptyTokensFile(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
await tokensTabPanel
.getByRole("button", { name: "Add Token: Color" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
// Initially submit button should be disabled
await expect(submitButton).toBeDisabled();
// Fill in name but leave value empty
await nameField.click();
await nameField.fill("primary");
// Submit button should remain disabled when value is empty
await expect(submitButton).toBeDisabled();
});
test("User duplicate color token", async ({ page }) => {
const { tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
await expect(tokensSidebar).toBeVisible();
unfoldTokenTree(tokensSidebar, "color", "colors.blue.100");
const colorToken = tokensSidebar.getByRole("button", {
name: "100",
});
await colorToken.click({ button: "right" });
await expect(tokenContextMenuForToken).toBeVisible();
await tokenContextMenuForToken.getByText("Duplicate token").click();
await expect(tokenContextMenuForToken).not.toBeVisible();
await expect(
tokensSidebar.getByRole("button", { name: "colors.blue.100-copy" }),
).toBeVisible();
});
test.describe("Tokens tab - edition", () => {
test("User edits typography token and all fields are valid", async ({
page,
}) => {
@@ -1388,67 +1645,7 @@ test.describe("Tokens - CRUD", () => {
await expect(colorTokenChanged).toBeVisible();
});
test("User creates grouped color token", async ({ page }) => {
const { workspacePage, tokensUpdateCreateModal, tokensSidebar } =
await setupEmptyTokensFile(page);
await tokensSidebar
.getByRole("button", { name: "Add Token: Color" })
.click();
// Create grouped color token with mouse
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
const valueField = tokensUpdateCreateModal.getByLabel("Value");
await nameField.click();
await nameField.fill("dark.primary");
await valueField.click();
await valueField.fill("red");
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await expect(submitButton).toBeEnabled();
await submitButton.click();
await unfoldTokenTree(tokensSidebar, "color", "dark.primary");
await expect(tokensSidebar.getByLabel("primary")).toBeEnabled();
});
test("User cant create regular token with value missing", async ({
page,
}) => {
const { tokensUpdateCreateModal } = await setupEmptyTokensFile(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
await tokensTabPanel
.getByRole("button", { name: "Add Token: Color" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
// Initially submit button should be disabled
await expect(submitButton).toBeDisabled();
// Fill in name but leave value empty
await nameField.click();
await nameField.fill("primary");
// Submit button should remain disabled when value is empty
await expect(submitButton).toBeDisabled();
});
test("User changes color token color while keeping custom color space", async ({
test("User edits color token color while keeping custom color space", async ({
page,
}) => {
const { workspacePage, tokensUpdateCreateModal, tokenThemesSetsSidebar } =
@@ -1502,30 +1699,9 @@ test.describe("Tokens - CRUD", () => {
await valueSaturationSelector.click({ position: { x: 0, y: 0 } });
await expect(valueField).toHaveValue(/^rgba(.*)$/);
});
});
test("User duplicate color token", async ({ page }) => {
const { tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
await expect(tokensSidebar).toBeVisible();
unfoldTokenTree(tokensSidebar, "color", "colors.blue.100");
const colorToken = tokensSidebar.getByRole("button", {
name: "100",
});
await colorToken.click({ button: "right" });
await expect(tokenContextMenuForToken).toBeVisible();
await tokenContextMenuForToken.getByText("Duplicate token").click();
await expect(tokenContextMenuForToken).not.toBeVisible();
await expect(
tokensSidebar.getByRole("button", { name: "colors.blue.100-copy" }),
).toBeVisible();
});
test.describe("Tokens tab - delete", () => {
test("User delete color token", async ({ page }) => {
const { tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
@@ -1546,4 +1722,40 @@ test.describe("Tokens - CRUD", () => {
await expect(tokenContextMenuForToken).not.toBeVisible();
await expect(colorToken).not.toBeVisible();
});
test("User removes node and all child tokens", async ({ page }) => {
const { tokensSidebar, workspacePage } = await setupTokensFile(page);
await expect(tokensSidebar).toBeVisible();
// Expand color tokens
unfoldTokenTree(tokensSidebar, "color", "colors.blue.100");
// Verify that the node and child token are visible before deletion
const colorNode = tokensSidebar.getByRole("button", {
name: "blue",
exact: true,
});
const colorNodeToken = tokensSidebar.getByRole("button", {
name: "100",
});
// Select a node and right click on it to open context menu
await expect(colorNode).toBeVisible();
await expect(colorNodeToken).toBeVisible();
await colorNode.click({ button: "right" });
// select "Delete" from the context menu
const deleteNodeButton = page.getByRole("button", {
name: "Delete",
exact: true,
});
await expect(deleteNodeButton).toBeVisible();
await deleteNodeButton.click();
// Verify that the node is removed
await expect(colorNode).not.toBeVisible();
// Verify that child token is also removed
await expect(colorNodeToken).not.toBeVisible();
});
});

View File

@@ -17,7 +17,6 @@
--app-background: var(--color-background-primary);
--loader-background: var(--color-background-primary);
--panel-title-background-color: var(--color-background-secondary);
// BUTTONS
--button-foreground-hover: var(--color-accent-primary);

View File

@@ -433,11 +433,22 @@
ptk/WatchEvent
(watch [it state _]
(let [data (dsh/lookup-file-data state)
changes (-> (pcb/empty-changes it)
(pcb/with-library-data data)
(pcb/set-token set-id token-id nil))]
(rx/of (dch/commit-changes changes))))))
(defn bulk-delete-tokens
[set-id token-ids]
(dm/assert! (uuid? set-id))
(dm/assert! (every? uuid? token-ids))
(ptk/reify ::bulk-delete-tokens
ptk/WatchEvent
(watch [_ _ _]
(apply rx/of
(map #(delete-token set-id %) token-ids)))))
(defn duplicate-token
[token-id]
(dm/assert! (uuid? token-id))
@@ -505,6 +516,19 @@
(update state :workspace-tokens assoc :token-context-menu params)
(update state :workspace-tokens dissoc :token-context-menu)))))
(defn assign-token-node-context-menu
[{:keys [position] :as params}]
(when params
(assert (gpt/point? position) "expected a point instance for `position` param"))
(ptk/reify ::show-token-node-context-menu
ptk/UpdateEvent
(update [_ state]
(if params
(update state :workspace-tokens assoc :token-node-context-menu params)
(update state :workspace-tokens dissoc :token-node-context-menu)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TOKEN-SET UI OPS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@@ -483,6 +483,9 @@
(def workspace-active-theme-paths
(l/derived (d/nilf ctob/get-active-theme-paths) tokens-lib))
(def workspace-all-tokens-map
(l/derived (d/nilf ctob/get-all-tokens-map) tokens-lib))
(defn token-sets-at-path-all-active
[group-path]
(l/derived

View File

@@ -38,6 +38,7 @@
[app.main.ui.ds.product.loader :refer [loader*]]
[app.main.ui.ds.product.milestone :refer [milestone*]]
[app.main.ui.ds.product.milestone-group :refer [milestone-group*]]
[app.main.ui.ds.product.panel-title :refer [panel-title*]]
[app.main.ui.ds.storybook :as sb]
[app.main.ui.ds.tooltip.tooltip :refer [tooltip*]]
[app.main.ui.ds.utilities.date :refer [date*]]
@@ -81,6 +82,7 @@
:Milestone milestone*
:MilestoneGroup milestone-group*
:Date date*
:PanelTitle panel-title*
:set-default-translations
(fn [data]

View File

@@ -216,7 +216,7 @@
is-selected-on-focus nillable
tokens applied-token empty-to-end
on-change on-blur on-focus on-detach
property align ref]
property align ref name]
:rest props}]
(let [;; NOTE: we use mfu/bean here for transparently handle
@@ -662,7 +662,10 @@
label (get token :name)
token-value (or (get token :resolved-value)
(or (mf/ref-val last-value*)
(fmt/format-number value)))]
(fmt/format-number value)))
token-value (if (= name :opacity)
(* 100 token-value)
token-value)]
(mf/spread-props props
{:id id
:label label

View File

@@ -7,6 +7,7 @@
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/typography.scss" as *;
@use "ds/_utils.scss" as *;
.option-list {
--options-dropdown-icon-fg-color: var(--color-foreground-secondary);
@@ -15,32 +16,32 @@
--options-dropdown-border-color: var(--color-background-quaternary);
position: absolute;
top: $sz-36;
width: var(--dropdown-width, 100%);
inset-block-start: $sz-36;
inline-size: var(--dropdown-width, 100%);
transform: translateX(var(--dropdown-translate-distance, 0));
background-color: var(--options-dropdown-bg-color);
border-radius: $br-8;
border: $b-1 solid var(--options-dropdown-border-color);
padding-block: var(--sp-xs);
margin-block-end: 0;
max-height: $sz-400;
max-block-size: $sz-400;
overflow-y: auto;
overflow-x: hidden;
z-index: var(--z-index-dropdown);
}
.left-align {
left: var(--dropdown-offset, 0);
inset-inline-start: var(--dropdown-offset, 0);
}
.right-align {
right: var(--dropdown-offset, 0);
inset-inline-end: var(--dropdown-offset, 0);
}
.option-separator {
border: $b-1 solid var(--options-dropdown-border-color);
margin-top: var(--sp-xs);
margin-bottom: var(--sp-xs);
margin-block-start: var(--sp-xs);
margin-block-end: var(--sp-xs);
}
.group-option,
@@ -51,11 +52,11 @@
gap: var(--sp-xs);
color: var(--color-foreground-secondary);
padding-inline: var(--sp-s);
height: var(--sp-xxxl);
block-size: var(--sp-xxxl);
}
.option-empty {
justify-content: center;
text-align: center;
padding: 0 40px;
padding: 0 px2rem(40);
}

View File

@@ -19,17 +19,19 @@
[:expandable {:optional true} :boolean]
[:expanded {:optional true} :boolean]
[:icon {:optional true} :string]
[:on-toggle-expand fn?]])
[:on-toggle-expand {:optional true} fn?]
[:on-context-menu {:optional true} fn?]])
(mf/defc layer-button*
{::mf/schema schema:layer-button}
[{:keys [label description class is-expandable expanded icon on-toggle-expand children] :rest props}]
[{:keys [label description class is-expandable expanded icon on-toggle-expand on-context-menu children] :rest props}]
(let [button-props (mf/spread-props props
{:class [class (stl/css-case :layer-button true
:layer-button--expandable is-expandable
:layer-button--expanded expanded)]
:type "button"
:on-click on-toggle-expand})]
:on-click on-toggle-expand
:on-context-menu on-context-menu})]
[:div {:class (stl/css :layer-button-wrapper)}
[:> "button" button-props
[:div {:class (stl/css :layer-button-content)}

View File

@@ -0,0 +1,34 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.ds.product.panel-title
(:require-macros
[app.main.style :as stl])
(:require
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.util.i18n :refer [tr]]
[rumext.v2 :as mf]))
(def ^:private schema:panel-title
[:map
[:class {:optional true} :string]
[:text :string]
[:on-close {:optional true} fn?]])
(mf/defc panel-title*
{::mf/schema schema:panel-title}
[{:keys [class text on-close] :rest props}]
(let [props
(mf/spread-props props {:class [class (stl/css :panel-title)]})]
[:> :div props
[:span {:class (stl/css :panel-title-text)} text]
(when on-close
[:> icon-button* {:variant "ghost"
:aria-label (tr "labels.close")
:on-click on-close
:icon i/close}])]))

View File

@@ -0,0 +1,26 @@
{ /* This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
Copyright (c) KALEIDOS INC */ }
import { Canvas, Meta } from '@storybook/addon-docs/blocks';
import * as PanelTitle from "./panel_title.stories";
<Meta title="Product/PanelTitle" />
# PanelTitle
The `panel-title*` is used as a header for some sidebar sections.
<Canvas of={PanelTitle.Default} />
## Technical notes
The only mandatory parameter is `text`. Usually you'll want to pass a function property `on-close` that will be called when the user clicks on the close button on the right.
```clj
[:> panel-title* {:class class
:text text
:on-close on-close}]
```

View File

@@ -0,0 +1,25 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@use "ds/_sizes.scss" as *;
@use "ds/_borders.scss" as *;
@use "ds/typography.scss" as t;
.panel-title {
display: flex;
align-items: center;
justify-content: center;
block-size: $sz-32;
border-radius: $br-8;
background-color: var(--color-background-secondary);
}
.panel-title-text {
@include t.use-typography("headline-small");
flex-grow: 1;
text-align: center;
color: var(--color-foreground-primary);
}

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import Components from "@target/components";
const { PanelTitle } = Components;
export default {
title: "Product/PanelTitle",
component: PanelTitle,
argTypes: {
text: {
control: { type: "text" },
},
},
args: {
text: "Lorem ipsum",
onClose: () => null,
},
render: ({ ...args }) => <PanelTitle {...args} />,
};
export const Default = {};

View File

@@ -312,8 +312,8 @@
[]
(let [on-reload (mf/use-fn #(js/location.reload))]
[:> error-container* {}
[:div {:class (stl/css :main-message)} (tr "labels.webgl-context-lost.main-message")]
[:div {:class (stl/css :desc-message)} (tr "labels.webgl-context-lost.desc-message")]
[:div {:class (stl/css :main-message)} (tr "errors.webgl-context-lost.main-message")]
[:div {:class (stl/css :desc-message)} (tr "errors.webgl-context-lost.desc-message")]
[:div {:class (stl/css :buttons-container)}
[:> button* {:variant "primary" :on-click on-reload}
(tr "labels.reload-page")]]]))

View File

@@ -276,7 +276,11 @@
:wglobal wglobal
:layout layout}])
(when (or (not (and file-loaded? page-id))
(and wasm-renderer-enabled? (not @first-frame-rendered?)))
;; in wasm renderer, extend the pixel loader until the first frame is rendered
;; but do not apply it when switching pages
(and wasm-renderer-enabled?
(not file-loaded?)
(not @first-frame-rendered?)))
[:> workspace-loader*])]]]]]]))
(mf/defc workspace-page*

View File

@@ -16,9 +16,9 @@
[app.main.ui.comments :as cmt]
[app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.context :as ctx]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.product.empty-state :refer [empty-state*]]
[app.main.ui.ds.product.panel-title :refer [panel-title*]]
[app.main.ui.icons :as deprecated-icon]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
@@ -121,15 +121,12 @@
(st/emit! (with-meta (dcmt/open-thread thread) {::ev/origin "viewer"}))
(st/emit! (dwcm/navigate-to-comment thread)))))]
[:div {:class (stl/css-case :comments-section true
:from-viewer from-viewer)}
[:div {:class (stl/css-case :comments-section-title true
:viewer-title from-viewer)}
[:span (tr "labels.comments")]
[:> icon-button* {:variant "ghost"
:aria-label (tr "labels.close")
:on-click close-section
:icon i/close}]]
[:div {:class (stl/css-case :comments-section true
:from-viewer from-viewer)}
[:> panel-title* {:class (stl/css :comments-title)
:text (tr "labels.comments")
:on-close close-section}]
[:button {:class (stl/css :mode-dropdown-wrapper)
:on-click toggle-mode-selector}

View File

@@ -18,25 +18,8 @@
padding: 0 deprecated.$s-8;
}
.comments-section-title {
@include deprecated.flexCenter;
@include deprecated.uppercaseTitleTipography;
position: relative;
height: deprecated.$s-32;
min-height: deprecated.$s-32;
margin: deprecated.$s-8 deprecated.$s-8 0 deprecated.$s-8;
border-radius: deprecated.$br-8;
background-color: var(--panel-title-background-color);
span {
@include deprecated.flexCenter;
flex-grow: 1;
color: var(--title-foreground-color-hover);
}
}
.viewer-title {
margin: 0;
margin-block-start: deprecated.$s-8;
.comments-title {
margin: var(--sp-s) var(--sp-s) 0 var(--sp-s);
}
.mode-dropdown-wrapper {

View File

@@ -11,12 +11,11 @@
[app.common.data.macros :as dm]
[app.main.data.workspace :as dw]
[app.main.store :as st]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.product.panel-title :refer [panel-title*]]
[app.main.ui.icons :as deprecated-icon]
[app.util.debug :as dbg]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[app.util.i18n :refer [tr]]
[rumext.v2 :as mf]))
(mf/defc debug-panel*
@@ -35,12 +34,9 @@
(st/emit! (dw/remove-layout-flag :debug-panel))))]
[:div {:class (dm/str class " " (stl/css :debug-panel))}
[:div {:class (stl/css :panel-title)}
[:span "Debugging tools"]
[:> icon-button* {:variant "ghost"
:aria-label (tr "labels.close")
:on-click handle-close
:icon i/close}]]
[:> panel-title* {:class (stl/css :debug-panel-title)
:text (tr "workspace.debug.title")
:on-close handle-close}]
[:div {:class (stl/css :debug-panel-inner)}
(for [option (sort-by d/name dbg/options)]

View File

@@ -12,21 +12,12 @@
background-color: var(--panel-background-color);
}
.panel-title {
@include deprecated.flexCenter;
@include deprecated.uppercaseTitleTipography;
position: relative;
height: deprecated.$s-32;
min-height: deprecated.$s-32;
margin: deprecated.$s-8 deprecated.$s-8 0 deprecated.$s-8;
border-radius: deprecated.$br-8;
background-color: var(--panel-title-background-color);
.debug-panel-title {
margin: var(--sp-s) var(--sp-s) 0 var(--sp-s);
}
span {
@include deprecated.flexCenter;
flex-grow: 1;
color: var(--title-foreground-color-hover);
}
.debug-panel-inner {
padding: deprecated.$s-16 deprecated.$s-8;
}
.checkbox-wrapper {
@@ -39,7 +30,3 @@
@extend .checkbox-icon;
cursor: pointer;
}
.debug-panel-inner {
padding: deprecated.$s-16 deprecated.$s-8;
}

View File

@@ -13,7 +13,7 @@
[app.main.data.workspace :as dw]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.icons :as deprecated-icon]
[app.main.ui.ds.product.panel-title :refer [panel-title*]]
[debug :as dbg]
[rumext.v2 :as mf]))
@@ -125,11 +125,9 @@
(map (d/getf objects)))]
[:div {:class (stl/css :shape-info)}
[:div {:class (stl/css :shape-info-title)}
[:span "Debug"]
[:div {:class (stl/css :close-button)
:on-click #(dbg/disable! :shape-panel)}
deprecated-icon/close]]
[:> panel-title* {:class (stl/css :shape-info-title)
:text "Debug"
:on-close #(dbg/disable! :shape-panel)}]
(if (empty? selected)
[:div {:class (stl/css :attrs-container)} "No shapes selected"]

View File

@@ -16,34 +16,7 @@
}
.shape-info-title {
@include deprecated.flexCenter;
@include deprecated.uppercaseTitleTipography;
position: relative;
height: deprecated.$s-32;
min-height: deprecated.$s-32;
margin: deprecated.$s-8 deprecated.$s-8 0 deprecated.$s-8;
border-radius: deprecated.$br-8;
background-color: var(--panel-title-background-color);
span {
@include deprecated.flexCenter;
flex-grow: 1;
color: var(--title-foreground-color-hover);
}
}
.close-button {
@extend .button-tertiary;
position: absolute;
right: deprecated.$s-2;
top: deprecated.$s-2;
height: deprecated.$s-28;
width: deprecated.$s-28;
border-radius: deprecated.$br-6;
svg {
@extend .button-icon;
stroke: var(--icon-foreground);
}
margin: var(--sp-s) var(--sp-s) 0 var(--sp-s);
}
.attrs-container {

View File

@@ -13,23 +13,6 @@
background-color: var(--panel-background-color);
}
.history-toolbox-title {
@include deprecated.flexCenter;
@include deprecated.uppercaseTitleTipography;
position: relative;
height: deprecated.$s-32;
min-height: deprecated.$s-32;
margin: deprecated.$s-8 deprecated.$s-8 0 deprecated.$s-8;
border-radius: deprecated.$br-8;
background-color: var(--panel-title-background-color);
span {
@include deprecated.flexCenter;
flex-grow: 1;
color: var(--title-foreground-color-hover);
}
}
.history-entry-empty {
display: flex;
flex-direction: column;

View File

@@ -55,7 +55,6 @@
(-> (deref tokens)
(select-keys (get tk/tokens-by-input name))
(not-empty))))
on-detach-attr
(mf/use-fn
(mf/deps on-detach name)

View File

@@ -9,13 +9,17 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.types.token :as tk]
[app.main.data.workspace :as dw]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.tokens.application :as dwta]
[app.main.features :as features]
[app.main.store :as st]
[app.main.ui.components.numeric-input :refer [numeric-input*]]
[app.main.ui.components.numeric-input :as deprecated-input]
[app.main.ui.components.select :refer [select]]
[app.main.ui.context :as muc]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.numeric-input :refer [numeric-input*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.render-wasm.api :as wasm.api]
[app.util.i18n :as i18n :refer [tr]]
@@ -39,11 +43,16 @@
(defn- check-layer-menu-props
[old-props new-props]
(let [old-values (unchecked-get old-props "values")
new-values (unchecked-get new-props "values")]
new-values (unchecked-get new-props "values")
old-applied-tokens (unchecked-get old-props "appliedTokens")
new-applied-tokens (unchecked-get new-props "appliedTokens")]
(and (identical? (unchecked-get old-props "class")
(unchecked-get new-props "class"))
(identical? (unchecked-get old-props "ids")
(unchecked-get new-props "ids"))
(identical? old-applied-tokens
new-applied-tokens)
(identical? (get old-values :opacity)
(get new-values :opacity))
(identical? (get old-values :blend-mode)
@@ -53,12 +62,53 @@
(identical? (get old-values :hidden)
(get new-values :hidden)))))
(mf/defc numeric-input-wrapper*
{::mf/private true}
[{:keys [values name applied-tokens align on-detach] :rest props}]
(let [tokens (mf/use-ctx muc/active-tokens-by-type)
tokens (mf/with-memo [tokens name]
(delay
(-> (deref tokens)
(select-keys (get tk/tokens-by-input name))
(not-empty))))
on-detach-attr (mf/use-fn
(mf/deps on-detach name)
#(on-detach % name))
applied-token (get applied-tokens name)
opacity-value (or (get values name) 1)
props (mf/spread-props props
{:placeholder (if (or (= :multiple (:applied-tokens values))
(= :multiple opacity-value))
(tr "settings.multiple")
"--")
:applied-token applied-token
:tokens (if (delay? tokens) @tokens tokens)
:align align
:on-detach on-detach-attr
:name name
:value (* 100 opacity-value)})]
[:> numeric-input* props]))
(mf/defc layer-menu*
{::mf/wrap [#(mf/memo' % check-layer-menu-props)]}
[{:keys [ids values]}]
(let [hidden? (get values :hidden)
[{:keys [ids values applied-tokens]}]
(let [token-numeric-inputs
(features/use-feature "tokens/numeric-input")
hidden? (get values :hidden)
blocked? (get values :blocked)
on-detach-token
(mf/use-fn
(mf/deps ids)
(fn [token attr]
(st/emit! (dwta/unapply-token {:token (first token)
:attributes #{attr}
:shape-ids ids}))))
current-blend-mode (or (get values :blend-mode) :normal)
current-opacity (opacity->string (:opacity values))
@@ -118,6 +168,17 @@
(let [value (/ value 100)]
(on-change ids :opacity value))))
on-opacity-change
(mf/use-fn
(mf/deps on-change handle-opacity-change)
(fn [value]
(if (or (string? value) (int? value))
(handle-opacity-change value)
(do
(st/emit! (dwta/toggle-token {:token (first value)
:attrs #{:opacity}
:shape-ids ids}))))))
handle-set-hidden
(mf/use-fn
(mf/deps ids)
@@ -176,8 +237,9 @@
preview-complete?))
(swap! state* assoc :selected-blend-mode current-blend-mode)))
[:div {:class (stl/css-case :element-set-content true
:hidden hidden?)}
[:section {:class (stl/css-case :element-set-content true
:hidden hidden?)
:aria-label "layer-menu-section"}
[:div {:class (stl/css :select)}
[:& select
{:default-value selected-blend-mode
@@ -187,16 +249,34 @@
:class (stl/css-case :hidden-select hidden?)
:on-pointer-enter-option handle-blend-mode-enter
:on-pointer-leave-option handle-blend-mode-leave}]]
[:div {:class (stl/css :input)
:title (tr "workspace.options.opacity")}
[:span {:class (stl/css :icon)} "%"]
[:> numeric-input*
{:value current-opacity
:placeholder "--"
:on-change handle-opacity-change
:min 0
:max 100
:className (stl/css :numeric-input)}]]
(if token-numeric-inputs
[:> numeric-input-wrapper*
{:on-change on-opacity-change
:on-detach on-detach-token
:icon i/percentage
:min 0
:max 100
:name :opacity
:property (tr "workspace.options.opacity")
:applied-tokens applied-tokens
:align :right
:class (stl/css :numeric-input-wrapper)
:values values}]
[:div {:class (stl/css :input)
:title (tr "workspace.options.opacity")}
[:span {:class (stl/css :icon)} "%"]
[:> deprecated-input/numeric-input*
{:value current-opacity
:placeholder "--"
:on-change handle-opacity-change
:min 0
:max 100
:className (stl/css :numeric-input)}]])
[:div {:class (stl/css :actions)}

View File

@@ -6,6 +6,7 @@
@use "refactor/common-refactor.scss" as deprecated;
@use "../../../sidebar/common/sidebar.scss" as sidebar;
@use "ds/_utils.scss" as *;
.element-set-content {
@include sidebar.option-grid-structure;
@@ -43,3 +44,9 @@
}
}
}
.numeric-input-wrapper {
grid-column: span 2;
--dropdown-width: var(--7-columns-dropdown-width);
--dropdown-offset: #{px2rem(-35)};
}

View File

@@ -85,6 +85,7 @@
[:*
[:> layer-menu* {:ids ids
:type type
:applied-tokens applied-tokens
:values layer-values}]
[:> measures-menu* {:ids ids

View File

@@ -84,6 +84,7 @@
[:*
[:> layer-menu* {:ids ids
:type type
:applied-tokens applied-tokens
:values layer-values}]
[:> measures-menu* {:ids ids

View File

@@ -100,6 +100,7 @@
[:*
[:> layer-menu* {:ids ids
:type shape-type
:applied-tokens applied-tokens
:values layer-values}]
[:> measures-menu* {:ids ids
:applied-tokens applied-tokens

View File

@@ -111,6 +111,7 @@
[:div {:class (stl/css :options)}
[:> layer-menu* {:type type
:ids layer-ids
:applied-tokens applied-tokens
:values layer-values}]
[:> measures-menu* {:type type
:ids measure-ids

View File

@@ -382,7 +382,7 @@
objects
objects)))
[layer-ids layer-values]
[layer-ids layer-values layer-tokens]
(get-attrs shapes objects :layer)
[text-ids text-values]
@@ -406,7 +406,7 @@
[exports-ids exports-values]
(get-attrs shapes objects :exports)
[layout-container-ids layout-container-values layout-contianer-tokens]
[layout-container-ids layout-container-values layout-container-tokens]
(get-attrs shapes objects :layout-container)
[layout-item-ids layout-item-values {}]
@@ -442,6 +442,7 @@
(when-not (empty? layer-ids)
[:> layer-menu* {:type type
:ids layer-ids
:applied-tokens layer-tokens
:values layer-values}])
(when-not (empty? measure-ids)
@@ -459,7 +460,7 @@
{:type type
:ids layout-container-ids
:values layout-container-values
:applied-tokens layout-contianer-tokens
:applied-tokens layout-container-tokens
:multiple true}]
(when (or is-layout-child? has-flex-layout-container?)

View File

@@ -84,6 +84,7 @@
[:*
[:> layer-menu* {:ids ids
:applied-tokens applied-tokens
:type type
:values layer-values}]
[:> measures-menu* {:ids ids

View File

@@ -85,6 +85,7 @@
[:*
[:> layer-menu* {:ids ids
:type type
:applied-tokens applied-tokens
:values layer-values}]
[:> measures-menu* {:ids ids
:type type

View File

@@ -125,6 +125,7 @@
[:*
[:> layer-menu* {:ids ids
:type type
:applied-tokens applied-tokens
:values layer-values}]
[:> measures-menu*
{:ids ids

View File

@@ -18,8 +18,8 @@
[app.main.data.workspace.shortcuts]
[app.main.store :as st]
[app.main.ui.components.search-bar :refer [search-bar*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]]
[app.main.ui.ds.product.panel-title :refer [panel-title*]]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[app.util.strings :refer [matches-search]]
@@ -487,13 +487,9 @@
(dom/focus! (dom/get-element "shortcut-search")))
[:div {:class (dm/str class " " (stl/css :shortcuts))}
[:div {:class (stl/css :shortcuts-header)}
[:div {:class (stl/css :shortcuts-title)} (tr "shortcuts.title")]
[:> icon-button* {:variant "ghost"
:icon i/close
:class (stl/css :shortcuts-close-button)
:on-click close-fn
:aria-label (tr "labels.close")}]]
[:> panel-title* {:class (stl/css :shortcuts-title)
:text (tr "shortcuts.title")
:on-close close-fn}]
[:div {:class (stl/css :search-field)}
[:> search-bar* {:on-change on-search-term-change-2

View File

@@ -18,27 +18,8 @@
margin: deprecated.$s-16 deprecated.$s-12 deprecated.$s-4 deprecated.$s-12;
}
.shortcuts-header {
@include deprecated.flexCenter;
@include deprecated.uppercaseTitleTipography;
position: relative;
height: deprecated.$s-32;
padding: deprecated.$s-2 deprecated.$s-2 deprecated.$s-2 0;
margin: deprecated.$s-4 deprecated.$s-4 0 deprecated.$s-4;
border-radius: deprecated.$br-6;
background-color: var(--panel-title-background-color);
.shortcuts-title {
@include deprecated.flexCenter;
flex-grow: 1;
color: var(--title-foreground-color-hover);
}
.shortcuts-close-button {
position: absolute;
right: 0;
top: 0;
}
.shortcuts-title {
margin: var(--sp-s) var(--sp-s) 0 var(--sp-s);
}
.section {

View File

@@ -13,6 +13,7 @@
[app.main.data.helpers :as dsh]
[app.main.data.modal :as modal]
[app.main.data.workspace :as dw]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.title-bar :refer [title-bar*]]
@@ -22,9 +23,11 @@
[app.main.ui.hooks :as hooks]
[app.main.ui.icons :as deprecated-icon]
[app.main.ui.notifications.badge :refer [badge-notification]]
[app.render-wasm.api :as wasm.api]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
[app.util.timers :as timers]
[cuerdas.core :as str]
[okulary.core :as l]
[rumext.v2 :as mf]))
@@ -52,6 +55,8 @@
refs/workspace-data
=))
;; --- Page Item
(mf/defc page-item
@@ -63,6 +68,22 @@
navigate-fn (mf/use-fn (mf/deps id) #(st/emit! :interrupt (dcm/go-to-workspace :page-id id)))
read-only? (mf/use-ctx ctx/workspace-read-only?)
on-click
(mf/use-fn
(mf/deps id)
(fn []
;; when using the wasm renderer, apply a blur effect to the viewport canvas
(if (features/active-feature? @st/state "render-wasm/v1")
(do
(wasm.api/capture-canvas-pixels)
(wasm.api/apply-canvas-blur)
;; NOTE: it seems we need two RAF so the blur is actually applied and visible
;; in the canvas :(
(timers/raf
(fn []
(timers/raf navigate-fn))))
(navigate-fn))))
on-delete
(mf/use-fn
(mf/deps id)
@@ -155,7 +176,7 @@
:selected selected?)
:data-testid (dm/str "page-" id)
:tab-index "0"
:on-click navigate-fn
:on-click on-click
:on-double-click on-double-click
:on-context-menu on-context-menu}
[:div {:class (stl/css :page-icon)}

View File

@@ -14,8 +14,10 @@
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.main.ui.workspace.tokens.management.context-menu :refer [token-context-menu]]
[app.main.ui.workspace.tokens.management.group :refer [token-group*]]
[app.main.ui.workspace.tokens.management.node-context-menu :refer [token-node-context-menu*]]
[app.util.array :as array]
[app.util.i18n :refer [tr]]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(defn- get-sorted-token-groups
@@ -120,7 +122,27 @@
[empty-group filled-group]
(mf/with-memo [tokens-by-type]
(get-sorted-token-groups tokens-by-type))]
(get-sorted-token-groups tokens-by-type))
;; Filter tokens by their path and return their ids
filter-tokens-by-path-ids
(mf/use-fn
(mf/deps tokens)
(fn [type path]
(->> tokens
(filter (fn [token]
(let [[_ token-value] token]
(and (= (:type token-value) type) (str/starts-with? (:name token-value) path)))))
(mapv (fn [token]
(let [[_ token-value] token]
(:id token-value)))))))
delete-node
(mf/with-memo [tokens selected-token-set-id]
(fn [node type]
(let [path (:path node)
tokens-in-path-ids (filter-tokens-by-path-ids type path)]
(st/emit! (dwtl/bulk-delete-tokens selected-token-set-id tokens-in-path-ids)))))]
(mf/with-effect [tokens-lib selected-token-set-id]
(when (and tokens-lib
@@ -134,6 +156,7 @@
[:*
[:& token-context-menu]
[:> token-node-context-menu* {:on-delete-node delete-node}]
[:& selected-set-info* {:tokens-lib tokens-lib
:selected-token-set-id selected-token-set-id}]

View File

@@ -140,6 +140,9 @@
error
(get-in @form [:errors input-name])
extra-error
(get-in @form [:extra-errors input-name])
value
(get-in @form [:data input-name] "")
@@ -247,9 +250,14 @@
:hint-type (:type hint)})
props
(if (and error touched?)
(cond
(and error touched?)
(mf/spread-props props {:hint-type "error"
:hint-message (:message error)})
(and extra-error touched?)
(mf/spread-props props {:hint-type "error"
:hint-message (:message extra-error)})
:else
props)]
(mf/with-effect [resolve-stream tokens token input-name]

View File

@@ -23,21 +23,19 @@
(let [token-type
(or (:type token) token-type)
tokens-in-selected-set
(mf/deref refs/workspace-all-tokens-in-selected-set)
token-path
(mf/with-memo [token]
(cft/token-name->path (:name token)))
tokens-tree-in-selected-set
(mf/with-memo [token-path tokens-in-selected-set]
(-> (ctob/tokens-tree tokens-in-selected-set)
all-tokens (mf/deref refs/workspace-all-tokens-map)
all-tokens
(mf/with-memo [token-path all-tokens]
(-> (ctob/tokens-tree all-tokens)
(d/dissoc-in token-path)))
props
(mf/spread-props props {:token-type token-type
:tokens-tree-in-selected-set tokens-tree-in-selected-set
:tokens-in-selected-set tokens-in-selected-set
:all-token-tree all-tokens
:token token})
text-case-props (mf/spread-props props {:input-value-placeholder (tr "workspace.tokens.text-case-value-enter")})
text-decoration-props (mf/spread-props props {:input-value-placeholder (tr "workspace.tokens.text-decoration-value-enter")})

View File

@@ -16,6 +16,7 @@
[app.main.data.helpers :as dh]
[app.main.data.modal :as modal]
[app.main.data.workspace.tokens.application :as dwta]
[app.main.data.workspace.tokens.errors :as wte]
[app.main.data.workspace.tokens.library-edit :as dwtl]
[app.main.data.workspace.tokens.propagation :as dwtp]
[app.main.data.workspace.tokens.remapping :as remap]
@@ -88,14 +89,13 @@
action
is-create
selected-token-set-id
tokens-tree-in-selected-set
all-token-tree
token-type
make-schema
input-component
initial
type
value-subfield
tokens-in-selected-set
input-value-placeholder] :as props}]
(let [make-schema (or make-schema default-make-schema)
@@ -124,6 +124,9 @@
tokens
(mf/deref refs/workspace-active-theme-sets-tokens)
tokens-in-selected-set
(mf/deref refs/workspace-all-tokens-in-selected-set)
tokens
(mf/with-memo [tokens tokens-in-selected-set token]
;; Ensure that the resolved value uses the currently editing token
@@ -134,8 +137,8 @@
(assoc (:name token) token)))
schema
(mf/with-memo [tokens-tree-in-selected-set active-tab]
(make-schema tokens-tree-in-selected-set active-tab))
(mf/with-memo [all-token-tree active-tab]
(make-schema all-token-tree active-tab))
initial
(mf/with-memo [token]
@@ -224,7 +227,12 @@
:description description}))
(dwtl/toggle-token-path path)
(dwtp/propagate-workspace-tokens)
(modal/hide!))))))))))]
(modal/hide!)))))
;; WORKAROUND: display validation errors in the form instead of crashing
(fn [{:keys [errors]}]
(let [error-messages (wte/humanize-errors errors)
error-message (first error-messages)]
(swap! form assoc-in [:extra-errors :value] {:message error-message}))))))))]
[:> fc/form* {:class (stl/css :form-wrapper)
:form form

View File

@@ -88,7 +88,7 @@
expandable? (d/nilv (seq tokens) false)
on-context-menu
on-pill-context-menu
(mf/use-fn
(fn [event token]
(dom/prevent-default event)
@@ -98,6 +98,15 @@
:errors (:errors token)
:token-id (:id token)}))))
on-node-context-menu
(mf/use-fn
(fn [event node]
(dom/prevent-default event)
(st/emit! (dwtl/assign-token-node-context-menu
{:node node
:type type
:position (dom/get-client-position event)}))))
on-toggle-open-click
(mf/use-fn
(mf/deps type expandable?)
@@ -159,4 +168,5 @@
:selected-token-set-id selected-token-set-id
:is-selected-inside-layout is-selected-inside-layout
:on-token-pill-click on-token-pill-click
:on-context-menu on-context-menu}])]))
:on-pill-context-menu on-pill-context-menu
:on-node-context-menu on-node-context-menu}])]))

View File

@@ -0,0 +1,83 @@
(ns app.main.ui.workspace.tokens.management.node-context-menu
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.data.workspace.tokens.library-edit :as dwtl]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.dropdown :refer [dropdown]]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[okulary.core :as l]
[rumext.v2 :as mf]))
(def ^:private schema:token-node-context-menu
[:map
[:on-delete-node fn?]])
(def ^:private tokens-node-menu-ref
(l/derived :token-node-context-menu refs/workspace-tokens))
(defn- prevent-default
[event]
(dom/prevent-default event)
(dom/stop-propagation event))
(mf/defc token-node-context-menu*
{::mf/schema schema:token-node-context-menu}
[{:keys [on-delete-node]}]
(let [mdata (mf/deref tokens-node-menu-ref)
is-open? (boolean mdata)
dropdown-ref (mf/use-ref)
dropdown-action (mf/use-ref)
dropdown-direction* (mf/use-state "down")
dropdown-direction (deref dropdown-direction*)
dropdown-direction-change* (mf/use-ref 0)
top (+ (get-in mdata [:position :y]) 5)
left (+ (get-in mdata [:position :x]) 5)
delete-node (mf/use-fn
(mf/deps mdata)
(fn []
(let [node (get mdata :node)
type (get mdata :type)]
(when node
(on-delete-node node type)))))]
(mf/with-effect [is-open?]
(when (and (not= 0 (mf/ref-val dropdown-direction-change*)) (= false is-open?))
(reset! dropdown-direction* "down")
(mf/set-ref-val! dropdown-direction-change* 0)))
(mf/with-effect [is-open? dropdown-ref dropdown-action]
(let [dropdown-element (mf/ref-val dropdown-ref)]
(when (and (= 0 (mf/ref-val dropdown-direction-change*)) dropdown-element)
(let [is-outside? (dom/is-element-outside? dropdown-element)]
(reset! dropdown-direction* (if is-outside? "up" "down"))
(mf/set-ref-val! dropdown-direction-change* (inc (mf/ref-val dropdown-direction-change*)))))))
;; FIXME: perf optimization
(when is-open?
(mf/portal
(mf/html
[:& dropdown {:show is-open?
:on-close #(st/emit! (dwtl/assign-token-node-context-menu nil))}
[:div {:class (stl/css :token-node-context-menu)
:data-testid "tokens-context-menu-for-token-node"
:ref dropdown-ref
:data-direction dropdown-direction
:style {:--bottom (if (= dropdown-direction "up")
"40px"
"unset")
:--top (dm/str top "px")
:left (dm/str left "px")}
:on-context-menu prevent-default}
(when mdata
[:ul {:class (stl/css :token-node-context-menu-list)}
[:li {:class (stl/css :token-node-context-menu-listitem)}
[:button {:class (stl/css :token-node-context-menu-action)
:type "button"
:on-click delete-node}
(tr "labels.delete")]]])]])
(dom/get-body)))))

View File

@@ -0,0 +1,70 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@use "ds/_utils.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_borders.scss" as *;
@use "ds/typography.scss" as t;
@use "ds/spacing.scss" as *;
@use "ds/mixins.scss" as *;
.token-node-context-menu {
--menu-inline-size: #{px2rem(240)};
position: absolute;
z-index: var(--z-index-dropdown);
}
.token-node-context-menu[data-direction="up"] {
bottom: var(--bottom);
}
.token-node-context-menu[data-direction="down"] {
top: var(--top);
}
.token-node-context-menu-list {
inline-size: var(--menu-inline-size);
padding: var(--sp-xs);
border-radius: $br-8;
border: $b-2 solid var(--color-background-quaternary);
background-color: var(--color-background-tertiary);
max-block-size: 100vh;
overflow-y: auto;
box-shadow: 0px 0px $sz-12 0px var(--menu-shadow-color);
}
.token-node-context-menu-action {
--context-menu-item-bg-color: none;
--context-menu-item-fg-color: var(--color-foreground-primary);
--context-menu-item-border-color: none;
@include t.use-typography("body-small");
appearance: none;
background: var(--context-menu-item-bg-color);
border: $b-1 solid var(--context-menu-item-border-color);
color: var(--context-menu-item-fg-color);
border-radius: $br-8;
cursor: pointer;
block-size: px2rem(32);
inline-size: 100%;
display: flex;
align-items: center;
padding: var(--sp-xs);
&:hover {
--context-menu-item-bg-color: var(--color-background-quaternary);
}
&:focus {
--context-menu-item-bg-color: var(--menu-background-color-focus);
--context-menu-item-border-color: var(--color-background-tertiary);
}
&[aria-selected="true"] {
--context-menu-item-bg-color: var(--color-background-quaternary);
}
}

View File

@@ -10,6 +10,7 @@
[app.common.path-names :as cpn]
[app.common.types.tokens-lib :as ctob]
[app.main.data.workspace.tokens.library-edit :as dwtl]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.ds.layers.layer-button :refer [layer-button*]]
[app.main.ui.workspace.tokens.management.token-pill :refer [token-pill*]]
@@ -26,7 +27,8 @@
[:selected-token-set-id {:optional true} :any]
[:tokens-lib {:optional true} :any]
[:on-token-pill-click {:optional true} fn?]
[:on-context-menu {:optional true} fn?]])
[:on-pill-context-menu {:optional true} fn?]
[:on-node-context-menu {:optional true} fn?]])
(mf/defc folder-node*
{::mf/schema schema:folder-node}
@@ -39,22 +41,29 @@
selected-token-set-id
tokens-lib
on-token-pill-click
on-context-menu]}]
on-pill-context-menu
on-node-context-menu]}]
(let [full-path (str (name type) "." (:path node))
is-folder-expanded (contains? (set (or unfolded-token-paths [])) full-path)
swap-folder-expanded (mf/use-fn
(mf/deps (:path node) type)
(fn []
(let [path (str (name type) "." (:path node))]
(st/emit! (dwtl/toggle-token-path path)))))]
(st/emit! (dwtl/toggle-token-path path)))))
node-context-menu-prep (mf/use-fn
(mf/deps on-node-context-menu node)
(fn [event]
(when on-node-context-menu
(on-node-context-menu event node))))]
[:li {:class (stl/css :folder-node)}
[:> layer-button* {:label (:name node)
:expanded is-folder-expanded
:aria-expanded is-folder-expanded
:aria-controls (str "folder-children-" (:path node))
:is-expandable (not (:leaf node))
:on-toggle-expand swap-folder-expanded}]
:on-toggle-expand swap-folder-expanded
:on-context-menu node-context-menu-prep}]
(when is-folder-expanded
(let [children-fn (:children-fn node)]
[:div {:class (stl/css :folder-children-wrapper)
@@ -63,16 +72,17 @@
(let [children (children-fn)]
(for [child children]
(if (not (:leaf child))
[:ul {:class (stl/css :node-parent)}
[:> folder-node* {:key (:path child)
:type type
[:ul {:class (stl/css :node-parent)
:key (:path child)}
[:> folder-node* {:type type
:node child
:unfolded-token-paths unfolded-token-paths
:selected-shapes selected-shapes
:is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens active-theme-tokens
:on-token-pill-click on-token-pill-click
:on-context-menu on-context-menu
:on-pill-context-menu on-pill-context-menu
:on-node-context-menu on-node-context-menu
:tokens-lib tokens-lib
:selected-token-set-id selected-token-set-id}]]
(let [id (:id (:leaf child))
@@ -84,7 +94,7 @@
:is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens active-theme-tokens
:on-click on-token-pill-click
:on-context-menu on-context-menu}])))))]))]))
:on-context-menu on-pill-context-menu}])))))]))]))
(def ^:private schema:token-tree
[:map
@@ -97,7 +107,8 @@
[:selected-token-set-id {:optional true} :any]
[:tokens-lib {:optional true} :any]
[:on-token-pill-click {:optional true} fn?]
[:on-context-menu {:optional true} fn?]])
[:on-pill-context-menu {:optional true} fn?]
[:on-node-context-menu {:optional true} fn?]])
(mf/defc token-tree*
{::mf/schema schema:token-tree}
@@ -110,12 +121,19 @@
tokens-lib
selected-token-set-id
on-token-pill-click
on-context-menu]}]
on-pill-context-menu
on-node-context-menu]}]
(let [separator "."
tree (mf/use-memo
(mf/deps tokens)
(fn []
(cpn/build-tree-root tokens separator)))]
(cpn/build-tree-root tokens separator)))
can-edit? (:can-edit (deref refs/permissions))
on-node-context-menu (mf/use-fn
(mf/deps can-edit? on-node-context-menu)
(fn [event node]
(when can-edit?
(on-node-context-menu event node))))]
[:div {:class (stl/css :token-tree-wrapper)}
(for [node tree]
(if (:leaf node)
@@ -127,7 +145,7 @@
:is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens active-theme-tokens
:on-click on-token-pill-click
:on-context-menu on-context-menu}])
:on-context-menu on-pill-context-menu}])
;; Render segment folder
[:ul {:class (stl/css :node-parent)
:key (:path node)}
@@ -138,6 +156,7 @@
:is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens active-theme-tokens
:on-token-pill-click on-token-pill-click
:on-context-menu on-context-menu
:on-node-context-menu on-node-context-menu
:on-pill-context-menu on-pill-context-menu
:tokens-lib tokens-lib
:selected-token-set-id selected-token-set-id}]]))]))

View File

@@ -20,7 +20,7 @@
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.combobox :refer [combobox*]]
[app.main.ui.ds.controls.input :refer [input*]]
[app.main.ui.ds.controls.radio-buttons :refer [radio-buttons*]]
[app.main.ui.ds.controls.switch :refer [switch*]]
[app.main.ui.ds.controls.utilities.label :refer [label*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
@@ -85,21 +85,6 @@
:on-click create-theme}
(tr "workspace.tokens.add-new-theme")]]]]))
(mf/defc switch*
[{:keys [selected? name on-change]}]
(let [selected (if selected? :on :off)]
[:> radio-buttons* {:selected selected
:on-change on-change
:name name
:options [{:id "on"
:icon i/tick
:label (tr "workspace.tokens.theme.enable")
:value :on}
{:id "off"
:icon i/close
:label (tr "workspace.tokens.theme.disable")
:value :off}]}]))
(mf/defc themes-overview
[{:keys [change-view]}]
(let [active-theme-paths (mf/deref refs/workspace-active-theme-paths)
@@ -137,6 +122,9 @@
(dom/prevent-default e)
(dom/stop-propagation e)
(st/emit! (dwtl/delete-token-theme id)))
on-switch-theme
(fn []
(st/emit! (dwtl/toggle-token-theme-active id)))
on-edit-theme
(fn [e]
(dom/prevent-default e)
@@ -146,16 +134,10 @@
:class (stl/css :theme-row)}
[:div {:class (stl/css :theme-switch-row)}
;; FIXME: FIREEEEEEEEEE THIS
[:div {:on-click (fn [e]
(dom/prevent-default e)
(dom/stop-propagation e)
(st/emit! (dwtl/toggle-token-theme-active id)))}
[:> switch* {:name (tr "workspace.tokens.theme-name" name)
:on-change (constantly nil)
:selected? selected?}]]]
[:div {:class (stl/css :theme-name-row)}
[:> text* {:as "span" :typography "body-medium" :class (stl/css :theme-name) :title name} name]]
[:> switch* {:id name
:label name
:on-change on-switch-theme
:default-checked selected?}]]
[:div {:class (stl/css :theme-actions-row)}

View File

@@ -228,7 +228,7 @@
:class (stl/css :main-toolbar-options-button)
:icon i/bug
:aria-pressed (contains? layout :debug-panel)
:aria-label "Debugging tool"
:aria-label (tr "workspace.toolbar.debug")
:tooltip-placement "bottom"
:on-click toggle-debug-panel}]])]]

View File

@@ -312,6 +312,11 @@
(js/console.error "Error initializing canvas context:" e)
false))]
(reset! canvas-init? init?)
(when init?
;; Restore previous canvas pixels immediately after context initialization
;; This happens before initialize-viewport is called
(wasm.api/apply-canvas-blur)
(wasm.api/restore-previous-canvas-pixels))
(when-not init?
(js/alert "WebGL not supported")
(st/emit! (dcm/go-to-dashboard-recent))))))))
@@ -340,6 +345,7 @@
(mf/with-effect [@canvas-init? zoom vbox background]
(when (and @canvas-init? (not @initialized?))
(wasm.api/clear-canvas-pixels)
(wasm.api/initialize-viewport base-objects zoom vbox background)
(reset! initialized? true)))

View File

@@ -29,6 +29,7 @@
[app.main.worker :as mw]
[app.render-wasm.api.fonts :as f]
[app.render-wasm.api.texts :as t]
[app.render-wasm.api.webgl :as webgl]
[app.render-wasm.deserializers :as dr]
[app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem]
@@ -37,7 +38,6 @@
[app.render-wasm.serializers :as sr]
[app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.svg-filters :as svg-filters]
;; FIXME: rename; confunsing name
[app.render-wasm.wasm :as wasm]
[app.util.debug :as dbg]
[app.util.dom :as dom]
@@ -279,30 +279,6 @@
[string]
(+ (count string) 1))
(defn- create-webgl-texture-from-image
"Creates a WebGL texture from an HTMLImageElement or ImageBitmap and returns the texture object"
[gl image-element]
(let [texture (.createTexture ^js gl)]
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) texture)
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_WRAP_S ^js gl) (.-CLAMP_TO_EDGE ^js gl))
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_WRAP_T ^js gl) (.-CLAMP_TO_EDGE ^js gl))
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_MIN_FILTER ^js gl) (.-LINEAR ^js gl))
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_MAG_FILTER ^js gl) (.-LINEAR ^js gl))
(.texImage2D ^js gl (.-TEXTURE_2D ^js gl) 0 (.-RGBA ^js gl) (.-RGBA ^js gl) (.-UNSIGNED_BYTE ^js gl) image-element)
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) nil)
texture))
(defn- get-webgl-context
"Gets the WebGL context from the WASM module"
[]
(when wasm/context-initialized?
(let [gl-obj (unchecked-get wasm/internal-module "GL")]
(when gl-obj
;; Get the current WebGL context from Emscripten
;; The GL object has a currentContext property that contains the context handle
(let [current-ctx (.-currentContext ^js gl-obj)]
(when current-ctx
(.-GLctx ^js current-ctx)))))))
(defn- get-texture-id-for-gl-object
"Registers a WebGL texture with Emscripten's GL object system and returns its ID"
@@ -332,8 +308,8 @@
(->> (retrieve-image url)
(rx/map
(fn [img]
(when-let [gl (get-webgl-context)]
(let [texture (create-webgl-texture-from-image gl img)
(when-let [gl (webgl/get-webgl-context)]
(let [texture (webgl/create-webgl-texture-from-image gl img)
texture-id (get-texture-id-for-gl-object texture)
width (.-width ^js img)
height (.-height ^js img)
@@ -979,6 +955,7 @@
(set-shape-grow-type grow-type))
(set-shape-layout shape)
(set-layout-data shape)
(set-shape-selrect selrect)
(let [pending_thumbnails (into [] (concat
@@ -1055,8 +1032,9 @@
(perf/end-measure "set-objects")
(process-pending shapes thumbnails full noop-fn
(fn []
(when render-callback (render-callback))
(render-finish)
(if render-callback
(render-callback)
(render-finish))
(ug/dispatch! (ug/event "penpot:wasm:set-objects")))))))
(defn clear-focus-mode
@@ -1384,8 +1362,9 @@
all-children
(->> ids
(mapcat #(cfh/get-children-with-self objects %)))]
(h/call wasm/internal-module "_init_shapes_pool" (count all-children))
(run! (partial set-object objects) all-children)
(run! set-object all-children)
(let [content (-> (calculate-bool* bool-type ids)
(path.impl/path-data))]
@@ -1448,6 +1427,12 @@
result)))
(defn apply-canvas-blur
[]
(when wasm/canvas
(dom/set-style! wasm/canvas "filter" "blur(4px)")))
(defn init-wasm-module
[module]
(let [default-fn (unchecked-get module "default")
@@ -1469,3 +1454,8 @@
(js/console.error cause)
(p/resolved false)))))
(p/resolved false))))
;; Re-export public WebGL functions
(def capture-canvas-pixels webgl/capture-canvas-pixels)
(def restore-previous-canvas-pixels webgl/restore-previous-canvas-pixels)
(def clear-canvas-pixels webgl/clear-canvas-pixels)

View File

@@ -0,0 +1,166 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.render-wasm.api.webgl
"WebGL utilities for pixel capture and rendering"
(:require
[app.common.logging :as log]
[app.render-wasm.wasm :as wasm]
[app.util.dom :as dom]))
(defn get-webgl-context
"Gets the WebGL context from the WASM module"
[]
(when wasm/context-initialized?
(let [gl-obj (unchecked-get wasm/internal-module "GL")]
(when gl-obj
;; Get the current WebGL context from Emscripten
;; The GL object has a currentContext property that contains the context handle
(let [current-ctx (.-currentContext ^js gl-obj)]
(when current-ctx
(.-GLctx ^js current-ctx)))))))
(defn create-webgl-texture-from-image
"Creates a WebGL texture from an HTMLImageElement or ImageBitmap and returns the texture object"
[gl image-element]
(let [texture (.createTexture ^js gl)]
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) texture)
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_WRAP_S ^js gl) (.-CLAMP_TO_EDGE ^js gl))
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_WRAP_T ^js gl) (.-CLAMP_TO_EDGE ^js gl))
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_MIN_FILTER ^js gl) (.-LINEAR ^js gl))
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_MAG_FILTER ^js gl) (.-LINEAR ^js gl))
(.texImage2D ^js gl (.-TEXTURE_2D ^js gl) 0 (.-RGBA ^js gl) (.-RGBA ^js gl) (.-UNSIGNED_BYTE ^js gl) image-element)
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) nil)
texture))
;; FIXME: temporary function until we are able to keep the same <canvas> across pages.
(defn- draw-imagedata-to-webgl
"Draws ImageData to a WebGL2 context by creating a texture"
[gl image-data]
(let [width (.-width ^js image-data)
height (.-height ^js image-data)
texture (.createTexture ^js gl)]
;; Bind texture and set parameters
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) texture)
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_WRAP_S ^js gl) (.-CLAMP_TO_EDGE ^js gl))
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_WRAP_T ^js gl) (.-CLAMP_TO_EDGE ^js gl))
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_MIN_FILTER ^js gl) (.-LINEAR ^js gl))
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_MAG_FILTER ^js gl) (.-LINEAR ^js gl))
(.texImage2D ^js gl (.-TEXTURE_2D ^js gl) 0 (.-RGBA ^js gl) (.-RGBA ^js gl) (.-UNSIGNED_BYTE ^js gl) image-data)
;; Set up viewport
(.viewport ^js gl 0 0 width height)
;; Vertex & Fragment shaders
;; Since we are only calling this function once (on page switch), we don't need
;; to cache the compiled shaders somewhere else (cannot be reused in a differen context).
(let [vertex-shader-source "#version 300 es
in vec2 a_position;
in vec2 a_texCoord;
out vec2 v_texCoord;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
v_texCoord = a_texCoord;
}"
fragment-shader-source "#version 300 es
precision highp float;
in vec2 v_texCoord;
uniform sampler2D u_texture;
out vec4 fragColor;
void main() {
fragColor = texture(u_texture, v_texCoord);
}"
vertex-shader (.createShader ^js gl (.-VERTEX_SHADER ^js gl))
fragment-shader (.createShader ^js gl (.-FRAGMENT_SHADER ^js gl))
program (.createProgram ^js gl)]
(.shaderSource ^js gl vertex-shader vertex-shader-source)
(.compileShader ^js gl vertex-shader)
(when-not (.getShaderParameter ^js gl vertex-shader (.-COMPILE_STATUS ^js gl))
(log/error :hint "Vertex shader compilation failed"
:log (.getShaderInfoLog ^js gl vertex-shader)))
(.shaderSource ^js gl fragment-shader fragment-shader-source)
(.compileShader ^js gl fragment-shader)
(when-not (.getShaderParameter ^js gl fragment-shader (.-COMPILE_STATUS ^js gl))
(log/error :hint "Fragment shader compilation failed"
:log (.getShaderInfoLog ^js gl fragment-shader)))
(.attachShader ^js gl program vertex-shader)
(.attachShader ^js gl program fragment-shader)
(.linkProgram ^js gl program)
(if (.getProgramParameter ^js gl program (.-LINK_STATUS ^js gl))
(do
(.useProgram ^js gl program)
;; Create full-screen quad vertices (normalized device coordinates)
(let [position-location (.getAttribLocation ^js gl program "a_position")
texcoord-location (.getAttribLocation ^js gl program "a_texCoord")
position-buffer (.createBuffer ^js gl)
texcoord-buffer (.createBuffer ^js gl)
positions #js [-1.0 -1.0 1.0 -1.0 -1.0 1.0 -1.0 1.0 1.0 -1.0 1.0 1.0]
texcoords #js [0.0 0.0 1.0 0.0 0.0 1.0 0.0 1.0 1.0 0.0 1.0 1.0]]
;; Set up position buffer
(.bindBuffer ^js gl (.-ARRAY_BUFFER ^js gl) position-buffer)
(.bufferData ^js gl (.-ARRAY_BUFFER ^js gl) (js/Float32Array. positions) (.-STATIC_DRAW ^js gl))
(.enableVertexAttribArray ^js gl position-location)
(.vertexAttribPointer ^js gl position-location 2 (.-FLOAT ^js gl) false 0 0)
;; Set up texcoord buffer
(.bindBuffer ^js gl (.-ARRAY_BUFFER ^js gl) texcoord-buffer)
(.bufferData ^js gl (.-ARRAY_BUFFER ^js gl) (js/Float32Array. texcoords) (.-STATIC_DRAW ^js gl))
(.enableVertexAttribArray ^js gl texcoord-location)
(.vertexAttribPointer ^js gl texcoord-location 2 (.-FLOAT ^js gl) false 0 0)
;; Set texture uniform
(.activeTexture ^js gl (.-TEXTURE0 ^js gl))
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) texture)
(let [texture-location (.getUniformLocation ^js gl program "u_texture")]
(.uniform1i ^js gl texture-location 0))
;; draw
(.drawArrays ^js gl (.-TRIANGLES ^js gl) 0 6)
;; cleanup
(.deleteBuffer ^js gl position-buffer)
(.deleteBuffer ^js gl texcoord-buffer)
(.deleteShader ^js gl vertex-shader)
(.deleteShader ^js gl fragment-shader)
(.deleteProgram ^js gl program)))
(log/error :hint "Program linking failed"
:log (.getProgramInfoLog ^js gl program)))
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) nil)
(.deleteTexture ^js gl texture))))
(defn restore-previous-canvas-pixels
"Restores previous canvas pixels into the new canvas"
[]
(when-let [previous-canvas-pixels wasm/canvas-pixels]
(when-let [gl wasm/gl-context]
(draw-imagedata-to-webgl gl previous-canvas-pixels)
(set! wasm/canvas-pixels nil))))
(defn clear-canvas-pixels
[]
(when wasm/canvas
(let [context wasm/gl-context]
(.clearColor ^js context 0 0 0 0.0)
(.clear ^js context (.-COLOR_BUFFER_BIT ^js context))
(.clear ^js context (.-DEPTH_BUFFER_BIT ^js context))
(.clear ^js context (.-STENCIL_BUFFER_BIT ^js context)))
(dom/set-style! wasm/canvas "filter" "none")
(set! wasm/canvas-pixels nil)))
(defn capture-canvas-pixels
"Captures the pixels of the viewport canvas"
[]
(when wasm/canvas
(let [context wasm/gl-context
width (.-width wasm/canvas)
height (.-height wasm/canvas)
buffer (js/Uint8ClampedArray. (* width height 4))
_ (.readPixels ^js context 0 0 width height (.-RGBA ^js context) (.-UNSIGNED_BYTE ^js context) buffer)
image-data (js/ImageData. buffer width height)]
(set! wasm/canvas-pixels image-data))))

View File

@@ -12,6 +12,8 @@
;; Reference to the HTML canvas element.
(defonce canvas nil)
;; Reference to the captured pixels of the canvas (for page switching effect)
(defonce canvas-pixels nil)
;; Reference to the Emscripten GL context wrapper.
(defonce gl-context-handle nil)
@@ -56,3 +58,4 @@
:stroke-linecap shared/RawStrokeLineCap
:stroke-linejoin shared/RawStrokeLineJoin
:fill-rule shared/RawFillRule})

View File

@@ -802,9 +802,10 @@
([uri name]
(open-new-window uri name "noopener,noreferrer"))
([uri name features]
(let [new-window (.open js/window (str uri) name features)]
(when-let [new-window (.open js/window (str uri) name features)]
(when (not= name "_blank")
(.reload (.-location new-window))))))
(when-let [location (.-location new-window)]
(.reload location))))))
(defn browser-back
[]

View File

@@ -179,6 +179,7 @@
(->> (render-canvas-blob canvas width height bgcolor)
(p/fnly (fn [data cause]
(wasm.api/clear-canvas)
(if cause
(rx/error! subs cause)
(rx/push! subs

View File

@@ -5476,6 +5476,10 @@ msgstr "Delete row and shapes"
msgid "workspace.context-menu.grid-track.row.duplicate"
msgstr "Duplicate row"
#: src/app/main/ui/workspace/sidebar/debug.cljs:37
msgid "workspace.debug.title"
msgstr "Debugging tools"
#: src/app/main/ui/workspace/sidebar/layers.cljs:512
msgid "workspace.focus.focus-mode"
msgstr "Focus mode"
@@ -8421,6 +8425,10 @@ msgstr "Comments (%s)"
msgid "workspace.toolbar.curve"
msgstr "Curve (%s)"
#: src/app/main/ui/workspace/top_toolbar.cljs:231
msgid "workspace.toolbar.debug"
msgstr "Debugging tools"
#: src/app/main/ui/workspace/top_toolbar.cljs:172
msgid "workspace.toolbar.ellipse"
msgstr "Ellipse (%s)"
@@ -8731,10 +8739,10 @@ msgstr ""
msgid "workspace.versions.warning.text"
msgstr "Autosaved versions will be kept for %s days."
msgid "labels.webgl-context-lost.main-message"
msgid "errors.webgl-context-lost.main-message"
msgstr "Oops! The canvas context was lost"
msgid "labels.webgl-context-lost.desc-message"
msgid "errors.webgl-context-lost.desc-message"
msgstr "WebGL has stopped working. Please reload the page to reset it"
#, unused

View File

@@ -5461,6 +5461,10 @@ msgstr "Borrar fila con el contenido"
msgid "workspace.context-menu.grid-track.row.duplicate"
msgstr "Duplicar fila"
#: src/app/main/ui/workspace/sidebar/debug.cljs:37
msgid "workspace.debug.title"
msgstr "Herramientas de depuración"
#: src/app/main/ui/workspace/sidebar/layers.cljs:512
msgid "workspace.focus.focus-mode"
msgstr "Modo foco"
@@ -7965,7 +7969,7 @@ msgstr "Line height (multiplicador, px o %) o {alias}"
#: src/app/main/data/workspace/tokens/errors.cljs:57
msgid "workspace.tokens.missing-references"
msgstr "Referéncias de tokens no encontradas:"
msgstr "Referencias de tokens no encontradas: "
#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:123
msgid "workspace.tokens.more-options"
@@ -8282,6 +8286,10 @@ msgstr "Comentarios (%s)"
msgid "workspace.toolbar.curve"
msgstr "Curva (%s)"
#: src/app/main/ui/workspace/top_toolbar.cljs:231
msgid "workspace.toolbar.debug"
msgstr "Herramientas de depuración"
#: src/app/main/ui/workspace/top_toolbar.cljs:172
msgid "workspace.toolbar.ellipse"
msgstr "Elipse (%s)"
@@ -8582,8 +8590,8 @@ msgstr "Los autoguardados duran %s días."
msgid "workspace.viewport.click-to-close-path"
msgstr "Pulsar para cerrar la ruta"
msgid "labels.webgl-context-lost.main-message"
msgid "errors.webgl-context-lost.main-message"
msgstr "Ups! Se ha perdido el contexto del canvas"
msgid "labels.webgl-context-lost.desc-message"
msgstr "WebGL ha dejado de funcionar. Por favor, recarga la página para restaurarlo"
msgid "errors.webgl-context-lost.desc-message"
msgstr "WebGL ha dejado de funcionar. Por favor, recarga la página para restaurarlo"

View File

@@ -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
```
pnpm run start:styles-example
npm 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 `pnpm install` to install the required dependencies.
Then, run `pnpm run start` to launch the plugins wrapper.
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.
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
pnpm run start:plugin:contrast
npm run start:plugin:contrast
```
Finally, open in your browser the specific port. In this specific example would be `http://localhost:4302`
@@ -49,22 +49,21 @@ 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 | 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 |
| 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 |
## Web Apps
| App | Description | PORT | Start command | URL |
| --------------- | ----------------------------------------------------------------- | ---- | -------------------------------- | ---------------------- |
| 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/ |
| 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/ |
## Creating a plugin from scratch

View File

@@ -1,51 +0,0 @@
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,
},
},
},
];

View File

@@ -1,79 +0,0 @@
{
"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"
}
}
}
}

View File

@@ -1,127 +0,0 @@
/* @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;
}

View File

@@ -1,144 +0,0 @@
<div class="container">
<p class="title-l">Design tokens plugin POC</p>
<div class="columns">
<div class="panels">
<div class="panel">
<div class="panel-heading">
<p class="headline-m">THEMES</p>
<button
type="button"
data-appearance="secondary"
(click)="addTheme()"
>
+
</button>
</div>
<ul data-handler="themes-list">
@for (theme of themes; track theme.id) {
<li class="body-m panel-item theme-item">
<span>{{ theme.group }} / {{ theme.name }}</span>
<button
type="button"
data-appearance="secondary"
(click)="renameTheme(theme.id, theme.name)"
>
🖊️
</button>
<button
type="button"
data-appearance="secondary"
(click)="deleteTheme(theme.id)"
>
</button>
<div class="checkbox-container">
<input
class="checkbox-input"
type="checkbox"
id="checkbox1"
[checked]="isThemeActive(theme.id)"
(change)="toggleTheme(theme.id)"
/>
</div>
</li>
}
</ul>
</div>
<div class="panel">
<div class="panel-heading">
<p class="headline-m">SETS</p>
<button type="button" data-appearance="secondary" (click)="addSet()">
+
</button>
</div>
<ul data-handler="sets-list">
@for (set of sets; track set.id) {
<li
class="body-m panel-item set-item"
[class.selected]="set.id === currentSetId"
>
<span (click)="loadTokens(set.id)">
{{ set.name }}
</span>
<button
type="button"
data-appearance="secondary"
(click)="renameSet(set.id, set.name)"
>
🖊️
</button>
<button
type="button"
data-appearance="secondary"
(click)="deleteSet(set.id)"
>
</button>
<div class="checkbox-container">
<input
class="checkbox-input"
type="checkbox"
id="checkbox1"
[checked]="isSetActive(set.id)"
(change)="toggleSet(set.id)"
/>
</div>
</li>
}
</ul>
</div>
</div>
<div class="panels">
<div class="panel">
<p class="headline-m">TOKENS</p>
<ul data-handler="tokens-list">
@for (group of tokenGroups; track group[0]) {
<li class="body-m token-group">
<span>{{ group[0] }}</span>
<button
type="button"
data-appearance="secondary"
(click)="addToken(group[0])"
>
+
</button>
</li>
@for (token of group[1]; track token.id) {
<li
class="body-m panel-item token-item"
(click)="applyToken(token.id)"
>
<span>{{ token.name }}</span>
<button
type="button"
data-appearance="secondary"
(click)="renameToken(token.id, token.name)"
>
🖊️
</button>
<button
type="button"
data-appearance="secondary"
(click)="deleteToken(token.id)"
>
</button>
</li>
}
}
</ul>
</div>
</div>
</div>
<div class="buttons">
<button type="button" data-appearance="primary" (click)="loadLibrary()">
Load
</button>
</div>
</div>

View File

@@ -1,290 +0,0 @@
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<MessageEvent<PluginMessageEvent>>(
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);
}
}

View File

@@ -1,11 +0,0 @@
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),
],
};

View File

@@ -1,3 +0,0 @@
import { Routes } from '@angular/router';
export const routes: Routes = [];

View File

@@ -1 +0,0 @@
*

View File

@@ -1,2 +0,0 @@
/*
Access-Control-Allow-Origin: *

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -1,14 +0,0 @@
{
"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"
]
}

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Angular example plugin</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -1,7 +0,0 @@
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),
);

View File

@@ -1,112 +0,0 @@
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;

View File

@@ -1,246 +0,0 @@
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<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.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);
// }
// }
}

View File

@@ -1,23 +0,0 @@
/* @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);
}

View File

@@ -1,10 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": []
},
"files": ["src/main.ts"],
"include": ["src/**/*.d.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"]
}

View File

@@ -1,7 +0,0 @@
{
"extends": "./tsconfig.json",
"include": ["src/**/*.ts"],
"compilerOptions": {
"types": ["node"]
}
}

View File

@@ -1,33 +0,0 @@
{
"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
}
}

View File

@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": []
},
"files": ["src/plugin.ts"],
"include": ["../../libs/plugin-types/index.d.ts"]
}

View File

File diff suppressed because it is too large Load Diff

27170
plugins/package-lock.json generated Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,6 @@
"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",

View File

@@ -90,6 +90,18 @@ impl Type {
}
}
pub fn clear_corners(&mut self) {
match self {
Type::Rect(data) => {
data.corners = None;
}
Type::Frame(data) => {
data.corners = None;
}
_ => {}
}
}
pub fn path(&self) -> Option<&Path> {
match self {
Type::Path(path) => Some(path),
@@ -694,9 +706,11 @@ impl Shape {
pub fn set_corners(&mut self, raw_corners: (f32, f32, f32, f32)) {
if let Some(corners) = make_corners(raw_corners) {
self.shape_type.set_corners(corners);
self.invalidate_bounds();
self.invalidate_extrect();
} else {
self.shape_type.clear_corners();
}
self.invalidate_bounds();
self.invalidate_extrect();
}
pub fn set_svg(&mut self, svg: skia::svg::Dom) {
@@ -1590,6 +1604,13 @@ mod tests {
} else {
unreachable!();
}
shape.set_corners((0.0, 0.0, 0.0, 0.0));
if let Type::Rect(Rect { corners, .. }) = shape.shape_type {
assert_eq!(corners, None);
} else {
unreachable!();
}
}
#[test]