Compare commits

...

51 Commits

Author SHA1 Message Date
David Barragán Merino
c1335961b4 🔧 Deploy penpot api documentation 2026-01-21 18:52:07 +01:00
Andrey Antukh
b70eb768e0 Merge remote-tracking branch 'origin/staging' into develop 2026-01-21 13:51:41 +01:00
Andrey Antukh
b8c70be9a2 Make frontend build and watch process more resilent to errors 2026-01-21 13:44:35 +01:00
Andrey Antukh
525adcfcbe Add wasm build on watch app script (devenv) 2026-01-21 13:44:35 +01:00
Eva Marco
7cce4c6532 🐛 Fix unhandled exception tokens creation dialog (#8136) 2026-01-21 13:09:22 +01:00
Alejandro Alonso
a3fdd8b691 Merge pull request #8147 from penpot/niwinz-staging-file-menu-issue
 Use correct team-id on file-menu on dashboard
2026-01-21 12:54:28 +01:00
Andrey Antukh
b6a9579c98 Use correct team-id on file-menu on dashboard
Before the changes on this commit, the team object is used for
retrieve the id, where we already have team-id. Additionally, the
team object resolution is async operation and is not available on
the first render which causes strange issues on automated flows
(playwright) where an option is clicked when the async flow is
still pending and we have no team object loaded.
2026-01-21 12:23:44 +01:00
Andrey Antukh
4397ede5c1 Merge branch 'staging-render' into develop 2026-01-21 10:18:15 +01:00
Andrey Antukh
ff25df0457 Merge remote-tracking branch 'origin/staging' into staging-render 2026-01-21 10:17:22 +01:00
Eva Marco
8c7fd0af4b 🐛 Fix shadow reference validation (#8132) 2026-01-21 09:17:03 +01:00
Andrey Antukh
cf46051f56 🔥 Remove .traivis.yml file from the repository 2026-01-20 19:40:31 +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
6393330ee1 Merge remote-tracking branch 'origin/staging' 2026-01-20 16:25:10 +01:00
Andrey Antukh
8252bc485e 📚 Fix oidc callback related documentation issue 2026-01-20 16:24:12 +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
Alonso Torres
47775a9e2c Merge pull request #8134 from penpot/alotor-fix-plugins-export
🐛 Fix problem with export in plugins
2026-01-20 15:03:04 +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
Andrey Antukh
8191d04114 Use non-legacy config example on docker compose file 2026-01-20 13:25:55 +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
Alejandro Alonso
b7c2d9a079 Merge pull request #8130 from penpot/superalex-improve-zoom-pan-performance-7
🐛 Fix some tiles disappear after fast zoom and pan
2026-01-20 12:56:02 +01:00
Alejandro Alonso
aeb34a6f64 Merge pull request #8109 from penpot/superalex-fix-text-selrect-calculation
🐛 Render wasm typography token issues
2026-01-20 12:54:45 +01:00
Alejandro Alonso
6fa0c3af0c 🐛 Fix some tiles disappear after fast zoom and pan 2026-01-20 12:40:01 +01:00
Alejandro Alonso
260b9fb040 🐛 Fix texts with auto size updated via tokens with render wasm
activated
2026-01-20 12:39:17 +01:00
Alejandro Alonso
884954f4ff 🐛 Fix text selrect calculation 2026-01-20 12:37:57 +01:00
Andrey Antukh
88f0f75174 Merge pull request #8129 from penpot/niwinz-staging-bugfix-1
 Several improvements for build process
2026-01-20 12:26:55 +01:00
Andrey Antukh
1ffa956251 Include timestamp on version tag 2026-01-20 12:26:39 +01:00
Andrey Antukh
31054099ff Use pseudo-names on release builds of frontend (#8105) 2026-01-20 12:26:39 +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
Eva Marco
983487d73c 🐛 Fix shadow token reference validation (#8128) 2026-01-20 10:56:27 +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
Andrey Antukh
5c71c57dd9 Merge tag '2.12.1' 2025-12-30 15:37:30 +01:00
Andrey Antukh
5abc1aafb4 Merge tag '2.12.0-RC3' 2025-12-12 12:19:29 +01:00
Andrey Antukh
935728aa39 🔧 Backport build-tag github workflow from develop 2025-12-05 10:26:01 +01:00
86 changed files with 1509 additions and 704 deletions

View File

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

View File

@@ -10,6 +10,11 @@ jobs:
runs-on: penpot-runner-02 runs-on: penpot-runner-02
steps: 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 - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4

View File

@@ -22,6 +22,11 @@ jobs:
runs-on: penpot-runner-02 runs-on: penpot-runner-02
steps: 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 - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
@@ -66,6 +71,15 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} 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) - name: Extract metadata (tags, labels)
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5

View File

@@ -0,0 +1,73 @@
name: Plugins/api-doc deployer
on:
push:
branches:
- develop
- staging
- main
paths:
- "plugins/**"
- ".github/workflows/deploy-plugin-docs.yml"
- "wrangle-penpot-plugins-api-doc.toml"
workflow_dispatch:
inputs:
gh_ref:
description: 'Name of the branch or ref'
type: string
required: true
default: 'develop'
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
defaults:
run:
working-directory: plugins
steps:
- name: Extract some useful variables
id: vars
run: |
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ steps.vars.outputs.gh_ref }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
cache: "pnpm"
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build docs
run: pnpm run build:doc
- name: Select Worker name
run: |
REF="${{ steps.vars.outputs.gh_ref }}"
case "$REF" in
main) echo "WORKER_NAME=penpot-plugins-api-doc-pro" >> $GITHUB_ENV ;;
staging) echo "WORKER_NAME=penpot-plugins-api-doc-pre" >> $GITHUB_ENV ;;
develop) echo "WORKER_NAME=penpot-plugins-api-doc-hourly" >> $GITHUB_ENV ;;
*) echo "Unsupported branch ${REF}" && exit 1 ;;
esac
- name: Deploy to Cloudflare Workers
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: deploy --config wrangle-penpot-plugins-api-doc.toml --name ${{ env.WORKER_NAME }}

View File

@@ -1,40 +0,0 @@
dist: xenial
language: generic
sudo: required
cache:
directories:
- $HOME/.m2
services:
- docker
branches:
only:
- master
- develop
install:
- curl -O https://download.clojure.org/install/linux-install-1.10.1.447.sh
- chmod +x linux-install-1.10.1.447.sh
- sudo ./linux-install-1.10.1.447.sh
before_script:
- env | sort
script:
- ./manage.sh build-devenv
- ./manage.sh run-frontend-tests
- ./manage.sh run-backend-tests
- ./manage.sh build-images
- ./manage.sh run
after_script:
- docker images
notifications:
email: false
env:
- NODE_VERSION=10.16.0

View File

@@ -14,7 +14,8 @@
- Tokens panel nested path view [Taiga #9966](https://tree.taiga.io/project/penpot/us/9966) - 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) - 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) - 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 ### :bug: Bugs fixed
@@ -22,6 +23,8 @@
- Fix wrong image in the onboarding invitation block [Taiga #13040](https://tree.taiga.io/project/penpot/issue/13040) - 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 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 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)
## 2.13.0 (Unreleased) ## 2.13.0 (Unreleased)
@@ -169,7 +172,6 @@ example. It's still usable as before, we just removed the example.
- Deprecated configuration variables with the prefix `PENPOT_ASSETS_*`, and will be - Deprecated configuration variables with the prefix `PENPOT_ASSETS_*`, and will be
removed in future versions: removed in future versions:
- The `PENPOT_ASSETS_STORAGE_BACKEND` becomes `PENPOT_OBJECTS_STORAGE_BACKEND` and its - The `PENPOT_ASSETS_STORAGE_BACKEND` becomes `PENPOT_OBJECTS_STORAGE_BACKEND` and its
values passes from (`assets-fs` or `assets-s3`) to (`fs` or `s3`) values passes from (`assets-fs` or `assets-s3`) to (`fs` or `s3`)
- The `PENPOT_STORAGE_ASSETS_FS_DIRECTORY` becomes `PENPOT_OBJECTS_STORAGE_FS_DIRECTORY` - The `PENPOT_STORAGE_ASSETS_FS_DIRECTORY` becomes `PENPOT_OBJECTS_STORAGE_FS_DIRECTORY`

View File

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

View File

@@ -152,9 +152,9 @@ services:
# AWS_ACCESS_KEY_ID: <KEY_ID> # AWS_ACCESS_KEY_ID: <KEY_ID>
# AWS_SECRET_ACCESS_KEY: <ACCESS_KEY> # AWS_SECRET_ACCESS_KEY: <ACCESS_KEY>
# PENPOT_ASSETS_STORAGE_BACKEND: assets-s3 # PENPOT_OBJECTS_STORAGE_BACKEND: s3
# PENPOT_STORAGE_ASSETS_S3_ENDPOINT: <ENDPOINT> # PENPOT_OBJECTS_STORAGE_S3_ENDPOINT: <ENDPOINT>
# PENPOT_STORAGE_ASSETS_S3_BUCKET: <BUKET_NAME> # PENPOT_OBJECTS_STORAGE_S3_BUCKET: <BUKET_NAME>
## Telemetry. When enabled, a periodical process will send anonymous data about this ## Telemetry. When enabled, a periodical process will send anonymous data about this
## instance. Telemetry data will enable us to learn how the application is used, ## instance. Telemetry data will enable us to learn how the application is used,

View File

@@ -114,14 +114,7 @@ configuration.
The callback has the following format: The callback has the following format:
```html ```html
https://<your_domain>/api/auth/oauth/<oauth_provider>/callback https://<your_domain>/api/auth/oidc/callback
```
You will need to change <your_domain> and <oauth_provider> according to your setup.
This is how it looks with Gitlab provider:
```html
https://<your_domain>/api/auth/oauth/gitlab/callback
``` ```
#### Google #### Google

View File

@@ -48,7 +48,7 @@
"watch:app:main": "clojure -M:dev:shadow-cljs watch main worker storybook", "watch:app:main": "clojure -M:dev:shadow-cljs watch main worker storybook",
"clear:shadow-cache": "rm -rf .shadow-cljs", "clear:shadow-cache": "rm -rf .shadow-cljs",
"watch": "exit 0", "watch": "exit 0",
"watch:app": "yarn run clear:shadow-cache && concurrently --kill-others-on-fail \"yarn run watch:app:assets\" \"yarn run watch:app:main\" \"yarn run watch:app:libs\"", "watch:app": "yarn run clear:shadow-cache && yarn run build:wasm && concurrently --kill-others-on-fail \"yarn run watch:app:assets\" \"yarn run watch:app:main\" \"yarn run watch:app:libs\"",
"watch:storybook": "yarn run build:storybook:assets && concurrently --kill-others-on-fail \"storybook dev -p 6006 --no-open\" \"node ./scripts/watch-storybook.js\"" "watch:storybook": "yarn run build:storybook:assets && concurrently --kill-others-on-fail \"storybook dev -p 6006 --no-open\" \"node ./scripts/watch-storybook.js\""
}, },
"devDependencies": { "devDependencies": {

View File

@@ -101,6 +101,70 @@ test.describe("Tokens: Apply token", () => {
await expect(brTokenPillXL).not.toBeVisible(); 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 }) => { test("User applies typography token to a text shape", async ({ page }) => {
const { workspacePage, tokensSidebar, tokenContextMenuForToken } = const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTypographyTokensFile(page); await setupTypographyTokensFile(page);
@@ -129,189 +193,6 @@ test.describe("Tokens: Apply token", () => {
await expect(fontSizeInput).toHaveValue("100"); 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 ({ test("User adds shadow token with multiple shadows and applies it to shape", async ({
page, page,
}) => { }) => {

View File

@@ -14,7 +14,7 @@ test.beforeEach(async ({ page }) => {
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-tokens.json"); 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 }) => { test("User creates border radius token", async ({ page }) => {
await testTokenCreationFlow(page, { await testTokenCreationFlow(page, {
tokenLabel: "Border Radius", tokenLabel: "Border Radius",
@@ -1008,6 +1008,41 @@ test.describe("Tokens - CRUD", () => {
).toBeEnabled(); ).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 }) => { test("User creates typography token", async ({ page }) => {
const emptyNameError = "Name should be at least 1 character"; const emptyNameError = "Name should be at least 1 character";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } = const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
@@ -1256,6 +1291,228 @@ test.describe("Tokens - CRUD", () => {
).toBeEnabled(); ).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 ({ test("User edits typography token and all fields are valid", async ({
page, page,
}) => { }) => {
@@ -1388,67 +1645,7 @@ test.describe("Tokens - CRUD", () => {
await expect(colorTokenChanged).toBeVisible(); await expect(colorTokenChanged).toBeVisible();
}); });
test("User creates grouped color token", async ({ page }) => { test("User edits color token color while keeping custom color space", async ({
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 ({
page, page,
}) => { }) => {
const { workspacePage, tokensUpdateCreateModal, tokenThemesSetsSidebar } = const { workspacePage, tokensUpdateCreateModal, tokenThemesSetsSidebar } =
@@ -1502,30 +1699,9 @@ test.describe("Tokens - CRUD", () => {
await valueSaturationSelector.click({ position: { x: 0, y: 0 } }); await valueSaturationSelector.click({ position: { x: 0, y: 0 } });
await expect(valueField).toHaveValue(/^rgba(.*)$/); await expect(valueField).toHaveValue(/^rgba(.*)$/);
}); });
});
test("User duplicate color token", async ({ page }) => { test.describe("Tokens tab - delete", () => {
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 delete color token", async ({ page }) => { test("User delete color token", async ({ page }) => {
const { tokensSidebar, tokenContextMenuForToken } = const { tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page); await setupTokensFile(page);
@@ -1546,4 +1722,40 @@ test.describe("Tokens - CRUD", () => {
await expect(tokenContextMenuForToken).not.toBeVisible(); await expect(tokenContextMenuForToken).not.toBeVisible();
await expect(colorToken).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); --app-background: var(--color-background-primary);
--loader-background: var(--color-background-primary); --loader-background: var(--color-background-primary);
--panel-title-background-color: var(--color-background-secondary);
// BUTTONS // BUTTONS
--button-foreground-hover: var(--color-accent-primary); --button-foreground-hover: var(--color-accent-primary);

View File

@@ -17,17 +17,18 @@
<meta name="twitter:site" content="@penpotapp"> <meta name="twitter:site" content="@penpotapp">
<meta name="twitter:creator" content="@penpotapp"> <meta name="twitter:creator" content="@penpotapp">
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)"> <meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)">
<link id="theme" href="css/main.css?version={{& version}}" rel="stylesheet" type="text/css" /> <link id="theme" href="css/main.css?version={{& version_tag}}" rel="stylesheet" type="text/css" />
{{#isDebug}} {{#isDebug}}
<link href="css/debug.css?version={{& version}}" rel="stylesheet" type="text/css" /> <link href="css/debug.css?version={{& version_tag}}" rel="stylesheet" type="text/css" />
{{/isDebug}} {{/isDebug}}
<link rel="icon" href="images/favicon.png" /> <link rel="icon" href="images/favicon.png?version={{& version_tag }}" />
<script type="importmap">{{& manifest.importmap }}</script> <script type="importmap">{{& manifest.importmap }}</script>
<script type="module"> <script type="module">
globalThis.penpotVersion = "{{& version}}"; globalThis.penpotVersion = "{{& version}}";
globalThis.penpotVersionTag = "{{& version_tag}}";
globalThis.penpotBuildDate = "{{& build_date}}"; globalThis.penpotBuildDate = "{{& build_date}}";
globalThis.penpotWorkerURI = "{{& manifest.worker_main}}"; globalThis.penpotWorkerURI = "{{& manifest.worker_main}}";
</script> </script>

View File

@@ -3,10 +3,11 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Penpot - Rasterizer</title> <title>Penpot - Rasterizer</title>
<link rel="icon" href="images/favicon.png" /> <link rel="icon" href="images/favicon.png?version={{& version_tag }}" />
<script> <script>
globalThis.penpotVersion = "{{& version}}"; globalThis.penpotVersion = "{{& version}}";
globalThis.penpotVersionTag = "{{& version_tag}}";
globalThis.penpotBuildDate = "{{& build_date}}"; globalThis.penpotBuildDate = "{{& build_date}}";
globalThis.penpotWorkerURI = "{{& manifest.worker_main}}"; globalThis.penpotWorkerURI = "{{& manifest.worker_main}}";
</script> </script>

View File

@@ -4,10 +4,12 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta http-equiv="x-ua-compatible" content="ie=edge" /> <meta http-equiv="x-ua-compatible" content="ie=edge" />
<title>Penpot - Render</title> <title>Penpot - Render</title>
<link rel="icon" href="images/favicon.png" />
<link rel="icon" href="images/favicon.png?version={{& version_tag }}" />
<script> <script>
globalThis.penpotVersion = "{{& version}}"; globalThis.penpotVersion = "{{& version}}";
globalThis.penpotVersionTag = "{{& version_tag}}";
globalThis.penpotBuildDate = "{{& build_date}}"; globalThis.penpotBuildDate = "{{& build_date}}";
</script> </script>

View File

@@ -27,9 +27,11 @@ export function startWorker() {
}); });
} }
export const isDebug = process.env.NODE_ENV !== "production"; export const IS_DEBUG = process.env.NODE_ENV !== "production";
export const CURRENT_VERSION = process.env.CURRENT_VERSION || "develop"; export const BUILD_DATE = process.env.BUILD_DATE || (new Date().toString()) ;
export const BUILD_DATE = process.env.BUILD_DATE || "" + new Date(); export const BUILD_TS = process.env.BUILD_TS || Date.now();
export const VERSION = process.env.VERSION || "develop";
export const VERSION_TAG = process.env.VERSION_TAG || VERSION;
async function findFiles(basePath, predicate, options = {}) { async function findFiles(basePath, predicate, options = {}) {
predicate = predicate =
@@ -172,6 +174,7 @@ export async function watch(baseDir, predicate, callback) {
const watcher = new Watcher(baseDir, { const watcher = new Watcher(baseDir, {
persistent: true, persistent: true,
recursive: true, recursive: true,
debounce: 500
}); });
watcher.on("change", (path) => { watcher.on("change", (path) => {
@@ -179,8 +182,19 @@ export async function watch(baseDir, predicate, callback) {
callback(path); callback(path);
} }
}); });
watcher.on("error", (cause) => {
console.log("WATCHER ERROR", cause);
});
} }
export async function ensureDirectories() {
await fs.mkdir("./resources/public/js/worker/", { recursive: true });
await fs.mkdir("./resources/public/css/", { recursive: true });
}
async function readManifestFile(resource) { async function readManifestFile(resource) {
const manifestPath = "resources/public/" + resource; const manifestPath = "resources/public/" + resource;
let content = await fs.readFile(manifestPath, { encoding: "utf8" }); let content = await fs.readFile(manifestPath, { encoding: "utf8" });
@@ -193,25 +207,25 @@ async function generateManifest() {
render_main: "./js/render.js", render_main: "./js/render.js",
rasterizer_main: "./js/rasterizer.js", rasterizer_main: "./js/rasterizer.js",
config: "./js/config.js?version=" + CURRENT_VERSION, config: "./js/config.js?version=" + VERSION_TAG,
polyfills: "./js/polyfills.js?version=" + CURRENT_VERSION, polyfills: "./js/polyfills.js?version=" + VERSION_TAG,
libs: "./js/libs.js?version=" + CURRENT_VERSION, libs: "./js/libs.js?version=" + VERSION_TAG,
worker_main: "./js/worker/main.js?version=" + CURRENT_VERSION, worker_main: "./js/worker/main.js?version=" + VERSION_TAG,
default_translations: "./js/translation.en.js?version=" + CURRENT_VERSION, default_translations: "./js/translation.en.js?version=" + VERSION_TAG,
importmap: JSON.stringify({ importmap: JSON.stringify({
"imports": { "imports": {
"./js/shared.js": "./js/shared.js?version=" + CURRENT_VERSION, "./js/shared.js": "./js/shared.js?version=" + VERSION_TAG,
"./js/main.js": "./js/main.js?version=" + CURRENT_VERSION, "./js/main.js": "./js/main.js?version=" + VERSION_TAG,
"./js/render.js": "./js/render.js?version=" + CURRENT_VERSION, "./js/render.js": "./js/render.js?version=" + VERSION_TAG,
"./js/render-wasm.js": "./js/render-wasm.js?version=" + CURRENT_VERSION, "./js/render-wasm.js": "./js/render-wasm.js?version=" + VERSION_TAG,
"./js/rasterizer.js": "./js/rasterizer.js?version=" + CURRENT_VERSION, "./js/rasterizer.js": "./js/rasterizer.js?version=" + VERSION_TAG,
"./js/main-dashboard.js": "./js/main-dashboard.js?version=" + CURRENT_VERSION, "./js/main-dashboard.js": "./js/main-dashboard.js?version=" + VERSION_TAG,
"./js/main-auth.js": "./js/main-auth.js?version=" + CURRENT_VERSION, "./js/main-auth.js": "./js/main-auth.js?version=" + VERSION_TAG,
"./js/main-viewer.js": "./js/main-viewer.js?version=" + CURRENT_VERSION, "./js/main-viewer.js": "./js/main-viewer.js?version=" + VERSION_TAG,
"./js/main-settings.js": "./js/main-settings.js?version=" + CURRENT_VERSION, "./js/main-settings.js": "./js/main-settings.js?version=" + VERSION_TAG,
"./js/main-workspace.js": "./js/main-workspace.js?version=" + CURRENT_VERSION, "./js/main-workspace.js": "./js/main-workspace.js?version=" + VERSION_TAG,
"./js/util-highlight.js": "./js/util-highlight.js?version=" + CURRENT_VERSION "./js/util-highlight.js": "./js/util-highlight.js?version=" + VERSION_TAG
} }
}) })
}; };
@@ -222,11 +236,12 @@ async function generateManifest() {
async function renderTemplate(path, context = {}, partials = {}) { async function renderTemplate(path, context = {}, partials = {}) {
const content = await fs.readFile(path, { encoding: "utf-8" }); const content = await fs.readFile(path, { encoding: "utf-8" });
const ts = Math.floor(new Date());
context = Object.assign({}, context, { context = Object.assign({}, context, {
ts: ts, isDebug: IS_DEBUG,
isDebug, version: VERSION,
version_tag: VERSION_TAG,
build_date: BUILD_DATE,
build_ts: BUILD_TS,
}); });
return mustache.render(content, context, partials); return mustache.render(content, context, partials);
@@ -257,6 +272,9 @@ const markedOptions = {
marked.use(markedOptions); marked.use(markedOptions);
export async function compileTranslations() { export async function compileTranslations() {
const outputDir = "resources/public/js/";
await fs.mkdir(outputDir, { recursive: true });
const langs = [ const langs = [
"ar", "ar",
"ca", "ca",
@@ -338,7 +356,6 @@ export async function compileTranslations() {
} }
const esm = `export default ${JSON.stringify(result, null, 0)};\n`; const esm = `export default ${JSON.stringify(result, null, 0)};\n`;
const outputDir = "resources/public/js/";
const outputFile = ph.join(outputDir, "translation." + lang + ".js"); const outputFile = ph.join(outputDir, "translation." + lang + ".js");
await fs.writeFile(outputFile, esm); await fs.writeFile(outputFile, esm);
} }
@@ -390,7 +407,6 @@ async function generateSvgSprites() {
} }
async function generateTemplates() { async function generateTemplates() {
const isDebug = process.env.NODE_ENV !== "production";
await fs.mkdir("./resources/public/", { recursive: true }); await fs.mkdir("./resources/public/", { recursive: true });
const manifest = await generateManifest(); const manifest = await generateManifest();
@@ -415,10 +431,7 @@ async function generateTemplates() {
}; };
const context = { const context = {
manifest: manifest, manifest: manifest
version: CURRENT_VERSION,
build_date: BUILD_DATE,
isDebug,
}; };
content = await renderTemplate( content = await renderTemplate(
@@ -487,7 +500,7 @@ export async function compileStyles() {
await fs.mkdir("./resources/public/css", { recursive: true }); await fs.mkdir("./resources/public/css", { recursive: true });
await fs.writeFile("./resources/public/css/main.css", result); await fs.writeFile("./resources/public/css/main.css", result);
if (isDebug) { if (IS_DEBUG) {
let debugCSS = await compileSassDebug(worker); let debugCSS = await compileSassDebug(worker);
await fs.writeFile("./resources/public/css/debug.css", debugCSS); await fs.writeFile("./resources/public/css/debug.css", debugCSS);
} }
@@ -500,17 +513,43 @@ export async function compileStyles() {
export async function compileSvgSprites() { export async function compileSvgSprites() {
const start = process.hrtime(); const start = process.hrtime();
log.info("init: compile svgsprite"); log.info("init: compile svgsprite");
await generateSvgSprites(); let error = false;
try {
await generateSvgSprites();
} catch (cause) {
error = cause;
}
const end = process.hrtime(start); const end = process.hrtime(start);
log.info("done: compile svgsprite", `(${ppt(end)})`);
if (error) {
log.error("error: compile svgsprite", `(${ppt(end)})`);
console.error(error);
} else {
log.info("done: compile svgsprite", `(${ppt(end)})`);
}
} }
export async function compileTemplates() { export async function compileTemplates() {
const start = process.hrtime(); const start = process.hrtime();
let error = false;
log.info("init: compile templates"); log.info("init: compile templates");
await generateTemplates();
try {
await generateTemplates();
} catch (cause) {
error = cause;
}
const end = process.hrtime(start); const end = process.hrtime(start);
log.info("done: compile templates", `(${ppt(end)})`);
if (error) {
log.error("error: compile templates", `(${ppt(end)})`);
console.error(error);
} else {
log.info("done: compile templates", `(${ppt(end)})`);
}
} }
export async function compilePolyfills() { export async function compilePolyfills() {

View File

@@ -28,14 +28,12 @@ async function compileFile(path) {
], ],
sourceMap: false, sourceMap: false,
}); });
// console.dir(result);
resolve({ resolve({
inputPath: path, inputPath: path,
outputPath: dest, outputPath: dest,
css: result.css, css: result.css,
}); });
} catch (cause) { } catch (cause) {
console.error(cause);
reject(cause); reject(cause);
} }
}); });

View File

@@ -2,26 +2,26 @@
# NOTE: this script should be called from the parent directory to # NOTE: this script should be called from the parent directory to
# properly work. # properly work.
set -ex
export INCLUDE_STORYBOOK=${BUILD_STORYBOOK:-no}; export INCLUDE_STORYBOOK=${BUILD_STORYBOOK:-no};
export INCLUDE_WASM=${BUILD_WASM:-yes}; export INCLUDE_WASM=${BUILD_WASM:-yes};
export CURRENT_VERSION=$1;
export BUILD_DATE=$(date -R);
export CURRENT_HASH=${CURRENT_HASH:-$(git rev-parse --short HEAD)};
export EXTRA_PARAMS=$SHADOWCLJS_EXTRA_PARAMS; export EXTRA_PARAMS=$SHADOWCLJS_EXTRA_PARAMS;
export TS=$(date +%s);
export BUILD_DATE=$(date -R);
export BUILD_TS=$(date +%s);
export VERSION=${1:-develop};
export VERSION_TAG="${VERSION}-${BUILD_TS}";
# Some cljs reacts on this environment variable for define more # Some cljs reacts on this environment variable for define more
# performant code on macros (example: rumext) # performant code on macros (example: rumext)
export NODE_ENV=production; export NODE_ENV=production;
echo "Current path:"
echo $PATH
set -ex
corepack enable; corepack enable;
corepack install; corepack install;
yarn install || exit 1; yarn install;
rm -rf target/dist; rm -rf target/dist;
rm -rf resources/public; rm -rf resources/public;
@@ -37,7 +37,7 @@ yarn run build:app:main $EXTRA_PARAMS;
yarn run build:app:libs; yarn run build:app:libs;
yarn run build:app:assets; yarn run build:app:assets;
sed -i "s/\.\/render.js/.\/render.js?version=$CURRENT_VERSION/g" resources/public/js/worker/main*.js sed -i "s/\.\/render.js/.\/render.js?version=$VERSION_TAG/g" resources/public/js/worker/main*.js
rsync -avr resources/public/ target/dist/ rsync -avr resources/public/ target/dist/

View File

@@ -1,5 +1,6 @@
import * as h from "./_helpers.js"; import * as h from "./_helpers.js";
await h.ensureDirectories();
await h.compileStyles(); await h.compileStyles();
await h.copyAssets(); await h.copyAssets();
await h.copyWasmPlayground(); await h.copyWasmPlayground();

View File

@@ -2,18 +2,16 @@
# NOTE: this script should be called from the parent directory to # NOTE: this script should be called from the parent directory to
# properly work. # properly work.
export CURRENT_VERSION=$1; set -ex
export BUILD_TS=$(date +%s);
export BUILD_DATE=$(date -R); export BUILD_DATE=$(date -R);
export CURRENT_HASH=${CURRENT_HASH:-$(git rev-parse --short HEAD)};
export TS=$(date +%s); export VERSION=${1:-develop};
export VERSION_TAG="${VERSION}-${BUILD_TS}";
export NODE_ENV=production; export NODE_ENV=production;
echo "Current path:"
echo $PATH
set -ex
corepack enable; corepack enable;
corepack install || exit 1; corepack install || exit 1;
yarn install || exit 1; yarn install || exit 1;

View File

@@ -12,19 +12,31 @@ let sass = null;
async function compileSassAll() { async function compileSassAll() {
const start = process.hrtime(); const start = process.hrtime();
let error = false;
log.info("init: compile styles"); log.info("init: compile styles");
sass = await h.compileSassAll(worker); try {
let output = await h.concatSass(sass); sass = await h.compileSassAll(worker);
await fs.writeFile("./resources/public/css/main.css", output); let output = await h.concatSass(sass);
await fs.writeFile("./resources/public/css/main.css", output);
if (isDebug) { if (isDebug) {
let debugCSS = await h.compileSassDebug(worker); let debugCSS = await h.compileSassDebug(worker);
await fs.writeFile("./resources/public/css/debug.css", debugCSS); await fs.writeFile("./resources/public/css/debug.css", debugCSS);
}
} catch (cause) {
error = cause;
} }
const end = process.hrtime(start); const end = process.hrtime(start);
log.info("done: compile styles", `(${ppt(end)})`);
if (error) {
log.error("error: compile styles", `(${ppt(end)})`);
console.error(error);
} else {
log.info("done: compile styles", `(${ppt(end)})`);
}
} }
async function compileSass(path) { async function compileSass(path) {
@@ -48,7 +60,7 @@ async function compileSass(path) {
} }
} }
await fs.mkdir("./resources/public/css/", { recursive: true }); await h.ensureDirectories();
await compileSassAll(); await compileSassAll();
await h.copyAssets(); await h.copyAssets();
await h.copyWasmPlayground(); await h.copyWasmPlayground();

View File

@@ -95,6 +95,7 @@
(def browser (parse-browser)) (def browser (parse-browser))
(def platform (parse-platform)) (def platform (parse-platform))
(def version-tag (obj/get global "penpotVersionTag"))
(def terms-of-service-uri (obj/get global "penpotTermsOfServiceURI")) (def terms-of-service-uri (obj/get global "penpotTermsOfServiceURI"))
(def privacy-policy-uri (obj/get global "penpotPrivacyPolicyURI")) (def privacy-policy-uri (obj/get global "penpotPrivacyPolicyURI"))
(def flex-help-uri (obj/get global "penpotGridHelpURI" "https://help.penpot.app/user-guide/flexible-layouts/")) (def flex-help-uri (obj/get global "penpotGridHelpURI" "https://help.penpot.app/user-guide/flexible-layouts/"))
@@ -190,9 +191,8 @@
(defn resolve-href (defn resolve-href
[resource] [resource]
(let [version (get version :full) (let [href (-> public-uri
href (-> public-uri (u/ensure-path-slash)
(u/ensure-path-slash) (u/join resource)
(u/join resource) (get :path))]
(get :path))] (str href "?version=" version-tag)))
(str href "?version=" version)))

View File

@@ -27,8 +27,10 @@
[app.main.data.workspace.colors :as wdc] [app.main.data.workspace.colors :as wdc]
[app.main.data.workspace.shape-layout :as dwsl] [app.main.data.workspace.shape-layout :as dwsl]
[app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.texts :as dwt]
[app.main.data.workspace.transforms :as dwtr] [app.main.data.workspace.transforms :as dwtr]
[app.main.data.workspace.undo :as dwu] [app.main.data.workspace.undo :as dwu]
[app.main.features :as features]
[app.main.fonts :as fonts] [app.main.fonts :as fonts]
[app.main.store :as st] [app.main.store :as st]
[app.util.i18n :refer [tr]] [app.util.i18n :refer [tr]]
@@ -300,11 +302,20 @@
update-fn (fn [node _] update-fn (fn [node _]
(-> node (-> node
(d/txt-merge txt-attrs) (d/txt-merge txt-attrs)
(cty/remove-typography-from-node)))] (cty/remove-typography-from-node)))
(dwsh/update-shapes shape-ids ;; Check if any attribute affects text layout (requires resize)
#(txt/update-text-content % update-node? update-fn nil) affects-layout? (some #(contains? txt-attrs %) [:font-size :font-family :font-weight :letter-spacing :line-height])]
{:ignore-touched true (ptk/reify ::generate-text-shape-update
:page-id page-id}))) ptk/WatchEvent
(watch [_ state _]
(cond-> (rx/of (dwsh/update-shapes shape-ids
#(txt/update-text-content % update-node? update-fn nil)
{:ignore-touched true
:page-id page-id}))
(and affects-layout?
(features/active-feature? state "render-wasm/v1"))
(rx/merge
(rx/of (dwt/resize-wasm-text-all shape-ids))))))))
(defn update-line-height (defn update-line-height
([value shape-ids attributes] (update-line-height value shape-ids attributes nil)) ([value shape-ids attributes] (update-line-height value shape-ids attributes nil))
@@ -353,11 +364,17 @@
(-> node (-> node
(d/txt-merge txt-attrs) (d/txt-merge txt-attrs)
(cty/remove-typography-from-node))))] (cty/remove-typography-from-node))))]
(dwsh/update-shapes shape-ids (ptk/reify ::generate-font-family-text-shape-update
(fn [shape] ptk/WatchEvent
(txt/update-text-content shape update-node? #(update-fn %1 (ctst/font-weight-applied? shape)) nil)) (watch [_ state _]
{:ignore-touched true (cond-> (rx/of (dwsh/update-shapes shape-ids
:page-id page-id}))) (fn [shape]
(txt/update-text-content shape update-node? #(update-fn %1 (ctst/font-weight-applied? shape)) nil))
{:ignore-touched true
:page-id page-id}))
(features/active-feature? state "render-wasm/v1")
(rx/merge
(rx/of (dwt/resize-wasm-text-all shape-ids))))))))
(defn- create-font-family-text-attrs (defn- create-font-family-text-attrs
[value] [value]
@@ -425,10 +442,16 @@
(-> node (-> node
(d/txt-merge txt-attrs) (d/txt-merge txt-attrs)
(cty/remove-typography-from-node))))] (cty/remove-typography-from-node))))]
(dwsh/update-shapes shape-ids (ptk/reify ::generate-font-weight-text-shape-update
#(txt/update-text-content % update-node? update-fn nil) ptk/WatchEvent
{:ignore-touched true (watch [_ state _]
:page-id page-id}))) (cond-> (rx/of (dwsh/update-shapes shape-ids
#(txt/update-text-content % update-node? update-fn nil)
{:ignore-touched true
:page-id page-id}))
(features/active-feature? state "render-wasm/v1")
(rx/merge
(rx/of (dwt/resize-wasm-text-all shape-ids))))))))
(defn update-font-weight (defn update-font-weight
([value shape-ids attributes] (update-font-weight value shape-ids attributes nil)) ([value shape-ids attributes] (update-font-weight value shape-ids attributes nil))

View File

@@ -433,11 +433,22 @@
ptk/WatchEvent ptk/WatchEvent
(watch [it state _] (watch [it state _]
(let [data (dsh/lookup-file-data state) (let [data (dsh/lookup-file-data state)
changes (-> (pcb/empty-changes it) changes (-> (pcb/empty-changes it)
(pcb/with-library-data data) (pcb/with-library-data data)
(pcb/set-token set-id token-id nil))] (pcb/set-token set-id token-id nil))]
(rx/of (dch/commit-changes changes)))))) (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 (defn duplicate-token
[token-id] [token-id]
(dm/assert! (uuid? token-id)) (dm/assert! (uuid? token-id))
@@ -505,6 +516,19 @@
(update state :workspace-tokens assoc :token-context-menu params) (update state :workspace-tokens assoc :token-context-menu params)
(update state :workspace-tokens dissoc :token-context-menu))))) (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 ;; TOKEN-SET UI OPS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

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

View File

@@ -193,11 +193,11 @@
restore-fn restore-fn
(fn [_] (fn [_]
(st/emit! (dd/restore-files-immediately (st/emit! (dd/restore-files-immediately
(with-meta {:team-id (:id current-team) (with-meta {:team-id current-team-id
:ids (into #{} d/xf:map-id files)} :ids (into #{} d/xf:map-id files)}
{:on-success #(st/emit! (ntf/success (tr "dashboard.restore-success-notification" (:name file))) {:on-success #(st/emit! (ntf/success (tr "dashboard.restore-success-notification" (:name file)))
(dd/fetch-projects (:id current-team)) (dd/fetch-projects current-team-id)
(dd/fetch-deleted-files (:id current-team))) (dd/fetch-deleted-files current-team-id))
:on-error #(st/emit! (ntf/error (tr "dashboard.errors.error-on-restore-file" (:name file))))})))) :on-error #(st/emit! (ntf/error (tr "dashboard.errors.error-on-restore-file" (:name file))))}))))
on-restore-immediately on-restore-immediately
@@ -214,7 +214,7 @@
on-delete-immediately on-delete-immediately
(fn [] (fn []
(let [accept-fn #(st/emit! (dd/delete-files-immediately (let [accept-fn #(st/emit! (dd/delete-files-immediately
{:team-id (:id current-team) {:team-id current-team-id
:ids (into #{} d/xf:map-id files)}))] :ids (into #{} d/xf:map-id files)}))]
(st/emit! (st/emit!
(modal/show {:type :confirm (modal/show {:type :confirm
@@ -244,8 +244,7 @@
(for [project current-projects] (for [project current-projects]
{:name (get-project-name project) {:name (get-project-name project)
:id (get-project-id project) :id (get-project-id project)
:handler (on-move (:id current-team) :handler (on-move current-team-id (:id project))})
(:id project))})
(when (seq other-teams) (when (seq other-teams)
[{:name (tr "dashboard.move-to-other-team") [{:name (tr "dashboard.move-to-other-team")
:id "move-to-other-team" :id "move-to-other-team"

View File

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

View File

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

View File

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

View File

@@ -19,17 +19,19 @@
[:expandable {:optional true} :boolean] [:expandable {:optional true} :boolean]
[:expanded {:optional true} :boolean] [:expanded {:optional true} :boolean]
[:icon {:optional true} :string] [: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/defc layer-button*
{::mf/schema schema: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 (let [button-props (mf/spread-props props
{:class [class (stl/css-case :layer-button true {:class [class (stl/css-case :layer-button true
:layer-button--expandable is-expandable :layer-button--expandable is-expandable
:layer-button--expanded expanded)] :layer-button--expanded expanded)]
:type "button" :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)} [:div {:class (stl/css :layer-button-wrapper)}
[:> "button" button-props [:> "button" button-props
[:div {:class (stl/css :layer-button-content)} [: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

@@ -23,6 +23,7 @@
touched? (and (contains? (:data @form) input-name) touched? (and (contains? (:data @form) input-name)
(get-in @form [:touched input-name])) (get-in @form [:touched input-name]))
error (get-in @form [:errors input-name]) error (get-in @form [:errors input-name])
value (get-in @form [:data input-name] "") value (get-in @form [:data input-name] "")
@@ -52,7 +53,8 @@
(let [form (mf/use-ctx context) (let [form (mf/use-ctx context)
disabled? (or (and (some? form) disabled? (or (and (some? form)
(or (not (:valid @form)) (or (not (:valid @form))
(seq (:external-errors @form)))) (seq (:async-errors @form))
(seq (:extra-errors @form))))
(true? disabled)) (true? disabled))
handle-key-down-save handle-key-down-save
(mf/use-fn (mf/use-fn

View File

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

View File

@@ -276,7 +276,11 @@
:wglobal wglobal :wglobal wglobal
:layout layout}]) :layout layout}])
(when (or (not (and file-loaded? page-id)) (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*])]]]]]])) [:> workspace-loader*])]]]]]]))
(mf/defc workspace-page* (mf/defc workspace-page*

View File

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

View File

@@ -18,25 +18,8 @@
padding: 0 deprecated.$s-8; padding: 0 deprecated.$s-8;
} }
.comments-section-title { .comments-title {
@include deprecated.flexCenter; margin: var(--sp-s) var(--sp-s) 0 var(--sp-s);
@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;
} }
.mode-dropdown-wrapper { .mode-dropdown-wrapper {

View File

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

View File

@@ -12,21 +12,12 @@
background-color: var(--panel-background-color); background-color: var(--panel-background-color);
} }
.panel-title { .debug-panel-title {
@include deprecated.flexCenter; margin: var(--sp-s) var(--sp-s) 0 var(--sp-s);
@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 { .debug-panel-inner {
@include deprecated.flexCenter; padding: deprecated.$s-16 deprecated.$s-8;
flex-grow: 1;
color: var(--title-foreground-color-hover);
}
} }
.checkbox-wrapper { .checkbox-wrapper {
@@ -39,7 +30,3 @@
@extend .checkbox-icon; @extend .checkbox-icon;
cursor: pointer; 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.data.workspace :as dw]
[app.main.refs :as refs] [app.main.refs :as refs]
[app.main.store :as st] [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] [debug :as dbg]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
@@ -125,11 +125,9 @@
(map (d/getf objects)))] (map (d/getf objects)))]
[:div {:class (stl/css :shape-info)} [:div {:class (stl/css :shape-info)}
[:div {:class (stl/css :shape-info-title)} [:> panel-title* {:class (stl/css :shape-info-title)
[:span "Debug"] :text "Debug"
[:div {:class (stl/css :close-button) :on-close #(dbg/disable! :shape-panel)}]
:on-click #(dbg/disable! :shape-panel)}
deprecated-icon/close]]
(if (empty? selected) (if (empty? selected)
[:div {:class (stl/css :attrs-container)} "No shapes selected"] [:div {:class (stl/css :attrs-container)} "No shapes selected"]

View File

@@ -16,34 +16,7 @@
} }
.shape-info-title { .shape-info-title {
@include deprecated.flexCenter; margin: var(--sp-s) var(--sp-s) 0 var(--sp-s);
@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);
}
} }
.attrs-container { .attrs-container {

View File

@@ -13,23 +13,6 @@
background-color: var(--panel-background-color); 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 { .history-entry-empty {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

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

View File

@@ -9,13 +9,17 @@
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.types.token :as tk]
[app.main.data.workspace :as dw] [app.main.data.workspace :as dw]
[app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.tokens.application :as dwta]
[app.main.features :as features] [app.main.features :as features]
[app.main.store :as st] [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.components.select :refer [select]]
[app.main.ui.context :as muc]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [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.main.ui.ds.foundations.assets.icon :as i]
[app.render-wasm.api :as wasm.api] [app.render-wasm.api :as wasm.api]
[app.util.i18n :as i18n :refer [tr]] [app.util.i18n :as i18n :refer [tr]]
@@ -39,11 +43,16 @@
(defn- check-layer-menu-props (defn- check-layer-menu-props
[old-props new-props] [old-props new-props]
(let [old-values (unchecked-get old-props "values") (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") (and (identical? (unchecked-get old-props "class")
(unchecked-get new-props "class")) (unchecked-get new-props "class"))
(identical? (unchecked-get old-props "ids") (identical? (unchecked-get old-props "ids")
(unchecked-get new-props "ids")) (unchecked-get new-props "ids"))
(identical? old-applied-tokens
new-applied-tokens)
(identical? (get old-values :opacity) (identical? (get old-values :opacity)
(get new-values :opacity)) (get new-values :opacity))
(identical? (get old-values :blend-mode) (identical? (get old-values :blend-mode)
@@ -53,12 +62,53 @@
(identical? (get old-values :hidden) (identical? (get old-values :hidden)
(get new-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/defc layer-menu*
{::mf/wrap [#(mf/memo' % check-layer-menu-props)]} {::mf/wrap [#(mf/memo' % check-layer-menu-props)]}
[{:keys [ids values]}] [{:keys [ids values applied-tokens]}]
(let [hidden? (get values :hidden) (let [token-numeric-inputs
(features/use-feature "tokens/numeric-input")
hidden? (get values :hidden)
blocked? (get values :blocked) 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-blend-mode (or (get values :blend-mode) :normal)
current-opacity (opacity->string (:opacity values)) current-opacity (opacity->string (:opacity values))
@@ -118,6 +168,17 @@
(let [value (/ value 100)] (let [value (/ value 100)]
(on-change ids :opacity value)))) (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 handle-set-hidden
(mf/use-fn (mf/use-fn
(mf/deps ids) (mf/deps ids)
@@ -176,8 +237,9 @@
preview-complete?)) preview-complete?))
(swap! state* assoc :selected-blend-mode current-blend-mode))) (swap! state* assoc :selected-blend-mode current-blend-mode)))
[:div {:class (stl/css-case :element-set-content true [:section {:class (stl/css-case :element-set-content true
:hidden hidden?)} :hidden hidden?)
:aria-label "layer-menu-section"}
[:div {:class (stl/css :select)} [:div {:class (stl/css :select)}
[:& select [:& select
{:default-value selected-blend-mode {:default-value selected-blend-mode
@@ -187,16 +249,34 @@
:class (stl/css-case :hidden-select hidden?) :class (stl/css-case :hidden-select hidden?)
:on-pointer-enter-option handle-blend-mode-enter :on-pointer-enter-option handle-blend-mode-enter
:on-pointer-leave-option handle-blend-mode-leave}]] :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* (if token-numeric-inputs
{:value current-opacity
:placeholder "--" [:> numeric-input-wrapper*
:on-change handle-opacity-change {:on-change on-opacity-change
:min 0 :on-detach on-detach-token
:max 100 :icon i/percentage
:className (stl/css :numeric-input)}]] :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)} [:div {:class (stl/css :actions)}

View File

@@ -6,6 +6,7 @@
@use "refactor/common-refactor.scss" as deprecated; @use "refactor/common-refactor.scss" as deprecated;
@use "../../../sidebar/common/sidebar.scss" as sidebar; @use "../../../sidebar/common/sidebar.scss" as sidebar;
@use "ds/_utils.scss" as *;
.element-set-content { .element-set-content {
@include sidebar.option-grid-structure; @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 [:> layer-menu* {:ids ids
:type type :type type
:applied-tokens applied-tokens
:values layer-values}] :values layer-values}]
[:> measures-menu* {:ids ids [:> measures-menu* {:ids ids

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,8 +18,8 @@
[app.main.data.workspace.shortcuts] [app.main.data.workspace.shortcuts]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.components.search-bar :refer [search-bar*]] [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.foundations.assets.icon :as i :refer [icon*]]
[app.main.ui.ds.product.panel-title :refer [panel-title*]]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.i18n :refer [tr]] [app.util.i18n :refer [tr]]
[app.util.strings :refer [matches-search]] [app.util.strings :refer [matches-search]]
@@ -487,13 +487,9 @@
(dom/focus! (dom/get-element "shortcut-search"))) (dom/focus! (dom/get-element "shortcut-search")))
[:div {:class (dm/str class " " (stl/css :shortcuts))} [:div {:class (dm/str class " " (stl/css :shortcuts))}
[:div {:class (stl/css :shortcuts-header)} [:> panel-title* {:class (stl/css :shortcuts-title)
[:div {:class (stl/css :shortcuts-title)} (tr "shortcuts.title")] :text (tr "shortcuts.title")
[:> icon-button* {:variant "ghost" :on-close close-fn}]
:icon i/close
:class (stl/css :shortcuts-close-button)
:on-click close-fn
:aria-label (tr "labels.close")}]]
[:div {:class (stl/css :search-field)} [:div {:class (stl/css :search-field)}
[:> search-bar* {:on-change on-search-term-change-2 [:> 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; margin: deprecated.$s-16 deprecated.$s-12 deprecated.$s-4 deprecated.$s-12;
} }
.shortcuts-header { .shortcuts-title {
@include deprecated.flexCenter; margin: var(--sp-s) var(--sp-s) 0 var(--sp-s);
@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;
}
} }
.section { .section {

View File

@@ -13,6 +13,7 @@
[app.main.data.helpers :as dsh] [app.main.data.helpers :as dsh]
[app.main.data.modal :as modal] [app.main.data.modal :as modal]
[app.main.data.workspace :as dw] [app.main.data.workspace :as dw]
[app.main.features :as features]
[app.main.refs :as refs] [app.main.refs :as refs]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.components.title-bar :refer [title-bar*]] [app.main.ui.components.title-bar :refer [title-bar*]]
@@ -22,9 +23,11 @@
[app.main.ui.hooks :as hooks] [app.main.ui.hooks :as hooks]
[app.main.ui.icons :as deprecated-icon] [app.main.ui.icons :as deprecated-icon]
[app.main.ui.notifications.badge :refer [badge-notification]] [app.main.ui.notifications.badge :refer [badge-notification]]
[app.render-wasm.api :as wasm.api]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]] [app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd] [app.util.keyboard :as kbd]
[app.util.timers :as timers]
[cuerdas.core :as str] [cuerdas.core :as str]
[okulary.core :as l] [okulary.core :as l]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
@@ -52,6 +55,8 @@
refs/workspace-data refs/workspace-data
=)) =))
;; --- Page Item ;; --- Page Item
(mf/defc 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))) 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?) 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 on-delete
(mf/use-fn (mf/use-fn
(mf/deps id) (mf/deps id)
@@ -155,7 +176,7 @@
:selected selected?) :selected selected?)
:data-testid (dm/str "page-" id) :data-testid (dm/str "page-" id)
:tab-index "0" :tab-index "0"
:on-click navigate-fn :on-click on-click
:on-double-click on-double-click :on-double-click on-double-click
:on-context-menu on-context-menu} :on-context-menu on-context-menu}
[:div {:class (stl/css :page-icon)} [:div {:class (stl/css :page-icon)}

View File

@@ -14,8 +14,10 @@
[app.main.ui.ds.foundations.typography.text :refer [text*]] [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.context-menu :refer [token-context-menu]]
[app.main.ui.workspace.tokens.management.group :refer [token-group*]] [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.array :as array]
[app.util.i18n :refer [tr]] [app.util.i18n :refer [tr]]
[cuerdas.core :as str]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
(defn- get-sorted-token-groups (defn- get-sorted-token-groups
@@ -120,7 +122,27 @@
[empty-group filled-group] [empty-group filled-group]
(mf/with-memo [tokens-by-type] (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] (mf/with-effect [tokens-lib selected-token-set-id]
(when (and tokens-lib (when (and tokens-lib
@@ -134,6 +156,7 @@
[:* [:*
[:& token-context-menu] [:& token-context-menu]
[:> token-node-context-menu* {:on-delete-node delete-node}]
[:& selected-set-info* {:tokens-lib tokens-lib [:& selected-set-info* {:tokens-lib tokens-lib
:selected-token-set-id selected-token-set-id}] :selected-token-set-id selected-token-set-id}]

View File

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

View File

@@ -236,12 +236,14 @@
(on-composite-input-change form field value false)) (on-composite-input-change form field value false))
([form field value trim?] ([form field value trim?]
(letfn [(clean-errors [errors] (letfn [(clean-errors [errors]
(-> errors (some-> errors
(dissoc field) (update :value #(when (map? %) (dissoc % field)))
(not-empty)))] (update :value #(when (seq %) %))
(not-empty)))]
(swap! form (fn [state] (swap! form (fn [state]
(-> state (-> state
(assoc-in [:data :value field] (if trim? (str/trim value) value)) (assoc-in [:data :value field] (if trim? (str/trim value) value))
(assoc-in [:touched :value field] true)
(update :errors clean-errors) (update :errors clean-errors)
(update :extra-errors clean-errors))))))) (update :extra-errors clean-errors)))))))
@@ -257,6 +259,9 @@
value value
(get-in @form [:data :value input-name] "") (get-in @form [:data :value input-name] "")
touched?
(get-in @form [:touched :value input-name])
resolve-stream resolve-stream
(mf/with-memo [token] (mf/with-memo [token]
(if-let [value (get-in token [:value input-name])] (if-let [value (get-in token [:value input-name])]
@@ -284,7 +289,7 @@
:hint-message (:message hint) :hint-message (:message hint)
:hint-type (:type hint)}) :hint-type (:type hint)})
props props
(if error (if (and touched? error)
(mf/spread-props props {:hint-type "error" (mf/spread-props props {:hint-type "error"
:hint-message (:message error)}) :hint-message (:message error)})
props) props)
@@ -332,6 +337,7 @@
message (tr "workspace.tokens.resolved-value" (or resolved-value value))] message (tr "workspace.tokens.resolved-value" (or resolved-value value))]
(swap! form update :errors dissoc :value) (swap! form update :errors dissoc :value)
(swap! form update :extra-errors dissoc :value) (swap! form update :extra-errors dissoc :value)
(swap! form update :async-errors dissoc :reference)
(if (= input-value (str resolved-value)) (if (= input-value (str resolved-value))
(reset! hint* {}) (reset! hint* {})
(reset! hint* {:message message :type "hint"})))))))] (reset! hint* {:message message :type "hint"})))))))]

View File

@@ -23,21 +23,19 @@
(let [token-type (let [token-type
(or (:type token) token-type) (or (:type token) token-type)
tokens-in-selected-set
(mf/deref refs/workspace-all-tokens-in-selected-set)
token-path token-path
(mf/with-memo [token] (mf/with-memo [token]
(cft/token-name->path (:name token))) (cft/token-name->path (:name token)))
tokens-tree-in-selected-set all-tokens (mf/deref refs/workspace-all-tokens-map)
(mf/with-memo [token-path tokens-in-selected-set]
(-> (ctob/tokens-tree tokens-in-selected-set) all-tokens
(mf/with-memo [token-path all-tokens]
(-> (ctob/tokens-tree all-tokens)
(d/dissoc-in token-path))) (d/dissoc-in token-path)))
props props
(mf/spread-props props {:token-type token-type (mf/spread-props props {:token-type token-type
:tokens-tree-in-selected-set tokens-tree-in-selected-set :all-token-tree all-tokens
:tokens-in-selected-set tokens-in-selected-set
:token token}) :token token})
text-case-props (mf/spread-props props {:input-value-placeholder (tr "workspace.tokens.text-case-value-enter")}) 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")}) 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.helpers :as dh]
[app.main.data.modal :as modal] [app.main.data.modal :as modal]
[app.main.data.workspace.tokens.application :as dwta] [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.library-edit :as dwtl]
[app.main.data.workspace.tokens.propagation :as dwtp] [app.main.data.workspace.tokens.propagation :as dwtp]
[app.main.data.workspace.tokens.remapping :as remap] [app.main.data.workspace.tokens.remapping :as remap]
@@ -88,14 +89,13 @@
action action
is-create is-create
selected-token-set-id selected-token-set-id
tokens-tree-in-selected-set all-token-tree
token-type token-type
make-schema make-schema
input-component input-component
initial initial
type type
value-subfield value-subfield
tokens-in-selected-set
input-value-placeholder] :as props}] input-value-placeholder] :as props}]
(let [make-schema (or make-schema default-make-schema) (let [make-schema (or make-schema default-make-schema)
@@ -105,13 +105,6 @@
active-tab* (mf/use-state #(if (cft/is-reference? token) :reference :composite)) active-tab* (mf/use-state #(if (cft/is-reference? token) :reference :composite))
active-tab (deref active-tab*) active-tab (deref active-tab*)
on-toggle-tab
(mf/use-fn
(mf/deps)
(fn [new-tab]
(let [new-tab (keyword new-tab)]
(reset! active-tab* new-tab))))
token token
(mf/with-memo [token] (mf/with-memo [token]
(or token {:type token-type})) (or token {:type token-type}))
@@ -124,6 +117,9 @@
tokens tokens
(mf/deref refs/workspace-active-theme-sets-tokens) (mf/deref refs/workspace-active-theme-sets-tokens)
tokens-in-selected-set
(mf/deref refs/workspace-all-tokens-in-selected-set)
tokens tokens
(mf/with-memo [tokens tokens-in-selected-set token] (mf/with-memo [tokens tokens-in-selected-set token]
;; Ensure that the resolved value uses the currently editing token ;; Ensure that the resolved value uses the currently editing token
@@ -134,8 +130,8 @@
(assoc (:name token) token))) (assoc (:name token) token)))
schema schema
(mf/with-memo [tokens-tree-in-selected-set active-tab] (mf/with-memo [all-token-tree active-tab]
(make-schema tokens-tree-in-selected-set active-tab)) (make-schema all-token-tree active-tab))
initial initial
(mf/with-memo [token] (mf/with-memo [token]
@@ -148,6 +144,17 @@
(fm/use-form :schema schema (fm/use-form :schema schema
:initial initial) :initial initial)
on-toggle-tab
(mf/use-fn
(mf/deps form)
(fn [new-tab]
(let [new-tab (keyword new-tab)]
(if (= new-tab :reference)
(swap! form assoc-in [:async-errors :reference]
{:message "Need valid reference"})
(swap! form update :async-errors dissoc :reference))
(reset! active-tab* new-tab))))
on-cancel on-cancel
(mf/use-fn (mf/use-fn
(fn [e] (fn [e]
@@ -224,7 +231,12 @@
:description description})) :description description}))
(dwtl/toggle-token-path path) (dwtl/toggle-token-path path)
(dwtp/propagate-workspace-tokens) (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) [:> fc/form* {:class (stl/css :form-wrapper)
:form form :form form

View File

@@ -291,6 +291,7 @@
[:color {:optional true} [:maybe :string]] [:color {:optional true} [:maybe :string]]
[:color-result {:optional true} ::sm/any] [:color-result {:optional true} ::sm/any]
[:inset {:optional true} [:maybe :boolean]]]]] [:inset {:optional true} [:maybe :boolean]]]]]
(if (= active-tab :reference) (if (= active-tab :reference)
[:reference {:optional false} ::sm/text] [:reference {:optional false} ::sm/text]
[:reference {:optional true} [:maybe :string]])]] [:reference {:optional true} [:maybe :string]])]]

View File

@@ -88,7 +88,7 @@
expandable? (d/nilv (seq tokens) false) expandable? (d/nilv (seq tokens) false)
on-context-menu on-pill-context-menu
(mf/use-fn (mf/use-fn
(fn [event token] (fn [event token]
(dom/prevent-default event) (dom/prevent-default event)
@@ -98,6 +98,15 @@
:errors (:errors token) :errors (:errors token)
:token-id (:id 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 on-toggle-open-click
(mf/use-fn (mf/use-fn
(mf/deps type expandable?) (mf/deps type expandable?)
@@ -159,4 +168,5 @@
:selected-token-set-id selected-token-set-id :selected-token-set-id selected-token-set-id
:is-selected-inside-layout is-selected-inside-layout :is-selected-inside-layout is-selected-inside-layout
:on-token-pill-click on-token-pill-click :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.path-names :as cpn]
[app.common.types.tokens-lib :as ctob] [app.common.types.tokens-lib :as ctob]
[app.main.data.workspace.tokens.library-edit :as dwtl] [app.main.data.workspace.tokens.library-edit :as dwtl]
[app.main.refs :as refs]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.ds.layers.layer-button :refer [layer-button*]] [app.main.ui.ds.layers.layer-button :refer [layer-button*]]
[app.main.ui.workspace.tokens.management.token-pill :refer [token-pill*]] [app.main.ui.workspace.tokens.management.token-pill :refer [token-pill*]]
@@ -26,7 +27,8 @@
[:selected-token-set-id {:optional true} :any] [:selected-token-set-id {:optional true} :any]
[:tokens-lib {:optional true} :any] [:tokens-lib {:optional true} :any]
[:on-token-pill-click {:optional true} fn?] [: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/defc folder-node*
{::mf/schema schema:folder-node} {::mf/schema schema:folder-node}
@@ -39,22 +41,29 @@
selected-token-set-id selected-token-set-id
tokens-lib tokens-lib
on-token-pill-click on-token-pill-click
on-context-menu]}] on-pill-context-menu
on-node-context-menu]}]
(let [full-path (str (name type) "." (:path node)) (let [full-path (str (name type) "." (:path node))
is-folder-expanded (contains? (set (or unfolded-token-paths [])) full-path) is-folder-expanded (contains? (set (or unfolded-token-paths [])) full-path)
swap-folder-expanded (mf/use-fn swap-folder-expanded (mf/use-fn
(mf/deps (:path node) type) (mf/deps (:path node) type)
(fn [] (fn []
(let [path (str (name type) "." (:path node))] (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)} [:li {:class (stl/css :folder-node)}
[:> layer-button* {:label (:name node) [:> layer-button* {:label (:name node)
:expanded is-folder-expanded :expanded is-folder-expanded
:aria-expanded is-folder-expanded :aria-expanded is-folder-expanded
:aria-controls (str "folder-children-" (:path node)) :aria-controls (str "folder-children-" (:path node))
:is-expandable (not (:leaf 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 (when is-folder-expanded
(let [children-fn (:children-fn node)] (let [children-fn (:children-fn node)]
[:div {:class (stl/css :folder-children-wrapper) [:div {:class (stl/css :folder-children-wrapper)
@@ -63,16 +72,17 @@
(let [children (children-fn)] (let [children (children-fn)]
(for [child children] (for [child children]
(if (not (:leaf child)) (if (not (:leaf child))
[:ul {:class (stl/css :node-parent)} [:ul {:class (stl/css :node-parent)
[:> folder-node* {:key (:path child) :key (:path child)}
:type type [:> folder-node* {:type type
:node child :node child
:unfolded-token-paths unfolded-token-paths :unfolded-token-paths unfolded-token-paths
:selected-shapes selected-shapes :selected-shapes selected-shapes
:is-selected-inside-layout is-selected-inside-layout :is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens active-theme-tokens :active-theme-tokens active-theme-tokens
:on-token-pill-click on-token-pill-click :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 :tokens-lib tokens-lib
:selected-token-set-id selected-token-set-id}]] :selected-token-set-id selected-token-set-id}]]
(let [id (:id (:leaf child)) (let [id (:id (:leaf child))
@@ -84,7 +94,7 @@
:is-selected-inside-layout is-selected-inside-layout :is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens active-theme-tokens :active-theme-tokens active-theme-tokens
:on-click on-token-pill-click :on-click on-token-pill-click
:on-context-menu on-context-menu}])))))]))])) :on-context-menu on-pill-context-menu}])))))]))]))
(def ^:private schema:token-tree (def ^:private schema:token-tree
[:map [:map
@@ -97,7 +107,8 @@
[:selected-token-set-id {:optional true} :any] [:selected-token-set-id {:optional true} :any]
[:tokens-lib {:optional true} :any] [:tokens-lib {:optional true} :any]
[:on-token-pill-click {:optional true} fn?] [: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/defc token-tree*
{::mf/schema schema:token-tree} {::mf/schema schema:token-tree}
@@ -110,12 +121,19 @@
tokens-lib tokens-lib
selected-token-set-id selected-token-set-id
on-token-pill-click on-token-pill-click
on-context-menu]}] on-pill-context-menu
on-node-context-menu]}]
(let [separator "." (let [separator "."
tree (mf/use-memo tree (mf/use-memo
(mf/deps tokens) (mf/deps tokens)
(fn [] (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)} [:div {:class (stl/css :token-tree-wrapper)}
(for [node tree] (for [node tree]
(if (:leaf node) (if (:leaf node)
@@ -127,7 +145,7 @@
:is-selected-inside-layout is-selected-inside-layout :is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens active-theme-tokens :active-theme-tokens active-theme-tokens
:on-click on-token-pill-click :on-click on-token-pill-click
:on-context-menu on-context-menu}]) :on-context-menu on-pill-context-menu}])
;; Render segment folder ;; Render segment folder
[:ul {:class (stl/css :node-parent) [:ul {:class (stl/css :node-parent)
:key (:path node)} :key (:path node)}
@@ -138,6 +156,7 @@
:is-selected-inside-layout is-selected-inside-layout :is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens active-theme-tokens :active-theme-tokens active-theme-tokens
:on-token-pill-click on-token-pill-click :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 :tokens-lib tokens-lib
:selected-token-set-id selected-token-set-id}]]))])) :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.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.combobox :refer [combobox*]] [app.main.ui.ds.controls.combobox :refer [combobox*]]
[app.main.ui.ds.controls.input :refer [input*]] [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.controls.utilities.label :refer [label*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]] [app.main.ui.ds.foundations.typography.heading :refer [heading*]]
@@ -85,21 +85,6 @@
:on-click create-theme} :on-click create-theme}
(tr "workspace.tokens.add-new-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 (mf/defc themes-overview
[{:keys [change-view]}] [{:keys [change-view]}]
(let [active-theme-paths (mf/deref refs/workspace-active-theme-paths) (let [active-theme-paths (mf/deref refs/workspace-active-theme-paths)
@@ -137,6 +122,9 @@
(dom/prevent-default e) (dom/prevent-default e)
(dom/stop-propagation e) (dom/stop-propagation e)
(st/emit! (dwtl/delete-token-theme id))) (st/emit! (dwtl/delete-token-theme id)))
on-switch-theme
(fn []
(st/emit! (dwtl/toggle-token-theme-active id)))
on-edit-theme on-edit-theme
(fn [e] (fn [e]
(dom/prevent-default e) (dom/prevent-default e)
@@ -146,16 +134,10 @@
:class (stl/css :theme-row)} :class (stl/css :theme-row)}
[:div {:class (stl/css :theme-switch-row)} [:div {:class (stl/css :theme-switch-row)}
;; FIXME: FIREEEEEEEEEE THIS [:> switch* {:id name
[:div {:on-click (fn [e] :label name
(dom/prevent-default e) :on-change on-switch-theme
(dom/stop-propagation e) :default-checked selected?}]]
(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]]
[:div {:class (stl/css :theme-actions-row)} [:div {:class (stl/css :theme-actions-row)}

View File

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

View File

@@ -312,6 +312,11 @@
(js/console.error "Error initializing canvas context:" e) (js/console.error "Error initializing canvas context:" e)
false))] false))]
(reset! canvas-init? init?) (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? (when-not init?
(js/alert "WebGL not supported") (js/alert "WebGL not supported")
(st/emit! (dcm/go-to-dashboard-recent)))))))) (st/emit! (dcm/go-to-dashboard-recent))))))))
@@ -340,6 +345,7 @@
(mf/with-effect [@canvas-init? zoom vbox background] (mf/with-effect [@canvas-init? zoom vbox background]
(when (and @canvas-init? (not @initialized?)) (when (and @canvas-init? (not @initialized?))
(wasm.api/clear-canvas-pixels)
(wasm.api/initialize-viewport base-objects zoom vbox background) (wasm.api/initialize-viewport base-objects zoom vbox background)
(reset! initialized? true))) (reset! initialized? true)))

View File

@@ -1185,7 +1185,6 @@
{:cmd :export-shapes {:cmd :export-shapes
:profile-id (:profile-id @st/state) :profile-id (:profile-id @st/state)
:wait true :wait true
:skip-children (:skip-children value false)
:exports [{:file-id file-id :exports [{:file-id file-id
:page-id page-id :page-id page-id
:object-id id :object-id id

View File

@@ -29,6 +29,7 @@
[app.main.worker :as mw] [app.main.worker :as mw]
[app.render-wasm.api.fonts :as f] [app.render-wasm.api.fonts :as f]
[app.render-wasm.api.texts :as t] [app.render-wasm.api.texts :as t]
[app.render-wasm.api.webgl :as webgl]
[app.render-wasm.deserializers :as dr] [app.render-wasm.deserializers :as dr]
[app.render-wasm.helpers :as h] [app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem] [app.render-wasm.mem :as mem]
@@ -37,7 +38,6 @@
[app.render-wasm.serializers :as sr] [app.render-wasm.serializers :as sr]
[app.render-wasm.serializers.color :as sr-clr] [app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.svg-filters :as svg-filters] [app.render-wasm.svg-filters :as svg-filters]
;; FIXME: rename; confunsing name
[app.render-wasm.wasm :as wasm] [app.render-wasm.wasm :as wasm]
[app.util.debug :as dbg] [app.util.debug :as dbg]
[app.util.dom :as dom] [app.util.dom :as dom]
@@ -279,30 +279,6 @@
[string] [string]
(+ (count string) 1)) (+ (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 (defn- get-texture-id-for-gl-object
"Registers a WebGL texture with Emscripten's GL object system and returns its ID" "Registers a WebGL texture with Emscripten's GL object system and returns its ID"
@@ -332,8 +308,8 @@
(->> (retrieve-image url) (->> (retrieve-image url)
(rx/map (rx/map
(fn [img] (fn [img]
(when-let [gl (get-webgl-context)] (when-let [gl (webgl/get-webgl-context)]
(let [texture (create-webgl-texture-from-image gl img) (let [texture (webgl/create-webgl-texture-from-image gl img)
texture-id (get-texture-id-for-gl-object texture) texture-id (get-texture-id-for-gl-object texture)
width (.-width ^js img) width (.-width ^js img)
height (.-height ^js img) height (.-height ^js img)
@@ -979,6 +955,7 @@
(set-shape-grow-type grow-type)) (set-shape-grow-type grow-type))
(set-shape-layout shape) (set-shape-layout shape)
(set-layout-data shape)
(set-shape-selrect selrect) (set-shape-selrect selrect)
(let [pending_thumbnails (into [] (concat (let [pending_thumbnails (into [] (concat
@@ -1055,8 +1032,9 @@
(perf/end-measure "set-objects") (perf/end-measure "set-objects")
(process-pending shapes thumbnails full noop-fn (process-pending shapes thumbnails full noop-fn
(fn [] (fn []
(when render-callback (render-callback)) (if render-callback
(render-finish) (render-callback)
(render-finish))
(ug/dispatch! (ug/event "penpot:wasm:set-objects"))))))) (ug/dispatch! (ug/event "penpot:wasm:set-objects")))))))
(defn clear-focus-mode (defn clear-focus-mode
@@ -1384,8 +1362,9 @@
all-children all-children
(->> ids (->> ids
(mapcat #(cfh/get-children-with-self objects %)))] (mapcat #(cfh/get-children-with-self objects %)))]
(h/call wasm/internal-module "_init_shapes_pool" (count all-children)) (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) (let [content (-> (calculate-bool* bool-type ids)
(path.impl/path-data))] (path.impl/path-data))]
@@ -1448,6 +1427,12 @@
result))) result)))
(defn apply-canvas-blur
[]
(when wasm/canvas
(dom/set-style! wasm/canvas "filter" "blur(4px)")))
(defn init-wasm-module (defn init-wasm-module
[module] [module]
(let [default-fn (unchecked-get module "default") (let [default-fn (unchecked-get module "default")
@@ -1469,3 +1454,8 @@
(js/console.error cause) (js/console.error cause)
(p/resolved false))))) (p/resolved false)))))
(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. ;; Reference to the HTML canvas element.
(defonce canvas nil) (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. ;; Reference to the Emscripten GL context wrapper.
(defonce gl-context-handle nil) (defonce gl-context-handle nil)
@@ -56,3 +58,4 @@
:stroke-linecap shared/RawStrokeLineCap :stroke-linecap shared/RawStrokeLineCap
:stroke-linejoin shared/RawStrokeLineJoin :stroke-linejoin shared/RawStrokeLineJoin
:fill-rule shared/RawFillRule}) :fill-rule shared/RawFillRule})

View File

@@ -114,7 +114,7 @@
(defn- load (defn- load
[locale] [locale]
(let [path (str "./translation." locale ".js?version=" (:full cf/version))] (let [path (str "./translation." locale ".js?version=" cf/version-tag)]
(->> (mod/import path) (->> (mod/import path)
(p/fmap (fn [result] (unchecked-get result "default"))) (p/fmap (fn [result] (unchecked-get result "default")))
(p/fnly (fn [data cause] (p/fnly (fn [data cause]

View File

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

View File

@@ -5476,6 +5476,10 @@ msgstr "Delete row and shapes"
msgid "workspace.context-menu.grid-track.row.duplicate" msgid "workspace.context-menu.grid-track.row.duplicate"
msgstr "Duplicate row" 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 #: src/app/main/ui/workspace/sidebar/layers.cljs:512
msgid "workspace.focus.focus-mode" msgid "workspace.focus.focus-mode"
msgstr "Focus mode" msgstr "Focus mode"
@@ -8421,6 +8425,10 @@ msgstr "Comments (%s)"
msgid "workspace.toolbar.curve" msgid "workspace.toolbar.curve"
msgstr "Curve (%s)" 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 #: src/app/main/ui/workspace/top_toolbar.cljs:172
msgid "workspace.toolbar.ellipse" msgid "workspace.toolbar.ellipse"
msgstr "Ellipse (%s)" msgstr "Ellipse (%s)"
@@ -8731,10 +8739,10 @@ msgstr ""
msgid "workspace.versions.warning.text" msgid "workspace.versions.warning.text"
msgstr "Autosaved versions will be kept for %s days." 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" 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" msgstr "WebGL has stopped working. Please reload the page to reset it"
#, unused #, unused

View File

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

View File

@@ -0,0 +1,4 @@
name = "penpot-plugins-api-doc"
compatibility_date = "2025-01-01"
assets = { directory = "dist/doc" }

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
export CURRENT_VERSION=${CURRENT_VERSION:-develop}; export VERSION_TAG=${VERSION:-develop};
if [ "$NODE_ENV" = "production" ]; then if [ "$NODE_ENV" = "production" ]; then
export BUILD_MODE="release"; export BUILD_MODE="release";
@@ -81,7 +81,7 @@ function copy_artifacts {
cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.js $DEST/$BUILD_NAME.js; cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.js $DEST/$BUILD_NAME.js;
cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm $DEST/$BUILD_NAME.wasm; cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm $DEST/$BUILD_NAME.wasm;
sed -i "s/render_wasm.wasm/$BUILD_NAME.wasm?version=$CURRENT_VERSION/g" $DEST/$BUILD_NAME.js; sed -i "s/render_wasm.wasm/$BUILD_NAME.wasm?version=$VERSION_TAG/g" $DEST/$BUILD_NAME.js;
yarn esbuild target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.js \ yarn esbuild target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.js \
--log-level=error \ --log-level=error \

View File

@@ -284,6 +284,7 @@ pub extern "C" fn set_view_end() {
performance::end_measure!("set_view_end::clear_tile_index"); performance::end_measure!("set_view_end::clear_tile_index");
performance::end_timed_log!("clear_tile_index", _clear_start); performance::end_timed_log!("clear_tile_index", _clear_start);
} }
state.render_state.sync_cached_viewbox();
performance::end_measure!("set_view_end"); performance::end_measure!("set_view_end");
performance::end_timed_log!("set_view_end", _end_start); performance::end_timed_log!("set_view_end", _end_start);
#[cfg(feature = "profile-macros")] #[cfg(feature = "profile-macros")]

View File

@@ -1136,6 +1136,7 @@ impl RenderState {
) -> Result<(), String> { ) -> Result<(), String> {
let _start = performance::begin_timed_log!("start_render_loop"); let _start = performance::begin_timed_log!("start_render_loop");
let scale = self.get_scale(); let scale = self.get_scale();
self.tile_viewbox.update(self.viewbox, scale); self.tile_viewbox.update(self.viewbox, scale);
self.focus_mode.reset(); self.focus_mode.reset();
@@ -2292,6 +2293,10 @@ impl RenderState {
(self.viewbox.zoom - self.cached_viewbox.zoom).abs() > f32::EPSILON (self.viewbox.zoom - self.cached_viewbox.zoom).abs() > f32::EPSILON
} }
pub fn sync_cached_viewbox(&mut self) {
self.cached_viewbox = self.viewbox;
}
pub fn mark_touched(&mut self, uuid: Uuid) { pub fn mark_touched(&mut self, uuid: Uuid) {
self.touched_ids.insert(uuid); self.touched_ids.insert(uuid);
} }

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> { pub fn path(&self) -> Option<&Path> {
match self { match self {
Type::Path(path) => Some(path), Type::Path(path) => Some(path),
@@ -694,9 +706,11 @@ impl Shape {
pub fn set_corners(&mut self, raw_corners: (f32, f32, f32, f32)) { pub fn set_corners(&mut self, raw_corners: (f32, f32, f32, f32)) {
if let Some(corners) = make_corners(raw_corners) { if let Some(corners) = make_corners(raw_corners) {
self.shape_type.set_corners(corners); self.shape_type.set_corners(corners);
self.invalidate_bounds(); } else {
self.invalidate_extrect(); self.shape_type.clear_corners();
} }
self.invalidate_bounds();
self.invalidate_extrect();
} }
pub fn set_svg(&mut self, svg: skia::svg::Dom) { pub fn set_svg(&mut self, svg: skia::svg::Dom) {
@@ -1515,6 +1529,7 @@ impl Shape {
|| !self.transform.is_identity() || !self.transform.is_identity()
|| !math::is_close_to(self.rotation, 0.0) || !math::is_close_to(self.rotation, 0.0)
|| matches!(self.shape_type, Type::Group(_) | Type::Frame(_)) || matches!(self.shape_type, Type::Group(_) | Type::Frame(_))
|| matches!(self.shape_type, Type::Text(_))
} }
pub fn count_visible_inner_strokes(&self) -> usize { pub fn count_visible_inner_strokes(&self) -> usize {
@@ -1590,6 +1605,13 @@ mod tests {
} else { } else {
unreachable!(); 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] #[test]

View File

@@ -100,6 +100,16 @@ impl<'a> State<'a> {
} }
pub fn start_render_loop(&mut self, timestamp: i32) -> Result<(), String> { pub fn start_render_loop(&mut self, timestamp: i32) -> Result<(), String> {
// If zoom changed, we MUST rebuild the tile index before using it.
// Otherwise, the index will have tiles from the old zoom level, causing visible
// tiles to appear empty. This can happen if start_render_loop() is called before
// set_view_end() finishes rebuilding the index, or if set_view_end() hasn't been
// called yet.
let zoom_changed = self.render_state.zoom_changed();
if zoom_changed {
self.rebuild_tiles_shallow();
}
self.render_state self.render_state
.start_render_loop(None, &self.shapes, timestamp, false)?; .start_render_loop(None, &self.shapes, timestamp, false)?;
Ok(()) Ok(())