Compare commits

..

16 Commits

Author SHA1 Message Date
Elena Torro
369979ffe6 WIP 2025-11-26 18:12:35 +01:00
Marina López
db0cbbbc2e 🐛 Fix logic preventing incorrect trial flow in subscription modal (#7831) 2025-11-26 12:08:02 +01:00
alonso.torres
48304bd26f 🐛 Fix issue when exporting files 2025-11-26 12:04:34 +01:00
Elena Torro
60e32bbc71 🐛 Fix text editor vertical align 2025-11-26 11:46:47 +01:00
André Carvalhais
54451608dc 💄 Fix spelling of 'smtp' in email configuration section
Corrected the spelling of 'smtp' in the documentation.

Signed-off-by: André Carvalhais <carvalhais@live.com>
2025-11-26 08:11:27 +01:00
Alejandro Alonso
b7727122d5 Merge pull request #7829 from penpot/alotor-fixes
🐛 Fix problem with thumbnails in parallel
2025-11-26 07:21:49 +01:00
alonso.torres
8880f07a6a 🐛 Fix problem with thumbnails in parallel 2025-11-25 17:56:00 +01:00
andrés gonzález
aaca2c41d8 📚 Add metadescriptions to some help center pages (#7821) 2025-11-25 17:00:14 +01:00
Belén Albeza
33417a4b20 🐛 Fix svg attrs stroke-linecap stroke-linejoin fill-rule 2025-11-25 12:43:40 +01:00
Andrés Moya
2640889dc8 🐛 Fix backwards compatibility importing files with token themes 2025-11-25 10:56:33 +01:00
alonso.torres
dd5f3396d1 🐛 Fix problem with layout z-index 2025-11-24 17:48:58 +01:00
Andrey Antukh
dedeae8641 🐛 Fix incorrect subscription fetching after profile registration 2025-11-24 14:36:46 +01:00
Andrey Antukh
a7552d412a Add explicit network asingation and alias on devenv compose 2025-11-24 14:36:46 +01:00
Aitor Moreno
f58475a7c9 🐛 Fix pasting application/transit+json (#7812) 2025-11-24 14:36:24 +01:00
Marina López
00bbb0bfb6 ♻️ Add format and refactor payments 2025-11-24 11:41:03 +01:00
Andrey Antukh
d93fe89c12 📎 Backport CI github workflog from develop 2025-11-24 10:48:51 +01:00
34 changed files with 966 additions and 408 deletions

View File

@@ -1,4 +1,4 @@
name: "CI: Tests"
name: "CI"
defaults:
run:
@@ -16,125 +16,140 @@ on:
- develop
- staging
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
jobs:
# lint:
# name: "Code Linter"
# runs-on: ubuntu-24.04
# container: penpotapp/devenv:latest
lint:
name: "Code Linter"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
# steps:
# - name: Checkout repository
# uses: actions/checkout@v4
steps:
- name: Checkout repository
uses: actions/checkout@v4
# - name: Check clojure code format
# run: |
# corepack enable;
# corepack install;
# yarn install
# yarn run fmt:clj:check
- name: Check clojure code format
run: |
corepack enable;
corepack install;
yarn install
yarn run fmt:clj:check
# test-common:
# name: "Common Tests"
# runs-on: ubuntu-24.04
# container: penpotapp/devenv:latest
test-common:
name: "Common Tests"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
# steps:
# - name: Checkout repository
# uses: actions/checkout@v4
steps:
- name: Checkout repository
uses: actions/checkout@v4
# - name: Run tests on JVM
# working-directory: ./common
# run: |
# clojure -M:dev:test
- name: Run tests on JVM
working-directory: ./common
run: |
clojure -M:dev:test
# - name: Run tests on NODE
# working-directory: ./common
# run: |
# corepack enable;
# corepack install;
# yarn install;
# yarn run test;
- name: Run tests on NODE
working-directory: ./common
run: |
corepack enable;
corepack install;
yarn install;
yarn run test;
# test-frontend:
# name: "Frontend Tests"
# runs-on: ubuntu-24.04
# container: penpotapp/devenv:latest
test-frontend:
name: "Frontend Tests"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
# steps:
# - name: Checkout repository
# uses: actions/checkout@v4
steps:
- name: Checkout repository
uses: actions/checkout@v4
# - name: Unit Tests
# working-directory: ./frontend
# run: |
# corepack enable;
# corepack install;
# yarn install;
# yarn run test;
- name: Unit Tests
working-directory: ./frontend
run: |
corepack enable;
corepack install;
yarn install;
yarn run test;
# - name: Component Tests
# working-directory: ./frontend
# run: |
# yarn run playwright install chromium --with-deps;
# yarn run build:storybook
- name: Component Tests
working-directory: ./frontend
run: |
yarn run playwright install chromium --with-deps;
yarn run build:storybook
# npx concurrently -k -s first -n "SB,TEST" -c "magenta,blue" \
# "npx http-server storybook-static --port 6006 --silent" \
# "npx wait-on tcp:6006 && yarn test:storybook"
npx concurrently -k -s first -n "SB,TEST" -c "magenta,blue" \
"npx http-server storybook-static --port 6006 --silent" \
"npx wait-on tcp:6006 && yarn test:storybook"
# - name: Check SCSS Format
# working-directory: ./frontend
# run: |
# yarn run lint:scss;
- name: Check SCSS Format
working-directory: ./frontend
run: |
yarn run lint:scss;
# test-backend:
# name: "Backend Tests"
# runs-on: ubuntu-24.04
# container: penpotapp/devenv:latest
test-backend:
name: "Backend Tests"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
# services:
# postgres:
# image: postgres:17
# # Provide the password for postgres
# env:
# POSTGRES_USER: penpot_test
# POSTGRES_PASSWORD: penpot_test
# POSTGRES_DB: penpot_test
services:
postgres:
image: postgres:17
# Provide the password for postgres
env:
POSTGRES_USER: penpot_test
POSTGRES_PASSWORD: penpot_test
POSTGRES_DB: penpot_test
# # Set health checks to wait until postgres has started
# options: >-
# --health-cmd pg_isready
# --health-interval 10s
# --health-timeout 5s
# --health-retries 5
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
# redis:
# image: valkey/valkey:9
redis:
image: valkey/valkey:9
# steps:
# - name: Checkout repository
# uses: actions/checkout@v4
steps:
- name: Checkout repository
uses: actions/checkout@v4
# test-library:
# name: "Library Tests"
# runs-on: ubuntu-24.04
# container: penpotapp/devenv:latest
- name: Run tests
working-directory: ./backend
env:
PENPOT_TEST_DATABASE_URI: "postgresql://postgres/penpot_test"
PENPOT_TEST_DATABASE_USERNAME: penpot_test
PENPOT_TEST_DATABASE_PASSWORD: penpot_test
PENPOT_TEST_REDIS_URI: "redis://redis/1"
# steps:
# - name: Checkout repository
# uses: actions/checkout@v4
run: |
clojure -M:dev:test --reporter kaocha.report/documentation
# - name: Run tests
# working-directory: ./library
# run: |
# corepack enable;
# corepack install;
# yarn install;
# yarn run build:bundle;
# yarn run test;
test-library:
name: "Library Tests"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
test-integration:
name: "Integration Tests"
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run tests
working-directory: ./library
run: |
corepack enable;
corepack install;
yarn install;
yarn run build:bundle;
yarn run test;
build-integration:
name: "Build Integration Bundle"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
@@ -157,17 +172,144 @@ jobs:
run: |
./build release
- name: Store Bundle Cache
uses: actions/cache@v4
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
test-integration-1:
name: "Integration Tests 1/4"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Restore Cache
uses: actions/cache/restore@v4
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
- name: Run Tests
working-directory: ./frontend
run: |
yarn run playwright install chromium --with-deps
yarn run test:e2e -x --workers=1 --reporter=line
corepack enable;
corepack install;
yarn install;
yarn run playwright install chromium --with-deps;
yarn run test:e2e -x --workers=2 --reporter=list --shard="1/4";
- name: Upload test result
uses: actions/upload-artifact@v4
if: always()
with:
name: integration-tests-result
name: integration-tests-result-1
path: frontend/test-results/
overwrite: true
retention-days: 3
test-integration-2:
name: "Integration Tests 2/4"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Restore Cache
uses: actions/cache/restore@v4
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
- name: Run Tests
working-directory: ./frontend
run: |
corepack enable;
corepack install;
yarn install;
yarn run playwright install chromium --with-deps;
yarn run test:e2e -x --workers=2 --reporter=list --shard "2/4";
- name: Upload test result
uses: actions/upload-artifact@v4
if: always()
with:
name: integration-tests-result-2
path: frontend/test-results/
overwrite: true
retention-days: 3
test-integration-3:
name: "Integration Tests 3/4"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Restore Cache
uses: actions/cache/restore@v4
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
- name: Run Tests
working-directory: ./frontend
run: |
corepack enable;
corepack install;
yarn install;
yarn run playwright install chromium --with-deps;
yarn run test:e2e -x --workers=2 --reporter=list --shard "3/4";
- name: Upload test result
uses: actions/upload-artifact@v4
if: always()
with:
name: integration-tests-result-3
path: frontend/test-results/
overwrite: true
retention-days: 3
test-integration-4:
name: "Integration Tests 3/4"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Restore Cache
uses: actions/cache/restore@v4
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
- name: Run Tests
working-directory: ./frontend
run: |
corepack enable;
corepack install;
yarn install;
yarn run playwright install chromium --with-deps;
yarn run test:e2e -x --workers=2 --reporter=list --shard "4/4";
- name: Upload test result
uses: actions/upload-artifact@v4
if: always()
with:
name: integration-tests-result-4
path: frontend/test-results/
overwrite: true
retention-days: 3

View File

@@ -71,6 +71,7 @@ example. It's still usable as before, we just removed the example.
- Add new shape validation mechanism for shapes [Github #7696](https://github.com/penpot/penpot/pull/7696)
- Apply color tokens from sidebar [Taiga #11353](https://tree.taiga.io/project/penpot/us/11353)
- Display tokens in the inspect tab [Taiga #9313](https://tree.taiga.io/project/penpot/us/9313)
- Refactor clipboard behavior to assess some minor inconsistencies and make pasting binary data faster. [Taiga #12571](https://tree.taiga.io/project/penpot/task/12571)
### :bug: Bugs fixed
@@ -85,6 +86,7 @@ example. It's still usable as before, we just removed the example.
- Fix shortcut conflict in text editor (increase/decrease font size vs word selection)
- Fix problem with plugins generating code for pages different than current one [Taiga #12312](https://tree.taiga.io/project/penpot/issue/12312)
- Fix input confirmation behavior is not uniform [Taiga #12294](https://tree.taiga.io/project/penpot/issue/12294)
- Fix copy/pasting application/transit+json [Taiga #12721](https://tree.taiga.io/project/penpot/issue/12721)
## 2.11.1

View File

@@ -1679,7 +1679,7 @@ Will return a value that matches this schema:
["id" {:optional true} :string]
["name" :string]
["description" :string]
["isSource" :boolean]
["isSource" {:optional true} :boolean]
["selectedTokenSets"
[:map-of :string [:enum "enabled" "disabled"]]]]]]
["$metadata" {:optional true}

View File

@@ -115,6 +115,11 @@ services:
volumes:
- "valkey_data:/data"
networks:
default:
aliases:
- redis
mailer:
image: sj26/mailcatcher:latest
restart: always
@@ -123,6 +128,12 @@ services:
ports:
- "1080:1080"
networks:
default:
aliases:
- mailer
# https://github.com/rroemhild/docker-test-openldap
ldap:
image: rroemhild/test-openldap:2.1
@@ -136,3 +147,9 @@ services:
nofile:
soft: 1024
hard: 1024
networks:
default:
aliases:
- ldap

View File

@@ -314,7 +314,7 @@ If you're using the official <code class="language-bash">docker-compose.yml</cod
## Email configuration
By default, <code class="language-bash">smpt</code> flag is disabled, the email will be
By default, <code class="language-bash">smtp</code> flag is disabled, the email will be
printed to the console, which means that the emails will be shown in the stdout.
Note that if you plan to invite members to a team, it is recommended that you enable SMTP

View File

@@ -1,5 +1,6 @@
---
title: 3.07. Abstraction levels
desc: "Penpot Technical Guide: organize data and logic in clear abstraction layers—ADTs, file ops, event-sourced changes, business rules, and data events."
---
# Code organization in abstraction levels

View File

@@ -1,5 +1,6 @@
---
title: 3.06. Backend Guide
desc: "Penpot Technical Guide: Backend basics - REPL setup, loading fixtures, database migrations, and clj-kondo linting to speed development workflows."
---
# Backend guide #

View File

@@ -1,5 +1,6 @@
---
title: 1.2 Install with Elestio
desc: "Step-by-step guide to deploy a self-hosted Penpot on Elestio: 3-minute setup, managed DNS/SMTP/SSL/backups, Docker Compose config, updates & support."
---
# Install with Elestio

View File

@@ -1,6 +1,7 @@
---
title: Design Tokens
order: 5
desc: Learn how to create, manage and apply Penpot Design Tokens using W3C DTCG format, with sets, themes, aliases, equations and JSON import/export.
---
<h1 id="design-tokens">Design Tokens</h1>

View File

@@ -195,7 +195,9 @@
(ptk/reify ::login-from-token
ptk/WatchEvent
(watch [_ _ _]
(->> (rx/of (logged-in (with-meta profile {::ev/source "login-with-token"})))
(->> (dp/on-fetch-profile-success profile)
(rx/map (fn [profile]
(logged-in (with-meta profile {::ev/source "login-with-token"}))))
;; NOTE: we need this to be asynchronous because the effect
;; should be called before proceed with the login process
(rx/observe-on :async)))))

View File

@@ -77,22 +77,25 @@
(rx/of (rt/nav-raw :href href)))
(rx/throw cause))))
(defn on-fetch-profile-success
[profile]
(if (and (contains? cf/flags :subscriptions)
(is-authenticated? profile))
(->> (rp/cmd! :get-subscription-usage {})
(rx/map (fn [{:keys [editors]}]
(update-in profile [:props :subscription] assoc :editors editors)))
(rx/catch (fn [cause]
(js/console.error "unexpected error on obtaining subscription usage" cause)
(rx/of profile))))
(rx/of profile)))
(defn fetch-profile
[]
(ptk/reify ::fetch-profile
ptk/WatchEvent
(watch [_ _ _]
(->> (rp/cmd! :get-profile)
(rx/mapcat (fn [profile]
(if (and (contains? cf/flags :subscriptions)
(is-authenticated? profile))
(->> (rp/cmd! :get-subscription-usage {})
(rx/map (fn [{:keys [editors]}]
(update-in profile [:props :subscription] assoc :editors editors)))
(rx/catch (fn [cause]
(js/console.error "unexpected error on obtaining subscription usage" cause)
(rx/of profile))))
(rx/of profile))))
(rx/mapcat on-fetch-profile-success)
(rx/map (partial ptk/data-event ::profile-fetched))
(rx/catch on-fetch-profile-exception)))))

View File

@@ -254,6 +254,10 @@
(declare ^:private paste-svg-text)
(declare ^:private paste-shapes)
(def ^:private default-options
#js {:decodeTransit t/decode-str
:allowHTMLPaste (features/active-feature? @st/state "text-editor/v2-html-paste")})
(defn create-paste-from-blob
[in-viewport?]
(fn [blob]
@@ -290,7 +294,7 @@
(ptk/reify ::paste-from-clipboard
ptk/WatchEvent
(watch [_ _ _]
(->> (clipboard/from-navigator)
(->> (clipboard/from-navigator default-options)
(rx/mapcat default-paste-from-blob)
(rx/take 1)))))
@@ -308,7 +312,7 @@
;; we forbid that scenario so the default behaviour is executed
(if is-editing?
(rx/empty)
(->> (clipboard/from-synthetic-clipboard-event event)
(->> (clipboard/from-synthetic-clipboard-event event default-options)
(rx/mapcat (create-paste-from-blob in-viewport?))))))))
(defn copy-selected-svg
@@ -478,7 +482,7 @@
(js/console.error "Clipboard error:" cause))
(rx/empty)))]
(->> (clipboard/from-navigator)
(->> (clipboard/from-navigator default-options)
(rx/mapcat #(.text %))
(rx/map decode-entry)
(rx/take 1)

View File

@@ -60,10 +60,10 @@
(defn render-thumbnail
[file-id revn]
(if (features/active-feature? @st/state "render-wasm/v1")
(->> (mw/ask! {:cmd :thumbnails/generate-for-file-wasm
:revn revn
:file-id file-id
:width thumbnail-width}))
(mw/ask! {:cmd :thumbnails/generate-for-file-wasm
:revn revn
:file-id file-id
:width thumbnail-width})
(->> (mw/ask! {:cmd :thumbnails/generate-for-file
:revn revn
:file-id file-id

View File

@@ -82,7 +82,7 @@
(mf/deps team-id selected files)
(fn []
(swap! state* assoc :status :exporting)
(->> (fexp/export-files :files files :type type)
(->> (fexp/export-files :files files :type selected)
(rx/subs!
(fn [{:keys [file-id error filename uri] :as result}]
(if error

View File

@@ -64,93 +64,132 @@
:on-click cta-link-with-icon} cta-text-with-icon
[:> icon* {:icon-id "open-link"
:size "s"}]])
(when (and cta-link cta-text (not show-button-cta)) [:button {:class (stl/css-case :cta-button true
:bottom-link (not (and cta-link-trial cta-text-trial)))
:on-click cta-link} cta-text])
(when (and cta-link cta-text show-button-cta) [:> button* {:variant "primary"
:type "button"
:class (stl/css-case :bottom-button (not (and cta-link-trial cta-text-trial)))
:on-click cta-link} cta-text])
(when (and cta-link-trial cta-text-trial) [:button {:class (stl/css :cta-button :bottom-link)
:on-click cta-link-trial} cta-text-trial])])
(when (and cta-link cta-text (not show-button-cta))
[:button {:class (stl/css-case :cta-button true
:bottom-link (not (and cta-link-trial cta-text-trial)))
:on-click cta-link} cta-text])
(when (and cta-link cta-text show-button-cta)
[:> button* {:variant "primary"
:type "button"
:class (stl/css-case :bottom-button (not (and cta-link-trial cta-text-trial)))
:on-click cta-link} cta-text])
(when (and cta-link-trial cta-text-trial)
[:button {:class (stl/css :cta-button :bottom-link)
:on-click cta-link-trial} cta-text-trial])])
(defn schema:seats-form [min-editors]
[:map {:title "SeatsForm"}
[:min-members [::sm/number {:min min-editors
:max 9999}]]])
:max 9999}]]
[:redirect-to-payment-details :boolean]])
(mf/defc subscribe-management-dialog
{::mf/register modal/components
::mf/register-as :management-dialog}
[{:keys [subscription-type current-subscription editors subscribe-to-trial]}]
(let [unlimited-modal-step* (mf/use-state 1)
unlimited-modal-step (deref unlimited-modal-step*)
subscription-name (if subscribe-to-trial
(if (= subscription-type "unlimited")
(tr "subscription.settings.unlimited-trial")
(tr "subscription.settings.enterprise-trial"))
(case subscription-type
"professional" (tr "subscription.settings.professional")
"unlimited" (tr "subscription.settings.unlimited")
"enterprise" (tr "subscription.settings.enterprise")))
min-editors (if (seq editors) (count editors) 1)
initial (mf/with-memo [min-editors]
{:min-members min-editors})
form (fm/use-form :schema (schema:seats-form min-editors)
:initial initial)
submit-in-progress* (mf/use-state false)
subscribe-to-unlimited (mf/use-fn
(mf/deps form)
(fn [add-payment-details]
(when (not @submit-in-progress*)
(let [data (:clean-data @form)
return-url (-> (rt/get-current-href) (rt/encode-url))
href (if add-payment-details
(dm/str "payments/subscriptions/create?type=unlimited&show=true&quantity=" (:min-members data) "&returnUrl=" return-url)
(dm/str "payments/subscriptions/create?type=unlimited&show=false&quantity=" (:min-members data) "&returnUrl=" return-url))]
(reset! submit-in-progress* true)
(reset! form nil)
(st/emit! (ptk/event ::ev/event {::ev/name "create-trial-subscription"
:type "unlimited"
:quantity (:min-members data)})
(rt/nav-raw :href href))))))
(let [unlimited-modal-step*
(mf/use-state 1)
subscribe-to-enterprise (mf/use-fn
(fn []
(st/emit! (ptk/event ::ev/event {::ev/name "create-trial-subscription"
:type "enterprise"}))
(let [return-url (-> (rt/get-current-href) (rt/encode-url))
href (dm/str "payments/subscriptions/create?type=enterprise&returnUrl=" return-url)]
(st/emit! (rt/nav-raw :href href)))))
unlimited-modal-step
(deref unlimited-modal-step*)
handle-accept-dialog (mf/use-fn
(fn []
(st/emit! (ptk/event ::ev/event {::ev/name "open-subscription-management"
::ev/origin "settings"
:section "subscription-management-modal"}))
(let [current-href (rt/get-current-href)
returnUrl (js/encodeURIComponent current-href)
href (dm/str "payments/subscriptions/show?returnUrl=" returnUrl)]
(st/emit! (rt/nav-raw :href href)))
(modal/hide!)))
handle-close-dialog (mf/use-fn
(fn []
(st/emit! (ptk/event ::ev/event {::ev/name "close-subscription-modal"}))
(modal/hide!)))
subscription-name
(if subscribe-to-trial
(if (= subscription-type "unlimited")
(tr "subscription.settings.unlimited-trial")
(tr "subscription.settings.enterprise-trial"))
(case subscription-type
"professional" (tr "subscription.settings.professional")
"unlimited" (tr "subscription.settings.unlimited")
"enterprise" (tr "subscription.settings.enterprise")))
handle-unlimited-modal-step (mf/use-fn
(mf/deps unlimited-modal-step)
(fn []
(if (= unlimited-modal-step 1)
(reset! unlimited-modal-step* 2)
(reset! unlimited-modal-step* 1))))
min-editors
(if (seq editors) (count editors) 1)
show-editors-list* (mf/use-state false)
show-editors-list (deref show-editors-list*)
handle-click (mf/use-fn
(fn [event]
(dom/stop-propagation event)
(swap! show-editors-list* not)))]
initial
(mf/with-memo [min-editors]
{:min-members min-editors
:redirect-to-payment-details false})
form
(fm/use-form :schema (schema:seats-form min-editors)
:initial initial)
submit-in-progress
(mf/use-ref false)
subscribe-to-unlimited
(mf/use-fn
(fn [min-members add-payment-details?]
(when-not (mf/ref-val submit-in-progress)
(mf/set-ref-val! submit-in-progress true)
(let [return-url (-> (rt/get-current-href)
(rt/encode-url))
href (dm/str "payments/subscriptions/create?type=unlimited&show="
add-payment-details? "&quantity="
min-members "&returnUrl=" return-url)]
(reset! form nil)
(st/emit! (ptk/event ::ev/event {::ev/name "create-trial-subscription"
:type "unlimited"
:quantity min-members})
(rt/nav-raw :href href))))))
subscribe-to-enterprise
(mf/use-fn
(fn []
(st/emit! (ptk/event ::ev/event {::ev/name "create-trial-subscription"
:type "enterprise"}))
(let [return-url (-> (rt/get-current-href) (rt/encode-url))
href (dm/str "payments/subscriptions/create?type=enterprise&returnUrl=" return-url)]
(st/emit! (rt/nav-raw :href href)))))
handle-accept-dialog
(mf/use-fn
(fn []
(st/emit! (ev/event {::ev/name "open-subscription-management"
::ev/origin "settings"
:section "subscription-management-modal"}))
(let [current-href (rt/get-current-href)
returnUrl (js/encodeURIComponent current-href)
href (dm/str "payments/subscriptions/show?returnUrl=" returnUrl)]
(st/emit! (rt/nav-raw :href href)))
(modal/hide!)))
handle-close-dialog
(mf/use-fn
(fn []
(st/emit! (ptk/event ::ev/event {::ev/name "close-subscription-modal"}))
(modal/hide!)))
show-editors-list*
(mf/use-state false)
show-editors-list
(deref show-editors-list*)
handle-click
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(swap! show-editors-list* not)))
on-submit
(mf/use-fn
(mf/deps current-subscription unlimited-modal-step*)
(fn [form]
(let [clean-data (get @form :clean-data)
min-members (get clean-data :min-members)
redirect? (get clean-data :redirect-to-payment-details)]
(if (or (contains? #{"unpaid" "canceled"} (:status current-subscription))
(= @unlimited-modal-step* 2))
(subscribe-to-unlimited min-members redirect?)
(swap! unlimited-modal-step* inc)))))
on-add-payments-click
(mf/use-fn
(fn []
(swap! form update :data assoc :redirect-to-payment-details true)))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-dialog)}
@@ -180,7 +219,8 @@
[:li {:key (dm/str (:id editor)) :class (stl/css :team-name)} "- " (:name editor)])]])])
(when (and
(or (and (= subscription-type "professional") (contains? #{"unlimited" "enterprise"} (:type current-subscription)))
(or (and (= subscription-type "professional")
(contains? #{"unlimited" "enterprise"} (:type current-subscription)))
(and (= subscription-type "unlimited") (= (:type current-subscription) "enterprise")))
(not (contains? #{"unpaid" "canceled"} (:status current-subscription)))
(not subscribe-to-trial))
@@ -189,7 +229,7 @@
(if (and (= subscription-type "unlimited")
(or subscribe-to-trial (contains? #{"unpaid" "canceled"} (:status current-subscription))))
[:& fm/form {:on-submit handle-unlimited-modal-step
[:& fm/form {:on-submit on-submit
:class (stl/css :seats-form)
:form form}
(when (= unlimited-modal-step 1)
@@ -232,7 +272,9 @@
:on-click handle-close-dialog}]
[:> fm/submit-button*
{:label (tr "labels.continue")
{:label (if (contains? #{"unpaid" "canceled"} (:status current-subscription))
(tr "subscription.settings.subscribe")
(tr "labels.continue"))
:class (stl/css :primary-button)}]]]])
(when (= unlimited-modal-step 2)
@@ -245,15 +287,14 @@
[:input
{:class (stl/css :cancel-button)
:type "button"
:value (tr "ubscription.settings.management-dialog.step-2-skip-button")
:on-click #(subscribe-to-unlimited false)}]
:type "submit"
:value (tr "subscription.settings.management-dialog.step-2-skip-button")}]
[:input
{:class (stl/css :primary-button)
:type "button"
:type "submit"
:value (tr "subscription.settings.management-dialog.step-2-add-payment-button")
:on-click #(subscribe-to-unlimited true)}]]]])]
:on-click on-add-payments-click}]]]])]
[:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons)}

View File

@@ -8,10 +8,12 @@
@use "ds/typography.scss" as t;
@use "ds/_borders.scss" as *;
@use "ds/spacing.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_utils.scss" as *;
.dashboard-section {
display: flex;
width: 100%;
inline-size: 100%;
justify-content: center;
align-items: center;
}
@@ -20,10 +22,10 @@
display: flex;
justify-content: center;
flex-direction: column;
max-width: deprecated.$s-500;
max-inline-size: $sz-500;
margin-block-end: var(--sp-xxxl);
width: deprecated.$s-580;
margin: deprecated.$s-92 auto deprecated.$s-120 auto;
inline-size: px2rem(580);
margin: px2rem(92) auto px2rem(120) auto;
justify-content: center;
}
@@ -98,8 +100,8 @@
.plan-title-icon {
@extend .button-icon;
stroke: var(--color-foreground-primary);
height: var(--sp-xl);
width: var(--sp-xl);
block-size: var(--sp-xl);
inline-size: var(--sp-xl);
border-radius: 6px;
border: 1.75px solid var(--color-foreground-primary);
stroke-width: 2.25px;
@@ -140,7 +142,7 @@
}
.other-subscriptions {
margin-block-start: deprecated.$s-52;
margin-block-start: px2rem(52);
}
.cta-button {
@@ -155,8 +157,8 @@
.cta-button svg {
@extend .button-icon;
height: var(--sp-l);
width: var(--sp-l);
block-size: var(--sp-l);
inline-size: var(--sp-l);
stroke: var(--color-accent-primary);
margin-inline-start: var(--sp-xs);
}
@@ -179,14 +181,12 @@
.modal-dialog {
@extend .modal-container-base;
display: grid;
grid-template-rows: auto 1fr auto;
max-height: initial;
min-width: deprecated.$s-548;
max-block-size: initial;
min-inline-size: px2rem(548);
}
.modal-dialog.subscription-success {
min-width: deprecated.$s-648;
min-inline-size: px2rem(648);
}
.close-btn {
@@ -232,11 +232,11 @@
.modal-success-content {
display: flex;
gap: deprecated.$s-40;
gap: $sz-40;
}
.modal-footer {
margin-block-start: deprecated.$s-40;
margin-block-start: $sz-40;
}
.action-buttons {
@@ -249,23 +249,28 @@
.primary-button {
@extend .modal-accept-btn;
min-block-size: $sz-32;
block-size: auto;
}
.cancel-button {
@extend .modal-cancel-btn;
min-block-size: $sz-32;
white-space: break-spaces;
block-size: auto;
}
.modal-start {
display: flex;
justify-content: center;
max-width: deprecated.$s-220;
max-inline-size: $sz-224;
svg {
width: 100%;
height: auto;
inline-size: 100%;
block-size: auto;
}
@media (max-width: 992px) {
@media (max-inline-size: 992px) {
display: none;
}
}
@@ -285,19 +290,19 @@
list-style-position: inside;
list-style-type: none;
margin-inline-start: var(--sp-xl);
max-height: deprecated.$s-216;
max-block-size: px2rem(216);
overflow-y: auto;
}
.input-field {
--input-icon-padding: var(--sp-s);
width: deprecated.$s-80;
inline-size: px2rem(80);
}
.error-message {
@include t.use-typography("body-small");
color: var(--color-foreground-error);
margin-block-start: deprecated.$s-8;
margin-block-start: var(--sp-s);
}
.editors-wrapper {
@@ -316,7 +321,7 @@
@include t.use-typography("body-small");
background-color: var(--color-background-tertiary);
border-radius: var(--sp-s);
margin-block-start: deprecated.$s-40;
margin-block-start: $sz-40;
padding-block: var(--sp-s);
padding-inline: var(--sp-m);
}

View File

@@ -313,15 +313,17 @@
(let [{:keys [height]} (wasm.api/get-text-dimensions shape-id)
selrect-transform (mf/deref refs/workspace-selrect)
[selrect transform] (dsh/get-selrect selrect-transform shape)
selrect-height (:height selrect)
max-height (max height selrect-height)
valign (-> shape :content :vertical-align)
y (:y selrect)
y (case valign
"bottom" (- y (- height (:height selrect)))
"center" (- y (/ (- height (:height selrect)) 2))
y (if (> height selrect-height)
(case valign
"bottom" (- y (- height selrect-height))
"center" (- y (/ (- height selrect-height) 2))
"top" y)
y)]
[(assoc selrect :y y :width (:width selrect) :height (max height (:height selrect))) transform])
[(assoc selrect :y y :width (:width selrect) :height max-height) transform])
(let [bounds (gst/shape->rect shape)
x (mth/min (dm/get-prop bounds :x)

View File

@@ -216,6 +216,18 @@
on-frame-leave (actions/on-frame-leave frame-hover)
on-frame-select (actions/on-frame-select selected read-only?)
;; Text Editor Event Handlers
on-text-keydown (fn [event]
(when (and text-editing? (.-key event))
(.preventDefault event)
(wasm.api/handle-text-keydown (.-key event))))
on-text-mousedown (fn [event]
(when text-editing?
(let [rect (.getBoundingClientRect (.-currentTarget event))
x (- (.-clientX event) (.-left rect))
y (- (.-clientY event) (.-top rect))]
(wasm.api/handle-text-mousedown x y))))
disable-events? (contains? layout :comments)
show-comments? (= drawing-tool :comments)
show-cursor-tooltip? tooltip
@@ -231,8 +243,9 @@
show-pixel-grid? (and (contains? layout :show-pixel-grid)
(>= zoom 8))
show-text-editor? (and editing-shape (= :text (:type editing-shape)))
;; show-text-editor? (and editing-shape (= :text (:type editing-shape)))
show-text-editor? false
hover-grid? (and (some? @hover-top-frame-id)
(ctl/grid-layout? objects @hover-top-frame-id))
@@ -419,7 +432,9 @@
:on-pointer-enter on-pointer-enter
:on-pointer-leave on-pointer-leave
:on-pointer-move on-pointer-move
:on-pointer-up on-pointer-up}
:on-pointer-up on-pointer-up
:on-key-down on-text-keydown
:on-mouse-down on-text-mousedown}
[:defs
;; This clip is so the handlers are not over the rulers

View File

@@ -128,11 +128,12 @@
(defn update-text-rect!
[id]
(mw/emit!
{:cmd :index/update-text-rect
:page-id (:current-page-id @st/state)
:shape-id id
:dimensions (get-text-dimensions id)}))
(when wasm/context-initialized?
(mw/emit!
{:cmd :index/update-text-rect
:page-id (:current-page-id @st/state)
:shape-id id
:dimensions (get-text-dimensions id)})))
(defn- ensure-text-content
@@ -198,70 +199,71 @@
(defn set-shape-children
[children]
(perf/begin-measure "set-shape-children")
(case (count children)
0
(h/call wasm/internal-module "_set_children_0")
(let [children (into [] (filter uuid?) children)]
(case (count children)
0
(h/call wasm/internal-module "_set_children_0")
1
(let [[c1] children
c1 (uuid/get-u32 c1)]
(h/call wasm/internal-module "_set_children_1"
(aget c1 0) (aget c1 1) (aget c1 2) (aget c1 3)))
1
(let [[c1] children
c1 (uuid/get-u32 c1)]
(h/call wasm/internal-module "_set_children_1"
(aget c1 0) (aget c1 1) (aget c1 2) (aget c1 3)))
2
(let [[c1 c2] children
c1 (uuid/get-u32 c1)
c2 (uuid/get-u32 c2)]
(h/call wasm/internal-module "_set_children_2"
(aget c1 0) (aget c1 1) (aget c1 2) (aget c1 3)
(aget c2 0) (aget c2 1) (aget c2 2) (aget c2 3)))
2
(let [[c1 c2] children
c1 (uuid/get-u32 c1)
c2 (uuid/get-u32 c2)]
(h/call wasm/internal-module "_set_children_2"
(aget c1 0) (aget c1 1) (aget c1 2) (aget c1 3)
(aget c2 0) (aget c2 1) (aget c2 2) (aget c2 3)))
3
(let [[c1 c2 c3] children
c1 (uuid/get-u32 c1)
c2 (uuid/get-u32 c2)
c3 (uuid/get-u32 c3)]
(h/call wasm/internal-module "_set_children_3"
(aget c1 0) (aget c1 1) (aget c1 2) (aget c1 3)
(aget c2 0) (aget c2 1) (aget c2 2) (aget c2 3)
(aget c3 0) (aget c3 1) (aget c3 2) (aget c3 3)))
3
(let [[c1 c2 c3] children
c1 (uuid/get-u32 c1)
c2 (uuid/get-u32 c2)
c3 (uuid/get-u32 c3)]
(h/call wasm/internal-module "_set_children_3"
(aget c1 0) (aget c1 1) (aget c1 2) (aget c1 3)
(aget c2 0) (aget c2 1) (aget c2 2) (aget c2 3)
(aget c3 0) (aget c3 1) (aget c3 2) (aget c3 3)))
4
(let [[c1 c2 c3 c4] children
c1 (uuid/get-u32 c1)
c2 (uuid/get-u32 c2)
c3 (uuid/get-u32 c3)
c4 (uuid/get-u32 c4)]
(h/call wasm/internal-module "_set_children_4"
(aget c1 0) (aget c1 1) (aget c1 2) (aget c1 3)
(aget c2 0) (aget c2 1) (aget c2 2) (aget c2 3)
(aget c3 0) (aget c3 1) (aget c3 2) (aget c3 3)
(aget c4 0) (aget c4 1) (aget c4 2) (aget c4 3)))
4
(let [[c1 c2 c3 c4] children
c1 (uuid/get-u32 c1)
c2 (uuid/get-u32 c2)
c3 (uuid/get-u32 c3)
c4 (uuid/get-u32 c4)]
(h/call wasm/internal-module "_set_children_4"
(aget c1 0) (aget c1 1) (aget c1 2) (aget c1 3)
(aget c2 0) (aget c2 1) (aget c2 2) (aget c2 3)
(aget c3 0) (aget c3 1) (aget c3 2) (aget c3 3)
(aget c4 0) (aget c4 1) (aget c4 2) (aget c4 3)))
5
(let [[c1 c2 c3 c4 c5] children
c1 (uuid/get-u32 c1)
c2 (uuid/get-u32 c2)
c3 (uuid/get-u32 c3)
c4 (uuid/get-u32 c4)
c5 (uuid/get-u32 c5)]
(h/call wasm/internal-module "_set_children_5"
(aget c1 0) (aget c1 1) (aget c1 2) (aget c1 3)
(aget c2 0) (aget c2 1) (aget c2 2) (aget c2 3)
(aget c3 0) (aget c3 1) (aget c3 2) (aget c3 3)
(aget c4 0) (aget c4 1) (aget c4 2) (aget c4 3)
(aget c5 0) (aget c5 1) (aget c5 2) (aget c5 3)))
5
(let [[c1 c2 c3 c4 c5] children
c1 (uuid/get-u32 c1)
c2 (uuid/get-u32 c2)
c3 (uuid/get-u32 c3)
c4 (uuid/get-u32 c4)
c5 (uuid/get-u32 c5)]
(h/call wasm/internal-module "_set_children_5"
(aget c1 0) (aget c1 1) (aget c1 2) (aget c1 3)
(aget c2 0) (aget c2 1) (aget c2 2) (aget c2 3)
(aget c3 0) (aget c3 1) (aget c3 2) (aget c3 3)
(aget c4 0) (aget c4 1) (aget c4 2) (aget c4 3)
(aget c5 0) (aget c5 1) (aget c5 2) (aget c5 3)))
;; Dynamic call for children > 5
(let [heap (mem/get-heap-u32)
size (mem/get-alloc-size children UUID-U8-SIZE)
offset (mem/alloc->offset-32 size)]
(reduce
(fn [offset id]
(mem.h32/write-uuid offset heap id))
offset
children)
(h/call wasm/internal-module "_set_children")))
;; Dynamic call for children > 5
(let [heap (mem/get-heap-u32)
size (mem/get-alloc-size children UUID-U8-SIZE)
offset (mem/alloc->offset-32 size)]
(reduce
(fn [offset id]
(mem.h32/write-uuid offset heap id))
offset
children)
(h/call wasm/internal-module "_set_children"))))
(perf/end-measure "set-shape-children")
nil)
@@ -468,14 +470,14 @@
[attrs]
(let [style (:style attrs)
;; Filter to only supported attributes
allowed-keys #{:fill :fillRule :strokeLinecap :strokeLinejoin}
allowed-keys #{:fill :fillRule :fill-rule :strokeLinecap :stroke-linecap :strokeLinejoin :stroke-linejoin}
attrs (-> attrs
(dissoc :style)
(merge style)
(select-keys allowed-keys))
fill-rule (-> attrs :fillRule sr/translate-fill-rule)
stroke-linecap (-> attrs :strokeLinecap sr/translate-stroke-linecap)
stroke-linejoin (-> attrs :strokeLinejoin sr/translate-stroke-linejoin)
fill-rule (or (-> attrs :fill-rule sr/translate-fill-rule) (-> attrs :fillRule sr/translate-fill-rule))
stroke-linecap (or (-> attrs :stroke-linecap sr/translate-stroke-linecap) (-> attrs :strokeLinecap sr/translate-stroke-linecap))
stroke-linejoin (or (-> attrs :stroke-linejoin sr/translate-stroke-linejoin) (-> attrs :strokeLinejoin sr/translate-stroke-linejoin))
fill-none (= "none" (-> attrs :fill))]
(h/call wasm/internal-module "_set_shape_svg_attrs" fill-rule stroke-linecap stroke-linejoin fill-none)))
@@ -739,7 +741,7 @@
(d/nilv align-self 0)
is-absolute
(d/nilv z-index))))
(d/nilv z-index 0))))
(defn clear-layout
[]
@@ -1031,8 +1033,9 @@
(into full-acc full)))
{:thumbnails thumbnails-acc :full full-acc}))]
(perf/end-measure "set-objects")
(process-pending shapes thumbnails full render-callback
(process-pending shapes thumbnails full noop-fn
(fn []
(when render-callback (render-callback))
(ug/dispatch! (ug/event "penpot:wasm:set-objects")))))))
(defn clear-focus-mode
@@ -1209,6 +1212,7 @@
(defn init-canvas-context
[canvas]
(let [gl (unchecked-get wasm/internal-module "GL")
flags (debug-flags)
context-id (if (dbg/enabled? :wasm-gl-context-init-error) "fail" "webgl2")
@@ -1255,6 +1259,19 @@
(h/call wasm/internal-module "_hide_grid")
(request-render "clear-grid"))
;; Text Editor Functions
(defn handle-text-keydown
[key]
(when (and wasm/internal-module key)
(h/call wasm/internal-module "_handle_keydown" key)
(request-render "text-editor-keydown")))
(defn handle-text-mousedown
[x y]
(when (and wasm/internal-module x y)
(h/call wasm/internal-module "_handle_mousedown" x y)
(request-render "text-editor-mousedown")))
(defn get-grid-coords
[position]
(let [offset (h/call wasm/internal-module

View File

@@ -83,12 +83,13 @@
(defn update-text-layout
[id]
(let [shape-id-buffer (uuid/get-u32 id)]
(h/call wasm/internal-module "_update_shape_text_layout_for"
(aget shape-id-buffer 0)
(aget shape-id-buffer 1)
(aget shape-id-buffer 2)
(aget shape-id-buffer 3))))
(when wasm/context-initialized?
(let [shape-id-buffer (uuid/get-u32 id)]
(h/call wasm/internal-module "_update_shape_text_layout_for"
(aget shape-id-buffer 0)
(aget shape-id-buffer 1)
(aget shape-id-buffer 2)
(aget shape-id-buffer 3)))))
;; IMPORTANT: Only TTF fonts can be stored.
(defn- store-font-buffer

View File

@@ -18,47 +18,56 @@
"image/svg+xml"])
(def ^:private default-options
#js {:decodeTransit t/decode-str})
#js {:decodeTransit t/decode-str
:allowHTMLPaste false})
(defn- from-data-transfer
"Get clipboard stream from DataTransfer instance"
[data-transfer]
(->> (rx/from (impl/fromDataTransfer data-transfer default-options))
(rx/mapcat #(rx/from %))))
([data-transfer]
(from-data-transfer data-transfer default-options))
([data-transfer options]
(->> (rx/from (impl/fromDataTransfer data-transfer options))
(rx/mapcat #(rx/from %)))))
(defn from-navigator
[]
(->> (rx/from (impl/fromNavigator default-options))
(rx/mapcat #(rx/from %))))
([]
(from-navigator default-options))
([options]
(->> (rx/from (impl/fromNavigator options))
(rx/mapcat #(rx/from %)))))
(defn from-clipboard-event
"Get clipboard stream from clipboard event"
[event]
(let [cdata (.-clipboardData ^js event)]
(from-data-transfer cdata)))
([event]
(from-clipboard-event event default-options))
([event options]
(let [cdata (.-clipboardData ^js event)]
(from-data-transfer cdata options))))
(defn from-synthetic-clipboard-event
"Get clipboard stream from syntetic clipboard event"
[event]
(let [target
(dom/get-target event)
([event options]
(let [target
(dom/get-target event)
content-editable?
(dom/is-content-editable? target)
content-editable?
(dom/is-content-editable? target)
is-input?
(= (dom/get-tag-name target) "INPUT")]
is-input?
(= (dom/get-tag-name target) "INPUT")]
;; ignore when pasting into an editable control
(when-not (or content-editable? is-input?)
(-> event
(dom/event->browser-event)
(from-clipboard-event)))))
(when-not (or content-editable? is-input?)
(-> event
(dom/event->browser-event)
(from-clipboard-event options))))))
(defn from-drop-event
"Get clipboard stream from drop event"
[event]
(from-data-transfer (.-dataTransfer ^js event)))
([event]
(from-drop-event event default-options))
([event options]
(from-data-transfer (.-dataTransfer ^js event) options)))
;; FIXME: rename to `write-text`
(defn to-clipboard

View File

@@ -27,6 +27,7 @@ const exclusiveTypes = [
/**
* @typedef {Object} ClipboardSettings
* @property {Function} [decodeTransit]
* @property {boolean} [allowHTMLPaste]
*/
/**
@@ -38,9 +39,7 @@ const exclusiveTypes = [
*/
function parseText(text, options) {
options = options || {};
const decodeTransit = options["decodeTransit"];
if (decodeTransit) {
try {
decodeTransit(text);
@@ -57,18 +56,85 @@ function parseText(text, options) {
}
}
/**
* Filters ClipboardItem types
*
* @param {ClipboardSettings} options
* @returns {Function<AllowedTypesFilterFunction>}
*/
function filterAllowedTypes(options) {
/**
* @param {string} type
* @returns {boolean}
*/
return function filter(type) {
if (
(!("allowHTMLPaste" in options) || !options["allowHTMLPaste"]) &&
type === "text/html"
) {
return false;
}
return allowedTypes.includes(type);
};
}
/**
* Filters DataTransferItems
*
* @param {ClipboardSettings} options
* @returns {Function<AllowedTypesFilterFunction>}
*/
function filterAllowedItems(options) {
/**
* @param {DataTransferItem}
* @returns {boolean}
*/
return function filter(item) {
if (
(!("allowHTMLPaste" in options) || !options["allowHTMLPaste"]) &&
item.type === "text/html"
) {
return false;
}
return allowedTypes.includes(item.type);
};
}
/**
* Sorts ClipboardItem types
*
* @param {string} a
* @param {string} b
* @returns {number}
*/
function sortTypes(a, b) {
return allowedTypes.indexOf(a) - allowedTypes.indexOf(b);
}
/**
* Sorts DataTransferItems
*
* @param {DataTransferItem} a
* @param {DataTransferItem} b
* @returns {number}
*/
function sortItems(a, b) {
return allowedTypes.indexOf(a.type) - allowedTypes.indexOf(b.type);
}
/**
*
* @param {ClipboardSettings} [options]
* @returns {Promise<Array<Blob>>}
*/
export async function fromNavigator(options) {
options = options || {};
const items = await navigator.clipboard.read();
return Promise.all(
Array.from(items).map(async (item) => {
const itemAllowedTypes = Array.from(item.types)
.filter((type) => allowedTypes.includes(type))
.sort((a, b) => allowedTypes.indexOf(a) - allowedTypes.indexOf(b));
.filter(filterAllowedTypes(options))
.sort(sortTypes);
if (
itemAllowedTypes.length === 1 &&
@@ -96,12 +162,11 @@ export async function fromNavigator(options) {
* @returns {Promise<Array<Blob>>}
*/
export async function fromDataTransfer(dataTransfer, options) {
options = options || {};
const items = await Promise.all(
Array.from(dataTransfer.items)
.filter((item) => allowedTypes.includes(item.type))
.sort(
(a, b) => allowedTypes.indexOf(a.type) - allowedTypes.indexOf(b.type),
)
.filter(filterAllowedItems(options))
.sort(sortItems)
.map(async (item) => {
if (item.kind === "file") {
return Promise.resolve(item.getAsFile());

View File

@@ -13,6 +13,7 @@
[app.common.logging :as log]
[app.common.types.color :as cc]
[app.common.uri :as u]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.fonts :as fonts]
[app.main.render :as render]
@@ -125,58 +126,75 @@
(def thumbnail-aspect-ratio (/ 2 3))
(defmethod impl/handler :thumbnails/generate-for-file-wasm
[{:keys [file-id revn width] :as message} _]
(defn render-canvas-blob
[canvas width height background-color]
(-> (.convertToBlob canvas)
(p/then
(fn [blob]
(rds/renderToStaticMarkup
(mf/element
svg-wrapper
#js {:data-uri (blob->uri blob)
:width width
:height height
:background background-color}))))))
(defn process-wasm-thumbnail
[{:keys [id file-id revn width] :as message}]
(->> (rx/from @init-wasm)
(rx/mapcat #(request-data-for-thumbnail file-id revn false))
(rx/mapcat
(fn [{:keys [page] :as file}]
(rx/create
(fn [subs]
(try
(let [background-color (or (:background page) cc/canvas)
height (* width thumbnail-aspect-ratio)
canvas (js/OffscreenCanvas. width height)
init? (wasm.api/init-canvas-context canvas)]
(if init?
(let [objects (:objects page)
frame (some->> page :thumbnail-frame-id (get objects))
vbox (if frame
(-> (gsb/get-object-bounds objects frame)
(grc/fix-aspect-ratio thumbnail-aspect-ratio))
(render/calculate-dimensions objects thumbnail-aspect-ratio))
zoom (/ width (:width vbox))]
(let [background-color (or (:background page) cc/canvas)
height (* width thumbnail-aspect-ratio)
canvas (js/OffscreenCanvas. width height)
init? (wasm.api/init-canvas-context canvas)]
(if init?
(let [objects (:objects page)
frame (some->> page :thumbnail-frame-id (get objects))
vbox (if frame
(-> (gsb/get-object-bounds objects frame)
(grc/fix-aspect-ratio thumbnail-aspect-ratio))
(render/calculate-dimensions objects thumbnail-aspect-ratio))
zoom (/ width (:width vbox))]
(wasm.api/initialize-viewport
objects zoom vbox background-color
(fn []
(if frame
(wasm.api/render-sync-shape (:id frame))
(wasm.api/render-sync))
(wasm.api/initialize-viewport
objects zoom vbox background-color
(fn []
(if frame
(wasm.api/render-sync-shape (:id frame))
(wasm.api/render-sync))
(-> (.convertToBlob canvas)
(p/then
(fn [blob]
(let [data
(rds/renderToStaticMarkup
(mf/element
svg-wrapper
#js {:data-uri (blob->uri blob)
:width width
:height height
:background background-color}))]
(rx/push! subs {:data data :file-id file-id :revn revn}))))
(p/catch #(do (.error js/console %)
(rx/error! subs %)))
(p/finally #(rx/end! subs))))))
(-> (render-canvas-blob canvas width height background-color)
(p/then #(rx/push! subs {:id id :data % :file-id file-id :revn revn}))
(p/catch #(rx/error! subs %))
(p/finally #(rx/end! subs))))))
(do (rx/error! subs "Error loading webgl context")
(rx/end! subs)))
(rx/end! subs))
nil)
nil)))))))
(catch :default err
(.error js/console err)
(rx/error! subs err)
(rx/end! subs)))))))))
(defonce thumbs-subject (rx/subject))
(defonce thumbs-stream
(->> thumbs-subject
(rx/mapcat process-wasm-thumbnail)
(rx/share)))
(defmethod impl/handler :thumbnails/generate-for-file-wasm
[message _]
(rx/create
(fn [subs]
(let [id (uuid/next)
sid
(->> thumbs-stream
(rx/filter #(= id (:id %)))
(rx/subs!
#(do
(rx/push! subs %)
(rx/end! subs))))]
(rx/push! thumbs-subject (assoc message :id id))
#(rx/dispose! sid)))))

View File

@@ -432,6 +432,7 @@ dependencies = [
"indexmap",
"macros",
"skia-safe",
"text_editor",
"uuid",
]
@@ -577,6 +578,14 @@ dependencies = [
"xattr",
]
[[package]]
name = "text_editor"
version = "0.1.0"
dependencies = [
"skia-safe",
"wasm-bindgen",
]
[[package]]
name = "toml"
version = "0.8.19"

View File

@@ -32,6 +32,7 @@ skia-safe = { version = "0.87.0", default-features = false, features = [
"binary-cache",
"webp",
] }
text_editor = { path = "src/text_editor" }
uuid = { version = "1.11.0", features = ["v4", "js"] }
[profile.release]

View File

@@ -650,6 +650,38 @@ pub extern "C" fn set_modifiers() {
});
}
#[no_mangle]
pub extern "C" fn handle_keydown(key_ptr: *const std::os::raw::c_char) {
if key_ptr.is_null() {
return;
}
let key = unsafe {
match std::ffi::CStr::from_ptr(key_ptr).to_str() {
Ok(s) => s,
Err(_) => return, // Invalid UTF-8, skip
}
};
with_state_mut!(state, {
state.render_state.text_editor.handle_keydown(key);
});
render_sync();
}
#[no_mangle]
pub extern "C" fn handle_mousedown(x: f32, y: f32) {
// Basic sanity checks
if !x.is_finite() || !y.is_finite() {
return;
}
with_state_mut!(state, {
state.render_state.text_editor.handle_mousedown(x, y);
});
render_sync();
}
fn main() {
#[cfg(target_arch = "wasm32")]
init_gl!();

View File

@@ -215,6 +215,7 @@ pub(crate) struct RenderState {
pub options: RenderOptions,
pub surfaces: Surfaces,
pub fonts: FontStore,
pub text_editor: ::text_editor::TextEditor,
pub viewbox: Viewbox,
pub cached_viewbox: Viewbox,
pub cached_target_snapshot: Option<skia::Image>,
@@ -288,12 +289,14 @@ impl RenderState {
let viewbox = Viewbox::new(width as f32, height as f32);
let tiles = tiles::TileHashMap::new();
let text_editor = ::text_editor::TextEditor::new(fonts.debug_font.clone());
RenderState {
gpu_state: gpu_state.clone(),
options: RenderOptions::default(),
surfaces,
fonts,
text_editor,
viewbox,
cached_viewbox: Viewbox::new(0., 0.),
cached_target_snapshot: None,
@@ -916,6 +919,8 @@ impl RenderState {
ui::render(self, shapes);
debug::render_wasm_label(self);
self.text_editor.render(self.surfaces.canvas(SurfaceId::Target));
self.flush_and_submit();
}
}
@@ -1641,9 +1646,9 @@ impl RenderState {
}
children_ids.sort_by(|id1, id2| {
let z1 = tree.get(id1).map_or_else(|| 0, |s| s.z_index());
let z2 = tree.get(id2).map_or_else(|| 0, |s| s.z_index());
z1.cmp(&z2)
let z1 = tree.get(id1).map(|s| s.z_index()).unwrap_or(0);
let z2 = tree.get(id2).map(|s| s.z_index()).unwrap_or(0);
z2.cmp(&z1)
});
}
@@ -1791,6 +1796,8 @@ impl RenderState {
ui::render(self, tree);
debug::render_wasm_label(self);
self.text_editor.render(self.surfaces.canvas(SurfaceId::Target));
Ok(())
}

View File

@@ -21,7 +21,7 @@ pub struct FontStore {
font_mgr: FontMgr,
font_provider: textlayout::TypefaceFontProvider,
font_collection: textlayout::FontCollection,
debug_font: Font,
pub debug_font: Font,
fallback_fonts: HashSet<String>,
}

View File

@@ -332,6 +332,7 @@ fn propagate_reflow(
}
_ => {
// Other shapes don't have to be reflown
reflow_parent = true;
}
}

View File

@@ -1,10 +1,8 @@
use skia_safe::{self as skia, textlayout::FontCollection, Path, Point};
use skia_safe::{self as skia, Path, Point, textlayout::FontCollection};
use std::collections::HashMap;
mod shapes_pool;
mod text_editor;
pub use shapes_pool::{ShapesPool, ShapesPoolMutRef, ShapesPoolRef};
pub use text_editor::*;
use crate::render::RenderState;
use crate::shapes::Shape;
@@ -20,7 +18,6 @@ use crate::shapes::modifiers::grid_layout::grid_cell_data;
/// must not be shared between different Web Workers.
pub(crate) struct State<'a> {
pub render_state: RenderState,
pub text_editor_state: TextEditorState,
pub current_id: Option<Uuid>,
pub current_browser: u8,
pub shapes: ShapesPool<'a>,
@@ -28,9 +25,9 @@ pub(crate) struct State<'a> {
impl<'a> State<'a> {
pub fn new(width: i32, height: i32) -> Self {
let render_state = RenderState::new(width, height);
State {
render_state: RenderState::new(width, height),
text_editor_state: TextEditorState::new(),
render_state,
current_id: None,
current_browser: 0,
shapes: ShapesPool::new(),
@@ -49,16 +46,6 @@ impl<'a> State<'a> {
&self.render_state
}
#[allow(dead_code)]
pub fn text_editor_state_mut(&mut self) -> &mut TextEditorState {
&mut self.text_editor_state
}
#[allow(dead_code)]
pub fn text_editor_state(&self) -> &TextEditorState {
&self.text_editor_state
}
pub fn render_from_cache(&mut self) {
self.render_state.render_from_cache(&self.shapes);
}

View File

@@ -0,0 +1,8 @@
[package]
name = "text_editor"
version = "0.1.0"
edition = "2021"
[dependencies]
skia-safe = { version = "0.87.0", default-features = false, features = ["gl"] }
wasm-bindgen = "0.2"

View File

@@ -0,0 +1,12 @@
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn handle_keydown(key: String) {
// TODO: Handle keydown event
}
#[wasm_bindgen]
pub fn handle_mousedown(x: f32, y: f32) {
// TODO: Handle mousedown event
}

View File

@@ -0,0 +1,153 @@
pub mod events;
use skia_safe::{Canvas, Font, Paint, Point, Color};
pub struct TextEditor {
text: Vec<String>,
font: Font,
cursor_pos: Point,
}
impl TextEditor {
pub fn new(font: Font) -> Self {
TextEditor {
text: vec!["Hello, Skia!".to_string()],
font,
cursor_pos: Point::new(0.0, 0.0),
}
}
pub fn render(&self, canvas: &Canvas) {
let mut paint = Paint::default();
paint.set_color(Color::BLACK);
paint.set_anti_alias(true);
for (i, line) in self.text.iter().enumerate() {
canvas.draw_str(line, (20.0, 20.0 + (i as f32 * 18.0)), &self.font, &paint);
}
// Draw cursor - with bounds checking
let mut cursor_paint = Paint::default();
cursor_paint.set_color(Color::BLACK);
cursor_paint.set_anti_alias(true);
let y_idx = self.cursor_pos.y as usize;
let x_idx = self.cursor_pos.x as usize;
if y_idx < self.text.len() {
let line = &self.text[y_idx];
let safe_x_idx = x_idx.min(line.len());
let (x, _) = if safe_x_idx > 0 {
self.font.measure_str(&line[..safe_x_idx], None)
} else {
(0.0, skia_safe::Rect::new_empty())
};
let cursor_rect = skia_safe::Rect::from_xywh(
20.0 + x,
20.0 + (y_idx as f32 * 18.0) - 18.0,
1.0,
18.0
);
canvas.draw_rect(cursor_rect, &cursor_paint);
}
}
pub fn handle_keydown(&mut self, key: &str) {
if self.text.is_empty() {
self.text.push(String::new());
}
let y = self.cursor_pos.y as usize;
let x = self.cursor_pos.x as usize;
if y >= self.text.len() {
return;
}
match key {
"ArrowLeft" => {
if x > 0 {
self.cursor_pos.x -= 1.0;
} else if y > 0 {
self.cursor_pos.y -= 1.0;
self.cursor_pos.x = self.text[y - 1].len() as f32;
}
}
"ArrowRight" => {
if x < self.text[y].len() {
self.cursor_pos.x += 1.0;
} else if y < self.text.len() - 1 {
self.cursor_pos.y += 1.0;
self.cursor_pos.x = 0.0;
}
}
"ArrowUp" => {
if y > 0 {
self.cursor_pos.y -= 1.0;
let new_y = y - 1;
self.cursor_pos.x = self.cursor_pos.x.min(self.text[new_y].len() as f32);
}
}
"ArrowDown" => {
if y < self.text.len() - 1 {
self.cursor_pos.y += 1.0;
let new_y = y + 1;
self.cursor_pos.x = self.cursor_pos.x.min(self.text[new_y].len() as f32);
}
}
"Backspace" => {
if x > 0 {
self.text[y].remove(x - 1);
self.cursor_pos.x -= 1.0;
} else if y > 0 {
let line = self.text.remove(y);
self.cursor_pos.y -= 1.0;
self.cursor_pos.x = self.text[y - 1].len() as f32;
self.text[y - 1].push_str(&line);
}
}
"Enter" => {
let line = self.text[y].split_off(x);
self.text.insert(y + 1, line);
self.cursor_pos.y += 1.0;
self.cursor_pos.x = 0.0;
}
_ => {
if key.len() == 1 {
if let Some(ch) = key.chars().next() {
self.text[y].insert(x, ch);
self.cursor_pos.x += 1.0;
}
}
}
}
}
pub fn handle_mousedown(&mut self, x: f32, y: f32) {
println!("@@@ Mouse down at: ({}, {})", x, y);
if self.text.is_empty() {
self.text.push(String::new());
}
let line_height = 18.0;
let y_pos = ((y - 20.0) / line_height).floor().max(0.0) as usize;
let y_pos = y_pos.min(self.text.len() - 1);
self.cursor_pos.y = y_pos as f32;
let line = &self.text[y_pos];
let mut closest_pos = 0;
let mut min_dist = f32::MAX;
for i in 0..=line.len() {
let (width, _) = self.font.measure_str(&line[..i], None);
let dist = (x - (20.0 + width)).abs();
if dist < min_dist {
min_dist = dist;
closest_pos = i;
}
}
self.cursor_pos.x = closest_pos as f32;
}
}

View File

@@ -291,9 +291,10 @@ pub extern "C" fn set_shape_text_content() {
let bytes = mem::bytes();
with_current_shape_mut!(state, |shape: &mut Shape| {
let raw_text_data = RawParagraph::try_from(&bytes).unwrap();
shape
.add_paragraph(raw_text_data.into())
.expect("Failed to add paragraph");
if let Err(_) = shape.add_paragraph(raw_text_data.into()) {
println!("Error with set_shape_text_content on {:?}", shape.id);
}
});
mem::free_bytes();
}