mirror of
https://github.com/penpot/penpot.git
synced 2026-09-08 11:54:36 -04:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b08002bc0b | ||
|
|
87d9110ec5 | ||
|
|
774f5af013 | ||
|
|
6a9bf0fda5 |
No files matched your search
@@ -102,14 +102,17 @@ jobs:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Integration Tests (${{ matrix.shard }})"
|
||||
runs-on: penpot-extended-runner
|
||||
timeout-minutes: 40
|
||||
timeout-minutes: ${{ github.base_ref == 'staging' && 60 || 25 }}
|
||||
|
||||
needs: build-integration
|
||||
|
||||
# TEMPORARY (release stabilization): PRs targeting `staging` run on a
|
||||
# single serial shard, so new flakes cannot block the release work.
|
||||
# Remove the `github.base_ref` branch below to restore full parallelism.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: ${{ fromJSON(inputs.shards || '[1, 2, 3, 4]') }}
|
||||
shard: ${{ fromJSON(inputs.shards || (github.base_ref == 'staging' && '[1]' || '[1, 2, 3, 4]')) }}
|
||||
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
@@ -150,8 +153,12 @@ jobs:
|
||||
working-directory: ./frontend
|
||||
env:
|
||||
WORKERS: ${{ inputs.workers }}
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
WORKERS=${WORKERS:-2}
|
||||
# TEMPORARY (release stabilization): see the note on the matrix above.
|
||||
if [ -z "$WORKERS" ]; then
|
||||
if [ "$BASE_REF" = "staging" ]; then WORKERS=1; else WORKERS=2; fi
|
||||
fi
|
||||
echo "Running shard ${{ matrix.shard }}/${{ strategy.job-total }} with $WORKERS workers"
|
||||
pnpm exec playwright test --project default \
|
||||
--workers="$WORKERS" \
|
||||
@@ -229,18 +236,6 @@ jobs:
|
||||
fi
|
||||
jq -r -f ../.github/scripts/playwright-summary.jq report.json >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Kept for 30 days so flakiness rates can be aggregated across runs
|
||||
# without scraping job logs.
|
||||
- name: Upload JSON report
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: integration-json-report
|
||||
path: frontend/report.json
|
||||
overwrite: true
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload HTML report
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
|
||||
@@ -99,7 +99,6 @@ opencode.json
|
||||
/.idea
|
||||
*.iml
|
||||
/.claude
|
||||
/CLAUDE.md
|
||||
/.playwright-mcp
|
||||
/.devenv/mcp/
|
||||
/opencode.json
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
# Creating Pull Requests
|
||||
|
||||
PR only on explicit request.
|
||||
|
||||
## Branch Naming
|
||||
|
||||
- Primary: `issue-NNNN` — one branch per GitHub issue (e.g. `issue-11525`).
|
||||
- No issue: free-form descriptive name, dash-separated, no slashes (e.g. `fix-ellipse-icon-typo`, `feat-auto-link-libraries`).
|
||||
- If the user already created the branch, use it as-is — never rename.
|
||||
PR only on explicit request. Branch: issue/feature-specific; fallback `<type>/<short-description>` (`fix/...`, `feat/...`, `refactor/...`, `docs/...`, `chore/...`, `perf/...`).
|
||||
|
||||
## Target Branch
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ Center](https://help.penpot.app/).
|
||||
- [Reporting Bugs](#reporting-bugs)
|
||||
- [Pull Requests](#pull-requests)
|
||||
- [Workflow](#workflow)
|
||||
- [Branch naming](#branch-naming)
|
||||
- [Format](#format)
|
||||
- [Title format](#title-format)
|
||||
- [Description](#description)
|
||||
@@ -74,18 +73,6 @@ Advisories](https://github.com/penpot/penpot/security/advisories)
|
||||
4. **Format and lint** — run the checks described in
|
||||
[Formatting and Linting](#formatting-and-linting) before submitting.
|
||||
|
||||
### Branch naming
|
||||
|
||||
Branch names are not enforced, but we recommend the following:
|
||||
|
||||
- **`issue-NNNN`** — when working from a GitHub issue, name the branch after
|
||||
it (e.g. `issue-11525`). This makes each PR's origin self-evident.
|
||||
- Otherwise, use a short, descriptive name with words separated by hyphens
|
||||
and no slashes (e.g. `fix-ellipse-icon-typo`, `feat-auto-link-libraries`).
|
||||
|
||||
Since PRs are squash-merged, the branch name does not survive into the
|
||||
commit history — what matters is the [PR title](#title-format).
|
||||
|
||||
### Format
|
||||
|
||||
#### Title
|
||||
|
||||
@@ -366,14 +366,10 @@
|
||||
(if clone?
|
||||
(let [profile (profile/get-profile pool profile-id)
|
||||
project-id (:default-project-id profile)
|
||||
team (teams/get-team pool
|
||||
:profile-id profile-id
|
||||
:project-id project-id)
|
||||
cfg (assoc cfg
|
||||
::bfc/overwrite false
|
||||
::bfc/profile-id profile-id
|
||||
::bfc/project-id project-id
|
||||
::bfc/team-id (:id team)
|
||||
::bfc/input path
|
||||
::bfc/import-max-object-size (cf/get :binfile-import-max-object-size)
|
||||
::bfc/import-max-zip-entries (cf/get :binfile-import-max-zip-entries))]
|
||||
@@ -632,7 +628,6 @@
|
||||
::bfc/profile-id profile-id
|
||||
::bfc/project-id project-id
|
||||
::bfc/input path
|
||||
::bfc/team-id (:id team)
|
||||
::bfc/features (cfeat/get-team-enabled-features cf/flags team)
|
||||
::bfc/import-max-object-size (cf/get :binfile-import-max-object-size)
|
||||
::bfc/import-max-zip-entries (cf/get :binfile-import-max-zip-entries))]
|
||||
|
||||
@@ -400,7 +400,6 @@
|
||||
'app.rpc.commands.management
|
||||
'app.rpc.commands.media
|
||||
'app.rpc.commands.nitrate
|
||||
'app.rpc.commands.plugins
|
||||
'app.rpc.commands.profile
|
||||
'app.rpc.commands.projects
|
||||
'app.rpc.commands.search
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
(:require
|
||||
[app.auth :refer [derive-password-weak]]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
@@ -22,21 +21,14 @@
|
||||
[buddy.core.codecs :as bc]
|
||||
[buddy.core.nonce :as bn]))
|
||||
|
||||
(def ^:private
|
||||
schema:create-demo-profile
|
||||
[:map
|
||||
[:skip-onboarding {:optional true} ::sm/boolean]])
|
||||
|
||||
(sv/defmethod ::create-demo-profile
|
||||
"A command that is responsible of creating a demo purpose
|
||||
profile. It only works if the `demo-users` flag is enabled in the
|
||||
configuration."
|
||||
{::rpc/auth false
|
||||
::doc/added "1.15"
|
||||
::doc/changes [["1.15" "This method is migrated from mutations to commands."]
|
||||
["2.18" "Add optional `skip-onboarding` param. When true, the profile is created with `onboarding-viewed` and `release-notes-viewed` (current version) set, skipping the onboarding flow."]]
|
||||
::sm/params schema:create-demo-profile}
|
||||
[cfg {:keys [skip-onboarding]}]
|
||||
::doc/changes ["1.15" "This method is migrated from mutations to commands."]}
|
||||
[cfg _]
|
||||
|
||||
(when-not (contains? cf/flags :demo-users)
|
||||
(ex/raise :type :validation
|
||||
@@ -56,13 +48,7 @@
|
||||
:is-active true
|
||||
:is-demo true
|
||||
:password (derive-password-weak password)
|
||||
:props (cond-> {}
|
||||
skip-onboarding (assoc :onboarding-viewed true
|
||||
;; Redundant today: auth/create-profile
|
||||
;; overwrites this with the current
|
||||
;; version, kept so the skip does not
|
||||
;; depend on that default.
|
||||
:release-notes-viewed (:main cf/version)))}
|
||||
:props {}}
|
||||
profile (db/tx-run! cfg (fn [cfg]
|
||||
(->> (auth/create-profile cfg params)
|
||||
(auth/create-profile-rels cfg))))]
|
||||
|
||||
@@ -425,7 +425,6 @@
|
||||
cfg (-> cfg
|
||||
(assoc ::bfc/project-id project-id)
|
||||
(assoc ::bfc/profile-id profile-id)
|
||||
(assoc ::bfc/team-id (:id team))
|
||||
(assoc ::bfc/input template)
|
||||
(assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team))
|
||||
(assoc ::bfc/import-max-object-size (cf/get :binfile-import-max-object-size))
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
;; 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 Sucursal en España SL
|
||||
|
||||
(ns app.rpc.commands.plugins
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.types.plugins :as ctp]
|
||||
[app.db :as db]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.profile :as profile]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.util.services :as sv]))
|
||||
|
||||
(defn- validate-plugin-permissions!
|
||||
"Validates that all permissions in the plugin are within the valid set."
|
||||
[plugin]
|
||||
(let [permissions (:permissions plugin)
|
||||
invalid (remove ctp/valid-permissions permissions)]
|
||||
(when (seq invalid)
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-plugin-permissions
|
||||
:hint (str "Invalid permissions: " (pr-str (set invalid)))
|
||||
:invalid-permissions (set invalid)))))
|
||||
|
||||
(def ^:private
|
||||
schema:add-profile-plugin
|
||||
[:map {:title "add-profile-plugin"}
|
||||
[:plugin ctp/schema:registry-entry]])
|
||||
|
||||
(sv/defmethod ::add-profile-plugin
|
||||
{::doc/added "2.18"
|
||||
::sm/params schema:add-profile-plugin
|
||||
::sm/result ctp/schema:registry-entry
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id plugin]}]
|
||||
(validate-plugin-permissions! plugin)
|
||||
|
||||
(let [profile (profile/get-profile conn profile-id ::db/for-update true)
|
||||
plugins (get-in profile [:props :plugins] {:ids [] :data {}})
|
||||
plugin-id (:plugin-id plugin)
|
||||
plugins (-> plugins
|
||||
(update :ids #(vec (distinct (conj % plugin-id))))
|
||||
(assoc-in [:data plugin-id] plugin))]
|
||||
(db/update! conn :profile
|
||||
{:props (db/tjson (assoc (:props profile) :plugins plugins))}
|
||||
{:id profile-id}
|
||||
{::db/return-keys false})
|
||||
plugin))
|
||||
|
||||
(def ^:private
|
||||
schema:remove-profile-plugin
|
||||
[:map {:title "remove-profile-plugin"}
|
||||
[:plugin-id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::remove-profile-plugin
|
||||
{::doc/added "2.18"
|
||||
::sm/params schema:remove-profile-plugin
|
||||
::sm/result :nil
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id plugin-id]}]
|
||||
(let [profile (profile/get-profile conn profile-id ::db/for-update true)
|
||||
plugins (get-in profile [:props :plugins] {:ids [] :data {}})
|
||||
plugin-id-str (str plugin-id)
|
||||
plugins (-> plugins
|
||||
(update :ids #(vec (remove (partial = plugin-id-str) %)))
|
||||
(update :data dissoc plugin-id-str))]
|
||||
(db/update! conn :profile
|
||||
{:props (db/tjson (assoc (:props profile) :plugins plugins))}
|
||||
{:id profile-id}
|
||||
{::db/return-keys false})
|
||||
nil))
|
||||
@@ -12,7 +12,7 @@
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.types.plugins :as ctp]
|
||||
[app.common.types.plugins :refer [schema:plugin-registry]]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
@@ -54,11 +54,11 @@
|
||||
|
||||
(def system-managed-props
|
||||
"Props keys managed by the system (not user-writable via RPC)."
|
||||
#{:subscription :plugins})
|
||||
#{:subscription})
|
||||
|
||||
(def schema:props
|
||||
[:map {:title "ProfileProps" :closed true}
|
||||
[:plugins {:optional true} ctp/schema:plugin-registry]
|
||||
[:plugins {:optional true} schema:plugin-registry]
|
||||
[:renderer {:optional true} [::sm/one-of #{:svg :wasm}]]
|
||||
[:mcp-enabled {:optional true} ::sm/boolean]
|
||||
[:newsletter-updates {:optional true} ::sm/boolean]
|
||||
@@ -78,10 +78,6 @@
|
||||
[:map-of {:gen/max 10} :keyword [:map-of :keyword :string]]]
|
||||
[:nudge {:optional true} schema:nudge]])
|
||||
|
||||
(def schema:props-writeable
|
||||
"Props schema for user-writable fields (excludes system-managed keys)."
|
||||
(reduce sm/dissoc-key schema:props system-managed-props))
|
||||
|
||||
(def schema:profile
|
||||
[:map {:title "Profile"}
|
||||
[:id ::sm/uuid]
|
||||
@@ -467,7 +463,7 @@
|
||||
(def ^:private
|
||||
schema:update-profile-props
|
||||
[:map {:title "update-profile-props"}
|
||||
[:props schema:props-writeable]])
|
||||
[:props schema:props]])
|
||||
|
||||
(defn update-profile-props
|
||||
[{:keys [::db/conn] :as cfg} profile-id props]
|
||||
|
||||
@@ -235,8 +235,9 @@
|
||||
:organization-name (:name organization)
|
||||
:member-email (:email-to invitation)
|
||||
:member-id (:id member)
|
||||
:role role
|
||||
:user-who-send-invitation (str profile-id)}
|
||||
:role role}
|
||||
organization
|
||||
(assoc :user-who-send-invitation (str profile-id))
|
||||
|
||||
(not organization)
|
||||
(assoc :team-belongs-to-organization (boolean team-organization-id)
|
||||
|
||||
@@ -308,9 +308,7 @@
|
||||
(assoc :name "accept-organization-invitation")
|
||||
(assoc :props
|
||||
(-> props
|
||||
(assoc :organization-id organization-id-on-add
|
||||
:user-id (:id profile)
|
||||
:user-who-send-invitation (:created-by invitation))
|
||||
(assoc :organization-id organization-id-on-add)
|
||||
(audit/clean-props))))))
|
||||
|
||||
(cond-> (assoc claims :state :created)
|
||||
@@ -327,8 +325,6 @@
|
||||
(assoc :organization-id organization-id-on-add
|
||||
:organization-member-add-source organization-add-source
|
||||
:belongs-to-team-on-add (boolean team-id)
|
||||
:user-id (:id profile)
|
||||
:user-who-send-invitation (:created-by invitation)
|
||||
:organization-member-count-before
|
||||
organization-member-count-before)
|
||||
(audit/clean-props))}))))))
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
(:require
|
||||
[app.auth :as auth]
|
||||
[app.config :as cf]
|
||||
[app.rpc.commands.profile :as profile]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]))
|
||||
|
||||
@@ -39,38 +38,3 @@
|
||||
(with-redefs [cf/flags (disj cf/flags :demo-users)]
|
||||
(let [{:keys [error]} (th/command! {::th/type :create-demo-profile})]
|
||||
(t/is (th/ex-of-code? error :demo-users-not-allowed)))))
|
||||
|
||||
(t/deftest create-demo-profile-keeps-onboarding-by-default
|
||||
(with-redefs [cf/flags (conj cf/flags :demo-users)]
|
||||
(let [{:keys [error result]} (th/command! {::th/type :create-demo-profile})]
|
||||
(t/is (nil? error))
|
||||
(let [saved (th/db-get :profile {:email (:email result)})
|
||||
decoded (profile/decode-row saved)]
|
||||
(t/is (nil? (get-in decoded [:props :onboarding-viewed])))))))
|
||||
|
||||
(t/deftest create-demo-profile-skips-onboarding-when-requested
|
||||
(with-redefs [cf/flags (conj cf/flags :demo-users)]
|
||||
(let [{:keys [error result]} (th/command! {::th/type :create-demo-profile
|
||||
:skip-onboarding true})]
|
||||
(t/is (nil? error))
|
||||
(let [saved (th/db-get :profile {:email (:email result)})
|
||||
decoded (profile/decode-row saved)]
|
||||
(t/is (true? (get-in decoded [:props :onboarding-viewed])))
|
||||
(t/is (= (:main cf/version)
|
||||
(get-in decoded [:props :release-notes-viewed])))))))
|
||||
|
||||
(t/deftest create-demo-profile-explicit-false-keeps-onboarding
|
||||
(with-redefs [cf/flags (conj cf/flags :demo-users)]
|
||||
(let [{:keys [error result]} (th/command! {::th/type :create-demo-profile
|
||||
:skip-onboarding false})]
|
||||
(t/is (nil? error))
|
||||
(let [saved (th/db-get :profile {:email (:email result)})
|
||||
decoded (profile/decode-row saved)]
|
||||
(t/is (nil? (get-in decoded [:props :onboarding-viewed])))))))
|
||||
|
||||
(t/deftest create-demo-profile-rejects-non-boolean-skip-onboarding
|
||||
(with-redefs [cf/flags (conj cf/flags :demo-users)]
|
||||
(let [{:keys [error]} (th/command! {::th/type :create-demo-profile
|
||||
:skip-onboarding "yes"})]
|
||||
(t/is (th/ex-of-type? error :validation))
|
||||
(t/is (th/ex-of-code? error :params-validation)))))
|
||||
@@ -1,162 +0,0 @@
|
||||
;; 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 Sucursal en España SL
|
||||
|
||||
(ns backend-tests.rpc-plugins-test
|
||||
(:require
|
||||
[app.common.uuid :as uuid]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.profile :as profile]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
(def ^:private plugin-id-1 (str (uuid/next)))
|
||||
(def ^:private plugin-id-2 (str (uuid/next)))
|
||||
|
||||
(def ^:private valid-plugin
|
||||
{:plugin-id plugin-id-1
|
||||
:name "Test Plugin"
|
||||
:description "A test plugin"
|
||||
:host "https://example.com"
|
||||
:code "(function() { console.log('hello'); })()"
|
||||
:icon "icon.svg"
|
||||
:permissions #{"content:read" "content:write"}})
|
||||
|
||||
(t/deftest add-profile-plugin-accepts-valid-permissions
|
||||
(let [profile (th/create-profile* 1)
|
||||
data {::th/type :add-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin valid-plugin}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (some? (:result out)))
|
||||
|
||||
(let [saved (th/db-get :profile {:id (:id profile)})
|
||||
props (profile/decode-row saved)
|
||||
plugins (get-in props [:props :plugins])]
|
||||
(t/is (= [plugin-id-1] (:ids plugins)))
|
||||
(t/is (= valid-plugin (get-in plugins [:data plugin-id-1]))))))
|
||||
|
||||
(t/deftest add-profile-plugin-rejects-invalid-permissions
|
||||
(let [profile (th/create-profile* 1)
|
||||
plugin (assoc valid-plugin :permissions #{"content:read" "admin:delete"})
|
||||
data {::th/type :add-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin plugin}
|
||||
out (th/command! data)]
|
||||
|
||||
;; Schema validation catches invalid permissions before custom validation
|
||||
(t/is (th/ex-info? (:error out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation))
|
||||
|
||||
(let [saved (th/db-get :profile {:id (:id profile)})
|
||||
props (profile/decode-row saved)
|
||||
plugins (get-in props [:props :plugins])]
|
||||
(t/is (nil? plugins) "No plugins should be persisted when validation fails"))))
|
||||
|
||||
(t/deftest add-profile-plugin-updates-existing-plugin
|
||||
(let [profile (th/create-profile* 1)
|
||||
data1 {::th/type :add-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin valid-plugin}
|
||||
_ (th/command! data1)
|
||||
|
||||
updated-plugin (assoc valid-plugin :name "Updated Plugin")
|
||||
data2 {::th/type :add-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin updated-plugin}
|
||||
out (th/command! data2)]
|
||||
|
||||
(t/is (nil? (:error out)))
|
||||
|
||||
(let [saved (th/db-get :profile {:id (:id profile)})
|
||||
props (profile/decode-row saved)
|
||||
plugins (get-in props [:props :plugins])]
|
||||
(t/is (= 1 (count (:ids plugins))) "Should still have only one plugin")
|
||||
(t/is (= "Updated Plugin" (get-in plugins [:data plugin-id-1 :name]))))))
|
||||
|
||||
(t/deftest remove-profile-plugin-removes-plugin
|
||||
(let [profile (th/create-profile* 1)
|
||||
data1 {::th/type :add-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin valid-plugin}
|
||||
_ (th/command! data1)
|
||||
|
||||
data2 {::th/type :remove-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin-id (uuid/uuid plugin-id-1)}
|
||||
out (th/command! data2)]
|
||||
|
||||
(t/is (nil? (:error out)))
|
||||
|
||||
(let [saved (th/db-get :profile {:id (:id profile)})
|
||||
props (profile/decode-row saved)
|
||||
plugins (get-in props [:props :plugins])]
|
||||
(t/is (= [] (:ids plugins)))
|
||||
(t/is (empty? (:data plugins))))))
|
||||
|
||||
(t/deftest remove-profile-plugin-handles-nonexistent-plugin
|
||||
(let [profile (th/create-profile* 1)
|
||||
data {::th/type :remove-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin-id (uuid/next)}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (nil? (:error out)))
|
||||
|
||||
(let [saved (th/db-get :profile {:id (:id profile)})
|
||||
props (profile/decode-row saved)
|
||||
plugins (get-in props [:props :plugins])]
|
||||
(t/is (or (nil? plugins)
|
||||
(and (empty? (:ids plugins))
|
||||
(empty? (:data plugins))))
|
||||
"Plugins should be nil or empty when no plugins exist"))))
|
||||
|
||||
(t/deftest add-profile-plugin-multiple-plugins
|
||||
(let [profile (th/create-profile* 1)
|
||||
plugin1 valid-plugin
|
||||
plugin2 (assoc valid-plugin
|
||||
:plugin-id plugin-id-2
|
||||
:name "Second Plugin")
|
||||
|
||||
data1 {::th/type :add-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin plugin1}
|
||||
_ (th/command! data1)
|
||||
|
||||
data2 {::th/type :add-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin plugin2}
|
||||
_ (th/command! data2)]
|
||||
|
||||
(let [saved (th/db-get :profile {:id (:id profile)})
|
||||
props (profile/decode-row saved)
|
||||
plugins (get-in props [:props :plugins])]
|
||||
(t/is (= 2 (count (:ids plugins))))
|
||||
(t/is (contains? (set (:ids plugins)) plugin-id-1))
|
||||
(t/is (contains? (set (:ids plugins)) plugin-id-2))
|
||||
(t/is (= "Test Plugin" (get-in plugins [:data plugin-id-1 :name])))
|
||||
(t/is (= "Second Plugin" (get-in plugins [:data plugin-id-2 :name]))))))
|
||||
|
||||
(t/deftest update-profile-props-rejects-plugins
|
||||
(let [profile (th/create-profile* 1)
|
||||
data {::th/type :update-profile-props
|
||||
::rpc/profile-id (:id profile)
|
||||
:props {:plugins {:ids ["test"] :data {"test" valid-plugin}}}}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (th/ex-info? (:error out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation))
|
||||
|
||||
(let [saved (th/db-get :profile {:id (:id profile)})
|
||||
props (profile/decode-row saved)]
|
||||
(t/is (nil? (get-in props [:props :plugins]))
|
||||
":plugins must not be writable via update-profile-props"))))
|
||||
@@ -154,14 +154,10 @@
|
||||
(get-in % [:props :member-email])))
|
||||
events))]
|
||||
(doseq [event [create-organization update-organization]]
|
||||
(t/is (= (str (:id owner))
|
||||
(get-in event [:props :user-who-send-invitation])))
|
||||
(t/is (true? (get-in event [:props :team-belongs-to-organization])))
|
||||
(t/is (true? (get-in event [:props :adds-invitee-to-organization])))
|
||||
(t/is (true? (get-in event [:props :invitee-already-organization-member]))))
|
||||
|
||||
(t/is (= (str (:id owner))
|
||||
(get-in create-plain [:props :user-who-send-invitation])))
|
||||
(t/is (false? (get-in create-plain [:props :team-belongs-to-organization])))
|
||||
(t/is (false? (get-in create-plain [:props :adds-invitee-to-organization])))
|
||||
(t/is (false? (get-in create-plain [:props :invitee-already-organization-member])))))))
|
||||
@@ -525,9 +521,6 @@
|
||||
|
||||
(let [event (organization-event)]
|
||||
(t/is (= organization-id (get-in event [:props :organization-id])))
|
||||
(t/is (= (:id invitee) (get-in event [:props :user-id])))
|
||||
(t/is (= (:id inviter)
|
||||
(get-in event [:props :user-who-send-invitation])))
|
||||
(t/is (not (contains? (:props event) :organization-member-add-source)))
|
||||
(t/is (not (contains? (:props event) :belongs-to-team-on-add)))
|
||||
(t/is (not (contains? (:props event) :organization-member-count-before)))
|
||||
@@ -537,10 +530,6 @@
|
||||
(:origin @frontend-event)))
|
||||
(t/is (= organization-id
|
||||
(get-in @frontend-event [:props :organization-id])))
|
||||
(t/is (= (:id invitee)
|
||||
(get-in @frontend-event [:props :user-id])))
|
||||
(t/is (= (:id inviter)
|
||||
(get-in @frontend-event [:props :user-who-send-invitation])))
|
||||
(t/is (= "direct-organization-invitation"
|
||||
(get-in @frontend-event [:props :organization-member-add-source])))
|
||||
(t/is (false? (get-in @frontend-event [:props :belongs-to-team-on-add])))
|
||||
@@ -581,9 +570,6 @@
|
||||
(t/is (some #(= "accept-team-invitation-from" (:name %)) events))
|
||||
(t/is (= (:id team) (get-in event [:props :team-id])))
|
||||
(t/is (= organization-id (get-in event [:props :organization-id])))
|
||||
(t/is (= (:id invitee) (get-in event [:props :user-id])))
|
||||
(t/is (= (:id inviter)
|
||||
(get-in event [:props :user-who-send-invitation])))
|
||||
(t/is (not (contains? (:props event) :organization-member-add-source)))
|
||||
(t/is (not (contains? (:props event) :belongs-to-team-on-add)))
|
||||
(t/is (not (contains? (:props event) :organization-member-count-before)))
|
||||
@@ -592,10 +578,6 @@
|
||||
(t/is (= (:id team) (get-in @frontend-event [:props :team-id])))
|
||||
(t/is (= organization-id
|
||||
(get-in @frontend-event [:props :organization-id])))
|
||||
(t/is (= (:id invitee)
|
||||
(get-in @frontend-event [:props :user-id])))
|
||||
(t/is (= (:id inviter)
|
||||
(get-in @frontend-event [:props :user-who-send-invitation])))
|
||||
(t/is (= "team-invitation"
|
||||
(get-in @frontend-event [:props :organization-member-add-source])))
|
||||
(t/is (true? (get-in @frontend-event [:props :belongs-to-team-on-add])))
|
||||
|
||||
@@ -166,6 +166,7 @@
|
||||
(not (ctob/token-name-path-exists? token-name tokens-tree)))
|
||||
new-tokens))))]])
|
||||
(defn find-refs [value]
|
||||
(prn value)
|
||||
(cond
|
||||
(string? value)
|
||||
(cto/find-token-value-references value)
|
||||
|
||||
@@ -27,20 +27,6 @@
|
||||
schema:string
|
||||
schema:string]])
|
||||
|
||||
(def valid-permissions
|
||||
"Set of valid plugin permissions that can be granted to plugins."
|
||||
#{"content:read" "content:write"
|
||||
"library:read" "library:write"
|
||||
"comment:read" "comment:write"
|
||||
"clipboard:read" "clipboard:write"
|
||||
"user:read"
|
||||
"allow:downloads"
|
||||
"allow:localstorage"})
|
||||
|
||||
(def schema:permissions
|
||||
"Schema for plugin permissions - a set of valid permission strings."
|
||||
[:set {:gen/max 11} (into [:enum] (sort valid-permissions))])
|
||||
|
||||
(def schema:registry-entry
|
||||
[:map
|
||||
[:plugin-id :string]
|
||||
@@ -50,7 +36,7 @@
|
||||
[:host :string]
|
||||
[:code :string]
|
||||
[:icon {:optional true} :string]
|
||||
[:permissions schema:permissions]])
|
||||
[:permissions [:set :string]]])
|
||||
|
||||
(def schema:plugin-registry
|
||||
[:map
|
||||
|
||||
@@ -38,7 +38,7 @@ x-body-size: &penpot-http-body-size
|
||||
## Penpot SECRET KEY. It serves as a master key from which other keys for subsystems
|
||||
## (eg http sessions, or invitations) are derived.
|
||||
##
|
||||
## We recommend to use a truly randomly generated
|
||||
## We recommend to use a trully randomly generated
|
||||
## 512 bits base64 encoded string here. You can generate one with:
|
||||
##
|
||||
## python3 -c "import secrets; print(secrets.token_urlsafe(64))"
|
||||
@@ -78,7 +78,7 @@ services:
|
||||
# - "443:443"
|
||||
|
||||
penpot-frontend:
|
||||
image: "penpotapp/frontend:${PENPOT_VERSION:-2.17}"
|
||||
image: "penpotapp/frontend:${PENPOT_VERSION:-2.16}"
|
||||
restart: always
|
||||
ports:
|
||||
- 9001:8080
|
||||
@@ -111,7 +111,7 @@ services:
|
||||
# PENPOT_DISABLE_IPV6_LISTEN: "true"
|
||||
|
||||
penpot-backend:
|
||||
image: "penpotapp/backend:${PENPOT_VERSION:-2.17}"
|
||||
image: "penpotapp/backend:${PENPOT_VERSION:-2.16}"
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
@@ -180,13 +180,13 @@ services:
|
||||
PENPOT_SMTP_SSL: "false"
|
||||
|
||||
penpot-mcp:
|
||||
image: "penpotapp/mcp:${PENPOT_VERSION:-2.17}"
|
||||
image: "penpotapp/mcp:${PENPOT_VERSION:-2.16}"
|
||||
restart: always
|
||||
networks:
|
||||
- penpot
|
||||
|
||||
penpot-exporter:
|
||||
image: "penpotapp/exporter:${PENPOT_VERSION:-2.17}"
|
||||
image: "penpotapp/exporter:${PENPOT_VERSION:-2.16}"
|
||||
restart: always
|
||||
|
||||
depends_on:
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ Penpot MCP enables **multi-directional workflows** between design and code. Beca
|
||||
title="Quick demo: Penpot MCP server in action"
|
||||
width="100%"
|
||||
height="480"
|
||||
src="https://www.youtube.com/embed/7V01SKVG6PQ?rel=0"
|
||||
src="https://www.youtube.com/embed/CfvcgMQEmLk?rel=0"
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
|
||||
@@ -116,10 +116,6 @@ Your plugin can capture incoming messages from Penpot using the <code class="lan
|
||||
|
||||
```js
|
||||
window.addEventListener("message", (event) => {
|
||||
// Validate the source to ensure messages come from the parent (Penpot)
|
||||
if (event.source !== window.parent) {
|
||||
return;
|
||||
}
|
||||
// Handle the incoming message
|
||||
console.log(event.data);
|
||||
});
|
||||
@@ -133,11 +129,11 @@ This setup allows for two-way communication between Penpot and your plugin. Penp
|
||||
|
||||
```js
|
||||
// Sending a message back to Penpot from your plugin
|
||||
parent.postMessage(responseMessage, "*");
|
||||
parent.postMessage(responseMessage, targetOrigin);
|
||||
```
|
||||
|
||||
-<code class="language-js">responseMessage</code> is the data you want to send back to Penpot.
|
||||
- Using<code class="language-js">'*'</code> as the target origin is acceptable here because the message content is controlled by your plugin (the sender), not by untrusted input. If you know the exact Penpot origin, you can use it instead for stricter security.
|
||||
-<code class="language-js">targetOrigin</code> should be the origin of the Penpot application to ensure messages are only sent to the intended recipient. You can use<code class="language-js">'*'</code> to allow all.
|
||||
|
||||
### Summary
|
||||
|
||||
|
||||
@@ -31,10 +31,4 @@ desc: Begin with the Penpot user guide! Get quickstarts, shortcuts, and tutorial
|
||||
<p>Useful resources to better understand Penpot</p>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/user-guide/first-steps/migration-guide">
|
||||
<h2>Migration Guide →</h2>
|
||||
<p>Move a design system from Figma to Penpot</p>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
title: Migration Guide
|
||||
order: 6
|
||||
desc: Move a design system from Figma to Penpot. Read a short summary of the enterprise migration guide and open the full PDF.
|
||||
---
|
||||
|
||||
<h1 id="migration-guide">Migration Guide</h1>
|
||||
|
||||
<p class="main-paragraph">If you are moving a design system to Penpot, especially from Figma, start with the enterprise migration guide. It covers file and library migration, tokens, validation, dual-tool workflows, and how different roles can run a pilot.</p>
|
||||
|
||||
<div class="advice">
|
||||
<p><strong>Open the full guide (PDF)</strong></p>
|
||||
<p><a href="https://nextcloud.kaleidos.net/index.php/s/mKordyz62QF3PQ4?dir=/&editing=false&openfile=true" target="_blank" rel="noopener"><strong>The Enterprise Guide to Migrating Design Systems from Figma to Penpot</strong></a></p>
|
||||
</div>
|
||||
|
||||
<h2 id="what-the-guide-covers">What the guide covers</h2>
|
||||
<p>The document is written for teams that need to move more than a few mockups: libraries, tokens, variants, and the workflows around them. It focuses on Figma, but the same audit, pilot, and validation steps apply if you are coming from another tool.</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Before you export:</strong> audit critical files, component chains, token usage, and plugins that will not come along. Split oversized files and clean unused libraries while you are still in Figma.</li>
|
||||
<li><strong>Static assets:</strong> export SVG, PNG, or JPG from Figma and place them in Penpot.</li>
|
||||
<li><strong>Complex files and libraries:</strong> use the Penpot Exporter plugin for Figma (design files, slides, components, variants, auto layout, styles, variables, and libraries). Expect some layout cleanup, Figma Auto Layout becomes Flex and Grid in Penpot.</li>
|
||||
<li><strong>Tokens:</strong> if you already use Tokens Studio, export JSON and import it in Penpot. Native Figma Variables can go through Tokens Studio, or through the Exporter plugin.</li>
|
||||
<li><strong>Validate before you scale:</strong> migrate one representative file (or a sandbox library), write down recurring cleanup, then roll the same checklist out to the rest of the workspace.</li>
|
||||
<li><strong>People and pilots:</strong> the second half of the guide has paths for designers, frontend developers, DesignOps, design-system leads, and product/engineering pilots, including how Penpot MCP can help with post-import cleanup.</li>
|
||||
</ul>
|
||||
|
||||
<p>The guide also covers running Figma and Penpot in parallel for a while. The exporter is for one-off migration, not continuous sync.</p>
|
||||
|
||||
<h2 id="discuss-the-guide">Questions and discussion</h2>
|
||||
<p>If you want to ask about a migration, or share how yours is going, use the Community post <a href="https://community.penpot.app/t/the-enterprise-guide-to-migrating-design-systems-to-penpot/10768" target="_blank" rel="noopener">The Enterprise Guide to Migrating Design Systems to Penpot</a>.</p>
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Troubleshooting WebGL
|
||||
order: 7
|
||||
order: 5
|
||||
desc: Diagnose and fix common WebGL issues in Penpot, enable WebGL rendering (Beta), and troubleshoot browser, GPU, and system checks.
|
||||
---
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
[promesa.core :as p]))
|
||||
|
||||
(defn render
|
||||
[{:keys [file-id page-id share-id token scale type objects skip-children] :as params} on-object]
|
||||
[{:keys [file-id page-id share-id token scale type objects skip-children is-wasm] :as params} on-object]
|
||||
(letfn [(prepare-options [uri]
|
||||
#js {:screen #js {:width bw/default-viewport-width
|
||||
:height bw/default-viewport-height}
|
||||
@@ -25,7 +25,7 @@
|
||||
:height bw/default-viewport-height}
|
||||
:locale "en-US"
|
||||
:storageState #js {:cookies (bw/create-cookies uri {:token token})}
|
||||
:deviceScaleFactor scale
|
||||
:deviceScaleFactor (if is-wasm 1 scale) ;; wasm won't use deviceScaleFactor
|
||||
:userAgent bw/default-user-agent})
|
||||
|
||||
(render-object [page {:keys [id] :as object}]
|
||||
@@ -59,7 +59,9 @@
|
||||
:share-id share-id
|
||||
:object-id (mapv :id objects)
|
||||
:route "objects"
|
||||
:skip-children skip-children}
|
||||
:skip-children skip-children
|
||||
:wasm (when is-wasm "true")
|
||||
:scale scale}
|
||||
uri (-> (cf/get-internal-uri)
|
||||
(u/ensure-path-slash)
|
||||
(u/join "render.html")
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
[app.common.uuid :as uuid]
|
||||
;; Required for side effects: binds the generated enums.
|
||||
[app.wasm.enums]
|
||||
[cuerdas.core :as str]
|
||||
[promesa.core :as p]
|
||||
[shadow.esm :refer [dynamic-import]]))
|
||||
|
||||
@@ -191,22 +190,6 @@
|
||||
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)
|
||||
false)))))
|
||||
|
||||
(defn store-image-url!
|
||||
"Registers the public URL an image was loaded from. The SVG export emits
|
||||
linked `<image href>` from these, and falls back to Skia base64 when missing,
|
||||
so this should run for every media id the scene references (including
|
||||
already-cached images).
|
||||
|
||||
Does NOT call `mem/free`, for the same reason as `store-font-url!`."
|
||||
[image-id url]
|
||||
(when (and (some? url) (not (str/blank? url)))
|
||||
(let [bytes (js/Buffer.from url "utf-8")
|
||||
ptr (mem/alloc (.-byteLength bytes))
|
||||
quart (uuid/get-u32 image-id)]
|
||||
(mem/write-buffer ptr (mem/get-heap-u8) bytes)
|
||||
(h/call wasm/internal-module "_store_image_url"
|
||||
(aget quart 0) (aget quart 1) (aget quart 2) (aget quart 3)))))
|
||||
|
||||
(defn store-image!
|
||||
"Uploads one image's *encoded* bytes (PNG/JPEG — Skia decodes, no WebGL) into
|
||||
the WASM image store via `_store_image`. Buffer layout matches the Rust reader:
|
||||
|
||||
@@ -369,18 +369,13 @@
|
||||
"Fetches and stores every image the scene references (shape, stroke and
|
||||
text-span fills, enumerated by `app.common.types.shape.images`). Unlike fonts,
|
||||
the image store is not reset per request, so already-held images are skipped
|
||||
and repeated exports of a file reuse them.
|
||||
|
||||
Always registers a public media URL for each id so SVG export can emit linked
|
||||
`<image href>` even when the encoded bytes were already cached."
|
||||
and repeated exports of a file reuse them."
|
||||
[scene params]
|
||||
(let [all-ids (images/scene-image-ids scene)
|
||||
new-ids (remove wasm/image-cached? all-ids)]
|
||||
(l/dbg :hint "wasm render: provisioning images"
|
||||
:total (count all-ids)
|
||||
:cached (- (count all-ids) (count new-ids)))
|
||||
(doseq [image-id all-ids]
|
||||
(wasm/store-image-url! image-id (public-uri (str "assets/by-file-media-id/" image-id))))
|
||||
(->> new-ids
|
||||
(map (fn [image-id]
|
||||
(->> (fetch-file-media-bytes image-id params)
|
||||
|
||||
@@ -30,29 +30,29 @@ const globalDocument = globalThis?.document;
|
||||
* @type {Object}
|
||||
*/
|
||||
var _MAP = {
|
||||
8: "backspace",
|
||||
9: "tab",
|
||||
13: "enter",
|
||||
16: "shift",
|
||||
17: "ctrl",
|
||||
18: "alt",
|
||||
20: "capslock",
|
||||
27: "esc",
|
||||
32: "space",
|
||||
33: "pageup",
|
||||
34: "pagedown",
|
||||
35: "end",
|
||||
36: "home",
|
||||
37: "left",
|
||||
38: "up",
|
||||
39: "right",
|
||||
40: "down",
|
||||
45: "ins",
|
||||
46: "del",
|
||||
91: "meta",
|
||||
93: "meta",
|
||||
224: "meta",
|
||||
219: "219",
|
||||
8: 'backspace',
|
||||
9: 'tab',
|
||||
13: 'enter',
|
||||
16: 'shift',
|
||||
17: 'ctrl',
|
||||
18: 'alt',
|
||||
20: 'capslock',
|
||||
27: 'esc',
|
||||
32: 'space',
|
||||
33: 'pageup',
|
||||
34: 'pagedown',
|
||||
35: 'end',
|
||||
36: 'home',
|
||||
37: 'left',
|
||||
38: 'up',
|
||||
39: 'right',
|
||||
40: 'down',
|
||||
45: 'ins',
|
||||
46: 'del',
|
||||
91: 'meta',
|
||||
93: 'meta',
|
||||
224: 'meta',
|
||||
219: '219'
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -64,22 +64,22 @@ var _MAP = {
|
||||
* @type {Object}
|
||||
*/
|
||||
var _KEYCODE_MAP = {
|
||||
106: "*",
|
||||
107: "+",
|
||||
109: "-",
|
||||
110: ".",
|
||||
111: "/",
|
||||
186: ";",
|
||||
187: "=",
|
||||
188: ",",
|
||||
189: "-",
|
||||
190: ".",
|
||||
191: "/",
|
||||
192: "`",
|
||||
219: "[",
|
||||
220: "\\",
|
||||
221: "]",
|
||||
222: "'",
|
||||
106: '*',
|
||||
107: '+',
|
||||
109: '-',
|
||||
110: '.',
|
||||
111 : '/',
|
||||
186: ';',
|
||||
187: '=',
|
||||
188: ',',
|
||||
189: '-',
|
||||
190: '.',
|
||||
191: '/',
|
||||
192: '`',
|
||||
219: '[',
|
||||
220: '\\',
|
||||
221: ']',
|
||||
222: '\''
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -93,25 +93,25 @@ var _KEYCODE_MAP = {
|
||||
* @type {Object}
|
||||
*/
|
||||
var _SHIFT_MAP = {
|
||||
"~": "`",
|
||||
"!": "1",
|
||||
"@": "2",
|
||||
"#": "3",
|
||||
$: "4",
|
||||
"%": "5",
|
||||
"^": "6",
|
||||
"&": "7",
|
||||
"*": "8",
|
||||
"(": "9",
|
||||
")": "0",
|
||||
_: "-",
|
||||
"+": "=",
|
||||
":": ";",
|
||||
'\"': "'",
|
||||
"<": ",",
|
||||
">": ".",
|
||||
"?": "/",
|
||||
"|": "\\",
|
||||
'~': '`',
|
||||
'!': '1',
|
||||
'@': '2',
|
||||
'#': '3',
|
||||
'$': '4',
|
||||
'%': '5',
|
||||
'^': '6',
|
||||
'&': '7',
|
||||
'*': '8',
|
||||
'(': '9',
|
||||
')': '0',
|
||||
'_': '-',
|
||||
'+': '=',
|
||||
':': ';',
|
||||
'\"': '\'',
|
||||
'<': ',',
|
||||
'>': '.',
|
||||
'?': '/',
|
||||
'|': '\\'
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -124,12 +124,12 @@ var _SHIFT_MAP = {
|
||||
var globalNavigator = globalThis.navigator;
|
||||
|
||||
var _SPECIAL_ALIASES = {
|
||||
option: "alt",
|
||||
command: "meta",
|
||||
return: "enter",
|
||||
escape: "esc",
|
||||
plus: "+",
|
||||
mod: /Mac|iPod|iPhone|iPad/.test(globalNavigator?.platform) ? "meta" : "ctrl",
|
||||
'option': 'alt',
|
||||
'command': 'meta',
|
||||
'return': 'enter',
|
||||
'escape': 'esc',
|
||||
'plus': '+',
|
||||
'mod': /Mac|iPod|iPhone|iPad/.test(globalNavigator?.platform) ? 'meta' : 'ctrl'
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -146,13 +146,14 @@ var _REVERSE_MAP;
|
||||
* programatically
|
||||
*/
|
||||
for (var i = 1; i < 20; ++i) {
|
||||
_MAP[111 + i] = "f" + i;
|
||||
_MAP[111 + i] = 'f' + i;
|
||||
}
|
||||
|
||||
/**
|
||||
* loop through to map numbers on the numeric keypad
|
||||
*/
|
||||
for (i = 0; i <= 9; ++i) {
|
||||
|
||||
// This needs to use a string cause otherwise since 0 is falsey
|
||||
// mousetrap will never fire for numpad 0 pressed as part of a keydown
|
||||
// event.
|
||||
@@ -175,7 +176,7 @@ function _addEvent(object, type, callback) {
|
||||
return;
|
||||
}
|
||||
|
||||
object.attachEvent("on" + type, callback);
|
||||
object.attachEvent('on' + type, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -185,16 +186,17 @@ function _addEvent(object, type, callback) {
|
||||
* @return {string}
|
||||
*/
|
||||
function _characterFromEvent(e) {
|
||||
|
||||
// Numpad digits as "num0".."num9" — keeps them separate from main-row bindings across NumLock states and event types.
|
||||
if (e.code && e.code.indexOf("Numpad") === 0) {
|
||||
if (e.code && e.code.indexOf('Numpad') === 0) {
|
||||
var suffix = e.code.substring(6);
|
||||
if (suffix.length === 1 && suffix >= "0" && suffix <= "9") {
|
||||
return "num" + suffix;
|
||||
if (suffix.length === 1 && suffix >= '0' && suffix <= '9') {
|
||||
return 'num' + suffix;
|
||||
}
|
||||
}
|
||||
|
||||
// for keypress events we should return the character as is
|
||||
if (e.type == "keypress") {
|
||||
if (e.type == 'keypress') {
|
||||
var character = String.fromCharCode(e.which);
|
||||
|
||||
// if the shift key is not pressed then it is safe to assume
|
||||
@@ -223,9 +225,6 @@ function _characterFromEvent(e) {
|
||||
}
|
||||
|
||||
// if it is not in the special map
|
||||
if (typeof e.key === "string") {
|
||||
return e.key.toLowerCase();
|
||||
}
|
||||
|
||||
// with keydown and keyup events the character seems to always
|
||||
// come in as an uppercase character whether you are pressing shift
|
||||
@@ -241,7 +240,7 @@ function _characterFromEvent(e) {
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function _modifiersMatch(modifiers1, modifiers2) {
|
||||
return modifiers1.sort().join(",") === modifiers2.sort().join(",");
|
||||
return modifiers1.sort().join(',') === modifiers2.sort().join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -254,19 +253,19 @@ function _eventModifiers(e) {
|
||||
var modifiers = [];
|
||||
|
||||
if (e.shiftKey) {
|
||||
modifiers.push("shift");
|
||||
modifiers.push('shift');
|
||||
}
|
||||
|
||||
if (e.altKey) {
|
||||
modifiers.push("alt");
|
||||
modifiers.push('alt');
|
||||
}
|
||||
|
||||
if (e.ctrlKey) {
|
||||
modifiers.push("ctrl");
|
||||
modifiers.push('ctrl');
|
||||
}
|
||||
|
||||
if (e.metaKey) {
|
||||
modifiers.push("meta");
|
||||
modifiers.push('meta');
|
||||
}
|
||||
|
||||
return modifiers;
|
||||
@@ -309,7 +308,7 @@ function _stopPropagation(e) {
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function _isModifier(key) {
|
||||
return key == "shift" || key == "ctrl" || key == "alt" || key == "meta";
|
||||
return key == 'shift' || key == 'ctrl' || key == 'alt' || key == 'meta';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -322,6 +321,7 @@ function _getReverseMap() {
|
||||
if (!_REVERSE_MAP) {
|
||||
_REVERSE_MAP = {};
|
||||
for (var key in _MAP) {
|
||||
|
||||
// pull out the numeric keypad from here cause keypress should
|
||||
// be able to detect the keys from the character
|
||||
if (key > 95 && key < 112) {
|
||||
@@ -344,16 +344,17 @@ function _getReverseMap() {
|
||||
* @param {string=} action passed in
|
||||
*/
|
||||
function _pickBestAction(key, modifiers, action) {
|
||||
|
||||
// if no action was picked in we should try to pick the one
|
||||
// that we think would work best for this key
|
||||
if (!action) {
|
||||
action = _getReverseMap()[key] ? "keydown" : "keypress";
|
||||
action = _getReverseMap()[key] ? 'keydown' : 'keypress';
|
||||
}
|
||||
|
||||
// modifier keys don't work as expected with keypress,
|
||||
// switch to keydown
|
||||
if (action == "keypress" && modifiers.length) {
|
||||
action = "keydown";
|
||||
if (action == 'keypress' && modifiers.length) {
|
||||
action = 'keydown';
|
||||
}
|
||||
|
||||
return action;
|
||||
@@ -366,12 +367,12 @@ function _pickBestAction(key, modifiers, action) {
|
||||
* @return {Array}
|
||||
*/
|
||||
function _keysFromString(combination) {
|
||||
if (combination === "+") {
|
||||
return ["+"];
|
||||
if (combination === '+') {
|
||||
return ['+'];
|
||||
}
|
||||
|
||||
combination = combination.replace(/\+{2}/g, "+plus");
|
||||
return combination.split("+");
|
||||
combination = combination.replace(/\+{2}/g, '+plus');
|
||||
return combination.split('+');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -402,9 +403,9 @@ function _getKeyInfo(combination, action) {
|
||||
// if this is not a keypress event then we should
|
||||
// be smart about using shift keys
|
||||
// this will only work for US keyboards however
|
||||
if (action && action != "keypress" && _SHIFT_MAP[key]) {
|
||||
if (action && action != 'keypress' && _SHIFT_MAP[key]) {
|
||||
key = _SHIFT_MAP[key];
|
||||
modifiers.push("shift");
|
||||
modifiers.push('shift');
|
||||
}
|
||||
|
||||
// if this key is a modifier then add it to the list of modifiers
|
||||
@@ -420,7 +421,7 @@ function _getKeyInfo(combination, action) {
|
||||
return {
|
||||
key: key,
|
||||
modifiers: modifiers,
|
||||
action: action,
|
||||
action: action
|
||||
};
|
||||
}
|
||||
|
||||
@@ -509,7 +510,7 @@ function Mousetrap(targetElement) {
|
||||
doNotReset = doNotReset || {};
|
||||
|
||||
var activeSequences = false,
|
||||
key;
|
||||
key;
|
||||
|
||||
for (key in _sequenceLevels) {
|
||||
if (doNotReset[key]) {
|
||||
@@ -536,14 +537,7 @@ function Mousetrap(targetElement) {
|
||||
* @param {number=} level
|
||||
* @returns {Array}
|
||||
*/
|
||||
function _getMatches(
|
||||
character,
|
||||
modifiers,
|
||||
e,
|
||||
sequenceName,
|
||||
combination,
|
||||
level,
|
||||
) {
|
||||
function _getMatches(character, modifiers, e, sequenceName, combination, level) {
|
||||
var i;
|
||||
var callback;
|
||||
var matches = [];
|
||||
@@ -555,7 +549,7 @@ function Mousetrap(targetElement) {
|
||||
}
|
||||
|
||||
// if a modifier key is coming up on its own we should allow it
|
||||
if (action == "keyup" && _isModifier(character)) {
|
||||
if (action == 'keyup' && _isModifier(character)) {
|
||||
modifiers = [character];
|
||||
}
|
||||
|
||||
@@ -566,11 +560,7 @@ function Mousetrap(targetElement) {
|
||||
|
||||
// if a sequence name is not specified, but this is a sequence at
|
||||
// the wrong level then move onto the next match
|
||||
if (
|
||||
!sequenceName &&
|
||||
callback.seq &&
|
||||
_sequenceLevels[callback.seq] != callback.level
|
||||
) {
|
||||
if (!sequenceName && callback.seq && _sequenceLevels[callback.seq] != callback.level) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -587,20 +577,15 @@ function Mousetrap(targetElement) {
|
||||
// chrome will not fire a keypress if meta or control is down
|
||||
// safari will fire a keypress if meta or meta+shift is down
|
||||
// firefox will fire a keypress if meta or control is down
|
||||
if (
|
||||
(action == "keypress" && !e.metaKey && !e.ctrlKey) ||
|
||||
_modifiersMatch(modifiers, callback.modifiers)
|
||||
) {
|
||||
if ((action == 'keypress' && !e.metaKey && !e.ctrlKey) || _modifiersMatch(modifiers, callback.modifiers)) {
|
||||
|
||||
// when you bind a combination or sequence a second time it
|
||||
// should overwrite the first one. if a sequenceName or
|
||||
// combination is specified in this call it does just that
|
||||
//
|
||||
// @todo make deleting its own method?
|
||||
var deleteCombo = !sequenceName && callback.combo == combination;
|
||||
var deleteSequence =
|
||||
sequenceName &&
|
||||
callback.seq == sequenceName &&
|
||||
callback.level == level;
|
||||
var deleteSequence = sequenceName && callback.seq == sequenceName && callback.level == level;
|
||||
if (deleteCombo || deleteSequence) {
|
||||
self._callbacks[character].splice(i, 1);
|
||||
}
|
||||
@@ -623,6 +608,7 @@ function Mousetrap(targetElement) {
|
||||
* @returns void
|
||||
*/
|
||||
function _fireCallback(callback, e, combo, sequence) {
|
||||
|
||||
// if this event should not happen stop here
|
||||
if (self.stopCallback(e, e.target || e.srcElement, combo, sequence)) {
|
||||
return;
|
||||
@@ -642,7 +628,7 @@ function Mousetrap(targetElement) {
|
||||
* @param {Event} e
|
||||
* @returns void
|
||||
*/
|
||||
self._handleKey = function (character, modifiers, e) {
|
||||
self._handleKey = function(character, modifiers, e) {
|
||||
var callbacks = _getMatches(character, modifiers, e);
|
||||
var i;
|
||||
var doNotReset = {};
|
||||
@@ -658,12 +644,14 @@ function Mousetrap(targetElement) {
|
||||
|
||||
// loop through matching callbacks for this key event
|
||||
for (i = 0; i < callbacks.length; ++i) {
|
||||
|
||||
// fire for all sequence callbacks
|
||||
// this is because if for example you have multiple sequences
|
||||
// bound such as "g i" and "g t" they both need to fire the
|
||||
// callback for matching g cause otherwise you can only ever
|
||||
// match the first one
|
||||
if (callbacks[i].seq) {
|
||||
|
||||
// only fire callbacks for the maxLevel to prevent
|
||||
// subsequences from also firing
|
||||
//
|
||||
@@ -680,12 +668,7 @@ function Mousetrap(targetElement) {
|
||||
|
||||
// keep a list of which sequences were matches for later
|
||||
doNotReset[callbacks[i].seq] = 1;
|
||||
_fireCallback(
|
||||
callbacks[i].callback,
|
||||
e,
|
||||
callbacks[i].combo,
|
||||
callbacks[i].seq,
|
||||
);
|
||||
_fireCallback(callbacks[i].callback, e, callbacks[i].combo, callbacks[i].seq);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -717,16 +700,12 @@ function Mousetrap(targetElement) {
|
||||
//
|
||||
// we ignore keypresses in a sequence that directly follow a keydown
|
||||
// for the same character
|
||||
var ignoreThisKeypress = e.type == "keypress" && _ignoreNextKeypress;
|
||||
if (
|
||||
e.type == _nextExpectedAction &&
|
||||
!_isModifier(character) &&
|
||||
!ignoreThisKeypress
|
||||
) {
|
||||
var ignoreThisKeypress = e.type == 'keypress' && _ignoreNextKeypress;
|
||||
if (e.type == _nextExpectedAction && !_isModifier(character) && !ignoreThisKeypress) {
|
||||
_resetSequences(doNotReset);
|
||||
}
|
||||
|
||||
_ignoreNextKeypress = processedSequenceCallback && e.type == "keydown";
|
||||
_ignoreNextKeypress = processedSequenceCallback && e.type == 'keydown';
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -736,9 +715,10 @@ function Mousetrap(targetElement) {
|
||||
* @returns void
|
||||
*/
|
||||
function _handleKeyEvent(e) {
|
||||
|
||||
// normalize e.which for key events
|
||||
// @see http://stackoverflow.com/questions/4285627/javascript-keycode-vs-charcode-utter-confusion
|
||||
if (typeof e.which !== "number") {
|
||||
if (typeof e.which !== 'number') {
|
||||
e.which = e.keyCode;
|
||||
}
|
||||
|
||||
@@ -750,7 +730,7 @@ function Mousetrap(targetElement) {
|
||||
}
|
||||
|
||||
// need to use === for the character check because the character can be 0
|
||||
if (e.type == "keyup" && _ignoreNextKeyup === character) {
|
||||
if (e.type == 'keyup' && _ignoreNextKeyup === character) {
|
||||
_ignoreNextKeyup = false;
|
||||
return;
|
||||
}
|
||||
@@ -781,6 +761,7 @@ function Mousetrap(targetElement) {
|
||||
* @returns void
|
||||
*/
|
||||
function _bindSequence(combo, keys, callback, action) {
|
||||
|
||||
// start off by adding a sequence level record for this combination
|
||||
// and setting the level to 0
|
||||
_sequenceLevels[combo] = 0;
|
||||
@@ -793,7 +774,7 @@ function Mousetrap(targetElement) {
|
||||
* @returns {Function}
|
||||
*/
|
||||
function _increaseSequence(nextAction) {
|
||||
return function () {
|
||||
return function() {
|
||||
_nextExpectedAction = nextAction;
|
||||
++_sequenceLevels[combo];
|
||||
_resetSequenceTimer();
|
||||
@@ -813,7 +794,7 @@ function Mousetrap(targetElement) {
|
||||
// we should ignore the next key up if the action is key down
|
||||
// or keypress. this is so if you finish a sequence and
|
||||
// release the key the final key will not trigger a keyup
|
||||
if (action !== "keyup") {
|
||||
if (action !== 'keyup') {
|
||||
_ignoreNextKeyup = _characterFromEvent(e);
|
||||
}
|
||||
|
||||
@@ -833,9 +814,7 @@ function Mousetrap(targetElement) {
|
||||
// ones are better suited to the key provided
|
||||
for (var i = 0; i < keys.length; ++i) {
|
||||
var isFinal = i + 1 === keys.length;
|
||||
var wrappedCallback = isFinal
|
||||
? _callbackAndReset
|
||||
: _increaseSequence(action || _getKeyInfo(keys[i + 1]).action);
|
||||
var wrappedCallback = isFinal ? _callbackAndReset : _increaseSequence(action || _getKeyInfo(keys[i + 1]).action);
|
||||
_bindSingle(keys[i], wrappedCallback, action, combo, i);
|
||||
}
|
||||
}
|
||||
@@ -850,21 +829,15 @@ function Mousetrap(targetElement) {
|
||||
* @param {number=} level - what part of the sequence the command is
|
||||
* @returns void
|
||||
*/
|
||||
function _bindSingle(
|
||||
combination,
|
||||
callback,
|
||||
action,
|
||||
sequenceName,
|
||||
level,
|
||||
overwrite,
|
||||
) {
|
||||
function _bindSingle(combination, callback, action, sequenceName, level, overwrite) {
|
||||
|
||||
// store a direct mapped reference for use with Mousetrap.trigger
|
||||
self._directMap[combination + ":" + action] = callback;
|
||||
self._directMap[combination + ':' + action] = callback;
|
||||
|
||||
// make sure multiple spaces in a row become a single space
|
||||
combination = combination.replace(/\s+/g, " ");
|
||||
combination = combination.replace(/\s+/g, ' ');
|
||||
|
||||
var sequence = combination.split(" ");
|
||||
var sequence = combination.split(' ');
|
||||
var info;
|
||||
|
||||
// if this pattern is a sequence of keys then run through this method
|
||||
@@ -882,14 +855,7 @@ function Mousetrap(targetElement) {
|
||||
|
||||
// remove an existing match if there is one
|
||||
if (overwrite) {
|
||||
_getMatches(
|
||||
info.key,
|
||||
info.modifiers,
|
||||
{ type: info.action },
|
||||
sequenceName,
|
||||
combination,
|
||||
level,
|
||||
);
|
||||
_getMatches(info.key, info.modifiers, {type: info.action}, sequenceName, combination, level);
|
||||
}
|
||||
|
||||
// add this call back to the array
|
||||
@@ -898,13 +864,13 @@ function Mousetrap(targetElement) {
|
||||
//
|
||||
// this is important because the way these are processed expects
|
||||
// the sequence ones to come first
|
||||
self._callbacks[info.key][sequenceName ? "unshift" : "push"]({
|
||||
self._callbacks[info.key][sequenceName ? 'unshift' : 'push']({
|
||||
callback: callback,
|
||||
modifiers: info.modifiers,
|
||||
action: info.action,
|
||||
seq: sequenceName,
|
||||
level: level,
|
||||
combo: combination,
|
||||
combo: combination
|
||||
});
|
||||
}
|
||||
|
||||
@@ -916,23 +882,16 @@ function Mousetrap(targetElement) {
|
||||
* @param {string|undefined} action
|
||||
* @returns void
|
||||
*/
|
||||
self._bindMultiple = function (combinations, callback, action, overwrite) {
|
||||
self._bindMultiple = function(combinations, callback, action, overwrite) {
|
||||
for (var i = 0; i < combinations.length; ++i) {
|
||||
_bindSingle(
|
||||
combinations[i],
|
||||
callback,
|
||||
action,
|
||||
undefined,
|
||||
undefined,
|
||||
overwrite,
|
||||
);
|
||||
_bindSingle(combinations[i], callback, action, undefined, undefined, overwrite);
|
||||
}
|
||||
};
|
||||
|
||||
if (targetElement) {
|
||||
_addEvent(targetElement, "keypress", _handleKeyEvent);
|
||||
_addEvent(targetElement, "keydown", _handleKeyEvent);
|
||||
_addEvent(targetElement, "keyup", _handleKeyEvent);
|
||||
_addEvent(targetElement, 'keypress', _handleKeyEvent);
|
||||
_addEvent(targetElement, 'keydown', _handleKeyEvent);
|
||||
_addEvent(targetElement, 'keyup', _handleKeyEvent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -950,7 +909,7 @@ function Mousetrap(targetElement) {
|
||||
* @param {string=} action - 'keypress', 'keydown', or 'keyup'
|
||||
* @returns void
|
||||
*/
|
||||
Mousetrap.prototype.bind = function (keys, callback, action, overwrite) {
|
||||
Mousetrap.prototype.bind = function(keys, callback, action, overwrite) {
|
||||
var self = this;
|
||||
keys = keys instanceof Array ? keys : [keys];
|
||||
self._bindMultiple.call(self, keys, callback, action, overwrite);
|
||||
@@ -974,9 +933,9 @@ Mousetrap.prototype.bind = function (keys, callback, action, overwrite) {
|
||||
* @param {string} action
|
||||
* @returns void
|
||||
*/
|
||||
Mousetrap.prototype.unbind = function (keys, action) {
|
||||
Mousetrap.prototype.unbind = function(keys, action) {
|
||||
var self = this;
|
||||
return self.bind.call(self, keys, function () {}, action);
|
||||
return self.bind.call(self, keys, function() {}, action);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -986,10 +945,10 @@ Mousetrap.prototype.unbind = function (keys, action) {
|
||||
* @param {string=} action
|
||||
* @returns void
|
||||
*/
|
||||
Mousetrap.prototype.trigger = function (keys, action) {
|
||||
Mousetrap.prototype.trigger = function(keys, action) {
|
||||
var self = this;
|
||||
if (self._directMap[keys + ":" + action]) {
|
||||
self._directMap[keys + ":" + action]({}, keys);
|
||||
if (self._directMap[keys + ':' + action]) {
|
||||
self._directMap[keys + ':' + action]({}, keys);
|
||||
}
|
||||
return self;
|
||||
};
|
||||
@@ -1001,7 +960,7 @@ Mousetrap.prototype.trigger = function (keys, action) {
|
||||
*
|
||||
* @returns void
|
||||
*/
|
||||
Mousetrap.prototype.reset = function () {
|
||||
Mousetrap.prototype.reset = function() {
|
||||
var self = this;
|
||||
self._callbacks = {};
|
||||
self._directMap = {};
|
||||
@@ -1019,20 +978,20 @@ Mousetrap.prototype.stopCallback = function (e, element, combo) {
|
||||
// if the element has the data attribute "mousetrap-dont-stop" then no need
|
||||
// to stop. It should be used like <div data-mousetrap-dont-stop>...</div>
|
||||
// or :div {:data-mousetrap-dont-stop true}
|
||||
if ("mousetrapDontStop" in element.dataset) {
|
||||
return false;
|
||||
if ('mousetrapDontStop' in element.dataset) {
|
||||
return false
|
||||
}
|
||||
|
||||
if ((" " + element.className + " ").indexOf(" mousetrap ") > -1) {
|
||||
if ((' ' + element.className + ' ').indexOf(' mousetrap ') > -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Keyup events need to be dispatched always. Otherwise some events can be stuck
|
||||
if (e.type == "keyup") {
|
||||
if (e.type == 'keyup') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ("composedPath" in e && typeof e.composedPath === "function") {
|
||||
if ('composedPath' in e && typeof e.composedPath === 'function') {
|
||||
// For open shadow trees, update `element` so that the following check works.
|
||||
const initialEventTarget = e.composedPath()[0];
|
||||
if (initialEventTarget !== e.target) {
|
||||
@@ -1041,22 +1000,20 @@ Mousetrap.prototype.stopCallback = function (e, element, combo) {
|
||||
}
|
||||
|
||||
// stop for input, select, textarea and button
|
||||
const shouldStop =
|
||||
element.tagName == "INPUT" ||
|
||||
element.tagName == "SELECT" ||
|
||||
element.tagName == "TEXTAREA" ||
|
||||
(element.tagName == "BUTTON" && combo.includes("tab")) ||
|
||||
(element.contentEditable &&
|
||||
(element.contentEditable == "true" ||
|
||||
element.contentEditable === "plaintext-only"));
|
||||
const shouldStop = element.tagName == "INPUT" ||
|
||||
element.tagName == "SELECT" ||
|
||||
element.tagName == "TEXTAREA" ||
|
||||
(element.tagName == "BUTTON" && combo.includes("tab")) ||
|
||||
(element.contentEditable && (element.contentEditable == "true" || element.contentEditable === "plaintext-only"));
|
||||
|
||||
return shouldStop;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* exposes _handleKey publicly so it can be overwritten by extensions
|
||||
*/
|
||||
Mousetrap.prototype.handleKey = function () {
|
||||
Mousetrap.prototype.handleKey = function() {
|
||||
var self = this;
|
||||
return self._handleKey.apply(self, arguments);
|
||||
};
|
||||
@@ -1071,7 +1028,7 @@ export function addKeycodes(object) {
|
||||
}
|
||||
}
|
||||
_REVERSE_MAP = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Init the global mousetrap functions
|
||||
|
||||
@@ -318,9 +318,7 @@ export class ShortcutsPage extends BaseWebSocketPage {
|
||||
this.exportShortcuts(),
|
||||
]);
|
||||
|
||||
expect(download.suggestedFilename()).toMatch(
|
||||
/^penpot-shortcuts-Princesa_Leia-\d{4}-\d{2}-\d{2}\.json$/,
|
||||
);
|
||||
expect(download.suggestedFilename()).toBe("penpot-shortcuts.json");
|
||||
|
||||
const path = await download.path();
|
||||
const content = await readFile(path, "utf-8");
|
||||
|
||||
@@ -600,12 +600,6 @@ export class WorkspacePage extends BaseWebSocketPage {
|
||||
.getByRole("button", { name: "Comments (C)" })
|
||||
.click(clickOptions);
|
||||
}
|
||||
|
||||
async toggleCommentsVisibilityFromMenu(clickOptions = {}) {
|
||||
await this.page.getByRole("button", { name: "Main menu" }).click();
|
||||
await this.page.getByText("view").last().click();
|
||||
await this.page.locator("#file-menu-comments").click(clickOptions);
|
||||
}
|
||||
}
|
||||
|
||||
export default WorkspacePage;
|
||||
@@ -35,64 +35,3 @@ test("Group bubbles when zooming out if they overlap", async ({ page }) => {
|
||||
/unread/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Opening the Comments section only temporarily overrides a disabled global comments setting", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspacePage = new WasmWorkspacePage(page);
|
||||
await workspacePage.setupEmptyFile();
|
||||
await workspacePage.setupFileWithComments();
|
||||
await workspacePage.goToWorkspace();
|
||||
|
||||
const bubble = page.getByTestId("floating-thread-bubble-1");
|
||||
|
||||
// "Display comments" is enabled by default, so the bubble is already visible.
|
||||
await expect(bubble).toBeVisible();
|
||||
|
||||
// Turn the global "Display comments" setting off from the main menu.
|
||||
await workspacePage.toggleCommentsVisibilityFromMenu();
|
||||
await expect(bubble).toBeHidden();
|
||||
|
||||
// Opening the Comments section shows comments regardless of the global setting.
|
||||
await workspacePage.showComments();
|
||||
await expect(bubble).toBeVisible();
|
||||
|
||||
// Closing the Comments section falls back to the (still disabled) global setting.
|
||||
await workspacePage.showComments();
|
||||
await expect(bubble).toBeHidden();
|
||||
|
||||
// The global setting itself must be untouched by opening/closing the section.
|
||||
await page.getByRole("button", { name: "Main menu" }).click();
|
||||
await page.getByText("view").last().click();
|
||||
await expect(page.locator("#file-menu-comments")).toContainText(
|
||||
"Show comments",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
test("Comments stay visible through opening and closing the Comments section when the global setting is enabled", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspacePage = new WasmWorkspacePage(page);
|
||||
await workspacePage.setupEmptyFile();
|
||||
await workspacePage.setupFileWithComments();
|
||||
await workspacePage.goToWorkspace();
|
||||
|
||||
const bubble = page.getByTestId("floating-thread-bubble-1");
|
||||
|
||||
// "Display comments" is enabled by default.
|
||||
await expect(bubble).toBeVisible();
|
||||
|
||||
await workspacePage.showComments();
|
||||
await expect(bubble).toBeVisible();
|
||||
|
||||
await workspacePage.showComments();
|
||||
await expect(bubble).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Main menu" }).click();
|
||||
await page.getByText("view").last().click();
|
||||
await expect(page.locator("#file-menu-comments")).toContainText(
|
||||
"Hide comments",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
@@ -233,6 +233,5 @@ test("BUG 13755 - Fix problem with text change modiifers", async ({ page }) => {
|
||||
name: "Width",
|
||||
exact: true,
|
||||
});
|
||||
// WASM auto-width includes the HTML paragraph-set 1px right margin.
|
||||
await expect(widthInput).toHaveValue("24");
|
||||
await expect(widthInput).toHaveValue("23");
|
||||
});
|
||||
@@ -115,7 +115,7 @@
|
||||
[{:keys [url] :as manifest} user-can-edit?]
|
||||
(if url
|
||||
;; If the saved manifest has a URL we fetch the manifest to check
|
||||
;; for updates and validate integrity
|
||||
;; for updates
|
||||
(->> (fetch-manifest url)
|
||||
(rx/subs!
|
||||
(fn [new-manifest]
|
||||
@@ -127,8 +127,6 @@
|
||||
(cond
|
||||
(and is-edition-plugin? (not user-can-edit?))
|
||||
(st/emit! (ntf/warn (tr "workspace.plugins.error.need-editor")))
|
||||
|
||||
;; Permissions changed - show permissions dialog
|
||||
(not= (:permissions new-manifest) (:permissions manifest))
|
||||
(modal/show!
|
||||
:plugin-permissions-update
|
||||
@@ -138,25 +136,15 @@
|
||||
(preg/install-plugin! new-manifest)
|
||||
(load-plugin! new-manifest))})
|
||||
|
||||
;; Manifest changed (code, name, etc.) - require re-confirmation
|
||||
;; This prevents execution of tampered/injected plugins
|
||||
(not= new-manifest manifest)
|
||||
(modal/show!
|
||||
:plugin-permissions-update
|
||||
{:plugin new-manifest
|
||||
:on-accept
|
||||
#(do
|
||||
(preg/install-plugin! new-manifest)
|
||||
(load-plugin! new-manifest))})
|
||||
|
||||
;; Manifests match exactly - safe to load
|
||||
(do (preg/install-plugin! new-manifest)
|
||||
(load-plugin! manifest))
|
||||
:else
|
||||
(load-plugin! manifest))))
|
||||
(fn [_err]
|
||||
;; Error fetching the manifest - can't verify integrity
|
||||
;; Show error instead of loading potentially tampered code
|
||||
(st/emit! (ntf/warn (tr "workspace.plugins.error.unreachable"))))))
|
||||
;; Bundled plugins (no URL) - trusted, load directly
|
||||
(fn []
|
||||
;; Error fetching the manifest we'll load the plugin with the
|
||||
;; old manifest
|
||||
(load-plugin! manifest))))
|
||||
(load-plugin! manifest)))
|
||||
|
||||
(defn close-plugin!
|
||||
|
||||
@@ -584,38 +584,11 @@
|
||||
(into {}))]
|
||||
(merge resolved dropped)))
|
||||
|
||||
(defn- valid-token-value?
|
||||
[[_ token]]
|
||||
(some? (:value token)))
|
||||
|
||||
(def ^:private xform-invalid-value-tokens
|
||||
(comp
|
||||
(remove valid-token-value?)
|
||||
(map (fn [[k token]]
|
||||
[k (assoc token :errors [(wte/get-error-code :error.token/empty-input)])]))))
|
||||
|
||||
(defn- merge-invalid-value-tokens
|
||||
"Tokens with a `nil` value (e.g. a composite typography token saved with
|
||||
no fields filled in) must never reach StyleDictionary: some of its
|
||||
preprocessors (`@tokens-studio/sd-transforms`'s font-styles preprocessor,
|
||||
in particular) assume a typography token's value is never null and throw
|
||||
an uncaught exception when it is, taking down token resolution for the
|
||||
whole file.
|
||||
|
||||
`tokens` is the full, unfiltered token map; `resolved` only contains the
|
||||
valid subset that was actually sent to StyleDictionary. Tag the invalid
|
||||
ones with the same \"empty value\" error the token forms already use
|
||||
instead of ever letting them reach the resolver."
|
||||
[tokens resolved]
|
||||
(into resolved xform-invalid-value-tokens tokens))
|
||||
|
||||
(defn resolve-tokens
|
||||
[tokens]
|
||||
(let [valid-tokens (into {} (filter valid-token-value?) tokens)
|
||||
tokens-tree (ctob/tokens-tree valid-tokens)]
|
||||
(->> (resolve-tokens-tree tokens-tree #(get valid-tokens (sd-token-name %)))
|
||||
(rx/map #(merge-name-collisions valid-tokens %))
|
||||
(rx/map #(merge-invalid-value-tokens tokens %)))))
|
||||
(let [tokens-tree (ctob/tokens-tree tokens)]
|
||||
(->> (resolve-tokens-tree tokens-tree #(get tokens (sd-token-name %)))
|
||||
(rx/map #(merge-name-collisions tokens %)))))
|
||||
|
||||
(defn resolve-tokens-interactive
|
||||
"Interactive check of resolving tokens.
|
||||
@@ -637,18 +610,15 @@
|
||||
computation we can restore any token, even clashing ones with the
|
||||
same :name path by just looking up that :id in the ids map."
|
||||
[tokens]
|
||||
(let [valid-tokens (into {} (filter valid-token-value?) tokens)
|
||||
{:keys [tokens-tree ids]} (ctob/backtrace-tokens-tree valid-tokens)]
|
||||
(->> (resolve-tokens-tree tokens-tree #(get ids (sd-token-uuid %)))
|
||||
(rx/map #(merge-name-collisions valid-tokens %))
|
||||
(rx/map #(merge-invalid-value-tokens tokens %)))))
|
||||
(let [{:keys [tokens-tree ids]} (ctob/backtrace-tokens-tree tokens)]
|
||||
(->> (resolve-tokens-tree tokens-tree #(get ids (sd-token-uuid %)))
|
||||
(rx/map #(merge-name-collisions tokens %)))))
|
||||
|
||||
(defn resolve-tokens-with-verbose-errors [tokens]
|
||||
(let [valid-tokens (into {} (filter valid-token-value?) tokens)]
|
||||
(resolve-tokens-tree
|
||||
(ctob/tokens-tree valid-tokens)
|
||||
#(get valid-tokens (sd-token-name %))
|
||||
(StyleDictionary. (assoc default-config :log {:verbosity "verbose"})))))
|
||||
(resolve-tokens-tree
|
||||
(ctob/tokens-tree tokens)
|
||||
#(get tokens (sd-token-name %))
|
||||
(StyleDictionary. (assoc default-config :log {:verbosity "verbose"}))))
|
||||
|
||||
;; === Hooks
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
[app.main.data.workspace.drawing.common :as common]
|
||||
[app.main.data.workspace.drawing.curve :as curve]
|
||||
[app.main.data.workspace.drawing.line :as line]
|
||||
[app.main.data.workspace.layout :as dwlo]
|
||||
[app.main.data.workspace.path :as path]
|
||||
[beicon.v2.core :as rx]
|
||||
[potok.v2.core :as ptk]))
|
||||
@@ -46,6 +47,11 @@
|
||||
(when (= tool :path)
|
||||
(rx/of (start-drawing :path)))
|
||||
|
||||
;; NOTE: comments are a special case and they manage they
|
||||
;; own interrupt cycle.
|
||||
(when (= tool :comments)
|
||||
(rx/of (dwlo/toggle-layout-flag :display-comments :force? true)))
|
||||
|
||||
(when (and (not= tool :comments)
|
||||
(not= tool :path))
|
||||
(let [stopper (rx/filter (ptk/type? ::clear-drawing) stream)]
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.geom.rect :as grc]
|
||||
[app.common.geom.shapes :as gsh]
|
||||
[app.common.logging :as log]
|
||||
[app.common.math :as mth]
|
||||
[app.common.types.component :as ctk]
|
||||
[app.common.types.container :as ctn]
|
||||
@@ -580,57 +579,44 @@
|
||||
modifiers (calculate-modifiers state ignore-constraints ignore-snap-pixel modif-tree page-id params)]
|
||||
(assoc state :workspace-modifiers modifiers))))))
|
||||
|
||||
(defn- without-nil-ids
|
||||
"Drop nil-keyed entries from a modif-tree. A nil shape id (possible in
|
||||
production builds, where the upstream asserts are elided) would crash
|
||||
the WASM heap write with `uuid/get-u32` being called on nil."
|
||||
[modif-tree]
|
||||
(if (contains? modif-tree nil)
|
||||
(do (log/warn :hint "modif-tree contains a nil shape id; ignoring entry")
|
||||
(dissoc modif-tree nil))
|
||||
modif-tree))
|
||||
|
||||
(defn- parse-structure-modifiers
|
||||
[modif-tree]
|
||||
(into
|
||||
[]
|
||||
(comp
|
||||
(mapcat
|
||||
(fn [[parent-id data]]
|
||||
(when (ctm/has-structure? (:modifiers data))
|
||||
(->> (concat
|
||||
(get-in data [:modifiers :structure-parent])
|
||||
(get-in data [:modifiers :structure-child]))
|
||||
(mapcat
|
||||
(fn [modifier]
|
||||
(case (:type modifier)
|
||||
:remove-children
|
||||
(->> (:value modifier)
|
||||
(map (fn [child-id]
|
||||
{:type :remove-children
|
||||
:parent parent-id
|
||||
:id child-id
|
||||
:index 0
|
||||
:value 0})))
|
||||
(mapcat
|
||||
(fn [[parent-id data]]
|
||||
(when (ctm/has-structure? (:modifiers data))
|
||||
(->> (concat
|
||||
(get-in data [:modifiers :structure-parent])
|
||||
(get-in data [:modifiers :structure-child]))
|
||||
(mapcat
|
||||
(fn [modifier]
|
||||
(case (:type modifier)
|
||||
:remove-children
|
||||
(->> (:value modifier)
|
||||
(map (fn [child-id]
|
||||
{:type :remove-children
|
||||
:parent parent-id
|
||||
:id child-id
|
||||
:index 0
|
||||
:value 0})))
|
||||
|
||||
:add-children
|
||||
(->> (:value modifier)
|
||||
(map (fn [child-id]
|
||||
{:type :add-children
|
||||
:parent parent-id
|
||||
:id child-id
|
||||
:index (:index modifier)
|
||||
:value 0})))
|
||||
:add-children
|
||||
(->> (:value modifier)
|
||||
(map (fn [child-id]
|
||||
{:type :add-children
|
||||
:parent parent-id
|
||||
:id child-id
|
||||
:index (:index modifier)
|
||||
:value 0})))
|
||||
|
||||
:scale-content
|
||||
[{:type :scale-content
|
||||
:parent parent-id
|
||||
:id parent-id
|
||||
:index 0
|
||||
:value (:value modifier)}]
|
||||
nil)))))))
|
||||
(filter (fn [{:keys [id parent]}]
|
||||
(and (some? id) (some? parent)))))
|
||||
:scale-content
|
||||
[{:type :scale-content
|
||||
:parent parent-id
|
||||
:id parent-id
|
||||
:index 0
|
||||
:value (:value modifier)}]
|
||||
nil)))))))
|
||||
modif-tree))
|
||||
|
||||
|
||||
@@ -638,7 +624,7 @@
|
||||
(let [default-transform (gmt/matrix)]
|
||||
(keep (fn [[id data]]
|
||||
(cond
|
||||
(or (nil? id) (= id uuid/zero))
|
||||
(= id uuid/zero)
|
||||
nil
|
||||
|
||||
(ctm/has-geometry? (:modifiers data))
|
||||
@@ -707,66 +693,65 @@
|
||||
subtree-ids-by-id selection-rect-cache]
|
||||
:or {ignore-constraints false ignore-snap-pixel false}
|
||||
:as params}]
|
||||
(let [modif-tree (without-nil-ids modif-tree)]
|
||||
(ptk/reify ::set-wasm-modifiers
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(let [property-changes (extract-property-changes modif-tree)]
|
||||
(if (d/not-empty? property-changes)
|
||||
(-> state
|
||||
(assoc :prev-wasm-props (:wasm-props state))
|
||||
(assoc :wasm-props property-changes))
|
||||
state)))
|
||||
(ptk/reify ::set-wasm-modifiers
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(let [property-changes (extract-property-changes modif-tree)]
|
||||
(if (d/not-empty? property-changes)
|
||||
(-> state
|
||||
(assoc :prev-wasm-props (:wasm-props state))
|
||||
(assoc :wasm-props property-changes))
|
||||
state)))
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
;; Entering an interactive transform (drag/resize/rotate). Flip
|
||||
;; the renderer into fast + atlas-backdrop mode so the live
|
||||
;; preview is cheap, tiles never appear sequentially and the main
|
||||
;; thread is not blocked. The pair is closed in
|
||||
;; `clear-local-transform`.
|
||||
(ensure-interactive-transform-start!)
|
||||
(let [snap-pixel? (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid))
|
||||
translation? (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))]
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
;; Entering an interactive transform (drag/resize/rotate). Flip
|
||||
;; the renderer into fast + atlas-backdrop mode so the live
|
||||
;; preview is cheap, tiles never appear sequentially and the main
|
||||
;; thread is not blocked. The pair is closed in
|
||||
;; `clear-local-transform`.
|
||||
(ensure-interactive-transform-start!)
|
||||
(let [snap-pixel? (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid))
|
||||
translation? (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))]
|
||||
|
||||
(if translation?
|
||||
;; Pure translation: no structure changes needed. If structure
|
||||
;; modifiers were active from a previous non-translation frame
|
||||
;; (e.g. shape hovered over a frame then dragged back out),
|
||||
;; clear them now so the shape is not clipped by the old frame.
|
||||
(when @wasm-structure-modifiers-active?
|
||||
(wasm.api/clean-modifiers)
|
||||
(vreset! wasm-structure-modifiers-active? false))
|
||||
(let [objects (dsh/lookup-page-objects state)]
|
||||
(set-wasm-props! objects (:prev-wasm-props state) (:wasm-props state))
|
||||
(wasm.api/clean-modifiers)
|
||||
(wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree))
|
||||
(vreset! wasm-structure-modifiers-active? true)))
|
||||
(let [geometry-entries (parse-geometry-modifiers modif-tree)
|
||||
root-modifiers (into [] (map (fn [[id data]] [id (:transform data)])) geometry-entries)
|
||||
wasm-ready? (wasm.api/initialized?)
|
||||
;; While the GL context is down (lost / mid-reload), keep the
|
||||
;; root transforms so SVG selection/preview can still move.
|
||||
;; `propagate-modifiers` returns [] when not ready, do not
|
||||
;; treat that as "no modifiers".
|
||||
modifiers
|
||||
(cond
|
||||
(or (not wasm-ready?)
|
||||
(and translation? (not snap-pixel?)))
|
||||
root-modifiers
|
||||
(if translation?
|
||||
;; Pure translation: no structure changes needed. If structure
|
||||
;; modifiers were active from a previous non-translation frame
|
||||
;; (e.g. shape hovered over a frame then dragged back out),
|
||||
;; clear them now so the shape is not clipped by the old frame.
|
||||
(when @wasm-structure-modifiers-active?
|
||||
(wasm.api/clean-modifiers)
|
||||
(vreset! wasm-structure-modifiers-active? false))
|
||||
(let [objects (dsh/lookup-page-objects state)]
|
||||
(set-wasm-props! objects (:prev-wasm-props state) (:wasm-props state))
|
||||
(wasm.api/clean-modifiers)
|
||||
(wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree))
|
||||
(vreset! wasm-structure-modifiers-active? true)))
|
||||
(let [geometry-entries (parse-geometry-modifiers modif-tree)
|
||||
root-modifiers (into [] (map (fn [[id data]] [id (:transform data)])) geometry-entries)
|
||||
wasm-ready? (wasm.api/initialized?)
|
||||
;; While the GL context is down (lost / mid-reload), keep the
|
||||
;; root transforms so SVG selection/preview can still move.
|
||||
;; `propagate-modifiers` returns [] when not ready, do not
|
||||
;; treat that as "no modifiers".
|
||||
modifiers
|
||||
(cond
|
||||
(or (not wasm-ready?)
|
||||
(and translation? (not snap-pixel?)))
|
||||
root-modifiers
|
||||
|
||||
:else
|
||||
(let [propagated (wasm.api/propagate-modifiers geometry-entries snap-pixel?)]
|
||||
(if (seq propagated) propagated root-modifiers)))]
|
||||
(when wasm-ready?
|
||||
(wasm.api/set-modifiers modifiers))
|
||||
(let [ids (into [] xf:map-key geometry-entries)
|
||||
selrect (when wasm-ready?
|
||||
(if (and translation? (not snap-pixel?) selection-rect-cache (seq modifiers))
|
||||
(cached-translation-selrect ids (second (first modifiers)) selection-rect-cache)
|
||||
(wasm.api/get-selection-rect ids)))]
|
||||
(rx/of (set-temporary-selrect selrect)
|
||||
(set-temporary-modifiers modifiers)))))))))
|
||||
:else
|
||||
(let [propagated (wasm.api/propagate-modifiers geometry-entries snap-pixel?)]
|
||||
(if (seq propagated) propagated root-modifiers)))]
|
||||
(when wasm-ready?
|
||||
(wasm.api/set-modifiers modifiers))
|
||||
(let [ids (into [] xf:map-key geometry-entries)
|
||||
selrect (when wasm-ready?
|
||||
(if (and translation? (not snap-pixel?) selection-rect-cache (seq modifiers))
|
||||
(cached-translation-selrect ids (second (first modifiers)) selection-rect-cache)
|
||||
(wasm.api/get-selection-rect ids)))]
|
||||
(rx/of (set-temporary-selrect selrect)
|
||||
(set-temporary-modifiers modifiers))))))))
|
||||
|
||||
(defn propagate-structure-modifiers
|
||||
[modif-tree objects]
|
||||
@@ -797,44 +782,58 @@
|
||||
subtree-ids-by-id]
|
||||
:or {ignore-constraints false ignore-snap-pixel false snap-ignore-axis nil undo-transation? true}
|
||||
:as params}]
|
||||
(let [modif-tree (without-nil-ids modif-tree)]
|
||||
(ptk/reify ::apply-wasm-modifiers
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [translation?
|
||||
(every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))]
|
||||
(wasm.api/clean-modifiers)
|
||||
(when-not translation?
|
||||
(wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree)))
|
||||
(ptk/reify ::apply-wasm-modifiers
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [translation?
|
||||
(every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))]
|
||||
(wasm.api/clean-modifiers)
|
||||
(when-not translation?
|
||||
(wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree)))
|
||||
|
||||
;; Apply property changes (e.g. grow-type) to WASM shapes before
|
||||
;; propagating geometry, so propagate_modifiers sees the updated state.
|
||||
(doseq [[id {:keys [property value]}] (extract-property-changes modif-tree)]
|
||||
(when (= property :grow-type)
|
||||
(wasm.api/use-shape id)
|
||||
(wasm.api/set-shape-grow-type value)))
|
||||
;; Apply property changes (e.g. grow-type) to WASM shapes before
|
||||
;; propagating geometry, so propagate_modifiers sees the updated state.
|
||||
(doseq [[id {:keys [property value]}] (extract-property-changes modif-tree)]
|
||||
(when (= property :grow-type)
|
||||
(wasm.api/use-shape id)
|
||||
(wasm.api/set-shape-grow-type value)))
|
||||
|
||||
(let [objects (dsh/lookup-page-objects state)
|
||||
(let [objects (dsh/lookup-page-objects state)
|
||||
|
||||
geometry-entries
|
||||
(parse-geometry-modifiers modif-tree)
|
||||
geometry-entries
|
||||
(parse-geometry-modifiers modif-tree)
|
||||
|
||||
snap-pixel?
|
||||
(and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid))
|
||||
snap-pixel?
|
||||
(and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid))
|
||||
|
||||
transforms
|
||||
(cond
|
||||
(and translation? (not snap-pixel?))
|
||||
;; Mirror WASM `propagate_modifiers` in CLJS: splat the
|
||||
;; translation matrix onto every descendant. Without
|
||||
;; this step the commit would only touch the dragged
|
||||
;; primaries and descendants would snap back to their
|
||||
;; pre-drag positions on drop.
|
||||
;;
|
||||
;; Skipped when `snap-pixel?` is on: WASM applies
|
||||
;; per-shape pixel correction (different scale/translate
|
||||
;; per descendant) which we can't replicate cheaply on
|
||||
;; the CLJS side.
|
||||
transforms
|
||||
(cond
|
||||
(and translation? (not snap-pixel?))
|
||||
;; Mirror WASM `propagate_modifiers` in CLJS: splat the
|
||||
;; translation matrix onto every descendant. Without
|
||||
;; this step the commit would only touch the dragged
|
||||
;; primaries and descendants would snap back to their
|
||||
;; pre-drag positions on drop.
|
||||
;;
|
||||
;; Skipped when `snap-pixel?` is on: WASM applies
|
||||
;; per-shape pixel correction (different scale/translate
|
||||
;; per descendant) which we can't replicate cheaply on
|
||||
;; the CLJS side.
|
||||
(reduce
|
||||
(fn [acc [id data]]
|
||||
(let [t (:transform data)
|
||||
subtree-ids
|
||||
(or (get subtree-ids-by-id id)
|
||||
(cfh/get-children-ids-with-self objects id))]
|
||||
(reduce (fn [a sid] (assoc a sid t)) acc subtree-ids)))
|
||||
{}
|
||||
geometry-entries)
|
||||
|
||||
;; Context lost / mid-reload: do not call into WASM. Use
|
||||
;; root transforms (and splat translation onto descendants
|
||||
;; when we can) so the commit still lands in file data.
|
||||
(not (wasm.api/initialized?))
|
||||
(if translation?
|
||||
(reduce
|
||||
(fn [acc [id data]]
|
||||
(let [t (:transform data)
|
||||
@@ -844,87 +843,71 @@
|
||||
(reduce (fn [a sid] (assoc a sid t)) acc subtree-ids)))
|
||||
{}
|
||||
geometry-entries)
|
||||
(into {}
|
||||
(map (fn [[id data]] [id (:transform data)]))
|
||||
geometry-entries))
|
||||
|
||||
;; Context lost / mid-reload: do not call into WASM. Use
|
||||
;; root transforms (and splat translation onto descendants
|
||||
;; when we can) so the commit still lands in file data.
|
||||
(not (wasm.api/initialized?))
|
||||
(if translation?
|
||||
(reduce
|
||||
(fn [acc [id data]]
|
||||
(let [t (:transform data)
|
||||
subtree-ids
|
||||
(or (get subtree-ids-by-id id)
|
||||
(cfh/get-children-ids-with-self objects id))]
|
||||
(reduce (fn [a sid] (assoc a sid t)) acc subtree-ids)))
|
||||
{}
|
||||
geometry-entries)
|
||||
(into {}
|
||||
(map (fn [[id data]] [id (:transform data)]))
|
||||
geometry-entries))
|
||||
:else
|
||||
(into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?)))
|
||||
|
||||
:else
|
||||
(into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?)))
|
||||
ignore-tree
|
||||
(calculate-ignore-tree-wasm transforms objects)
|
||||
|
||||
ignore-tree
|
||||
(calculate-ignore-tree-wasm transforms objects)
|
||||
options
|
||||
(-> params
|
||||
(assoc :reg-objects? true)
|
||||
(assoc :ignore-tree ignore-tree)
|
||||
(assoc :translation? translation?)
|
||||
;; Attributes that can change in the transform. This
|
||||
;; way we don't have to check all the attributes
|
||||
(assoc :attrs transform-attrs))
|
||||
|
||||
options
|
||||
(-> params
|
||||
(assoc :reg-objects? true)
|
||||
(assoc :ignore-tree ignore-tree)
|
||||
(assoc :translation? translation?)
|
||||
;; Attributes that can change in the transform. This
|
||||
;; way we don't have to check all the attributes
|
||||
(assoc :attrs transform-attrs))
|
||||
modif-tree
|
||||
(propagate-structure-modifiers modif-tree (dsh/lookup-page-objects state))
|
||||
|
||||
modif-tree
|
||||
(propagate-structure-modifiers modif-tree (dsh/lookup-page-objects state))
|
||||
ids
|
||||
(into (set (keys modif-tree)) xf:without-uuid-zero (keys transforms))
|
||||
|
||||
ids
|
||||
(into (set (keys modif-tree)) xf:without-uuid-zero (keys transforms))
|
||||
update-shape
|
||||
(fn [shape]
|
||||
(let [shape-id (dm/get-prop shape :id)
|
||||
transform (get transforms shape-id)
|
||||
modifiers (dm/get-in modif-tree [shape-id :modifiers])]
|
||||
(-> shape
|
||||
(gsh/apply-transform transform)
|
||||
(ctm/apply-structure-modifiers modifiers))))
|
||||
|
||||
update-shape
|
||||
(fn [shape]
|
||||
(let [shape-id (dm/get-prop shape :id)
|
||||
transform (get transforms shape-id)
|
||||
modifiers (dm/get-in modif-tree [shape-id :modifiers])]
|
||||
(-> shape
|
||||
(gsh/apply-transform transform)
|
||||
(ctm/apply-structure-modifiers modifiers))))
|
||||
bool-ids
|
||||
(into #{}
|
||||
(comp
|
||||
(mapcat (partial cfh/get-parents-with-self objects))
|
||||
(filter cfh/bool-shape?)
|
||||
(map :id))
|
||||
ids)
|
||||
|
||||
bool-ids
|
||||
(into #{}
|
||||
(comp
|
||||
(mapcat (partial cfh/get-parents-with-self objects))
|
||||
(filter cfh/bool-shape?)
|
||||
(map :id))
|
||||
ids)
|
||||
undo-id (js/Symbol)]
|
||||
(rx/concat
|
||||
(if undo-transation?
|
||||
(rx/of (dwu/start-undo-transaction undo-id))
|
||||
(rx/empty))
|
||||
(rx/of
|
||||
(clear-local-transform)
|
||||
(ptk/event ::dwg/move-frame-guides {:ids ids :transforms transforms})
|
||||
(ptk/event ::dwcm/move-frame-comment-threads transforms)
|
||||
(dwsh/update-shapes ids update-shape options)
|
||||
|
||||
undo-id (js/Symbol)]
|
||||
;; The update to the bool path needs to be in a different operation because it
|
||||
;; needs to have the updated children info.
|
||||
;; `update-layout? false`: recalculating a bool path can never change
|
||||
;; `:hidden`, and the layout check would recompute the whole boolean
|
||||
;; path in WASM once per bool shape just to find that out.
|
||||
(dwsh/update-shapes bool-ids path/update-bool-shape (assoc options
|
||||
:with-objects? true
|
||||
:update-layout? false)))
|
||||
|
||||
(rx/concat
|
||||
(if undo-transation?
|
||||
(rx/of (dwu/start-undo-transaction undo-id))
|
||||
(rx/empty))
|
||||
(rx/of
|
||||
(clear-local-transform)
|
||||
(ptk/event ::dwg/move-frame-guides {:ids ids :transforms transforms})
|
||||
(ptk/event ::dwcm/move-frame-comment-threads transforms)
|
||||
(dwsh/update-shapes ids update-shape options)
|
||||
|
||||
;; The update to the bool path needs to be in a different operation because it
|
||||
;; needs to have the updated children info.
|
||||
;; `update-layout? false`: recalculating a bool path can never change
|
||||
;; `:hidden`, and the layout check would recompute the whole boolean
|
||||
;; path in WASM once per bool shape just to find that out.
|
||||
(dwsh/update-shapes bool-ids path/update-bool-shape (assoc options
|
||||
:with-objects? true
|
||||
:update-layout? false)))
|
||||
|
||||
(if undo-transation?
|
||||
(rx/of (dwu/commit-undo-transaction undo-id))
|
||||
(rx/empty)))))))))
|
||||
(if undo-transation?
|
||||
(rx/of (dwu/commit-undo-transaction undo-id))
|
||||
(rx/empty))))))))
|
||||
|
||||
(def ^:private
|
||||
xf-rotation-shape
|
||||
|
||||
@@ -110,7 +110,6 @@
|
||||
:command (ds/c-mod "v")
|
||||
:subsections [:edit]
|
||||
:section [:workspace]
|
||||
:customizable false
|
||||
:fn (constantly nil)}
|
||||
|
||||
:paste-replace {:tooltip (ds/meta (ds/shift "V"))
|
||||
|
||||
@@ -335,15 +335,14 @@
|
||||
(let [page-id (:current-page-id state)
|
||||
objects (dsh/lookup-page-objects state page-id)
|
||||
shape (get objects shape-id)
|
||||
container (get objects (:parent-id shape))]
|
||||
(when (and (some? shape) (some? container))
|
||||
(let [width (+ (:width container) (:width shape) 20) ;; 20 is the default gap for variants
|
||||
x (- width (+ (:width shape) 30))] ;; 30 is the default margin for variants
|
||||
(rx/of
|
||||
(dwt/update-dimensions [(:parent-id shape)] :width width)
|
||||
(dwt/update-position shape-id
|
||||
{:x x}
|
||||
{:absolute? false}))))))))
|
||||
container (get objects (:parent-id shape))
|
||||
width (+ (:width container) (:width shape) 20) ;; 20 is the default gap for variants
|
||||
x (- width (+ (:width shape) 30))] ;; 30 is the default margin for variants
|
||||
(rx/of
|
||||
(dwt/update-dimensions [(:parent-id shape)] :width width)
|
||||
(dwt/update-position shape-id
|
||||
{:x x}
|
||||
{:absolute? false}))))))
|
||||
|
||||
(defn add-new-variant
|
||||
"Create a new variant and add it to the variant-container"
|
||||
@@ -360,40 +359,39 @@
|
||||
shape (get objects shape-id)
|
||||
shape (if (ctc/is-variant-container? shape)
|
||||
(get objects (last (:shapes shape)))
|
||||
shape)]
|
||||
(when (some? shape)
|
||||
(let [component-id (:component-id shape)
|
||||
component (ctkl/get-component data component-id)
|
||||
shape)
|
||||
component-id (:component-id shape)
|
||||
component (ctkl/get-component data component-id)
|
||||
|
||||
container-id (:parent-id shape)
|
||||
variant-container (get objects container-id)
|
||||
has-layout? (ctsl/any-layout? variant-container)
|
||||
container-id (:parent-id shape)
|
||||
variant-container (get objects container-id)
|
||||
has-layout? (ctsl/any-layout? variant-container)
|
||||
|
||||
new-component-id (uuid/next)
|
||||
new-shape-id (uuid/next)
|
||||
new-component-id (uuid/next)
|
||||
new-shape-id (uuid/next)
|
||||
|
||||
prop-num (dec (count (:variant-properties component)))
|
||||
prop-num (dec (count (:variant-properties component)))
|
||||
|
||||
changes (-> (pcb/empty-changes it page-id)
|
||||
(pcb/with-library-data data)
|
||||
(pcb/with-objects objects)
|
||||
(pcb/with-page-id page-id)
|
||||
(clv/generate-add-new-variant shape (:variant-id component) new-component-id new-shape-id prop-num))
|
||||
changes (-> (pcb/empty-changes it page-id)
|
||||
(pcb/with-library-data data)
|
||||
(pcb/with-objects objects)
|
||||
(pcb/with-page-id page-id)
|
||||
(clv/generate-add-new-variant shape (:variant-id component) new-component-id new-shape-id prop-num))
|
||||
|
||||
undo-id (js/Symbol)]
|
||||
(rx/concat
|
||||
(rx/of
|
||||
(dwu/start-undo-transaction undo-id)
|
||||
(dch/commit-changes changes)
|
||||
(when-not has-layout?
|
||||
(resposition-and-resize-variant new-shape-id))
|
||||
(dwu/commit-undo-transaction undo-id)
|
||||
(ptk/data-event :layout/update {:ids [(:parent-id shape)]})
|
||||
(if multiselect?
|
||||
(dws/shift-select-shapes new-shape-id)
|
||||
(dws/select-shape new-shape-id)))
|
||||
(->> (rx/of (focus-property (:id variant-container)))
|
||||
(rx/delay 250))))))))))
|
||||
undo-id (js/Symbol)]
|
||||
(rx/concat
|
||||
(rx/of
|
||||
(dwu/start-undo-transaction undo-id)
|
||||
(dch/commit-changes changes)
|
||||
(when-not has-layout?
|
||||
(resposition-and-resize-variant new-shape-id))
|
||||
(dwu/commit-undo-transaction undo-id)
|
||||
(ptk/data-event :layout/update {:ids [(:parent-id shape)]})
|
||||
(if multiselect?
|
||||
(dws/shift-select-shapes new-shape-id)
|
||||
(dws/select-shape new-shape-id)))
|
||||
(->> (rx/of (focus-property (:id variant-container)))
|
||||
(rx/delay 250))))))))
|
||||
|
||||
(defn transform-in-variant
|
||||
"Given the id of a main shape of a component, creates a variant structure for
|
||||
@@ -541,10 +539,9 @@
|
||||
(let [objects (dsh/lookup-page-objects state)
|
||||
selected-ids (dsh/lookup-selected state)
|
||||
selected-shapes (map (d/getf objects) selected-ids)
|
||||
add-new-variant? (and (seq selected-shapes) (every? ctc/is-variant? selected-shapes))
|
||||
add-new-variant? (every? ctc/is-variant? selected-shapes)
|
||||
undo-id (js/Symbol)]
|
||||
(cond
|
||||
add-new-variant?
|
||||
(if add-new-variant?
|
||||
(rx/concat
|
||||
(rx/of
|
||||
(ev/event {::ev/name "add-new-variant" ::ev/origin "workspace:shortcut-duplicate"})
|
||||
@@ -552,12 +549,7 @@
|
||||
(add-new-variant (first selected-ids) false))
|
||||
(rx/from (map #(add-new-variant % true) (rest selected-ids)))
|
||||
(rx/of (dwu/commit-undo-transaction undo-id)))
|
||||
|
||||
(seq selected-ids)
|
||||
(rx/of (dws/duplicate-selected true))
|
||||
|
||||
:else
|
||||
(rx/empty))))))
|
||||
(rx/of (dws/duplicate-selected true)))))))
|
||||
|
||||
(defn rename-variant
|
||||
"Rename the variant container and all components belonging to this variant"
|
||||
|
||||
@@ -96,10 +96,15 @@
|
||||
|
||||
(defn- merge-resize-debounce-opts
|
||||
[prev {:keys [undo-group undo-id skip-component-sync?]}]
|
||||
(cond-> (or prev {})
|
||||
(some? undo-group) (assoc :undo-group undo-group)
|
||||
(some? undo-id) (assoc :undo-id undo-id)
|
||||
skip-component-sync? (assoc :skip-component-sync? true)))
|
||||
;; One commit per batch, so one flag for every shape in it. Keep it on
|
||||
;; only while every resize in the batch is derived.
|
||||
(let [prev (or prev {:skip-component-sync? true})]
|
||||
(cond-> prev
|
||||
(some? undo-group) (assoc :undo-group undo-group)
|
||||
(some? undo-id) (assoc :undo-id undo-id)
|
||||
:always (assoc :skip-component-sync?
|
||||
(boolean (and skip-component-sync?
|
||||
(:skip-component-sync? prev)))))))
|
||||
|
||||
(defn resize-wasm-text-debounce-commit
|
||||
[]
|
||||
@@ -160,8 +165,7 @@
|
||||
(-> state
|
||||
(update ::resize-wasm-text-debounce-ids (fnil conj []) id)
|
||||
(update ::resize-wasm-text-reflow-tasks (fnil conj []) reflow-task)
|
||||
(cond-> (seq opts)
|
||||
(update ::resize-wasm-text-debounce-opts merge-resize-debounce-opts opts))
|
||||
(update ::resize-wasm-text-debounce-opts merge-resize-debounce-opts opts)
|
||||
(cond-> (nil? (::resize-wasm-text-debounce-event state))
|
||||
(assoc ::resize-wasm-text-debounce-event cur-event))))
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.ui.components.dropdown :refer [dropdown]]
|
||||
[app.main.ui.components.numeric-input :as deprecated-input]
|
||||
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
|
||||
[app.main.ui.icons :as deprecated-icon]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.keyboard :as kbd]
|
||||
[app.util.timers :as timers]
|
||||
@@ -182,10 +182,7 @@
|
||||
|
||||
[:span {:class (stl/css :dropdown-button)
|
||||
:on-click toggle-dropdown}
|
||||
[:> icon* {:icon-id i/arrow-down
|
||||
:size "m"
|
||||
:aria-hidden true
|
||||
:class (stl/css :dropdown-icon)}]]
|
||||
deprecated-icon/arrow]
|
||||
|
||||
[:& dropdown {:show (or is-open? false)
|
||||
:on-close close-dropdown}
|
||||
@@ -199,12 +196,9 @@
|
||||
[:li
|
||||
{:key (str element-id "-" index)
|
||||
:class (stl/css-case :dropdown-element true
|
||||
:is-selected (= (dm/str value) (dm/str current-value)))
|
||||
:is-selected (= (dm/str value) current-value))
|
||||
:data-value value
|
||||
:on-click select-item}
|
||||
[:span {:class (stl/css :label)} label]
|
||||
[:span {:class (stl/css :check-icon)}
|
||||
[:> icon* {:icon-id i/tick
|
||||
:aria-hidden true
|
||||
:size "s"
|
||||
:class (stl/css :check-tick)}]]])))]]]))
|
||||
deprecated-icon/tick]])))]]]))
|
||||
@@ -4,126 +4,87 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
// FIXME: we need this import for %asset-element
|
||||
@use "refactor/basic-rules.scss" as deprecated;
|
||||
@use "ds/_borders.scss" as *;
|
||||
@use "ds/_sizes.scss" as *;
|
||||
@use "ds/_utils.scss" as *;
|
||||
@use "ds/spacing.scss" as *;
|
||||
@use "ds/typography.scss" as *;
|
||||
|
||||
.editable-select {
|
||||
@include use-typography("body-small");
|
||||
|
||||
--editable-select-background-color: var(--color-background-tertiary);
|
||||
@extend %asset-element;
|
||||
|
||||
margin: 0;
|
||||
border: $b-1 solid var(--color-background-tertiary);
|
||||
border: $b-1 solid var(--input-border-color);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
inline-size: 100%;
|
||||
block-size: $sz-32;
|
||||
height: $sz-32;
|
||||
width: 100%;
|
||||
padding: var(--sp-s);
|
||||
border-radius: $br-8;
|
||||
cursor: pointer;
|
||||
background-color: var(--editable-select-background-color);
|
||||
color: var(--color-foreground-primary);
|
||||
|
||||
&:hover {
|
||||
--editable-select-background-color: var(--color-background-quaternary);
|
||||
}
|
||||
}
|
||||
.dropdown-button {
|
||||
display: flex;
|
||||
place-content: center;
|
||||
|
||||
.dropdown-button {
|
||||
display: flex;
|
||||
place-content: center;
|
||||
}
|
||||
svg {
|
||||
@extend %button-icon-small;
|
||||
|
||||
.dropdown-icon {
|
||||
color: var(--color-foreground-secondary);
|
||||
}
|
||||
|
||||
.custom-select-dropdown {
|
||||
position: absolute;
|
||||
inset-block-start: $sz-32;
|
||||
inset-inline-start: 0;
|
||||
inline-size: 100%;
|
||||
min-inline-size: px2rem(70);
|
||||
max-block-size: px2rem(320); // TODO: when this gets addressed in the DS, use a token
|
||||
padding: var(--sp-xxs);
|
||||
margin: 0;
|
||||
margin-block-start: px2rem(1);
|
||||
border: $b-2 solid var(--color-background-quaternary);
|
||||
border-radius: $br-8;
|
||||
z-index: var(--z-index-dropdown);
|
||||
overflow: hidden auto;
|
||||
background-color: var(--color-background-tertiary);
|
||||
color: var(--color-foreground-primary);
|
||||
box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark);
|
||||
}
|
||||
|
||||
.separator {
|
||||
margin: 0;
|
||||
block-size: $sz-12;
|
||||
}
|
||||
|
||||
.dropdown-element {
|
||||
--dropdown-element-color: var(--color-foreground-secondary);
|
||||
--dropdown-element-icon-color: var(--color-foreground-secondary);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-s);
|
||||
block-size: $sz-32;
|
||||
padding-block: 0;
|
||||
padding-inline: var(--sp-s);
|
||||
border-radius: $br-6;
|
||||
cursor: pointer;
|
||||
color: var(--dropdown-element-color);
|
||||
|
||||
@include use-typography("body-small");
|
||||
|
||||
&.is-selected {
|
||||
--dropdown-element-color: var(--color-foreground-primary);
|
||||
--dropdown-element-icon-color: var(--color-foreground-primary);
|
||||
|
||||
.check-tick {
|
||||
visibility: visible;
|
||||
transform: rotate(90deg);
|
||||
stroke: var(--icon-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--dropdown-element-color: var(--color-foreground-primary);
|
||||
--dropdown-element-icon-color: var(--color-foreground-primary);
|
||||
.custom-select-dropdown {
|
||||
@extend %dropdown-wrapper;
|
||||
|
||||
background-color: var(--color-background-quaternary);
|
||||
width: max-content;
|
||||
max-height: px2rem(320); // TODO: when this gets addressed in the DS, use a token
|
||||
.separator {
|
||||
margin: 0;
|
||||
height: $sz-12;
|
||||
}
|
||||
|
||||
.dropdown-element {
|
||||
@extend %dropdown-element-base;
|
||||
|
||||
color: var(--menu-foreground-color-rest);
|
||||
|
||||
.label {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
display: flex;
|
||||
place-content: center;
|
||||
|
||||
svg {
|
||||
@extend %button-icon-small;
|
||||
|
||||
visibility: hidden;
|
||||
stroke: var(--icon-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
color: var(--menu-foreground-color);
|
||||
|
||||
.check-icon svg {
|
||||
stroke: var(--menu-foreground-color);
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--menu-background-color-hover);
|
||||
color: var(--menu-foreground-color-hover);
|
||||
|
||||
.check-icon svg {
|
||||
stroke: var(--menu-foreground-color-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label,
|
||||
.check-icon {
|
||||
display: block;
|
||||
max-inline-size: 99%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex-grow: 1;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
display: flex;
|
||||
place-content: center;
|
||||
}
|
||||
|
||||
.check-tick {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
fill: none;
|
||||
stroke-width: 1.33px;
|
||||
visibility: hidden;
|
||||
color: var(--dropdown-element-icon-color);
|
||||
}
|
||||
@@ -43,12 +43,13 @@
|
||||
|
||||
(defn use-shortcuts
|
||||
[key shortcuts group-key]
|
||||
(mf/use-effect
|
||||
#js [(str key) shortcuts]
|
||||
(fn []
|
||||
(st/emit! (dsc/push-shortcuts key shortcuts group-key))
|
||||
(let [custom-shortcuts (mf/deref refs/custom-shortcuts)]
|
||||
(mf/use-effect
|
||||
#js [(str key) shortcuts custom-shortcuts]
|
||||
(fn []
|
||||
(st/emit! (dsc/pop-shortcuts key))))))
|
||||
(st/emit! (dsc/push-shortcuts key shortcuts group-key))
|
||||
(fn []
|
||||
(st/emit! (dsc/pop-shortcuts key)))))))
|
||||
|
||||
(defn- set-timer
|
||||
[state ms func]
|
||||
|
||||
@@ -118,8 +118,8 @@
|
||||
(tr "nitrate.form.contact-upgrade")
|
||||
(tr "nitrate.form.contact-trial"))]
|
||||
[:p {:class (stl/css :modal-text-large)}
|
||||
[:a {:class (stl/css :link) :href "mailto:sales@penpot.app"}
|
||||
"sales@penpot.app"]]
|
||||
[:a {:class (stl/css :link) :href "mailto:sales@penpot.net"}
|
||||
"sales@penpot.net"]]
|
||||
[:div {:class (stl/css :activation-code)}
|
||||
[:p {:class (stl/css :modal-text-large)}
|
||||
[:a {:class (stl/css :link)
|
||||
|
||||
@@ -93,18 +93,10 @@
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private known-shortcut-keys
|
||||
"Known shortcut keys per context, derived from the default shortcuts maps.
|
||||
Shortcuts marked :customizable false are excluded."
|
||||
(letfn [(collect-keys [shortcuts]
|
||||
(reduce-kv (fn [s k v]
|
||||
(if (false? (:customizable v))
|
||||
s
|
||||
(conj s k)))
|
||||
#{}
|
||||
shortcuts))]
|
||||
{:workspace (into (collect-keys psc/shortcuts) (collect-keys wsc/shortcuts))
|
||||
:dashboard (collect-keys dsc/shortcuts)
|
||||
:viewer (collect-keys vsc/shortcuts)}))
|
||||
"Known shortcut keys per context, derived from the default shortcuts maps."
|
||||
{:workspace (set (keys wsc/shortcuts))
|
||||
:dashboard (set (keys dsc/shortcuts))
|
||||
:viewer (set (keys vsc/shortcuts))})
|
||||
|
||||
(def ^:private schema:imported-shortcuts
|
||||
"Malli schema for an imported custom-shortcuts payload.
|
||||
@@ -381,8 +373,7 @@
|
||||
(mf/with-memo []
|
||||
(fn [_ shortcut search-term]
|
||||
(or (str/blank? search-term)
|
||||
(matches-search (:translation shortcut) search-term)
|
||||
(matches-search (ss/shortcut->command-string shortcut) search-term))))
|
||||
(matches-search (:translation shortcut) search-term))))
|
||||
|
||||
filter-personalized
|
||||
(mf/use-fn
|
||||
@@ -394,10 +385,8 @@
|
||||
customized? (and (contains? group-map shortcut-key)
|
||||
(not (str/blank? (get group-map shortcut-key))))]
|
||||
(and customized?
|
||||
(not (false? (:customizable shortcut)))
|
||||
(or (str/blank? search-term)
|
||||
(matches-search (:translation shortcut) search-term)
|
||||
(matches-search (ss/shortcut->command-string shortcut) search-term))))))
|
||||
(matches-search (:translation shortcut) search-term))))))
|
||||
|
||||
filter-disabled
|
||||
(mf/use-fn
|
||||
@@ -409,10 +398,8 @@
|
||||
in-group? (contains? group-map shortcut-key)
|
||||
blank? (str/blank? (get group-map shortcut-key))]
|
||||
(and in-group? blank?
|
||||
(not (false? (:customizable shortcut)))
|
||||
(or (str/blank? search-term)
|
||||
(matches-search (:translation shortcut) search-term)
|
||||
(matches-search (ss/shortcut->command-string shortcut) search-term))))))
|
||||
(matches-search (:translation shortcut) search-term))))))
|
||||
|
||||
on-import-file
|
||||
(mf/use-fn
|
||||
@@ -427,16 +414,11 @@
|
||||
|
||||
on-export
|
||||
(mf/use-fn
|
||||
(mf/deps shortcuts-json has-custom-shortcuts (:fullname profile))
|
||||
(mf/deps shortcuts-json has-custom-shortcuts)
|
||||
(fn []
|
||||
(when has-custom-shortcuts
|
||||
(let [fullname (-> (or (:fullname profile) "user")
|
||||
(str/replace #"[^a-zA-Z0-9\-_ ]" "")
|
||||
(str/replace #"\s+" "_"))
|
||||
date (.slice (.toISOString (js/Date.)) 0 10)
|
||||
filename (str "penpot-shortcuts-" fullname "-" date ".json")]
|
||||
(->> (wapi/create-blob shortcuts-json "application/json")
|
||||
(dom/trigger-download filename))))))
|
||||
(->> (wapi/create-blob shortcuts-json "application/json")
|
||||
(dom/trigger-download "penpot-shortcuts.json")))))
|
||||
|
||||
on-file-selected
|
||||
(mf/use-fn
|
||||
|
||||
@@ -848,8 +848,8 @@
|
||||
[:div {:class (stl/css :modal-text)}
|
||||
(tr "nitrate.form.enterprise-intro" ".") " " (if nitrate-license (tr "nitrate.form.contact-us-upgrade") (tr "nitrate.form.contact-us-free-trial"))]
|
||||
[:div {:class (stl/css :modal-text)}
|
||||
[:a {:class (stl/css :cta-button) :href "mailto:sales@penpot.app"}
|
||||
"sales@penpot.app"]]])]]))
|
||||
[:a {:class (stl/css :cta-button) :href "mailto:sales@penpot.net"}
|
||||
"sales@penpot.net"]]])]]))
|
||||
|
||||
(mf/defc nitrate-contact-sales-dialog
|
||||
{::mf/register modal/components
|
||||
@@ -898,7 +898,7 @@
|
||||
:on-click handle-close-dialog} (tr "ds.confirm-cancel")]
|
||||
[:> button* {:variant "primary"
|
||||
:type "button"
|
||||
:on-click #(dom/open-new-window (dm/str "mailto:sales@penpot.app?subject=Switch%20to%20the%20" subscription-type "%20plan"))} (tr "nitrate.contact-sales.button")]]])]]]))
|
||||
:on-click #(dom/open-new-window (dm/str "mailto:sales@penpot.net?subject=Switch%20to%20the%20" subscription-type "%20plan"))} (tr "nitrate.contact-sales.button")]]])]]]))
|
||||
|
||||
(mf/defc nitrate-cancel-contact-sales-dialog
|
||||
{::mf/register modal/components
|
||||
@@ -908,7 +908,7 @@
|
||||
(js/encodeURIComponent email)
|
||||
|
||||
mailto-url
|
||||
(dm/str "mailto:sales@penpot.app"
|
||||
(dm/str "mailto:sales@penpot.net"
|
||||
"?subject=Request%20to%20Cancel%20Enterprise%20Subscription"
|
||||
"&body=Hello%2C%0A%0A"
|
||||
"I%20would%20like%20to%20cancel%20my%20Enterprise%20subscription.%0A"
|
||||
@@ -930,8 +930,8 @@
|
||||
[:div {:class (stl/css :modal-content)}
|
||||
[:div {:class (stl/css :modal-text-medium)}
|
||||
(tr "nitrate.subscription.settings.manual-contact-us")]
|
||||
[:a {:class (stl/css :cta-link) :href "mailto:sales@penpot.app"}
|
||||
"sales@penpot.app"]
|
||||
[:a {:class (stl/css :cta-link) :href "mailto:sales@penpot.net"}
|
||||
"sales@penpot.net"]
|
||||
[:div {:class (stl/css :action-buttons)}
|
||||
[:> button* {:class (stl/css :button-full-width)
|
||||
:variant "primary"
|
||||
|
||||
@@ -10,13 +10,9 @@
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.config :as cf]
|
||||
[app.main.data.dashboard.shortcuts :as dsc]
|
||||
[app.main.data.dashboard.shortcuts.customize :as customize]
|
||||
[app.main.data.profile :as du]
|
||||
[app.main.data.shortcuts :as ds]
|
||||
[app.main.data.viewer.shortcuts :as vsc]
|
||||
[app.main.data.workspace.path.shortcuts :as psc]
|
||||
[app.main.data.workspace.shortcuts :as wsc]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.context :as ctx]
|
||||
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
|
||||
@@ -111,24 +107,14 @@
|
||||
(def ^:private import-contexts
|
||||
[:workspace :dashboard :viewer])
|
||||
|
||||
(def ^:private context->known-keys
|
||||
{:workspace (into #{} (concat (keys psc/shortcuts) (keys wsc/shortcuts)))
|
||||
:dashboard (into #{} (keys dsc/shortcuts))
|
||||
:viewer (into #{} (keys vsc/shortcuts))})
|
||||
|
||||
(defn- build-context-shortcuts
|
||||
[all-shortcuts ctx]
|
||||
(let [known-keys (get context->known-keys ctx)]
|
||||
(into {} (filter (fn [[k _]] (contains? known-keys k))) all-shortcuts)))
|
||||
|
||||
(defn- import-context-group
|
||||
"Imports a single context group from the payload, disabling any default
|
||||
shortcut whose command collides with a newly imported one, and any
|
||||
previously-imported entry in the same batch with a duplicate command."
|
||||
[group context-shortcuts]
|
||||
[group all-shortcuts]
|
||||
(reduce
|
||||
(fn [acc [command recorded-command]]
|
||||
(let [default-conflict (find-conflict recorded-command context-shortcuts command)
|
||||
(let [default-conflict (find-conflict recorded-command all-shortcuts command)
|
||||
acc-conflict (some (fn [[k v]]
|
||||
(when (and (not= k command) (= v recorded-command))
|
||||
k))
|
||||
@@ -150,8 +136,7 @@
|
||||
new-customs (reduce
|
||||
(fn [acc ctx]
|
||||
(if (contains? shortcuts ctx)
|
||||
(let [ctx-sc (build-context-shortcuts all-shortcuts ctx)]
|
||||
(assoc acc ctx (import-context-group (get shortcuts ctx) ctx-sc)))
|
||||
(assoc acc ctx (import-context-group (get shortcuts ctx) all-shortcuts))
|
||||
acc))
|
||||
current-customs
|
||||
import-contexts)]
|
||||
@@ -162,20 +147,6 @@
|
||||
[type item]
|
||||
(map (fn [[k v]] [k (assoc v :translation (translation-keyname type k))]) item))
|
||||
|
||||
(defn shortcut->command-string
|
||||
"Extract a lowercase searchable string from a shortcut entry's key combo(s).
|
||||
Prefers `:show-command` (display override) over `:command` (Mousetrap format),
|
||||
matching what the keycap UI renders. Joins vector commands (key sequences)
|
||||
with a space so every token is searchable. Returns \"\" when there is no
|
||||
command (e.g. a section/subsection node)."
|
||||
[shortcut]
|
||||
(let [cmd (or (:show-command shortcut) (:command shortcut))]
|
||||
(-> (cond
|
||||
(nil? cmd) ""
|
||||
(vector? cmd) (str/join " " cmd)
|
||||
:else (str cmd))
|
||||
(str/lower))))
|
||||
|
||||
(defn shortcuts->subsections
|
||||
[shortcuts]
|
||||
(let [subsections (into #{} (mapcat :subsections) (vals shortcuts))
|
||||
@@ -605,19 +576,11 @@
|
||||
[{:keys [elements filter-term is-match-section is-match-subsection
|
||||
editable? custom-shortcuts section-key conflicts hidden subsection-name]}]
|
||||
(let [shortcut-translations (->> elements vals (map :translation) sort)
|
||||
match-shortcut? (some (fn [info]
|
||||
(or (matches-search (:translation info) filter-term)
|
||||
(matches-search (shortcut->command-string info) filter-term)))
|
||||
(vals elements))
|
||||
match-shortcut? (some #(matches-search % filter-term) shortcut-translations)
|
||||
filtered (if (and (or is-match-section is-match-subsection) (not match-shortcut?))
|
||||
shortcut-translations
|
||||
(->> (vals elements)
|
||||
(filter (fn [info]
|
||||
(or (matches-search (:translation info) filter-term)
|
||||
(matches-search (shortcut->command-string info) filter-term))))
|
||||
(map :translation)
|
||||
sort))
|
||||
sorted-filtered filtered
|
||||
(filter #(matches-search % filter-term) shortcut-translations))
|
||||
sorted-filtered (sort filtered)
|
||||
trigger-ref (mf/use-ref nil)]
|
||||
|
||||
[:ul {:class (stl/css :sub-menu)
|
||||
@@ -634,9 +597,8 @@
|
||||
(get custom-shortcuts section-key))
|
||||
group-map (if (map? group-map) group-map {})
|
||||
customized? (contains? group-map command)
|
||||
has-conflict? (contains? conflicts command)
|
||||
customizable? (not (false? (:customizable command-info)))]
|
||||
(if (and editable? customizable?)
|
||||
has-conflict? (contains? conflicts command)]
|
||||
(if editable?
|
||||
[:> shortcut-row-editable* {:elements elements
|
||||
:custom-shortcuts custom-shortcuts
|
||||
:section-key section-key
|
||||
@@ -649,13 +611,9 @@
|
||||
:data-conflict (str has-conflict?)
|
||||
:aria-label command-translate
|
||||
:key command-translate}
|
||||
[:span
|
||||
[:span {:class (stl/css-case :command-name true
|
||||
:not-customizable-label (not customizable?))
|
||||
:id (dm/str command-translate "-label")}
|
||||
command-translate]
|
||||
(when (not customizable?)
|
||||
[:span {:class (stl/css :not-customizable-label)} "(not customizable)"])]
|
||||
[:span {:class (stl/css :command-name)
|
||||
:id (dm/str command-translate "-label")}
|
||||
command-translate]
|
||||
[:div {:class (stl/css :shortcut-actions)
|
||||
:aria-labelledby (dm/str command-translate "-label")}
|
||||
(if (and customized? (str/blank? content))
|
||||
|
||||
@@ -78,10 +78,6 @@
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.not-customizable-label {
|
||||
padding-inline-start: px2rem(6);
|
||||
}
|
||||
|
||||
// Editable rows
|
||||
|
||||
.shortcuts-name-editable {
|
||||
|
||||
@@ -159,14 +159,6 @@
|
||||
(or (.-isComposing native)
|
||||
(= 229 (.-keyCode event)))))
|
||||
|
||||
(defn- double-click?
|
||||
[^js native-event]
|
||||
(= (.-detail native-event) 2))
|
||||
|
||||
(defn- triple-click?
|
||||
[^js native-event]
|
||||
(>= (.-detail native-event) 3))
|
||||
|
||||
(defn- input-surface-class
|
||||
"Class list for the contenteditable capture surface.
|
||||
|
||||
@@ -198,8 +190,6 @@
|
||||
;; WASM `is_pointer_selection_active` guard), not on every hover move.
|
||||
dragging-ref (mf/use-ref false)
|
||||
|
||||
deferred-press-ref (mf/use-ref nil)
|
||||
|
||||
fallback-fonts (wasm.api/fonts-from-text-content (:content shape) false)
|
||||
fallback-families (map (fn [font]
|
||||
(font-family-from-font-id (:font-id font))) fallback-fonts)
|
||||
@@ -465,29 +455,23 @@
|
||||
(mf/use-fn
|
||||
(fn [^js event]
|
||||
(let [native-event (dom/event->native-event event)
|
||||
off-pt (dom/get-offset-position native-event)]
|
||||
off-pt (dom/get-offset-position native-event)]
|
||||
;; Repositioning the caret abandons the pending caret style (also
|
||||
;; covers click and double-click, which fire pointer-down first).
|
||||
(text-editor/clear-pending-caret-styles!)
|
||||
(mf/set-ref-val! dragging-ref true)
|
||||
(if (.-shiftKey event)
|
||||
(do
|
||||
(mf/set-ref-val! dragging-ref true)
|
||||
(wasm.api/text-editor-pointer-down-extend off-pt)
|
||||
;; Repaint the caret over the cached tiles instead of a full
|
||||
;; render, which flashes at high zoom.
|
||||
(wasm.api/render-text-editor-overlay!))
|
||||
(mf/set-ref-val! deferred-press-ref off-pt)))))
|
||||
(wasm.api/text-editor-pointer-down-extend off-pt)
|
||||
(wasm.api/text-editor-pointer-down off-pt))
|
||||
;; Repaint the caret over the cached tiles instead of a full render,
|
||||
;; which flashes at high zoom (see `render-text-editor-overlay!`).
|
||||
(wasm.api/render-text-editor-overlay!))))
|
||||
|
||||
on-pointer-move
|
||||
(mf/use-fn
|
||||
(fn [^js event]
|
||||
(let [native-event (dom/event->native-event event)
|
||||
off-pt (dom/get-offset-position native-event)]
|
||||
(when-let [pressed-pt (and (pos? (.-buttons native-event))
|
||||
(mf/ref-val deferred-press-ref))]
|
||||
(mf/set-ref-val! deferred-press-ref nil)
|
||||
(mf/set-ref-val! dragging-ref true)
|
||||
(wasm.api/text-editor-pointer-down pressed-pt))
|
||||
off-pt (dom/get-offset-position native-event)]
|
||||
(wasm.api/text-editor-pointer-move off-pt)
|
||||
;; Only while dragging: `text-editor-pointer-move` is a no-op
|
||||
;; otherwise, so avoid repainting on plain hover.
|
||||
@@ -498,37 +482,18 @@
|
||||
(mf/use-fn
|
||||
(fn [^js event]
|
||||
(let [native-event (dom/event->native-event event)
|
||||
off-pt (dom/get-offset-position native-event)
|
||||
dragging? (mf/ref-val dragging-ref)]
|
||||
off-pt (dom/get-offset-position native-event)]
|
||||
(mf/set-ref-val! dragging-ref false)
|
||||
(mf/set-ref-val! deferred-press-ref nil)
|
||||
(wasm.api/text-editor-pointer-up off-pt)
|
||||
;; Without a drag there is no pointer selection to close; the
|
||||
;; caret is placed by `on-click`.
|
||||
(when dragging?
|
||||
(wasm.api/render-text-editor-overlay!)))))
|
||||
(wasm.api/render-text-editor-overlay!))))
|
||||
|
||||
on-click
|
||||
(mf/use-fn
|
||||
(fn [^js event]
|
||||
(let [native-event (dom/event->native-event event)
|
||||
off-pt (dom/get-offset-position native-event)]
|
||||
(cond
|
||||
(triple-click? native-event)
|
||||
(do
|
||||
(wasm.api/text-editor-select-paragraph off-pt)
|
||||
(wasm.api/render-text-editor-overlay!))
|
||||
|
||||
;; `dblclick` selects the word right after. Shift+click still goes
|
||||
;; through: WASM consumes its skip-click flag there.
|
||||
(and (double-click? native-event)
|
||||
(not (.-shiftKey event)))
|
||||
nil
|
||||
|
||||
:else
|
||||
(do
|
||||
(wasm.api/text-editor-set-cursor-from-offset off-pt)
|
||||
(wasm.api/render-text-editor-overlay!))))))
|
||||
off-pt (dom/get-offset-position native-event)]
|
||||
(wasm.api/text-editor-set-cursor-from-offset off-pt)
|
||||
(wasm.api/render-text-editor-overlay!))))
|
||||
|
||||
on-double-click
|
||||
(mf/use-fn
|
||||
|
||||
@@ -290,18 +290,14 @@
|
||||
get-components-with-duplicated-variant-props-and-values
|
||||
(map :main-instance-id)))
|
||||
|
||||
(defn- get-variant-option
|
||||
[val]
|
||||
{:id val
|
||||
:label (if (str/blank? val) (str "(" (tr "labels.empty") ")") val)})
|
||||
|
||||
(defn- get-variant-options
|
||||
"Get variant options for a given property name"
|
||||
[prop-name prop-vals]
|
||||
(->> (filter #(= (:name %) prop-name) prop-vals)
|
||||
first
|
||||
:value
|
||||
(mapv get-variant-option)))
|
||||
(mapv (fn [val] {:id val
|
||||
:label (if (str/blank? val) (str "(" (tr "labels.empty") ")") val)}))))
|
||||
|
||||
(mf/defc component-variant-property*
|
||||
[{:keys [pos prop options on-prop-name-blur on-prop-value-change on-reorder]}]
|
||||
@@ -531,12 +527,8 @@
|
||||
(for [[pos prop] (map-indexed vector props-first)]
|
||||
(let [mixed-value? (not-every? #(= (:value prop) (:value (get % pos))) properties)
|
||||
base-options (get options-by-name (:name prop))
|
||||
no-options? (empty? base-options)
|
||||
boolean-pair (ctv/find-boolean-pair (mapv :id base-options))
|
||||
options (cond-> base-options
|
||||
no-options?
|
||||
(conj (get-variant-option (:value prop)))
|
||||
|
||||
mixed-value?
|
||||
(conj {:id mixed-label :label mixed-label :dimmed true}))]
|
||||
|
||||
@@ -557,7 +549,6 @@
|
||||
[:> select* {:default-selected (if mixed-value? mixed-label (:value prop))
|
||||
:options options
|
||||
:empty-to-end true
|
||||
:disabled no-options?
|
||||
:on-change (partial switch-component pos)
|
||||
:key (str (:value prop) "-" key)}]])]))]
|
||||
|
||||
|
||||
@@ -12,18 +12,16 @@
|
||||
[app.main.data.workspace.grid :as dw]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.components.dropdown :refer [dropdown]]
|
||||
[app.main.ui.components.editable-select :refer [editable-select]]
|
||||
[app.main.ui.components.numeric-input :as deprecated-input]
|
||||
[app.main.ui.components.select :refer [select]]
|
||||
[app.main.ui.components.title-bar :refer [title-bar*]]
|
||||
[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 :refer [icon*] :as i]
|
||||
[app.main.ui.ds.foundations.assets.icon :as i]
|
||||
[app.main.ui.icons :as deprecated-icon]
|
||||
[app.main.ui.workspace.sidebar.options.common :refer [advanced-options*]]
|
||||
[app.main.ui.workspace.sidebar.options.rows.color-row :refer [color-row*]]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.i18n :as i18n :refer [tr]]
|
||||
[app.util.keyboard :as kbd]
|
||||
[okulary.core :as l]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
@@ -35,49 +33,6 @@
|
||||
:separator
|
||||
18 12 10 8 6 4 3 2])
|
||||
|
||||
(mf/defc default-options-toggle*
|
||||
"Toggle button that opens the reset/save-as-default menu for a grid's
|
||||
params. Shared by the square, column and row grid-type layouts, each of
|
||||
which places it at a different point in their own layout."
|
||||
[{:keys [show disabled on-toggle]}]
|
||||
[:button {:class (stl/css-case :show-more-options true
|
||||
:selected show)
|
||||
:on-click on-toggle
|
||||
:disabled disabled}
|
||||
[:> icon* {:icon-id i/menu
|
||||
:size "m"
|
||||
:aria-hidden true
|
||||
:class (stl/css :show-options-icon)}]])
|
||||
|
||||
(mf/defc default-options-dropdown*
|
||||
"Dropdown menu with the reset/save-as-default options for a grid's params.
|
||||
Shared by the square, column and row grid-type layouts, each of which
|
||||
places it at a different point in their own layout (its `:class` controls
|
||||
the panel's positioning, which differs per layout)."
|
||||
[{:keys [class show on-close on-use-default on-set-as-default]}]
|
||||
(let [handle-key-down
|
||||
(fn [action]
|
||||
(fn [event]
|
||||
(when (or (kbd/enter? event) (kbd/space? event))
|
||||
(dom/prevent-default event)
|
||||
(action))))]
|
||||
[:& dropdown {:show show
|
||||
:on-close on-close}
|
||||
[:ul {:class class
|
||||
:role "menu"}
|
||||
[:li {:class (stl/css :option-btn)
|
||||
:role "menuitem"
|
||||
:tab-index 0
|
||||
:on-click on-use-default
|
||||
:on-key-down (handle-key-down on-use-default)}
|
||||
(tr "workspace.options.grid.params.use-default")]
|
||||
[:li {:class (stl/css :option-btn)
|
||||
:role "menuitem"
|
||||
:tab-index 0
|
||||
:on-click on-set-as-default
|
||||
:on-key-down (handle-key-down on-set-as-default)}
|
||||
(tr "workspace.options.grid.params.set-default")]]]))
|
||||
|
||||
(mf/defc grid-options*
|
||||
{::mf/wrap [mf/memo]}
|
||||
[{:keys [shape-id index grid frame-width frame-height default-grid-params]}]
|
||||
@@ -196,10 +151,7 @@
|
||||
[:button {:class (stl/css-case :show-options true
|
||||
:selected open?)
|
||||
:on-click toggle-advanced-options}
|
||||
[:> icon* {:icon-id i/menu
|
||||
:size "m"
|
||||
:aria-hidden true
|
||||
:class (stl/css :show-options-icon)}]]
|
||||
deprecated-icon/menu]
|
||||
[:div {:class (stl/css :type-select-wrapper)}
|
||||
[:& select
|
||||
{:class (stl/css :grid-type-select)
|
||||
@@ -211,10 +163,11 @@
|
||||
(if (= type :square)
|
||||
[:div {:class (stl/css :grid-size)
|
||||
:title (tr "workspace.options.size")}
|
||||
[:> numeric-input* {:min 0.01
|
||||
:value (or (:size params) "")
|
||||
:inner-class (stl/css :numeric-input)
|
||||
:on-change (handle-change :params :size)}]]
|
||||
[:> deprecated-input/numeric-input* {:min 0.01
|
||||
:value (or (:size params) "")
|
||||
:no-validate true
|
||||
:class (stl/css :numeric-input)
|
||||
:on-change (handle-change :params :size)}]]
|
||||
|
||||
[:div {:class (stl/css :editable-select-wrapper)}
|
||||
[:& editable-select {:value (:size params)
|
||||
@@ -251,14 +204,22 @@
|
||||
:origin :guides
|
||||
:on-change handle-change-color
|
||||
:on-detach handle-detach-color}]
|
||||
[:> default-options-toggle* {:show show-more-options?
|
||||
:disabled is-default
|
||||
:on-toggle toggle-more-options}]]
|
||||
[:> default-options-dropdown* {:class (stl/css :second-row)
|
||||
:show show-more-options?
|
||||
:on-close close-more-options
|
||||
:on-use-default handle-use-default
|
||||
:on-set-as-default handle-set-as-default}]])
|
||||
[:button {:class (stl/css-case :show-more-options true
|
||||
:selected show-more-options?)
|
||||
:on-click toggle-more-options}
|
||||
deprecated-icon/menu]]
|
||||
(when show-more-options?
|
||||
[:div {:class (stl/css :second-row)}
|
||||
[:button {:class (stl/css-case :btn-options true
|
||||
:disabled is-default)
|
||||
:disabled is-default
|
||||
:on-click handle-use-default}
|
||||
[:span (tr "workspace.options.grid.params.use-default")]]
|
||||
[:button {:class (stl/css-case :btn-options true
|
||||
:disabled is-default)
|
||||
:disabled is-default
|
||||
:on-click handle-set-as-default}
|
||||
[:span (tr "workspace.options.grid.params.set-default")]]])])
|
||||
|
||||
(when (or (= :column type) (= :row type))
|
||||
[:div {:class (stl/css :column-row)}
|
||||
@@ -291,39 +252,49 @@
|
||||
:title (if (= :row type)
|
||||
(tr "workspace.options.grid.params.height")
|
||||
(tr "workspace.options.grid.params.width"))}
|
||||
[:> numeric-input* {:placeholder "Auto"
|
||||
:on-change handle-change-item-length
|
||||
:nillable true
|
||||
:icon (if (= :row type) i/character-h i/character-w)
|
||||
:inner-class (stl/css :numeric-input)
|
||||
:value (or (:item-length params) "")}]]
|
||||
[:span {:class (stl/css :icon-text)}
|
||||
(if (= :row type)
|
||||
"H"
|
||||
"W")]
|
||||
[:> deprecated-input/numeric-input* {:placeholder "Auto"
|
||||
:on-change handle-change-item-length
|
||||
:is-nillable true
|
||||
:class (stl/css :numeric-input)
|
||||
:value (or (:item-length params) "")}]]
|
||||
|
||||
[:div {:class (stl/css :gutter)
|
||||
:title (tr "workspace.options.grid.params.gutter")}
|
||||
[:> numeric-input* {:placeholder "0"
|
||||
:on-change (handle-change :params :gutter)
|
||||
:nillable true
|
||||
:icon (if (= type :row) i/gap-vertical i/gap-horizontal)
|
||||
:inner-class (stl/css :numeric-input)
|
||||
:value (or (:gutter params) 0)}]]
|
||||
[:span {:class (stl/css-case :icon true
|
||||
:rotated (= type :row))}
|
||||
deprecated-icon/gap-horizontal]
|
||||
[:> deprecated-input/numeric-input* {:placeholder "0"
|
||||
:on-change (handle-change :params :gutter)
|
||||
:is-nillable true
|
||||
:class (stl/css :numeric-input)
|
||||
:value (or (:gutter params) 0)}]]
|
||||
|
||||
[:div {:class (stl/css :margin)
|
||||
:title (tr "workspace.options.grid.params.margin")}
|
||||
[:> numeric-input* {:placeholder "0"
|
||||
:on-change (handle-change :params :margin)
|
||||
:nillable true
|
||||
:icon (if (= type :column) i/margin-left-right i/margin-top-bottom)
|
||||
:inner-class (stl/css :numeric-input)
|
||||
:value (or (:margin params) 0)}]]
|
||||
[:span {:class (stl/css-case :icon true
|
||||
:rotated (= type :column))}
|
||||
deprecated-icon/grid-margin]
|
||||
[:> deprecated-input/numeric-input* {:placeholder "0"
|
||||
:on-change (handle-change :params :margin)
|
||||
:is-nillable true
|
||||
:class (stl/css :numeric-input)
|
||||
:value (or (:margin params) 0)}]]
|
||||
|
||||
[:> default-options-toggle* {:show show-more-options?
|
||||
:disabled is-default
|
||||
:on-toggle toggle-more-options}]
|
||||
[:> default-options-dropdown* {:class (stl/css :more-options)
|
||||
:show show-more-options?
|
||||
:on-close close-more-options
|
||||
:on-use-default handle-use-default
|
||||
:on-set-as-default handle-set-as-default}]]])])]))
|
||||
[:button {:class (stl/css-case :show-more-options true
|
||||
:selected show-more-options?)
|
||||
:on-click toggle-more-options
|
||||
:disabled is-default}
|
||||
deprecated-icon/menu]
|
||||
(when show-more-options?
|
||||
[:div {:class (stl/css :more-options)}
|
||||
[:button {:class (stl/css :option-btn)
|
||||
:on-click handle-use-default} (tr "workspace.options.grid.params.use-default")]
|
||||
[:button {:class (stl/css :option-btn)
|
||||
:on-click handle-set-as-default} (tr "workspace.options.grid.params.set-default")]])]])])]))
|
||||
|
||||
(defn- check-frame-grid-props
|
||||
[old-props new-props]
|
||||
|
||||
@@ -4,24 +4,9 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
@use "ds/_borders.scss" as *;
|
||||
@use "ds/_sizes.scss" as *;
|
||||
@use "ds/_utils.scss" as *;
|
||||
@use "ds/z-index.scss" as *;
|
||||
@use "ds/spacing.scss" as *;
|
||||
@use "ds/typography.scss" as *;
|
||||
@use "refactor/common-refactor.scss" as deprecated;
|
||||
@use "../../../sidebar/common/sidebar.scss" as sidebar;
|
||||
|
||||
// Shared by every disabled-looking control in the .hidden state below.
|
||||
@mixin hidden-control {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
box-sizing: border-box;
|
||||
color: var(--color-foreground-secondary);
|
||||
stroke: var(--color-foreground-secondary);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.element-set {
|
||||
@include sidebar.option-grid-structure;
|
||||
}
|
||||
@@ -31,17 +16,15 @@
|
||||
}
|
||||
|
||||
.title-spacing-board-grid {
|
||||
padding-inline-start: var(--sp-xxs);
|
||||
padding-left: deprecated.$s-2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.element-set-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xs);
|
||||
@include deprecated.flex-column;
|
||||
|
||||
grid-column: span 8;
|
||||
margin-block: var(--sp-xs) var(--sp-s);
|
||||
margin-inline: 0;
|
||||
margin: deprecated.$s-4 0 deprecated.$s-8 0;
|
||||
}
|
||||
|
||||
.grid-title {
|
||||
@@ -52,388 +35,266 @@
|
||||
grid-column: span 6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: px2rem(1);
|
||||
border-radius: $br-8;
|
||||
background-color: var(--color-background-primary);
|
||||
}
|
||||
gap: deprecated.$s-1;
|
||||
border-radius: deprecated.$br-8;
|
||||
background-color: var(--input-details-color);
|
||||
|
||||
.show-options {
|
||||
--show-options-background-color: var(--color-background-tertiary);
|
||||
--show-options-border-color: var(--color-background-tertiary);
|
||||
--show-options-color: var(--color-foreground-secondary);
|
||||
.show-options {
|
||||
@extend %button-secondary;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
block-size: $sz-32;
|
||||
inline-size: $sz-28;
|
||||
border-radius: $br-8 0 0 $br-8;
|
||||
box-sizing: border-box;
|
||||
border: $b-1 solid var(--show-options-border-color);
|
||||
background-color: var(--show-options-background-color);
|
||||
color: var(--show-options-color);
|
||||
height: deprecated.$s-32;
|
||||
width: deprecated.$s-28;
|
||||
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
|
||||
box-sizing: border-box;
|
||||
border: deprecated.$s-1 solid var(--input-border-color);
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
svg {
|
||||
@extend %button-icon;
|
||||
}
|
||||
|
||||
--show-options-border-color: var(--color-accent-primary);
|
||||
&.selected {
|
||||
@extend %button-icon-selected;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--show-options-background-color: var(--color-background-quaternary);
|
||||
--show-options-border-color: var(--color-background-quaternary);
|
||||
--show-options-color: var(--color-accent-primary);
|
||||
.type-select-wrapper {
|
||||
flex-grow: 1;
|
||||
width: deprecated.$s-96;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
height: deprecated.$s-32;
|
||||
|
||||
text-decoration: none;
|
||||
.grid-type-select {
|
||||
border-radius: 0;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
border: deprecated.$s-1 solid var(--input-border-color);
|
||||
|
||||
&:hover {
|
||||
border: deprecated.$s-1 solid var(--input-border-color-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
outline: none;
|
||||
.grid-size {
|
||||
@extend %asset-element;
|
||||
|
||||
--show-options-background-color: var(--color-background-secondary);
|
||||
--show-options-border-color: var(--color-background-quaternary);
|
||||
--show-options-color: var(--color-accent-primary);
|
||||
width: deprecated.$s-60;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding-left: deprecated.$s-8;
|
||||
border-radius: 0 deprecated.$br-8 deprecated.$br-8 0;
|
||||
|
||||
.numeric-input {
|
||||
@extend %input-base;
|
||||
@include deprecated.body-small-typography;
|
||||
}
|
||||
}
|
||||
|
||||
&[disabled],
|
||||
&:disabled {
|
||||
--show-options-background-color: var(--color-background-quaternary);
|
||||
--show-options-border-color: var(--color-background-quaternary);
|
||||
--show-options-color: var(--color-foreground-disabled);
|
||||
.editable-select-wrapper {
|
||||
@extend %asset-element;
|
||||
|
||||
cursor: unset;
|
||||
width: deprecated.$s-60;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
border-radius: 0 deprecated.$br-8 deprecated.$br-8 0;
|
||||
|
||||
.column-select {
|
||||
height: deprecated.$s-32;
|
||||
border-radius: 0 deprecated.$br-8 deprecated.$br-8 0;
|
||||
box-sizing: border-box;
|
||||
border: deprecated.$s-1 solid var(--input-border-color);
|
||||
|
||||
.numeric-input {
|
||||
@extend %input-base;
|
||||
@include deprecated.body-small-typography;
|
||||
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
@include deprecated.flex-center;
|
||||
|
||||
svg {
|
||||
@extend %button-icon;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.selected {
|
||||
outline: none;
|
||||
&.hidden {
|
||||
.show-options {
|
||||
@include deprecated.hidden-element;
|
||||
|
||||
--show-options-background-color: var(--color-background-quaternary);
|
||||
--show-options-border-color: var(--color-background-quaternary);
|
||||
--show-options-color: var(--color-accent-primary);
|
||||
border: deprecated.$s-1 solid var(--input-border-color-disabled);
|
||||
}
|
||||
|
||||
.type-select-wrapper,
|
||||
.editable-select-wrapper {
|
||||
@include deprecated.hidden-element;
|
||||
|
||||
.column-select,
|
||||
.grid-type-select {
|
||||
@include deprecated.hidden-element;
|
||||
|
||||
border: deprecated.$s-1 solid var(--input-border-color-disabled);
|
||||
}
|
||||
|
||||
.column-select {
|
||||
@include deprecated.hidden-element;
|
||||
|
||||
border-radius: 0 deprecated.$br-8 deprecated.$br-8 0;
|
||||
|
||||
.numeric-input {
|
||||
@include deprecated.hidden-element;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.grid-size {
|
||||
@include deprecated.hidden-element;
|
||||
|
||||
border: deprecated.$s-1 solid var(--input-border-color-disabled);
|
||||
|
||||
.icon {
|
||||
stroke: var(--input-foreground-color-disabled);
|
||||
}
|
||||
|
||||
.numeric-input {
|
||||
color: var(--input-foreground-color-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
.hidden-btn,
|
||||
.lock-btn {
|
||||
background-color: transparent;
|
||||
|
||||
svg {
|
||||
stroke: var(--input-foreground-color-disabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.type-select-wrapper {
|
||||
flex-grow: 1;
|
||||
inline-size: $sz-96;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
block-size: $sz-32;
|
||||
}
|
||||
|
||||
.type-select-wrapper .grid-type-select {
|
||||
--grid-type-select-border-color: var(--color-background-tertiary);
|
||||
|
||||
border-radius: 0;
|
||||
block-size: 100%;
|
||||
box-sizing: border-box;
|
||||
border: $b-1 solid var(--grid-type-select-border-color);
|
||||
|
||||
&:hover {
|
||||
--grid-type-select-border-color: var(--color-background-quaternary);
|
||||
}
|
||||
}
|
||||
|
||||
.grid-size {
|
||||
@include use-typography("body-small");
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
block-size: $sz-32;
|
||||
inline-size: px2rem(60);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding-inline-start: var(--sp-s);
|
||||
border-radius: 0 $br-8 $br-8 0;
|
||||
background-color: var(--color-background-tertiary);
|
||||
color: var(--color-foreground-primary);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-quaternary);
|
||||
color: var(--color-foreground-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.grid-size .numeric-input {
|
||||
--input-height: #{$sz-28};
|
||||
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.editable-select-wrapper {
|
||||
inline-size: px2rem(60);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editable-select-wrapper .column-select {
|
||||
block-size: $sz-32;
|
||||
border-radius: 0 $br-8 $br-8 0;
|
||||
box-sizing: border-box;
|
||||
border: $b-1 solid var(--color-background-tertiary);
|
||||
}
|
||||
|
||||
.column-select .numeric-input {
|
||||
@include use-typography("body-small");
|
||||
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
display: block;
|
||||
max-inline-size: 99%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
block-size: $sz-28;
|
||||
inline-size: 100%;
|
||||
flex-grow: 1;
|
||||
border-radius: $br-8;
|
||||
color: var(--color-foreground-primary);
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.hidden .show-options {
|
||||
@include hidden-control;
|
||||
|
||||
border: $b-1 solid var(--color-background-quaternary);
|
||||
}
|
||||
|
||||
.hidden .type-select-wrapper,
|
||||
.hidden .editable-select-wrapper {
|
||||
@include hidden-control;
|
||||
}
|
||||
|
||||
.hidden .column-select,
|
||||
.hidden .grid-type-select {
|
||||
@include hidden-control;
|
||||
|
||||
border: $b-1 solid var(--color-background-quaternary);
|
||||
}
|
||||
|
||||
.hidden .column-select {
|
||||
border-radius: 0 $br-8 $br-8 0;
|
||||
}
|
||||
|
||||
.hidden .column-select .numeric-input {
|
||||
@include hidden-control;
|
||||
}
|
||||
|
||||
.hidden .grid-size {
|
||||
@include hidden-control;
|
||||
|
||||
border: $b-1 solid var(--color-background-quaternary);
|
||||
}
|
||||
|
||||
.hidden .grid-size .numeric-input {
|
||||
--input-fg-color: var(--color-foreground-secondary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-xs);
|
||||
@include deprecated.flex-row;
|
||||
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.grid-advanced-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xs);
|
||||
margin-block-start: var(--sp-xs);
|
||||
@include deprecated.flex-column;
|
||||
|
||||
margin-top: deprecated.$s-4;
|
||||
}
|
||||
|
||||
.column-row,
|
||||
.square-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xs);
|
||||
@include deprecated.flex-column;
|
||||
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.advanced-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: var(--sp-xs);
|
||||
}
|
||||
gap: deprecated.$s-4;
|
||||
|
||||
.orientation-select-wrapper {
|
||||
inline-size: px2rem(92);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.color-wrapper {
|
||||
inline-size: px2rem(156);
|
||||
}
|
||||
|
||||
.show-more-options {
|
||||
--show-more-options-background-color: transparent;
|
||||
--show-more-options-border-color: transparent;
|
||||
--show-more-options-color: var(--color-foreground-secondary);
|
||||
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
block-size: $sz-32;
|
||||
inline-size: $sz-32;
|
||||
border-radius: $br-8;
|
||||
background-color: var(--show-more-options-background-color);
|
||||
border: $b-2 solid var(--show-more-options-border-color);
|
||||
color: var(--show-more-options-color);
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
border: $b-1 solid var(--color-accent-primary);
|
||||
|
||||
--show-more-options-background-color: var(--color-background-tertiary);
|
||||
--show-more-options-color: var(--color-foreground-primary);
|
||||
.orientation-select-wrapper {
|
||||
width: deprecated.$s-92;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--show-more-options-background-color: var(--color-background-quaternary);
|
||||
--show-more-options-border-color: var(--color-background-quaternary);
|
||||
--show-more-options-color: var(--color-accent-primary);
|
||||
.color-wrapper {
|
||||
width: deprecated.$s-156;
|
||||
}
|
||||
|
||||
&:active {
|
||||
outline: none;
|
||||
.show-more-options {
|
||||
@extend %button-tertiary;
|
||||
|
||||
--show-more-options-background-color: var(--color-background-secondary);
|
||||
--show-more-options-border-color: transparent;
|
||||
--show-more-options-color: var(--color-accent-primary);
|
||||
height: deprecated.$s-32;
|
||||
width: deprecated.$s-32;
|
||||
|
||||
svg {
|
||||
@extend %button-icon;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
@extend %button-icon-selected;
|
||||
}
|
||||
}
|
||||
|
||||
&[disabled],
|
||||
&:disabled {
|
||||
--show-more-options-color: var(--color-foreground-disabled);
|
||||
.height {
|
||||
@extend %input-element;
|
||||
@include deprecated.body-small-typography;
|
||||
|
||||
cursor: unset;
|
||||
pointer-events: none;
|
||||
.icon-text {
|
||||
padding-top: deprecated.$s-1;
|
||||
}
|
||||
}
|
||||
|
||||
&.selected {
|
||||
outline: none;
|
||||
.gutter,
|
||||
.margin {
|
||||
@extend %input-element;
|
||||
@include deprecated.body-small-typography;
|
||||
|
||||
--show-more-options-background-color: var(--color-background-quaternary);
|
||||
--show-more-options-border-color: var(--color-background-quaternary);
|
||||
--show-more-options-color: var(--color-accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.show-more-options svg {
|
||||
stroke: var(--show-more-options-color);
|
||||
}
|
||||
|
||||
.height,
|
||||
.gutter,
|
||||
.margin {
|
||||
--grid-param-input-color: var(--color-foreground-secondary);
|
||||
--grid-param-input-background-color: var(--color-background-tertiary);
|
||||
--grid-param-input-border-color: var(--color-background-tertiary);
|
||||
|
||||
@include use-typography("body-small");
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
block-size: $sz-32;
|
||||
border-radius: $br-8;
|
||||
background-color: var(--grid-param-input-background-color);
|
||||
border: $b-1 solid var(--grid-param-input-border-color);
|
||||
color: var(--grid-param-input-color);
|
||||
|
||||
&:hover {
|
||||
--grid-param-input-color: var(--color-foreground-primary);
|
||||
--grid-param-input-background-color: var(--color-background-quaternary);
|
||||
--grid-param-input-border-color: var(--color-background-quaternary);
|
||||
.icon {
|
||||
&.rotated svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
--grid-param-input-color: var(--color-foreground-primary);
|
||||
--grid-param-input-background-color: var(--color-background-primary);
|
||||
--grid-param-input-border-color: var(--color-accent-primary);
|
||||
}
|
||||
.more-options {
|
||||
@include deprecated.menu-shadow;
|
||||
@include deprecated.flex-column;
|
||||
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
--grid-param-input-background-color: var(--color-background-tertiary);
|
||||
}
|
||||
}
|
||||
position: absolute;
|
||||
top: calc(deprecated.$s-2 + deprecated.$s-28);
|
||||
right: 0;
|
||||
width: deprecated.$s-156;
|
||||
max-height: deprecated.$s-300;
|
||||
padding: deprecated.$s-2;
|
||||
margin: 0 0 deprecated.$s-40 0;
|
||||
margin-top: deprecated.$s-4;
|
||||
border-radius: deprecated.$br-8;
|
||||
z-index: deprecated.$z-index-4;
|
||||
overflow-y: auto;
|
||||
background-color: var(--menu-background-color);
|
||||
|
||||
.height .numeric-input,
|
||||
.gutter .numeric-input,
|
||||
.margin .numeric-input {
|
||||
--input-height: #{$sz-28};
|
||||
.option-btn {
|
||||
@include deprecated.button-style;
|
||||
|
||||
flex-grow: 1;
|
||||
margin-block: var(--sp-xxs);
|
||||
margin-inline: 0;
|
||||
padding-inline-start: $sz-6;
|
||||
}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: deprecated.$s-32;
|
||||
padding: 0 deprecated.$s-8;
|
||||
border-radius: deprecated.$br-6;
|
||||
color: var(--menu-foreground-color);
|
||||
|
||||
.more-options {
|
||||
box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xs);
|
||||
position: absolute;
|
||||
inset-block-start: calc(var(--sp-xxs) + $sz-28);
|
||||
inset-inline-end: 0;
|
||||
inline-size: px2rem(156);
|
||||
max-block-size: px2rem(300); // TODO: when this gets addressed in the DS, use a token
|
||||
padding: var(--sp-xxs);
|
||||
margin-block: var(--sp-xs) $sz-40;
|
||||
margin-inline: 0;
|
||||
border-radius: $br-8;
|
||||
z-index: var(--z-index-dropdown);
|
||||
overflow-y: auto;
|
||||
background-color: var(--color-background-tertiary);
|
||||
}
|
||||
|
||||
.option-btn {
|
||||
@include use-typography("body-small");
|
||||
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
block-size: $sz-32;
|
||||
padding-inline: var(--sp-s);
|
||||
border-radius: $br-6;
|
||||
color: var(--color-foreground-primary);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: var(--color-background-quaternary);
|
||||
color: var(--color-foreground-primary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
&:hover {
|
||||
background-color: var(--menu-background-color-hover);
|
||||
color: var(--menu-foreground-color-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.second-row {
|
||||
box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark);
|
||||
position: absolute;
|
||||
inset-block-start: $sz-32;
|
||||
inset-inline-start: unset;
|
||||
inset-inline-end: 0;
|
||||
inline-size: px2rem(108);
|
||||
max-block-size: var(--menu-max-height, px2rem(300));
|
||||
padding: var(--sp-xxs);
|
||||
margin: 0;
|
||||
margin-block-start: px2rem(1);
|
||||
border-radius: $br-8;
|
||||
z-index: var(--z-index-dropdown);
|
||||
overflow: hidden auto;
|
||||
background-color: var(--color-background-tertiary);
|
||||
color: var(--color-foreground-primary);
|
||||
@extend %dropdown-wrapper;
|
||||
|
||||
left: unset;
|
||||
right: 0;
|
||||
width: deprecated.$s-108;
|
||||
|
||||
.btn-options {
|
||||
@include deprecated.button-style;
|
||||
@extend %dropdown-element-base;
|
||||
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -276,7 +276,7 @@
|
||||
(seq strokes)
|
||||
[:> h/sortable-container* {}
|
||||
(for [[index value] (d/enumerate (:strokes values []))]
|
||||
[:> stroke-row* {:key (dm/str "stroke-" index)
|
||||
[:> stroke-row* {:key (dm/str "stroke-" index "-" (hash applied-tokens))
|
||||
:index index
|
||||
:stroke value
|
||||
:title (tr "workspace.options.stroke-color")
|
||||
|
||||
@@ -66,12 +66,7 @@
|
||||
(ss/build-all-shortcuts workspace-shortcuts dashboard-shortcuts viewer-shortcuts)
|
||||
|
||||
all-item-names (concat all-sc-names all-sub-names all-section-names)
|
||||
all-command-strings (->> (concat (vals workspace-shortcuts)
|
||||
(vals dashboard-shortcuts)
|
||||
(vals viewer-shortcuts))
|
||||
(map ss/shortcut->command-string))
|
||||
all-searchable-names (concat all-item-names all-command-strings)
|
||||
match-any? (some #(matches-search % filter-term) all-searchable-names)
|
||||
match-any? (some #(matches-search % filter-term) all-item-names)
|
||||
|
||||
manage-sections
|
||||
(fn [item]
|
||||
@@ -94,8 +89,7 @@
|
||||
(fn [section term]
|
||||
(let [node-seq (tree-seq :children #(vals (:children %)) (get all-shortcuts section))]
|
||||
(reduce (fn [acc node]
|
||||
(if (or (matches-search (:translation node) term)
|
||||
(matches-search (ss/shortcut->command-string node) term))
|
||||
(if (matches-search (:translation node) term)
|
||||
(add-ids acc node)
|
||||
acc))
|
||||
[]
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
[app.main.ui.workspace.tokens.management.forms.generic-form :as generic]
|
||||
[app.main.ui.workspace.tokens.management.forms.validators :refer [check-coll-self-reference check-self-reference default-validate-token]]
|
||||
[app.util.i18n :refer [tr]]
|
||||
[beicon.v2.core :as rx]
|
||||
[cuerdas.core :as str]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
@@ -42,14 +43,11 @@
|
||||
(defn- validate-typography-token
|
||||
[{:keys [token-value] :as props}]
|
||||
(cond
|
||||
;; Entering form without a value - show no error just resolve nil
|
||||
(nil? token-value) (rx/of nil)
|
||||
;; Validate refrence string
|
||||
(cto/composite-token-reference? token-value) (default-validate-token props)
|
||||
;; Validate composite token. `token-value` may be nil when the form is
|
||||
;; submitted without any composite field filled in — normalize it to `{}`
|
||||
;; so `check-empty-typography-token` catches it and rejects the submit,
|
||||
;; instead of silently saving a token with a `nil` value (which later
|
||||
;; crashes token resolution: the tokens-studio StyleDictionary
|
||||
;; preprocessor assumes a typography token's value is never null).
|
||||
;; Validate composite token
|
||||
:else
|
||||
(-> props
|
||||
(update :token-value
|
||||
|
||||
@@ -112,6 +112,13 @@
|
||||
manifest
|
||||
(.error js/console (clj->js (sm/explain ctp/schema:registry-entry manifest))))))
|
||||
|
||||
(defn save-to-store
|
||||
[]
|
||||
;; TODO: need this for the transition to the new schema. We can remove eventually
|
||||
(let [registry (update @registry :data d/update-vals d/without-nils)]
|
||||
(->> (rp/cmd! :update-profile-props {:props {:plugins registry}})
|
||||
(rx/subs! identity))))
|
||||
|
||||
(defn load-from-store
|
||||
[]
|
||||
(reset! registry (get-in @st/state [:profile :props :plugins] {})))
|
||||
@@ -120,8 +127,6 @@
|
||||
[]
|
||||
(load-from-store))
|
||||
|
||||
(declare remove-plugin!)
|
||||
|
||||
(defn install-plugin!
|
||||
[plugin]
|
||||
(letfn [(update-ids [ids]
|
||||
@@ -131,27 +136,17 @@
|
||||
(swap! registry #(-> %
|
||||
(update :ids update-ids)
|
||||
(update :data assoc (:plugin-id plugin) plugin)))
|
||||
(->> (rp/cmd! :add-profile-plugin {:plugin plugin})
|
||||
(rx/subs! identity
|
||||
(fn [err]
|
||||
(remove-plugin! plugin)
|
||||
(.error js/console "Failed to install plugin:" err))))))
|
||||
(save-to-store)))
|
||||
|
||||
(defn remove-plugin!
|
||||
[{:keys [plugin-id]}]
|
||||
(let [plugin (get-plugin plugin-id)]
|
||||
(letfn [(update-ids [ids]
|
||||
(->> ids
|
||||
(remove #(= % plugin-id))))]
|
||||
(swap! registry #(-> %
|
||||
(update :ids update-ids)
|
||||
(update :data dissoc plugin-id)))
|
||||
(->> (rp/cmd! :remove-profile-plugin {:plugin-id plugin-id})
|
||||
(rx/subs! identity
|
||||
(fn [err]
|
||||
(when plugin
|
||||
(install-plugin! plugin))
|
||||
(.error js/console "Failed to remove plugin:" err)))))))
|
||||
(letfn [(update-ids [ids]
|
||||
(->> ids
|
||||
(remove #(= % plugin-id))))]
|
||||
(swap! registry #(-> %
|
||||
(update :ids update-ids)
|
||||
(update :data dissoc plugin-id)))
|
||||
(save-to-store)))
|
||||
|
||||
(defn check-permission
|
||||
[plugin-id permission]
|
||||
|
||||
@@ -136,9 +136,7 @@
|
||||
(repo/cmd! :get-page {:file-id file-id
|
||||
:page-id page-id
|
||||
:share-id share-id
|
||||
:object-id (if (uuid? object-id)
|
||||
object-id
|
||||
(set object-id))
|
||||
:object-id object-id
|
||||
:features features}))
|
||||
(rx/tap (fn [[fonts]]
|
||||
(when (seq fonts)
|
||||
@@ -157,7 +155,7 @@
|
||||
[:embed {:optional true} :boolean]
|
||||
[:skip-children {:optional true} :boolean]
|
||||
[:object-id
|
||||
[:or [:vector ::sm/uuid] ::sm/uuid]]])
|
||||
[:or [::sm/set ::sm/uuid] ::sm/uuid]]])
|
||||
|
||||
(def ^:private coerce-render-objects-params
|
||||
(sm/coercer schema:render-objects))
|
||||
@@ -190,7 +188,7 @@
|
||||
{:file-id file-id
|
||||
:page-id page-id
|
||||
:share-id share-id
|
||||
:object-ids (into [] (distinct) object-id)
|
||||
:object-ids (into #{} object-id)
|
||||
:embed embed
|
||||
:skip-children skip-children
|
||||
:wasm wasm
|
||||
|
||||
@@ -311,7 +311,6 @@
|
||||
(def text-editor-get-active-shape-id text-editor/text-editor-get-active-shape-id)
|
||||
(def text-editor-select-all text-editor/text-editor-select-all)
|
||||
(def text-editor-select-word-boundary text-editor/text-editor-select-word-boundary)
|
||||
(def text-editor-select-paragraph text-editor/text-editor-select-paragraph)
|
||||
(def text-editor-sync-content text-editor/text-editor-sync-content)
|
||||
|
||||
(def dpr
|
||||
@@ -397,6 +396,11 @@
|
||||
(or (= wasm/internal-frame-type FRAME_TYPE_PARTIAL)
|
||||
(= wasm/internal-frame-type FRAME_TYPE_VIEWPORT_READY)))
|
||||
|
||||
(defn- frame-presented-target?
|
||||
"True when this frame recomposited Target (full or early viewport present)."
|
||||
[]
|
||||
(not= wasm/internal-frame-type FRAME_TYPE_PARTIAL))
|
||||
|
||||
(def ^:const RENDER-FLAG-SYNC-TILES 4) ;; Rebuild tile index without ending fast mode (pan/zoom pause).
|
||||
|
||||
(defn- internal-render
|
||||
@@ -502,8 +506,18 @@
|
||||
(try
|
||||
(when (is-text-editor-wasm-enabled @st/state)
|
||||
(text-editor/text-editor-update-blink timestamp)
|
||||
;; The editor overlay is painted by the WASM frame composition; only
|
||||
;; content/layout changes need a full shape re-render here.
|
||||
;; Only repaint the overlay when this frame recomposited Target (a full
|
||||
;; frame or early viewport present). A partial frame is flushed but not
|
||||
;; presented - Target still shows the last presented frame with the
|
||||
;; overlay already on it - so repainting the translucent selection over
|
||||
;; it stacks another layer every progressive frame: it darkens, then
|
||||
;; snaps back when the final frame presents from the clean Backbuffer
|
||||
;; (the blink at the end of a zoom over a selection, gh-10709).
|
||||
(when (frame-presented-target?)
|
||||
(text-editor/text-editor-render-overlay))
|
||||
;; Drain editor events. Only content/layout changes need a full shape
|
||||
;; re-render; selection/style changes are already reflected by the
|
||||
;; overlay redrawn just above.
|
||||
(when (drain-text-editor-events!)
|
||||
(request-render-preserving-target "text-editor-content")))
|
||||
(catch :default e
|
||||
@@ -880,25 +894,6 @@
|
||||
(h/call wasm/internal-module "_store_image")
|
||||
true)))))
|
||||
|
||||
(defn- store-image-url!
|
||||
"Registers the public URL an image was loaded from so SVG export can emit a
|
||||
linked `<image href>` instead of a Skia base64 embed."
|
||||
[image-id url]
|
||||
(when (and (wasm/live?) (some? url) (not (str/blank? url)))
|
||||
(let [buffer (uuid/get-u32 image-id)
|
||||
encoder (js/TextEncoder.)
|
||||
encoded (.encode encoder url)
|
||||
size (.-byteLength encoded)
|
||||
offset (mem/alloc size)
|
||||
heap (mem/get-heap-u8)]
|
||||
(.set heap encoded offset)
|
||||
(h/call wasm/internal-module "_store_image_url"
|
||||
(aget buffer 0)
|
||||
(aget buffer 1)
|
||||
(aget buffer 2)
|
||||
(aget buffer 3))
|
||||
true)))
|
||||
|
||||
(defn- store-image-texture
|
||||
"Creates a WebGL texture from a decoded image and passes the texture ID to
|
||||
WASM. This avoids decoding the image twice (once in browser, once in WASM)."
|
||||
@@ -941,7 +936,6 @@
|
||||
so Skia rasterizes them."
|
||||
[shape-id image-id thumbnail?]
|
||||
(let [url (cf/resolve-file-media {:id image-id} thumbnail?)]
|
||||
(store-image-url! image-id url)
|
||||
{:key url
|
||||
:thumbnail? thumbnail?
|
||||
:callback
|
||||
@@ -979,8 +973,6 @@
|
||||
(aget buffer 2)
|
||||
(aget buffer 3)
|
||||
thumbnail?)]
|
||||
;; Always register the URL (SVG export needs it even when bytes are cached).
|
||||
(store-image-url! id (cf/resolve-file-media {:id id} thumbnail?))
|
||||
(when (zero? cached-image?)
|
||||
(fetch-image shape-id id thumbnail?)))))
|
||||
|
||||
@@ -1015,7 +1007,6 @@
|
||||
(aget buffer 2)
|
||||
(aget buffer 3)
|
||||
thumbnail?)]
|
||||
(store-image-url! id (cf/resolve-file-media {:id id} thumbnail?))
|
||||
(when (zero? cached-image?)
|
||||
(fetch-image shape-id id thumbnail?))))
|
||||
(types.fills/get-image-ids fills))))))
|
||||
@@ -1044,7 +1035,6 @@
|
||||
(aget buffer 2)
|
||||
(aget buffer 3)
|
||||
thumbnail?)]
|
||||
(store-image-url! image-id (cf/resolve-file-media {:id image-id} thumbnail?))
|
||||
(when (zero? cached-image?)
|
||||
(fetch-image shape-id image-id thumbnail?))))
|
||||
image-ids))))
|
||||
@@ -1430,6 +1420,28 @@
|
||||
(let [local (get @st/state :workspace-local)]
|
||||
(or (:panning local) (:zooming local))))
|
||||
|
||||
(defn- render-text-editor-overlay-if-active!
|
||||
"Redraw the editor caret/selection straight onto the current Target frame when
|
||||
an editor is active (no-op otherwise). Used after the direct `_render_from_cache`
|
||||
/ `internal-render` calls of a view interaction, which bypass the rAF `render`
|
||||
loop that normally repaints the overlay. Without it the selection blinks out
|
||||
for the duration of a pan/zoom gesture over a text shape (gh-10709)."
|
||||
[]
|
||||
(when (is-text-editor-wasm-enabled @st/state)
|
||||
(text-editor/text-editor-render-overlay)))
|
||||
|
||||
(defn- render-text-editor-overlay-after-frame!
|
||||
"Repaint the overlay after a direct `internal-render`, but only when that
|
||||
render recomposited Target (a full frame or early viewport present). A partial
|
||||
frame is only flushed - Target keeps the last presented frame with the overlay
|
||||
already on it - so repainting the translucent selection then stacks another
|
||||
layer and it visibly darkens across the progressive frames before snapping
|
||||
back on the final present (the blink at the end of a zoom over a selection,
|
||||
gh-10709). The final full frame's own repaint keeps the overlay in place."
|
||||
[]
|
||||
(when (frame-presented-target?)
|
||||
(render-text-editor-overlay-if-active!)))
|
||||
|
||||
(defn finalize-view-interaction!
|
||||
"Ends an in-progress pan/zoom view interaction and triggers a full-quality
|
||||
render. No-ops when no view interaction is active.
|
||||
@@ -1448,7 +1460,12 @@
|
||||
;; this implicitly (`zoom_changed`); this extends it to pan/resize-triggered
|
||||
;; ends (e.g. selecting a shape opens the options panel and resizes the
|
||||
;; viewport), which previously blanked.
|
||||
(internal-render (js/performance.now) RENDER-FLAG-SYNC-TILES)))
|
||||
(internal-render (js/performance.now) RENDER-FLAG-SYNC-TILES)
|
||||
;; The direct render above bypasses the rAF `render` loop, so repaint the
|
||||
;; editor overlay explicitly. Only when this was a full frame: a progressive
|
||||
;; render keeps painting through the rAF loop and its partial frames must not
|
||||
;; be over-stamped (see `render-text-editor-overlay-after-frame!`).
|
||||
(render-text-editor-overlay-after-frame!)))
|
||||
|
||||
(def render-finish
|
||||
(letfn [(do-render []
|
||||
@@ -1457,7 +1474,9 @@
|
||||
(when (initialized?)
|
||||
(if (view-gesture-active?)
|
||||
;; Pan/zoom pause: render without ending the interaction.
|
||||
(internal-render (js/performance.now) RENDER-FLAG-SYNC-TILES)
|
||||
(do
|
||||
(internal-render (js/performance.now) RENDER-FLAG-SYNC-TILES)
|
||||
(render-text-editor-overlay-after-frame!))
|
||||
(finalize-view-interaction!))))]
|
||||
(fns/debounce do-render DEBOUNCE_DELAY_MS)))
|
||||
|
||||
@@ -1475,6 +1494,15 @@
|
||||
|
||||
(perf/begin-measure "render-from-cache")
|
||||
(h/call wasm/internal-module "_render_from_cache" 0)
|
||||
;; Keep the text-editor caret/selection glued to the shapes while the view
|
||||
;; changes. `_render_from_cache` re-composites shapes + UI at the new viewbox
|
||||
;; but omits the editor overlay, so without this the selection would vanish for
|
||||
;; the whole pan/zoom gesture and only flash back when the debounced full
|
||||
;; render lands — the blink seen when zooming in/out over a selection at high
|
||||
;; zoom (gh-10709). `_text_editor_render_overlay` draws straight onto the
|
||||
;; freshly composited Target (no Backbuffer re-compose) and no-ops when no
|
||||
;; editor is active.
|
||||
(render-text-editor-overlay-if-active!)
|
||||
(render-finish)
|
||||
(perf/end-measure "render-from-cache"))))
|
||||
|
||||
@@ -1494,60 +1522,6 @@
|
||||
[content]
|
||||
(or content (tc/v2-default-text-content)))
|
||||
|
||||
(def ^:private empty-text-font-state
|
||||
{:font-index {} :pending-faces #{}})
|
||||
|
||||
(defn- text-layout-fonts
|
||||
"Content and fallback faces (emoji, Noto, ...) that `set-shape-text-content`
|
||||
uploads, must match that path for pending-face tracking."
|
||||
[content]
|
||||
(into #{} (concat (f/get-content-fonts content)
|
||||
(fonts-from-text-content content false))))
|
||||
|
||||
(defn- text-font-face-keys-state
|
||||
"All font-face keys for a text content, and the subset not WASM-ready yet."
|
||||
[content]
|
||||
(reduce
|
||||
(fn [acc font]
|
||||
(let [font-data (f/make-font-data font)
|
||||
key (f/font-data-key font-data)
|
||||
pending? (not (f/font-ready? font-data))]
|
||||
(-> acc
|
||||
(update :font-face-keys conj key)
|
||||
(cond-> pending?
|
||||
(update :pending-font-face-keys conj key)))))
|
||||
{:font-face-keys #{} :pending-font-face-keys #{}}
|
||||
(text-layout-fonts content)))
|
||||
|
||||
(defn- acc-text-font-state
|
||||
[{:keys [font-index pending-faces]} id font-face-keys pending-font-face-keys]
|
||||
{:font-index (reduce (fn [idx face]
|
||||
(update idx face (fnil conj #{}) id))
|
||||
font-index
|
||||
(or font-face-keys #{}))
|
||||
:pending-faces (into (or pending-faces #{})
|
||||
(or pending-font-face-keys #{}))})
|
||||
|
||||
(defn text-font-state-for-shape
|
||||
"Build the font-face index for a single text shape (incremental updates)."
|
||||
[shape]
|
||||
(if (cfh/text-shape? shape)
|
||||
(let [content (ensure-text-content (:content shape))
|
||||
{:keys [font-face-keys pending-font-face-keys]}
|
||||
(text-font-face-keys-state content)]
|
||||
(acc-text-font-state empty-text-font-state
|
||||
(:id shape)
|
||||
font-face-keys
|
||||
pending-font-face-keys))
|
||||
empty-text-font-state))
|
||||
|
||||
(defn- shape-ids-for-pending-fonts
|
||||
[{:keys [font-index pending-faces]}]
|
||||
(when (seq pending-faces)
|
||||
(into #{}
|
||||
(mapcat #(get font-index % []))
|
||||
pending-faces)))
|
||||
|
||||
(defn- set-object-host-attrs
|
||||
"Host-specific attrs after structural upload (text/svg-raw/grid; optionally
|
||||
fills/strokes). When `skip-layout?` is true, flex+layout-item were already in
|
||||
@@ -1587,8 +1561,6 @@
|
||||
(set-layout-data shape)))
|
||||
|
||||
(let [is-text? (= type :text)
|
||||
{:keys [font-face-keys pending-font-face-keys]}
|
||||
(when is-text? (text-font-face-keys-state content))
|
||||
text-content-pending (when is-text? (set-shape-text-content id content))
|
||||
pending-thumbnails (into [] (concat
|
||||
text-content-pending
|
||||
@@ -1601,13 +1573,12 @@
|
||||
(set-shape-strokes id strokes false write-fills-strokes?)))]
|
||||
{:thumbnails pending-thumbnails
|
||||
:full pending-full
|
||||
:font-face-keys (or font-face-keys #{})
|
||||
:pending-font-face-keys (or pending-font-face-keys #{})})))
|
||||
:font-pending-ids (if (some :callback text-content-pending) [id] [])})))
|
||||
|
||||
(defn set-object
|
||||
[shape]
|
||||
(if-not (and shape (wasm/live?))
|
||||
{:thumbnails [] :full [] :font-face-keys #{} :pending-font-face-keys #{}}
|
||||
{:thumbnails [] :full [] :font-pending-ids []}
|
||||
(do
|
||||
(perf/begin-measure "set-object")
|
||||
(let [shape (svg-filters/apply-svg-derived shape)]
|
||||
@@ -1615,7 +1586,6 @@
|
||||
(let [result (set-object-host-attrs shape false)]
|
||||
(perf/end-measure "set-object")
|
||||
result)))))
|
||||
|
||||
(defn- update-text-layouts
|
||||
"Synchronously update text layouts for all shapes and send rect updates
|
||||
to the worker index."
|
||||
@@ -1640,29 +1610,6 @@
|
||||
:auto-height (not (mth/close? height (:height selrect) 0.1))
|
||||
false)))
|
||||
|
||||
(defonce ^:private pending-stale-selrect-ids (atom #{}))
|
||||
(defonce ^:private stale-selrect-sync-token (atom 0))
|
||||
|
||||
(defn- flush-stale-selrect-sync!
|
||||
[]
|
||||
(when-let [ids (seq (first (reset-vals! pending-stale-selrect-ids #{})))]
|
||||
(st/emit! (ptk/data-event ::stale-text-selrects {:ids (vec ids)}))))
|
||||
|
||||
(defn- schedule-stale-selrect-sync!
|
||||
"Coalesce stale-selrect emissions and defer until the first viewport tile
|
||||
pass completes, then run on idle so page load can paint first."
|
||||
[stale-ids]
|
||||
(swap! pending-stale-selrect-ids into stale-ids)
|
||||
(let [token (swap! stale-selrect-sync-token inc)
|
||||
flush-on-idle!
|
||||
(fn []
|
||||
(when (= token @stale-selrect-sync-token)
|
||||
(timers/schedule-on-idle
|
||||
(fn []
|
||||
(when (= token @stale-selrect-sync-token)
|
||||
(flush-stale-selrect-sync!))))))]
|
||||
(listen-tiles-render-complete-once! flush-on-idle!)))
|
||||
|
||||
(defn- sync-stale-text-selrects!
|
||||
"Emit the ids of auto-grow text shapes whose selrect no longer matches the
|
||||
measured layout, so the workspace resizes them (data-event instead of a
|
||||
@@ -1676,26 +1623,24 @@
|
||||
(map :id))
|
||||
shapes)]
|
||||
(when (seq stale-ids)
|
||||
(schedule-stale-selrect-sync! stale-ids))))
|
||||
(st/emit! (ptk/data-event ::stale-text-selrects {:ids stale-ids})))))
|
||||
|
||||
(defn- relayout-after-fonts!
|
||||
"Relayout text shapes once their pending fonts have resolved. Font fetches
|
||||
are deduped per URL, so only shapes that use a not-yet-ready face at upload
|
||||
time need a forced relayout; then re-sync selrects that drifted for those
|
||||
shapes only."
|
||||
[shapes text-font-state]
|
||||
(let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)
|
||||
affected-ids (or (shape-ids-for-pending-fonts text-font-state) #{})
|
||||
shapes-by-id (d/index-by :id shapes)]
|
||||
are deduped per URL and storing a font does not invalidate cached layouts,
|
||||
so every text shape (not only the fetch triggers in `font-pending-ids`)
|
||||
needs a forced relayout; then re-sync selrects that drifted."
|
||||
[shapes font-pending-ids]
|
||||
(let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)]
|
||||
(when (seq text-ids)
|
||||
(if (seq affected-ids)
|
||||
(let [affected-shapes (into [] (keep shapes-by-id) affected-ids)]
|
||||
(force-update-text-layouts affected-ids)
|
||||
(sync-stale-text-selrects! affected-shapes))
|
||||
(if (seq font-pending-ids)
|
||||
(do
|
||||
(force-update-text-layouts text-ids)
|
||||
(sync-stale-text-selrects! shapes))
|
||||
(update-text-layouts text-ids)))))
|
||||
|
||||
(defn process-pending
|
||||
[shapes thumbnails full text-font-state on-complete]
|
||||
[shapes thumbnails full font-pending-ids on-complete]
|
||||
(let [pending-thumbnails
|
||||
(d/index-by :key :callback thumbnails)
|
||||
|
||||
@@ -1718,24 +1663,18 @@
|
||||
(rx/reduce conj [])
|
||||
(rx/catch #(rx/empty))))
|
||||
(rx/subs!
|
||||
(fn [_]
|
||||
(relayout-after-fonts! shapes font-pending-ids)
|
||||
(request-render "images-loaded"))
|
||||
noop-fn
|
||||
noop-fn
|
||||
(fn []
|
||||
(relayout-after-fonts! shapes text-font-state)
|
||||
(request-render "images-loaded")
|
||||
(when (fn? on-complete) (on-complete)))))
|
||||
(fn [] (when (fn? on-complete) (on-complete)))))
|
||||
;; No pending images — complete immediately.
|
||||
(when on-complete (on-complete)))))
|
||||
|
||||
(defn process-object
|
||||
[shape]
|
||||
(let [{:keys [thumbnails full font-face-keys pending-font-face-keys]}
|
||||
(set-object shape)
|
||||
text-font-state (acc-text-font-state empty-text-font-state
|
||||
(:id shape)
|
||||
font-face-keys
|
||||
pending-font-face-keys)]
|
||||
(process-pending [shape] thumbnails full text-font-state noop-fn)))
|
||||
(let [{:keys [thumbnails full font-pending-ids]} (set-object shape)]
|
||||
(process-pending [shape] thumbnails full font-pending-ids noop-fn)))
|
||||
|
||||
(defn process-objects
|
||||
"Like process-object but for multiple shapes at once. Accumulates all
|
||||
@@ -1744,26 +1683,19 @@
|
||||
just the first shape that triggered the fetch."
|
||||
[shapes]
|
||||
(let [total-shapes (count shapes)
|
||||
{:keys [thumbnails full text-font-state]}
|
||||
(loop [index 0
|
||||
thumbnails-acc (transient [])
|
||||
full-acc (transient [])
|
||||
font-state-acc empty-text-font-state]
|
||||
{:keys [thumbnails full font-pending-ids]}
|
||||
(loop [index 0 thumbnails-acc (transient []) full-acc (transient []) font-acc (transient [])]
|
||||
(if (< index total-shapes)
|
||||
(let [shape (nth shapes index)
|
||||
{:keys [thumbnails full font-face-keys pending-font-face-keys]}
|
||||
(set-object shape)]
|
||||
{:keys [thumbnails full font-pending-ids]} (set-object shape)]
|
||||
(recur (inc index)
|
||||
(reduce conj! thumbnails-acc thumbnails)
|
||||
(reduce conj! full-acc full)
|
||||
(acc-text-font-state font-state-acc
|
||||
(:id shape)
|
||||
font-face-keys
|
||||
pending-font-face-keys)))
|
||||
(reduce conj! font-acc font-pending-ids)))
|
||||
{:thumbnails (persistent! thumbnails-acc)
|
||||
:full (persistent! full-acc)
|
||||
:text-font-state font-state-acc}))]
|
||||
(process-pending shapes thumbnails full text-font-state noop-fn)))
|
||||
:font-pending-ids (persistent! font-acc)}))]
|
||||
(process-pending shapes thumbnails full font-pending-ids noop-fn)))
|
||||
|
||||
(def ^:private ^:const BATCH_MAX_SHAPES 512)
|
||||
|
||||
@@ -1773,8 +1705,8 @@
|
||||
Structural attrs are uploaded in one `_set_shapes_batch` FFI per chunk;
|
||||
host-specific attrs (fills/strokes/text/grid/path) stay per-shape.
|
||||
|
||||
Returns {:thumbnails [...] :full [...] :text-font-state {...} :next-index n}"
|
||||
[shapes start-index thumbnails-acc full-acc text-font-state-acc]
|
||||
Returns {:thumbnails [...] :full [...] :font-pending-ids [...] :next-index n}"
|
||||
[shapes start-index thumbnails-acc full-acc font-pending-acc]
|
||||
(let [total (count shapes)
|
||||
end-index (min total (+ start-index BATCH_MAX_SHAPES))
|
||||
chunk (into [] (subvec (if (vector? shapes) shapes (vec shapes))
|
||||
@@ -1801,20 +1733,17 @@
|
||||
(loop [xs prepared
|
||||
t-acc (transient thumbnails-acc)
|
||||
f-acc (transient full-acc)
|
||||
font-state-acc text-font-state-acc]
|
||||
fp-acc (transient font-pending-acc)]
|
||||
(if-let [shape (first xs)]
|
||||
(let [{:keys [thumbnails full font-face-keys pending-font-face-keys]}
|
||||
(let [{:keys [thumbnails full font-pending-ids]}
|
||||
(set-object-host-attrs shape true :skip-fills-strokes? true)]
|
||||
(recur (next xs)
|
||||
(reduce conj! t-acc thumbnails)
|
||||
(reduce conj! f-acc full)
|
||||
(acc-text-font-state font-state-acc
|
||||
(:id shape)
|
||||
font-face-keys
|
||||
pending-font-face-keys)))
|
||||
(reduce conj! fp-acc font-pending-ids)))
|
||||
{:thumbnails (persistent! t-acc)
|
||||
:full (persistent! f-acc)
|
||||
:text-font-state font-state-acc
|
||||
:font-pending-ids (persistent! fp-acc)
|
||||
:next-index end-index}))))
|
||||
|
||||
(defn- set-objects-async
|
||||
@@ -1825,16 +1754,16 @@
|
||||
(let [total-shapes (count shapes)]
|
||||
(p/create
|
||||
(fn [resolve _reject]
|
||||
(letfn [(process-next-chunk [index thumbnails-acc full-acc text-font-state-acc]
|
||||
(letfn [(process-next-chunk [index thumbnails-acc full-acc font-pending-acc]
|
||||
(if (< index total-shapes)
|
||||
;; Process one time-budgeted chunk
|
||||
(let [{:keys [thumbnails full text-font-state next-index]}
|
||||
(let [{:keys [thumbnails full font-pending-ids next-index]}
|
||||
(process-shapes-chunk shapes index
|
||||
thumbnails-acc full-acc text-font-state-acc)]
|
||||
thumbnails-acc full-acc font-pending-acc)]
|
||||
;; Yield to browser, then continue with next chunk
|
||||
(-> (yield-to-browser)
|
||||
(p/then (fn [_]
|
||||
(process-next-chunk next-index thumbnails full text-font-state)))))
|
||||
(process-next-chunk next-index thumbnails full font-pending-ids)))))
|
||||
;; All chunks done - finalize
|
||||
(do
|
||||
(perf/end-measure "set-objects")
|
||||
@@ -1885,12 +1814,12 @@
|
||||
(if (fn? callback) (callback) (rx/empty))))
|
||||
(rx/reduce conj [])))
|
||||
(rx/subs!
|
||||
(fn [_]
|
||||
(relayout-after-fonts! shapes font-pending-acc)
|
||||
(request-render "images-loaded"))
|
||||
noop-fn
|
||||
noop-fn
|
||||
(fn []
|
||||
(relayout-after-fonts! shapes text-font-state-acc)
|
||||
(request-render "images-loaded")))))))))))]
|
||||
(process-next-chunk 0 [] [] empty-text-font-state))))))
|
||||
noop-fn)))))))))]
|
||||
(process-next-chunk 0 [] [] []))))))
|
||||
|
||||
|
||||
;; This is a version of process-pending that doesn't have sideffects
|
||||
@@ -1957,25 +1886,22 @@
|
||||
(when (and (contains? #{:path :bool} type) (some? (get shape :content)))
|
||||
(props/set-shape-path-content (get shape :content))))))
|
||||
(let [total-shapes (count prepared)
|
||||
{:keys [thumbnails full text-font-state]}
|
||||
{:keys [thumbnails full font-pending-ids]}
|
||||
(loop [index 0
|
||||
thumbnails-acc (transient [])
|
||||
full-acc (transient [])
|
||||
font-state-acc empty-text-font-state]
|
||||
font-acc (transient [])]
|
||||
(if (< index total-shapes)
|
||||
(let [shape (nth prepared index)
|
||||
{:keys [thumbnails full font-face-keys pending-font-face-keys]}
|
||||
{:keys [thumbnails full font-pending-ids]}
|
||||
(set-object-host-attrs shape true :skip-fills-strokes? true)]
|
||||
(recur (inc index)
|
||||
(reduce conj! thumbnails-acc thumbnails)
|
||||
(reduce conj! full-acc full)
|
||||
(acc-text-font-state font-state-acc
|
||||
(:id shape)
|
||||
font-face-keys
|
||||
pending-font-face-keys)))
|
||||
(reduce conj! font-acc font-pending-ids)))
|
||||
{:thumbnails (persistent! thumbnails-acc)
|
||||
:full (persistent! full-acc)
|
||||
:text-font-state font-state-acc}))]
|
||||
:font-pending-ids (persistent! font-acc)}))]
|
||||
(perf/end-measure "set-objects")
|
||||
(when on-shapes-ready (on-shapes-ready))
|
||||
(when (wasm/live?)
|
||||
@@ -1983,7 +1909,7 @@
|
||||
;; map to which tiles after a page switch.
|
||||
(h/call wasm/internal-module "_set_view_end")
|
||||
(reset! view-interaction-active? false)
|
||||
(process-pending shapes thumbnails full text-font-state
|
||||
(process-pending shapes thumbnails full font-pending-ids
|
||||
(fn []
|
||||
(if render-callback
|
||||
(render-callback)
|
||||
|
||||
@@ -252,12 +252,12 @@
|
||||
(let [text-content-pending (api/set-shape-text-content id v)
|
||||
pending-thumbnails (vec text-content-pending)
|
||||
pending-full (vec (api/set-shape-text-images id v))
|
||||
text-font-state (api/text-font-state-for-shape shape)]
|
||||
font-pending-ids (when (some :callback text-content-pending) [id])]
|
||||
;; FIXME: this is a hack to process the pending tasks
|
||||
;; asynchronously we should probably modify set-wasm-attr!
|
||||
;; to return a list of callbacks to be executed in a
|
||||
;; second pass.
|
||||
(api/process-pending [shape] pending-thumbnails pending-full text-font-state api/noop-fn)
|
||||
(api/process-pending [shape] pending-thumbnails pending-full font-pending-ids api/noop-fn)
|
||||
nil))
|
||||
|
||||
:grow-type
|
||||
@@ -341,8 +341,7 @@
|
||||
(when (d/not-empty? shape-changes)
|
||||
(->> (rx/from shape-changes)
|
||||
(rx/mapcat (fn [[shape-id props]] (process-shape! (get objects shape-id) props)))
|
||||
(rx/reduce conj [])
|
||||
(rx/subs! (fn [_] (api/request-render "set-wasm-attrs"))))))))
|
||||
(rx/subs! #(api/request-render "set-wasm-attrs")))))))
|
||||
|
||||
;; `conj` empty set initialization
|
||||
(def conj* (fnil conj (d/ordered-set)))
|
||||
|
||||
@@ -245,6 +245,11 @@
|
||||
(when (wasm/ready?)
|
||||
(h/call wasm/internal-module "_text_editor_update_blink" timestamp-ms)))
|
||||
|
||||
(defn text-editor-render-overlay
|
||||
[]
|
||||
(when (wasm/ready?)
|
||||
(h/call wasm/internal-module "_text_editor_render_overlay")))
|
||||
|
||||
(defn text-editor-render-caret
|
||||
"Re-compose the frame from the Backbuffer (the last full render) and draw the
|
||||
caret/selection overlay on top, submitting one atomic frame. Pixel identical
|
||||
@@ -445,11 +450,6 @@
|
||||
(when (wasm/ready?)
|
||||
(h/call wasm/internal-module "_text_editor_select_word_boundary" x y)))
|
||||
|
||||
(defn text-editor-select-paragraph
|
||||
[{:keys [x y]}]
|
||||
(when (wasm/ready?)
|
||||
(h/call wasm/internal-module "_text_editor_select_paragraph" x y)))
|
||||
|
||||
(defn text-editor-blur
|
||||
[]
|
||||
(when (wasm/ready?)
|
||||
@@ -565,54 +565,41 @@
|
||||
[]
|
||||
(reset! pending-caret-styles {}))
|
||||
|
||||
(defn merge-exported-texts-into-content
|
||||
"Merge exported spans back into the existing content tree.
|
||||
(defn- merge-exported-texts-into-content
|
||||
"Merge exported span texts back into the existing content tree.
|
||||
|
||||
The WASM editor may split or merge paragraphs (Enter / Backspace at a
|
||||
paragraph boundary, paste of several lines), so the exported structure can
|
||||
differ from the original one, and a positional merge would leave the text of
|
||||
one paragraph wearing the styling of another. Every exported span carries the
|
||||
position it had in the tree we last exchanged with WASM (`:p`/`:s`), so the
|
||||
styling is taken from there; a span WASM never saw falls back to its position
|
||||
and then to the last existing span.
|
||||
The WASM editor may split or merge paragraphs (Enter / Backspace at
|
||||
paragraph boundary), so the exported structure can differ from the
|
||||
original. When extra paragraphs or spans appear we clone styling from
|
||||
the nearest existing sibling; when fewer appear we truncate.
|
||||
|
||||
exported vector of paragraphs, each a vector of `{:p 0 :s 0 :t \"text\"}`
|
||||
content existing Penpot content map (root -> paragraph-set -> …)"
|
||||
[content exported]
|
||||
exported-texts vector of vectors [[\"span1\" \"span2\"] [\"p2s1\"]]
|
||||
content existing Penpot content map (root -> paragraph-set -> …)"
|
||||
[content exported-texts]
|
||||
(let [para-set (first (get content :children))
|
||||
orig-paras (get para-set :children)
|
||||
num-orig (count orig-paras)
|
||||
last-orig-para (when (seq orig-paras) (last orig-paras))
|
||||
template-span (when last-orig-para
|
||||
(-> last-orig-para :children last))
|
||||
|
||||
styling-para
|
||||
(fn [para-idx spans]
|
||||
(or (get orig-paras (get (first spans) :p))
|
||||
(get orig-paras para-idx)
|
||||
last-orig-para))
|
||||
|
||||
styling-span
|
||||
(fn [orig-para span-idx {:keys [p s]}]
|
||||
(or (get-in orig-paras [p :children s])
|
||||
(get-in orig-para [:children span-idx])
|
||||
(-> orig-para :children last)
|
||||
template-span))
|
||||
|
||||
new-paras
|
||||
(mapv (fn [para-idx spans]
|
||||
(let [orig-para (styling-para para-idx spans)]
|
||||
(mapv (fn [para-idx exported-span-texts]
|
||||
(let [orig-para (if (< para-idx num-orig)
|
||||
(nth orig-paras para-idx)
|
||||
(dissoc last-orig-para :children))
|
||||
orig-spans (get orig-para :children)
|
||||
num-orig-spans (count orig-spans)
|
||||
last-orig-span (when (seq orig-spans) (last orig-spans))]
|
||||
(assoc orig-para :children
|
||||
(if (seq spans)
|
||||
(mapv (fn [span-idx span]
|
||||
(-> (styling-span orig-para span-idx span)
|
||||
(assoc :text (get span :t))))
|
||||
(range (count spans))
|
||||
spans)
|
||||
;; A paragraph with no spans is dropped on the way
|
||||
;; back to WASM (and fails the content schema).
|
||||
[(assoc (or template-span {}) :text "")]))))
|
||||
(range (count exported))
|
||||
exported)
|
||||
(mapv (fn [span-idx new-text]
|
||||
(let [orig-span (if (< span-idx num-orig-spans)
|
||||
(nth orig-spans span-idx)
|
||||
(or last-orig-span template-span))]
|
||||
(assoc orig-span :text new-text)))
|
||||
(range (count exported-span-texts))
|
||||
exported-span-texts))))
|
||||
(range (count exported-texts))
|
||||
exported-texts)
|
||||
new-para-set (assoc para-set :children new-paras)]
|
||||
(assoc content :children [new-para-set])))
|
||||
|
||||
@@ -642,9 +629,9 @@
|
||||
[]
|
||||
(when (and (wasm/ready?) (text-editor-has-focus?))
|
||||
(let [shape-id (text-editor-get-active-shape-id)
|
||||
new-texts (when shape-id (text-editor-export-content))]
|
||||
new-texts (text-editor-export-content)]
|
||||
(when (and shape-id new-texts)
|
||||
(let [texts-clj (js->clj new-texts :keywordize-keys true)
|
||||
(let [texts-clj (js->clj new-texts)
|
||||
;; A brand-new empty text shape (single click) has no cached
|
||||
;; content yet, so fall back to a default template so the first
|
||||
;; keystrokes are synced back to the shape instead of dropped.
|
||||
@@ -683,11 +670,7 @@
|
||||
span-end (+ pos span-len)
|
||||
ol-start (max pos sel-start)
|
||||
ol-end (min span-end sel-end)
|
||||
;; An empty span has no range to overlap, but an empty
|
||||
;; line inside the selection still has to be restyled.
|
||||
has-overlap? (or (< ol-start ol-end)
|
||||
(and (zero? span-len)
|
||||
(<= sel-start pos sel-end)))]
|
||||
has-overlap? (< ol-start ol-end)]
|
||||
(if (not has-overlap?)
|
||||
(recur (rest spans) span-end (conj acc span))
|
||||
(let [before (when (> ol-start pos)
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
;; 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 Sucursal en España SL
|
||||
|
||||
(ns frontend-tests.logic.wasm-modifiers-nil-id-test
|
||||
"Reproduces the production crash \"Cannot read properties of null
|
||||
(reading '__u32_buffer')\".
|
||||
|
||||
A modif-tree containing a nil shape id (production builds elide the
|
||||
asserts that catch this upstream, e.g. `update-dimensions` called
|
||||
with `[(:parent-id shape)]` when `shape` is missing) reached
|
||||
`wasm.api/propagate-modifiers` / `wasm.api/set-structure-modifiers`,
|
||||
and `mem.h32/write-uuid` crashed calling `uuid/get-u32` on nil while
|
||||
writing to the WASM heap.
|
||||
|
||||
These tests assert that no nil id ever crosses the WASM boundary and
|
||||
that valid shapes in the same modif-tree are still processed."
|
||||
(:require
|
||||
[app.common.geom.rect :as grc]
|
||||
[app.common.math :as mth]
|
||||
[app.common.test-helpers.compositions :as ctho]
|
||||
[app.common.test-helpers.files :as cthf]
|
||||
[app.common.test-helpers.ids-map :as cthi]
|
||||
[app.common.test-helpers.shapes :as cths]
|
||||
[app.common.types.modifiers :as ctm]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.workspace.modifiers :as dwm]
|
||||
[app.render-wasm.api :as wasm.api]
|
||||
[cljs.test :as t :include-macros true]
|
||||
[frontend-tests.helpers.state :as ths]
|
||||
[frontend-tests.helpers.wasm :as thw]))
|
||||
|
||||
(def ^:private captured-geometry-entries
|
||||
"Entries passed to `wasm.api/propagate-modifiers` during a test."
|
||||
(atom []))
|
||||
|
||||
(def ^:private captured-structure-entries
|
||||
"Entries passed to `wasm.api/set-structure-modifiers` during a test."
|
||||
(atom []))
|
||||
|
||||
(defn- install-capturing-spies!
|
||||
"Replace the plain WASM mocks with variants that record their input.
|
||||
Must run after `thw/setup-wasm-mocks!` so teardown still restores
|
||||
the real implementations."
|
||||
[]
|
||||
(set! wasm.api/propagate-modifiers
|
||||
(fn [entries _pixel-precision]
|
||||
(swap! captured-geometry-entries into entries)
|
||||
(into []
|
||||
(map (fn [[id data]] [id (:transform data)]))
|
||||
entries)))
|
||||
(set! wasm.api/set-structure-modifiers
|
||||
(fn [entries]
|
||||
(swap! captured-structure-entries into entries)
|
||||
nil)))
|
||||
|
||||
(t/use-fixtures :each
|
||||
{:before (fn []
|
||||
(cthi/reset-idmap!)
|
||||
(reset! captured-geometry-entries [])
|
||||
(reset! captured-structure-entries [])
|
||||
(thw/setup-wasm-mocks!)
|
||||
(install-capturing-spies!))
|
||||
:after (fn []
|
||||
(thw/teardown-wasm-mocks!))})
|
||||
|
||||
(t/deftest nil-id-does-not-reach-propagate-modifiers
|
||||
;; A nil-keyed entry must be dropped before the WASM heap write while
|
||||
;; the valid entry is still resized.
|
||||
(t/async
|
||||
done
|
||||
(let [file (-> (cthf/sample-file :file1)
|
||||
(ctho/add-rect :rect1 :x 10 :y 20 :width 100 :height 50))
|
||||
store (ths/setup-store file)
|
||||
rect (cths/get-shape file :rect1)
|
||||
resize (ctm/change-dimensions-modifiers rect :width 200)
|
||||
modif-tree {nil {:modifiers resize}
|
||||
(:id rect) {:modifiers resize}}
|
||||
events [(dwm/apply-wasm-modifiers modif-tree {:ignore-snap-pixel true})]]
|
||||
(ths/run-store
|
||||
store done events
|
||||
(fn [new-state]
|
||||
(let [entry-ids (into #{} (map first) @captured-geometry-entries)
|
||||
file' (ths/get-file-from-state new-state)
|
||||
rect' (cths/get-shape file' :rect1)
|
||||
width (-> rect' :points grc/points->rect :width)]
|
||||
(t/is (not (contains? entry-ids nil)))
|
||||
(t/is (contains? entry-ids (:id rect)))
|
||||
(t/is (mth/close? 200 width))))))))
|
||||
|
||||
(t/deftest nil-id-does-not-reach-set-structure-modifiers
|
||||
;; A nil-keyed entry with structure modifiers must not produce
|
||||
;; structure entries with a nil :parent or :id.
|
||||
(t/async
|
||||
done
|
||||
(let [file (-> (cthf/sample-file :file1)
|
||||
(ctho/add-rect :rect1 :x 10 :y 20 :width 100 :height 50))
|
||||
store (ths/setup-store file)
|
||||
rect (cths/get-shape file :rect1)
|
||||
modif-tree {nil {:modifiers (ctm/add-children nil [(uuid/next)] 0)}
|
||||
(:id rect) {:modifiers (ctm/change-dimensions-modifiers rect :width 200)}}
|
||||
events [(dwm/apply-wasm-modifiers modif-tree {:ignore-snap-pixel true})]]
|
||||
(ths/run-store
|
||||
store done events
|
||||
(fn [_new-state]
|
||||
(t/is (every? #(some? (:parent %)) @captured-structure-entries))
|
||||
(t/is (every? #(some? (:id %)) @captured-structure-entries)))))))
|
||||
@@ -0,0 +1,64 @@
|
||||
;; 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 SUBSIDIARY SL
|
||||
|
||||
(ns frontend-tests.render-wasm.resize-debounce-opts-test
|
||||
(:require
|
||||
[app.common.test-helpers.compositions :as ctho]
|
||||
[app.common.test-helpers.files :as cthf]
|
||||
[app.common.test-helpers.ids-map :as cthi]
|
||||
[app.common.test-helpers.shapes :as cths]
|
||||
[app.main.data.workspace.wasm-text :as dwwt]
|
||||
[cljs.test :as t :include-macros true]
|
||||
[frontend-tests.helpers.pages :as thp]
|
||||
[frontend-tests.helpers.state :as ths]
|
||||
[frontend-tests.helpers.wasm :as thw]))
|
||||
|
||||
(t/use-fixtures :each
|
||||
{:before (fn []
|
||||
(thp/reset-idmap!)
|
||||
(thw/setup-wasm-mocks!))
|
||||
:after thw/teardown-wasm-mocks!})
|
||||
|
||||
(defn- setup-file []
|
||||
(-> (cthf/sample-file :file-1 :page-label :page-1)
|
||||
(ctho/add-text :text-1 "Derived")
|
||||
(ctho/add-text :text-2 "User edit")))
|
||||
|
||||
(defn- batch-opts
|
||||
[state]
|
||||
(get state :app.main.data.workspace.wasm-text/resize-wasm-text-debounce-opts))
|
||||
|
||||
(t/deftest derived-flag-does-not-leak-into-a-user-resize-in-the-same-batch
|
||||
(t/async
|
||||
done
|
||||
(let [file (setup-file)
|
||||
store (ths/setup-store file)
|
||||
text-1 (cths/get-shape file :text-1)
|
||||
text-2 (cths/get-shape file :text-2)
|
||||
;; Same tick => both ids land in the same debounce batch, which is
|
||||
;; what a font-load resize racing a sidebar resize produces.
|
||||
events [(dwwt/resize-wasm-text-debounce (:id text-1) {:skip-component-sync? true})
|
||||
(dwwt/resize-wasm-text-debounce (:id text-2) nil)]]
|
||||
(ths/run-store
|
||||
store done events
|
||||
(fn [state]
|
||||
;; 3, not 2: the batch owner re-emits itself so text-1 is queued twice.
|
||||
(t/is (= 3 (count (get state :app.main.data.workspace.wasm-text/resize-wasm-text-debounce-ids))))
|
||||
(t/is (not (:skip-component-sync? (batch-opts state)))))))))
|
||||
|
||||
(t/deftest derived-only-batch-keeps-the-flag
|
||||
(t/async
|
||||
done
|
||||
(let [file (setup-file)
|
||||
store (ths/setup-store file)
|
||||
text-1 (cths/get-shape file :text-1)
|
||||
text-2 (cths/get-shape file :text-2)
|
||||
events [(dwwt/resize-wasm-text-debounce (:id text-1) {:skip-component-sync? true})
|
||||
(dwwt/resize-wasm-text-debounce (:id text-2) {:skip-component-sync? true})]]
|
||||
(ths/run-store
|
||||
store done events
|
||||
(fn [state]
|
||||
(t/is (true? (:skip-component-sync? (batch-opts state)))))))))
|
||||
@@ -44,7 +44,6 @@
|
||||
[frontend-tests.logic.path-tools-test]
|
||||
[frontend-tests.logic.sidebar-transform-coalescing-test]
|
||||
[frontend-tests.logic.update-position-test]
|
||||
[frontend-tests.logic.wasm-modifiers-nil-id-test]
|
||||
[frontend-tests.main-errors-test]
|
||||
[frontend-tests.plugins.comments-test]
|
||||
[frontend-tests.plugins.context-shapes-test]
|
||||
@@ -64,6 +63,7 @@
|
||||
[frontend-tests.plugins.value-objects-test]
|
||||
[frontend-tests.render-dimensions-test]
|
||||
[frontend-tests.render-wasm.process-objects-test]
|
||||
[frontend-tests.render-wasm.resize-debounce-opts-test]
|
||||
[frontend-tests.render-wasm.text-editor-apply-styles-test]
|
||||
[frontend-tests.render-wasm.text-editor-caret-color-test]
|
||||
[frontend-tests.svg-fills-test]
|
||||
@@ -151,7 +151,6 @@
|
||||
'frontend-tests.main-errors-test
|
||||
'frontend-tests.logic.sidebar-transform-coalescing-test
|
||||
'frontend-tests.logic.update-position-test
|
||||
'frontend-tests.logic.wasm-modifiers-nil-id-test
|
||||
'frontend-tests.plugins.comments-test
|
||||
'frontend-tests.plugins.context-shapes-test
|
||||
'frontend-tests.plugins.file-test
|
||||
@@ -169,6 +168,7 @@
|
||||
'frontend-tests.plugins.utils-test
|
||||
'frontend-tests.plugins.value-objects-test
|
||||
'frontend-tests.render-wasm.process-objects-test
|
||||
'frontend-tests.render-wasm.resize-debounce-opts-test
|
||||
'frontend-tests.render-wasm.text-editor-apply-styles-test
|
||||
'frontend-tests.render-wasm.text-editor-caret-color-test
|
||||
'frontend-tests.svg-fills-test
|
||||
|
||||
@@ -122,43 +122,6 @@
|
||||
(get-in resolved-tokens ["typography.bad" :errors 0 :error/code])))
|
||||
(done))))))))
|
||||
|
||||
;; Regression: a token with a `nil` value (e.g. a composite typography
|
||||
;; token saved via the workspace form with no fields filled in) must never
|
||||
;; reach StyleDictionary — its `tokens-studio` preprocessor assumes a
|
||||
;; typography token's value is never null and throws an uncaught exception
|
||||
;; on it, which used to take down resolution for every other token in the
|
||||
;; file. It should be tagged with an empty-input error instead.
|
||||
(t/deftest resolve-tokens-nil-value-test
|
||||
(t/async
|
||||
done
|
||||
(let [tokens (-> (ctob/make-tokens-lib)
|
||||
(ctob/add-set (ctob/make-token-set :id (cthi/new-id! :core-set)
|
||||
:name "core"))
|
||||
(ctob/add-token (cthi/id :core-set)
|
||||
(ctob/make-token {:name "typography.empty"
|
||||
:value nil
|
||||
:type :typography}))
|
||||
(ctob/add-token (cthi/id :core-set)
|
||||
(ctob/make-token {:name "borderRadius.sm"
|
||||
:value "12px"
|
||||
:type :border-radius}))
|
||||
(ctob/get-all-tokens-map))]
|
||||
(->> (sd/resolve-tokens tokens)
|
||||
(rx/subs!
|
||||
(fn [resolved-tokens]
|
||||
(t/testing "the nil-value token is tagged with an error instead of crashing"
|
||||
(t/is (contains? resolved-tokens "typography.empty"))
|
||||
(t/is (nil? (get-in resolved-tokens ["typography.empty" :resolved-value])))
|
||||
(t/is (= :error.token/empty-input
|
||||
(get-in resolved-tokens ["typography.empty" :errors 0 :error/code]))))
|
||||
(t/testing "other tokens still resolve normally"
|
||||
(t/is (= 12 (get-in resolved-tokens ["borderRadius.sm" :resolved-value])))))
|
||||
(fn [err]
|
||||
(t/do-report {:type :error :message "Stream error" :actual err})
|
||||
(done))
|
||||
(fn []
|
||||
(done)))))))
|
||||
|
||||
(t/deftest resolve-tokens-interactive-test
|
||||
(t/async
|
||||
done
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
[app.main.ui.settings.restore-shortcuts-modal :as restore-modal]
|
||||
[app.main.ui.settings.shortcuts :as sut]
|
||||
[app.main.ui.shortcuts :as ui-shortcuts]
|
||||
[app.util.strings :refer [matches-search]]
|
||||
[cljs.test :as t :include-macros true]
|
||||
[clojure.string :as str]))
|
||||
|
||||
@@ -222,72 +221,3 @@
|
||||
(let [result (restore-modal/extract-shortcut-keys :next-frame {} :viewer)]
|
||||
(t/is (nth result 3)
|
||||
"Should return a default command for :next-frame in :viewer context")))
|
||||
|
||||
;; --- shortcut->command-string + command-based search --------------------
|
||||
;; The search in both the settings shortcuts page and the workspace sidebar
|
||||
;; matches shortcut entries by their translated name AND by their key-combo
|
||||
;; string. `shortcut->command-string` (in `app.main.ui.shortcuts`) extracts the
|
||||
;; searchable form from `:command`/`:show-command`; `matches-search` does the
|
||||
;; case-insensitive substring match. These tests pin that contract so searching
|
||||
;; e.g. "ctrl" surfaces every shortcut whose combo includes ctrl.
|
||||
|
||||
(t/deftest shortcut->command-string-extracts-string-command
|
||||
(t/testing "a plain string command is returned lowercased"
|
||||
(t/is (= "ctrl+z" (ui-shortcuts/shortcut->command-string
|
||||
{:command "ctrl+z"})))))
|
||||
|
||||
(t/deftest shortcut->command-string-joins-vector-command
|
||||
(t/testing "a vector command (key sequence) is joined with spaces so every
|
||||
token is individually searchable"
|
||||
(t/is (= "g v" (ui-shortcuts/shortcut->command-string
|
||||
{:command ["g" "v"]})))))
|
||||
|
||||
(t/deftest shortcut->command-string-prefers-show-command
|
||||
(t/testing ":show-command (display override) wins over :command"
|
||||
(t/is (= "shift+x" (ui-shortcuts/shortcut->command-string
|
||||
{:command "ctrl+z" :show-command "shift+x"})))))
|
||||
|
||||
(t/deftest shortcut->command-string-empty-for-section-node
|
||||
(t/testing "a node without :command/:show-command (e.g. a section or
|
||||
subsection heading) yields an empty string so it never matches a
|
||||
non-blank command search"
|
||||
(t/is (= "" (ui-shortcuts/shortcut->command-string
|
||||
{:translation "workspace"})))))
|
||||
|
||||
(t/deftest shortcut->command-string-lowercases
|
||||
(t/testing "the result is lowercased so search is case-insensitive"
|
||||
(t/is (= "ctrl+shift+z" (ui-shortcuts/shortcut->command-string
|
||||
{:command "Ctrl+Shift+Z"})))))
|
||||
|
||||
(t/deftest command-search-matches-ctrl-prefix
|
||||
(t/testing "searching 'ctrl' matches a shortcut whose command contains ctrl"
|
||||
(let [shortcut {:command "ctrl+shift+s"
|
||||
:translation "Save all"}]
|
||||
(t/is (matches-search (ui-shortcuts/shortcut->command-string shortcut)
|
||||
"ctrl")))))
|
||||
|
||||
(t/deftest command-search-does-not-match-when-command-lacks-term
|
||||
(t/testing "searching 'alt' does not match a shortcut with no alt in its combo"
|
||||
(let [shortcut {:command "ctrl+z"
|
||||
:translation "Undo"}]
|
||||
(t/is (not (matches-search (ui-shortcuts/shortcut->command-string shortcut)
|
||||
"alt"))))))
|
||||
|
||||
(t/deftest command-search-matches-key-sequence-vector
|
||||
(t/testing "searching a single key in a key-sequence vector command matches"
|
||||
(let [shortcut {:command ["g" "v"]
|
||||
:translation "Group"}]
|
||||
(t/is (matches-search (ui-shortcuts/shortcut->command-string shortcut)
|
||||
"g")))))
|
||||
|
||||
(t/deftest search-matches-by-translation-or-command
|
||||
(t/testing "a search term matches if it appears in either the translation or
|
||||
the command string — the OR that the filter predicates use"
|
||||
(let [shortcut {:command "ctrl+s"
|
||||
:translation "Save"}]
|
||||
;; by translation
|
||||
(t/is (or (matches-search (:translation shortcut) "save")
|
||||
(matches-search (ui-shortcuts/shortcut->command-string shortcut) "save")))
|
||||
;; by command
|
||||
(t/is (or (matches-search (:translation shortcut) "ctrl")
|
||||
(matches-search (ui-shortcuts/shortcut->command-string shortcut) "ctrl"))))))
|
||||
@@ -10,7 +10,6 @@
|
||||
|
||||
- **plugins-runtime**: `Library.createComponent()` now rejects invalid input (an empty shape list, or a shape inside a component copy) with a validation error instead of returning a component proxy pointing at nothing.
|
||||
- **plugins-runtime**: Setting an individual padding/margin side (`leftPadding`, `topMargin`, …) now re-derives the padding/margin type, switching to `multiple` when the four sides stop being symmetric (so the value is actually painted) and back to `simple` once top/bottom and left/right are mirrored again.
|
||||
- **plugins-runtime**: Removed the premature deep-hardening of the host plugin context, which froze shared host functions (including `Function.prototype`) before SES override taming, causing `TypeError: Cannot assign to read only property 'toString'` on later host-side function extension. Related to #11001.
|
||||
|
||||
## 1.5.0 (2026-07-08)
|
||||
|
||||
|
||||
@@ -39,8 +39,5 @@ export async function createPlugin(
|
||||
plugin,
|
||||
manifest,
|
||||
compartment: sandbox,
|
||||
get iframeWindow() {
|
||||
return plugin.iframeWindow;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { describe, it, vi, expect, beforeEach } from 'vitest';
|
||||
import { loadPlugin, setContextBuilder, getPlugins } from './load-plugin';
|
||||
import { createPlugin } from './create-plugin';
|
||||
import { ses } from './ses.js';
|
||||
import type { Context } from '@penpot/plugin-types';
|
||||
import type { Manifest } from './models/manifest.model.js';
|
||||
|
||||
vi.mock('./create-plugin', () => ({
|
||||
createPlugin: vi.fn(),
|
||||
}));
|
||||
|
||||
// NOTE: `./ses.js` is intentionally NOT mocked here: the test spies on the
|
||||
// real `ses.harden` to assert that `loadPlugin` never hardens the host
|
||||
// context.
|
||||
|
||||
describe('loadPlugin host context boundary (regression for #11001)', () => {
|
||||
let manifest: Manifest;
|
||||
|
||||
beforeEach(() => {
|
||||
manifest = {
|
||||
pluginId: 'test-plugin',
|
||||
name: 'Test Plugin',
|
||||
host: '',
|
||||
code: '',
|
||||
permissions: ['content:read'],
|
||||
};
|
||||
|
||||
vi.mocked(createPlugin).mockResolvedValue({
|
||||
plugin: {
|
||||
close: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
},
|
||||
} as unknown as Awaited<ReturnType<typeof createPlugin>>);
|
||||
});
|
||||
|
||||
it('does not freeze host-owned functions reachable through the context', async () => {
|
||||
const hostListener = function hostListener() {
|
||||
return 'host-value';
|
||||
};
|
||||
const nestedHostObject = {
|
||||
nestedFn() {
|
||||
return 'nested';
|
||||
},
|
||||
};
|
||||
const hostContext = {
|
||||
addListener: hostListener,
|
||||
nested: nestedHostObject,
|
||||
} as unknown as Context;
|
||||
|
||||
setContextBuilder(() => hostContext);
|
||||
|
||||
const hardenSpy = vi.spyOn(ses, 'harden');
|
||||
|
||||
await loadPlugin(manifest);
|
||||
|
||||
// The host context itself must be passed through untouched so the host
|
||||
// can keep modifying its own runtime objects (e.g. on page navigation).
|
||||
expect(createPlugin).toHaveBeenCalledWith(
|
||||
hostContext,
|
||||
manifest,
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Host-owned functions must remain extensible: page navigation and
|
||||
// runtime code may patch/augment them (e.g. assigning `toString` on a
|
||||
// wrapped listener). A deep `ses.harden(context)` here would freeze
|
||||
// them and turn such later assignments into
|
||||
// `TypeError: Cannot assign to read only property 'toString'`.
|
||||
expect(Object.isFrozen(hostListener)).toBe(false);
|
||||
expect(Object.isExtensible(hostListener)).toBe(true);
|
||||
expect(Object.isFrozen(nestedHostObject)).toBe(false);
|
||||
expect(() => {
|
||||
hostListener.toString = () => 'patched-by-host';
|
||||
}).not.toThrow();
|
||||
|
||||
expect(hardenSpy).not.toHaveBeenCalled();
|
||||
expect(getPlugins()).toHaveLength(1);
|
||||
|
||||
hardenSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,169 +0,0 @@
|
||||
import { describe, it, vi, expect, beforeAll } from 'vitest';
|
||||
import 'ses';
|
||||
import { loadPlugin, setContextBuilder, getPlugins } from './load-plugin';
|
||||
import type { Context } from '@penpot/plugin-types';
|
||||
import type { Manifest } from './models/manifest.model.js';
|
||||
|
||||
// Real initialization-path regression tests for #11001.
|
||||
//
|
||||
// NOTE: `./create-plugin`, `./plugin-manager` and
|
||||
// `./create-sandbox` are intentionally NOT mocked here. This spec exercises
|
||||
// the real `loadPlugin → createPlugin → createPluginManager → createSandbox`
|
||||
// path with the real SES implementation, mirroring the production
|
||||
// initialization order from `plugins-runtime/src/index.ts`:
|
||||
// repairIntrinsics (module load) → loadPlugin → createSandbox/hardenIntrinsics
|
||||
//
|
||||
// `hardenIntrinsics()` is deliberately NOT called up front: the first test
|
||||
// must run in the production window where only `repairIntrinsics` has run.
|
||||
// Tests run in declaration order; the later tests build on the locked-down
|
||||
// state the first real `loadPlugin` leaves behind (via `createSandbox`).
|
||||
//
|
||||
// Note on SES isolation: this suite depends on Vitest's default
|
||||
// file-level isolation. Each test file runs in a separate worker
|
||||
// process, so SES intrinsics frozen here do not leak into other
|
||||
// spec files.
|
||||
|
||||
const REPAIR_OPTIONS = {
|
||||
evalTaming: 'unsafeEval',
|
||||
stackFiltering: 'verbose',
|
||||
errorTaming: 'unsafe',
|
||||
consoleTaming: 'unsafe',
|
||||
errorTrapping: 'none',
|
||||
unhandledRejectionTrapping: 'none',
|
||||
};
|
||||
|
||||
function makeManifest(
|
||||
code: string,
|
||||
permissions: Manifest['permissions'],
|
||||
): Manifest {
|
||||
return {
|
||||
pluginId: 'test-plugin',
|
||||
name: 'Test Plugin',
|
||||
host: '',
|
||||
code,
|
||||
permissions,
|
||||
};
|
||||
}
|
||||
|
||||
function makeHostFixture() {
|
||||
const listenerTypes: string[] = [];
|
||||
const listeners = new Map<symbol, string>();
|
||||
// Inline code (empty host + non-URL code) resolves without network, so no
|
||||
// fetch mock is needed. UI/modal APIs are never touched by the probe code.
|
||||
const createRectangle = vi.fn(() => ({ type: 'rectangle-marker' }));
|
||||
const selection: object[] = [{ id: 'shape-1' }];
|
||||
const context = {
|
||||
addListener: (type: string, _callback: (...args: unknown[]) => unknown) => {
|
||||
const id = Symbol(type);
|
||||
listeners.set(id, type);
|
||||
listenerTypes.push(type);
|
||||
return id;
|
||||
},
|
||||
removeListener: (id: symbol) => {
|
||||
listeners.delete(id);
|
||||
},
|
||||
theme: 'dark',
|
||||
createRectangle,
|
||||
selection,
|
||||
// Host-only member: present on the raw context but NOT part of the
|
||||
// public penpot API. Plugin code must never see it (see B-2 below).
|
||||
__internalSecret: 'host-internal',
|
||||
} as unknown as Context;
|
||||
return { context, listenerTypes, createRectangle, selection };
|
||||
}
|
||||
|
||||
function lastCompartmentGlobalThis(): Record<string, unknown> {
|
||||
const plugins = getPlugins();
|
||||
const last = plugins[plugins.length - 1] as unknown as {
|
||||
compartment: { compartment: { globalThis: Record<string, unknown> } };
|
||||
};
|
||||
return last.compartment.compartment.globalThis;
|
||||
}
|
||||
|
||||
describe('loadPlugin real initialization path (regression for #11001)', () => {
|
||||
beforeAll(() => {
|
||||
// Production module-load step only: repairs intrinsics WITHOUT
|
||||
// installing override taming, exactly like `index.ts` at import time.
|
||||
(
|
||||
globalThis as unknown as { repairIntrinsics(opts: object): void }
|
||||
).repairIntrinsics({ ...REPAIR_OPTIONS });
|
||||
});
|
||||
|
||||
it('loads through the real path and keeps host function augmentation working', async () => {
|
||||
const fixture = makeHostFixture();
|
||||
setContextBuilder(() => fixture.context);
|
||||
|
||||
await loadPlugin(
|
||||
makeManifest('penpot.on("finish", function () {});', ['content:read']),
|
||||
);
|
||||
|
||||
// The plugin code really ran inside the sandbox: the manager registers
|
||||
// `themechange` + `finish`, and the plugin code adds its own `finish`
|
||||
// listener through the public API.
|
||||
expect(fixture.listenerTypes).toEqual(['themechange', 'finish', 'finish']);
|
||||
expect(getPlugins()).toHaveLength(1);
|
||||
|
||||
// The user-facing behavior from #11001: host-side augmentation of a
|
||||
// fresh function (e.g. assigning `toString` during page navigation)
|
||||
// succeeds after a real plugin load.
|
||||
const freshWrapper = function freshWrapper() {
|
||||
return 'navigation-wrapper';
|
||||
};
|
||||
expect(() => {
|
||||
freshWrapper.toString = () => 'patched-by-runtime';
|
||||
}).not.toThrow();
|
||||
expect(fixture.listenerTypes.length).toBe(3);
|
||||
});
|
||||
|
||||
it('denies the write API without permission and leaves the host untouched', async () => {
|
||||
const fixture = makeHostFixture();
|
||||
setContextBuilder(() => fixture.context);
|
||||
|
||||
await expect(
|
||||
loadPlugin(makeManifest('penpot.createRectangle();', ['content:read'])),
|
||||
).rejects.toThrow(/content:write/);
|
||||
expect(fixture.createRectangle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows the same write API with permission', async () => {
|
||||
const fixture = makeHostFixture();
|
||||
setContextBuilder(() => fixture.context);
|
||||
|
||||
await loadPlugin(
|
||||
makeManifest('penpot.createRectangle();', [
|
||||
'content:read',
|
||||
'content:write',
|
||||
]),
|
||||
);
|
||||
expect(fixture.createRectangle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not expose raw host-only context members to plugin code', async () => {
|
||||
const fixture = makeHostFixture();
|
||||
setContextBuilder(() => fixture.context);
|
||||
|
||||
await loadPlugin(
|
||||
makeManifest('globalThis.__probe = typeof penpot.__internalSecret;', [
|
||||
'content:read',
|
||||
]),
|
||||
);
|
||||
// The public `penpot` object is a boundary proxy over a curated API, not
|
||||
// the raw host context, so host-only members are invisible inside.
|
||||
expect(lastCompartmentGlobalThis()['__probe']).toBe('undefined');
|
||||
});
|
||||
|
||||
it('keeps safeReturn protection on returned values without blocking allowed edits', async () => {
|
||||
const fixture = makeHostFixture();
|
||||
setContextBuilder(() => fixture.context);
|
||||
|
||||
await loadPlugin(
|
||||
makeManifest(
|
||||
'penpot.createRectangle(); ' +
|
||||
'globalThis.__selectionFrozen = Object.isFrozen(penpot.selection);',
|
||||
['content:read', 'content:write'],
|
||||
),
|
||||
);
|
||||
expect(fixture.createRectangle).toHaveBeenCalledTimes(1);
|
||||
expect(lastCompartmentGlobalThis()['__selectionFrozen']).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -120,86 +120,15 @@ describe('plugin-loader', () => {
|
||||
});
|
||||
|
||||
it('should handle messages sent to plugins', async () => {
|
||||
const mockIframeWindow = { nodeType: 1 } as unknown as Window;
|
||||
const mockPluginWithIframe = {
|
||||
plugin: {
|
||||
close: mockClose,
|
||||
sendMessage: vi.fn(),
|
||||
},
|
||||
iframeWindow: mockIframeWindow,
|
||||
manifest: { ...manifest, host: 'http://localhost:4202' },
|
||||
} as unknown as Awaited<ReturnType<typeof createPlugin>>;
|
||||
|
||||
vi.mocked(createPlugin).mockResolvedValue(mockPluginWithIframe);
|
||||
|
||||
await loadPlugin(manifest);
|
||||
|
||||
const event = new MessageEvent('message', {
|
||||
data: 'test-message',
|
||||
origin: 'http://localhost:4202',
|
||||
});
|
||||
Object.defineProperty(event, 'source', { value: mockIframeWindow });
|
||||
window.dispatchEvent(event);
|
||||
window.dispatchEvent(new MessageEvent('message', { data: 'test-message' }));
|
||||
|
||||
expect(mockPluginWithIframe.plugin.sendMessage).toHaveBeenCalledWith(
|
||||
expect(mockPluginApi.plugin.sendMessage).toHaveBeenCalledWith(
|
||||
'test-message',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject messages from unrecognized sources', async () => {
|
||||
await loadPlugin(manifest);
|
||||
|
||||
const event = new MessageEvent('message', {
|
||||
data: 'malicious-message',
|
||||
origin: 'https://evil.com',
|
||||
});
|
||||
Object.defineProperty(event, 'source', {
|
||||
value: { nodeType: 999 } as unknown as Window,
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
|
||||
expect(mockPluginApi.plugin.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should only route messages to the sender plugin', async () => {
|
||||
const mockIframeWindow1 = { nodeType: 1 } as unknown as Window;
|
||||
const mockIframeWindow2 = { nodeType: 2 } as unknown as Window;
|
||||
|
||||
const mockPluginApi1 = {
|
||||
plugin: {
|
||||
close: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
},
|
||||
iframeWindow: mockIframeWindow1,
|
||||
manifest: { ...manifest, host: 'http://localhost:4202' },
|
||||
} as unknown as Awaited<ReturnType<typeof createPlugin>>;
|
||||
|
||||
const mockPluginApi2 = {
|
||||
plugin: {
|
||||
close: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
},
|
||||
iframeWindow: mockIframeWindow2,
|
||||
manifest: { ...manifest, host: 'http://localhost:4203' },
|
||||
} as unknown as Awaited<ReturnType<typeof createPlugin>>;
|
||||
|
||||
vi.mocked(createPlugin).mockResolvedValue(mockPluginApi1);
|
||||
await loadPlugin(manifest);
|
||||
|
||||
vi.mocked(createPlugin).mockResolvedValue(mockPluginApi2);
|
||||
await loadPlugin(manifest);
|
||||
|
||||
const event = new MessageEvent('message', {
|
||||
data: 'test',
|
||||
origin: 'http://localhost:4203',
|
||||
});
|
||||
Object.defineProperty(event, 'source', { value: mockIframeWindow2 });
|
||||
window.dispatchEvent(event);
|
||||
|
||||
expect(mockPluginApi2.plugin.sendMessage).toHaveBeenCalledWith('test');
|
||||
expect(mockPluginApi1.plugin.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should load plugin using ɵloadPlugin', async () => {
|
||||
await ɵloadPlugin(manifest);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Context } from '@penpot/plugin-types';
|
||||
import { loadManifest } from './parse-manifest.js';
|
||||
import { Manifest } from './models/manifest.model.js';
|
||||
import { createPlugin } from './create-plugin.js';
|
||||
import { ses } from './ses.js';
|
||||
|
||||
let plugins: Awaited<ReturnType<typeof createPlugin>>[] = [];
|
||||
|
||||
@@ -29,10 +30,8 @@ const closeAllPlugins = () => {
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
try {
|
||||
const senderPlugin = plugins.find((it) => it.iframeWindow === event.source);
|
||||
|
||||
if (senderPlugin) {
|
||||
senderPlugin.plugin.sendMessage(event.data);
|
||||
for (const it of plugins) {
|
||||
it.plugin.sendMessage(event.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -53,23 +52,8 @@ export const loadPlugin = async function (
|
||||
|
||||
closeAllPlugins();
|
||||
|
||||
// The host context is not deeply frozen at this load stage.
|
||||
//
|
||||
// The context still contains host-internal function objects and shared
|
||||
// prototypes that the host may legitimately extend after plugin load
|
||||
// (for example, by assigning custom properties). Deep-freezing here
|
||||
// would freeze those prototypes before SES override taming completes,
|
||||
// preventing later host-side mutations with a "Cannot assign to read
|
||||
// only property" TypeError.
|
||||
//
|
||||
// Responsibility boundary: this function forwards the context to the
|
||||
// sandbox layer without deep-freezing it. The public API that plugins
|
||||
// consume is constructed by the API module (`api/index.ts`), and
|
||||
// `createSandbox`'s proxy handler applies `ses.safeReturn` to values
|
||||
// crossing into the sandbox. Compartment isolation and intrinsics
|
||||
// hardening are performed by createSandbox, not here.
|
||||
const plugin = await createPlugin(
|
||||
context,
|
||||
ses.harden(context) as Context,
|
||||
manifest,
|
||||
() => {
|
||||
plugins = plugins.filter((api) => api !== plugin);
|
||||
|
||||
@@ -18,7 +18,6 @@ export class PluginModalElement extends HTMLElement {
|
||||
wrapper = document.createElement('div');
|
||||
#inner = document.createElement('div');
|
||||
#dragEvents: ReturnType<typeof dragHandler> | null = null;
|
||||
#iframe: HTMLIFrameElement | null = null;
|
||||
|
||||
setTheme(theme: Theme) {
|
||||
if (this.wrapper) {
|
||||
@@ -111,15 +110,15 @@ export class PluginModalElement extends HTMLElement {
|
||||
|
||||
header.appendChild(closeButton);
|
||||
|
||||
this.#iframe = document.createElement('iframe');
|
||||
this.#iframe.src = iframeSrc;
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = iframeSrc;
|
||||
|
||||
const allowList: string[] = [];
|
||||
if (allowClipboardRead) allowList.push('clipboard-read');
|
||||
if (allowClipboardWrite) allowList.push('clipboard-write');
|
||||
this.#iframe.allow = allowList.join('; ');
|
||||
iframe.allow = allowList.join('; ');
|
||||
|
||||
this.#iframe.sandbox.add(
|
||||
iframe.sandbox.add(
|
||||
'allow-scripts',
|
||||
'allow-forms',
|
||||
'allow-modals',
|
||||
@@ -130,10 +129,10 @@ export class PluginModalElement extends HTMLElement {
|
||||
);
|
||||
|
||||
if (allowDownloads) {
|
||||
this.#iframe.sandbox.add('allow-downloads');
|
||||
iframe.sandbox.add('allow-downloads');
|
||||
}
|
||||
|
||||
this.#iframe.addEventListener('load', () => {
|
||||
iframe.addEventListener('load', () => {
|
||||
this.shadowRoot?.dispatchEvent(
|
||||
new CustomEvent('load', {
|
||||
composed: true,
|
||||
@@ -160,12 +159,12 @@ export class PluginModalElement extends HTMLElement {
|
||||
);
|
||||
|
||||
this.addEventListener('message', (e: Event) => {
|
||||
if (!this.#iframe?.contentWindow) {
|
||||
if (!iframe.contentWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.#iframe.contentWindow.postMessage((e as CustomEvent).detail, '*');
|
||||
iframe.contentWindow.postMessage((e as CustomEvent).detail, '*');
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'plugin modal: failed to send message to iframe via postMessage.',
|
||||
@@ -178,7 +177,7 @@ export class PluginModalElement extends HTMLElement {
|
||||
|
||||
this.wrapper.appendChild(this.#inner);
|
||||
this.#inner.appendChild(header);
|
||||
this.#inner.appendChild(this.#iframe);
|
||||
this.#inner.appendChild(iframe);
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = modalCss;
|
||||
@@ -188,10 +187,6 @@ export class PluginModalElement extends HTMLElement {
|
||||
this.calculateZIndex();
|
||||
}
|
||||
|
||||
getIframeContentWindow(): Window | null {
|
||||
return this.#iframe?.contentWindow ?? null;
|
||||
}
|
||||
|
||||
size() {
|
||||
const width = Number(this.wrapper.style.width.replace('px', '') || '300');
|
||||
const height = Number(this.wrapper.style.height.replace('px', '') || '400');
|
||||
|
||||
@@ -157,9 +157,6 @@ export async function createPluginManager(
|
||||
}
|
||||
},
|
||||
getModal: () => modal,
|
||||
get iframeWindow(): Window | null {
|
||||
return modal?.getIframeContentWindow() ?? null;
|
||||
},
|
||||
registerListener,
|
||||
registerMessageCallback,
|
||||
sendMessage: (message: unknown) => {
|
||||
|
||||
@@ -108,7 +108,7 @@ flowchart TB
|
||||
FFI_Cursor["_text_editor_set_cursor_from_point<br/>_text_editor_move_cursor<br/>_text_editor_select_all"]
|
||||
FFI_Edit["_text_editor_insert_text<br/>_text_editor_delete_backward<br/>_text_editor_insert_paragraph"]
|
||||
FFI_Query["_text_editor_export_content<br/>_text_editor_get_selection<br/>_text_editor_poll_event"]
|
||||
FFI_Render["_text_editor_render_caret<br/>_text_editor_update_blink"]
|
||||
FFI_Render["_text_editor_render_overlay<br/>_text_editor_update_blink"]
|
||||
end
|
||||
|
||||
subgraph Rust["Rust Layer"]
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
#
|
||||
# Text snapshots reference `fonts/sourcesanspro-regular.ttf`; this script copies
|
||||
# the bundled font into `target/svg-preview/fonts/` so the gallery renders text.
|
||||
# Image-fill snapshots reference `images/test-fill.svg`; same idea for fills.
|
||||
#
|
||||
# When a test produced a pending change there will be a `*.snap.new` next to the
|
||||
# accepted `*.snap`; the gallery then shows "accepted" vs "new" side by side.
|
||||
@@ -28,14 +27,10 @@ OUT_DIR="$SCRIPT_DIR/target/svg-preview"
|
||||
OUT="$OUT_DIR/index.html"
|
||||
FONT_SRC="$SCRIPT_DIR/src/fonts/sourcesanspro-regular.ttf"
|
||||
FONT_DIR="$OUT_DIR/fonts"
|
||||
IMAGE_SRC="$SCRIPT_DIR/src/render/svg/fixtures/test-fill.svg"
|
||||
IMAGE_DIR="$OUT_DIR/images"
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
mkdir -p "$FONT_DIR"
|
||||
mkdir -p "$IMAGE_DIR"
|
||||
cp "$FONT_SRC" "$FONT_DIR/"
|
||||
cp "$IMAGE_SRC" "$IMAGE_DIR/"
|
||||
|
||||
# Prints the SVG body of a snapshot file: everything after the second `---`
|
||||
# line (the YAML front matter insta writes).
|
||||
|
||||
+11
-26
@@ -27,7 +27,6 @@ use options::RenderOptions;
|
||||
pub use surfaces::{SurfaceId, Surfaces};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::globals::get_text_editor_state;
|
||||
use crate::math;
|
||||
use crate::shapes::{
|
||||
all_with_ancestors, modifier_changes_text_layout, radius_to_sigma, Blur, BlurType, Corners,
|
||||
@@ -986,34 +985,11 @@ impl RenderState {
|
||||
debug::render(self);
|
||||
}
|
||||
if !self.preview_mode {
|
||||
self.render_text_editor_overlay(tree);
|
||||
ui::render(self, tree);
|
||||
}
|
||||
debug::render_wasm_label(self);
|
||||
}
|
||||
|
||||
/// Drawn on Target before the UI surface is composited, so rulers and guides
|
||||
/// stay above the selection band
|
||||
fn render_text_editor_overlay(&mut self, tree: ShapesPoolRef) {
|
||||
let editor_state = get_text_editor_state();
|
||||
let Some(shape_id) = editor_state.active_shape_id else {
|
||||
return;
|
||||
};
|
||||
let Some(shape) = tree.get(&shape_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let viewbox = self.viewbox;
|
||||
let options = self.options;
|
||||
text_editor::render_overlay(
|
||||
self.surfaces.canvas(SurfaceId::Target),
|
||||
&viewbox,
|
||||
&options,
|
||||
editor_state,
|
||||
shape,
|
||||
);
|
||||
}
|
||||
|
||||
/// Renders only the canvas background and UI surface (rulers/frame), without
|
||||
/// rebuilding or drawing any shape tiles. Used to show the viewport frame
|
||||
/// immediately before shape tiles are built (e.g., right after a DPR change).
|
||||
@@ -1428,6 +1404,13 @@ impl RenderState {
|
||||
.inner_shadows_visible()
|
||||
.any(|s| s.is_perceptible_at_scale_for(scale, shape.is_recursive())));
|
||||
|
||||
let has_inherited_drop_shadows = !skip_drop_shadows
|
||||
&& self.nested_shadows.iter().flatten().any(|shadow| {
|
||||
!shadow.hidden()
|
||||
&& shadow.style() == crate::shapes::ShadowStyle::Drop
|
||||
&& shadow.is_perceptible_at_scale(scale)
|
||||
});
|
||||
|
||||
// Clip is allowed: we apply the same stack on Current after scale+translate.
|
||||
// Opacity < 1 with SrcOver is OK: render_shape_enter already opened a
|
||||
// save_layer on Current; painting fills/strokes into that layer matches
|
||||
@@ -1445,8 +1428,9 @@ impl RenderState {
|
||||
shape.shape_type,
|
||||
Type::Rect(_) | Type::Circle | Type::Path(_) | Type::Bool(_) | Type::Frame(_)
|
||||
) && !(shape.fills.is_empty() && has_nested_fills);
|
||||
let is_direct_text =
|
||||
matches!(shape.shape_type, Type::Text(_)) && !shape.has_visible_strokes();
|
||||
let is_direct_text = matches!(shape.shape_type, Type::Text(_))
|
||||
&& !shape.has_visible_strokes()
|
||||
&& !has_inherited_drop_shadows;
|
||||
let can_render_directly = apply_to_current_surface
|
||||
&& offset.is_none()
|
||||
&& parent_shadows.is_none()
|
||||
@@ -1656,6 +1640,7 @@ impl RenderState {
|
||||
&& parent_shadows.is_none()
|
||||
&& (skip_effects
|
||||
|| (shape.blur.is_none()
|
||||
&& !has_inherited_drop_shadows
|
||||
&& !shape
|
||||
.drop_shadows_visible()
|
||||
.any(|s| s.is_perceptible_at_scale(self.get_scale()))
|
||||
|
||||
@@ -82,9 +82,6 @@ pub struct ImageStore {
|
||||
tick: Cell<u64>,
|
||||
/// gpu-only
|
||||
context: Option<Box<DirectContext>>,
|
||||
/// Source URL registered when the image was fetched (SVG export references
|
||||
/// this in linked `<image>` elements).
|
||||
source_urls: HashMap<Uuid, String>,
|
||||
}
|
||||
|
||||
/// Creates a Skia image from an existing WebGL texture.
|
||||
@@ -230,7 +227,6 @@ impl ImageStore {
|
||||
total_bytes: 0,
|
||||
tick: Cell::new(0),
|
||||
context: Some(Box::new(context.clone())),
|
||||
source_urls: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +239,6 @@ impl ImageStore {
|
||||
total_bytes: 0,
|
||||
tick: Cell::new(0),
|
||||
context: None,
|
||||
source_urls: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,14 +476,4 @@ impl ImageStore {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_source_url(&mut self, id: Uuid, url: String) {
|
||||
if !url.is_empty() {
|
||||
self.source_urls.insert(id, url);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn source_url(&self, id: &Uuid) -> Option<&str> {
|
||||
self.source_urls.get(id).map(String::as_str)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ use crate::render::vector::draw_shape_geometry;
|
||||
|
||||
/// Accumulates the SVG document body while drawing.
|
||||
pub(crate) struct SvgLayerCanvas {
|
||||
scale: f32,
|
||||
pub(super) scale: f32,
|
||||
page_rect: skia::Rect,
|
||||
tx: f32,
|
||||
ty: f32,
|
||||
@@ -97,28 +97,6 @@ impl SvgLayerCanvas {
|
||||
self.out.push_str("</g>");
|
||||
}
|
||||
|
||||
/// Appends raw SVG markup to the body (flushes any pending Skia fragment first).
|
||||
pub(super) fn push_raw(&mut self, markup: &str) {
|
||||
self.flush();
|
||||
self.out.push_str(markup);
|
||||
}
|
||||
|
||||
/// CTM for leaf content placed in page space: Scale * Translate * Centered.
|
||||
pub(super) fn page_shape_matrix_attr(&self, shape: &Shape) -> String {
|
||||
let mut ctm = skia::Matrix::scale((self.scale, self.scale));
|
||||
ctm = ctm * skia::Matrix::translate((self.tx, self.ty));
|
||||
ctm = ctm * shape.centered_transform();
|
||||
format!(
|
||||
"matrix({} {} {} {} {} {})",
|
||||
ctm.scale_x(),
|
||||
ctm.skew_y(),
|
||||
ctm.skew_x(),
|
||||
ctm.scale_y(),
|
||||
ctm.translate_x(),
|
||||
ctm.translate_y()
|
||||
)
|
||||
}
|
||||
|
||||
/// Emits a `<clipPath>` from a shape's geometry (in device/page space).
|
||||
///
|
||||
/// A mask can be a group too. Since a group has no geometry of its own, we
|
||||
|
||||
@@ -5,9 +5,8 @@ use skia_safe as skia;
|
||||
use crate::globals::TestRenderResourcesGuard;
|
||||
use crate::render::{FontStore, RenderResources};
|
||||
use crate::shapes::{
|
||||
Fill, FontFamily, FontStyle, Frame, Group, GrowType, ImageFill, Paragraph, Path, Rect, Segment,
|
||||
SolidColor, Stroke, StrokeKind, StrokeStyle, TextAlign, TextContent, TextDirection, TextSpan,
|
||||
Type,
|
||||
Fill, FontFamily, FontStyle, Frame, Group, GrowType, Paragraph, Rect, SolidColor, TextAlign,
|
||||
TextContent, TextDirection, TextSpan, Type,
|
||||
};
|
||||
use crate::state::ShapesPool;
|
||||
use crate::utils::uuid_from_u32_quartet;
|
||||
@@ -18,10 +17,6 @@ use super::render_tree_to_svg;
|
||||
/// Font URL referenced in exported SVG `@font-face` rules.
|
||||
pub(super) const TEST_FONT_URL: &str = "fonts/sourcesanspro-regular.ttf";
|
||||
|
||||
/// Media URL referenced by linked `<image href>` fills in SVG export tests.
|
||||
/// Relative path so `./preview-snapshots` can resolve it under `target/svg-preview/`.
|
||||
pub(super) const TEST_IMAGE_URL: &str = "images/test-fill.svg";
|
||||
|
||||
fn register_test_font_urls(fonts: &mut FontStore) {
|
||||
let family = FontFamily::new(Uuid::nil(), 400, FontStyle::Normal);
|
||||
fonts.set_source_url(&family.alias(), TEST_FONT_URL.to_string());
|
||||
@@ -32,31 +27,6 @@ pub(super) fn uid(n: u32) -> Uuid {
|
||||
uuid_from_u32_quartet(0, 0, 0, n)
|
||||
}
|
||||
|
||||
/// Adds a rectangle filled with a linked image (must call `render_with` / register URL).
|
||||
pub(super) fn add_image_rect(
|
||||
pool: &mut ShapesPool,
|
||||
id: Uuid,
|
||||
parent: Uuid,
|
||||
(l, t, r, b): (f32, f32, f32, f32),
|
||||
image_id: Uuid,
|
||||
keep_aspect_ratio: bool,
|
||||
opacity: u8,
|
||||
) {
|
||||
add_rect_with_fills(
|
||||
pool,
|
||||
id,
|
||||
parent,
|
||||
(l, t, r, b),
|
||||
vec![Fill::Image(ImageFill::new(
|
||||
image_id,
|
||||
opacity,
|
||||
200,
|
||||
100,
|
||||
keep_aspect_ratio,
|
||||
))],
|
||||
);
|
||||
}
|
||||
|
||||
/// Adds a solid-filled rectangle to the pool.
|
||||
pub(super) fn add_solid_rect(
|
||||
pool: &mut ShapesPool,
|
||||
@@ -97,101 +67,15 @@ pub(super) fn add_frame(
|
||||
(l, t, r, b): (f32, f32, f32, f32),
|
||||
color: skia::Color,
|
||||
clip: bool,
|
||||
) {
|
||||
add_frame_with_fills(
|
||||
pool,
|
||||
id,
|
||||
parent,
|
||||
(l, t, r, b),
|
||||
vec![Fill::Solid(SolidColor(color))],
|
||||
clip,
|
||||
);
|
||||
}
|
||||
|
||||
fn add_frame_with_fills(
|
||||
pool: &mut ShapesPool,
|
||||
id: Uuid,
|
||||
parent: Uuid,
|
||||
(l, t, r, b): (f32, f32, f32, f32),
|
||||
fills: Vec<Fill>,
|
||||
clip: bool,
|
||||
) {
|
||||
let shape = pool.add_shape(id);
|
||||
shape.set_parent(parent);
|
||||
shape.set_shape_type(Type::Frame(Frame::default()));
|
||||
shape.set_selrect(l, t, r, b);
|
||||
shape.set_fills(fills);
|
||||
shape.set_fills(vec![Fill::Solid(SolidColor(color))]);
|
||||
shape.set_clip(clip);
|
||||
}
|
||||
|
||||
/// Frame whose background is a linked image fill.
|
||||
pub(super) fn add_image_frame(
|
||||
pool: &mut ShapesPool,
|
||||
id: Uuid,
|
||||
parent: Uuid,
|
||||
(l, t, r, b): (f32, f32, f32, f32),
|
||||
image_id: Uuid,
|
||||
clip: bool,
|
||||
) {
|
||||
add_frame_with_fills(
|
||||
pool,
|
||||
id,
|
||||
parent,
|
||||
(l, t, r, b),
|
||||
vec![test_image_fill(image_id)],
|
||||
clip,
|
||||
);
|
||||
}
|
||||
|
||||
fn triangle_segments(closed: bool) -> Vec<Segment> {
|
||||
let mut segments = vec![
|
||||
Segment::MoveTo((10.0, 90.0)),
|
||||
Segment::LineTo((50.0, 10.0)),
|
||||
Segment::LineTo((90.0, 90.0)),
|
||||
];
|
||||
if closed {
|
||||
segments.push(Segment::Close);
|
||||
}
|
||||
segments
|
||||
}
|
||||
|
||||
fn add_path_with_fills(
|
||||
pool: &mut ShapesPool,
|
||||
id: Uuid,
|
||||
parent: Uuid,
|
||||
(l, t, r, b): (f32, f32, f32, f32),
|
||||
segments: Vec<Segment>,
|
||||
fills: Vec<Fill>,
|
||||
) {
|
||||
let shape = pool.add_shape(id);
|
||||
shape.set_parent(parent);
|
||||
shape.set_shape_type(Type::Path(Path::new(segments)));
|
||||
shape.set_selrect(l, t, r, b);
|
||||
shape.set_fills(fills);
|
||||
}
|
||||
|
||||
fn test_image_fill(image_id: Uuid) -> Fill {
|
||||
Fill::Image(ImageFill::new(image_id, 255, 200, 100, true))
|
||||
}
|
||||
|
||||
/// Triangle path (open or closed) with a linked image fill.
|
||||
pub(super) fn add_image_path(
|
||||
pool: &mut ShapesPool,
|
||||
id: Uuid,
|
||||
parent: Uuid,
|
||||
closed: bool,
|
||||
image_id: Uuid,
|
||||
) {
|
||||
add_path_with_fills(
|
||||
pool,
|
||||
id,
|
||||
parent,
|
||||
(0.0, 0.0, 100.0, 100.0),
|
||||
triangle_segments(closed),
|
||||
vec![test_image_fill(image_id)],
|
||||
);
|
||||
}
|
||||
|
||||
/// Adds an empty (unmasked) group.
|
||||
pub(super) fn add_group(
|
||||
pool: &mut ShapesPool,
|
||||
@@ -269,134 +153,9 @@ pub(super) fn add_text_with_fills(
|
||||
shape.set_shape_type(Type::Text(content));
|
||||
}
|
||||
|
||||
/// Adds a rectangle with a single solid stroke (no fill).
|
||||
pub(super) fn add_stroked_rect(
|
||||
pool: &mut ShapesPool,
|
||||
id: Uuid,
|
||||
parent: Uuid,
|
||||
(l, t, r, b): (f32, f32, f32, f32),
|
||||
stroke: Stroke,
|
||||
) {
|
||||
let shape = pool.add_shape(id);
|
||||
shape.set_parent(parent);
|
||||
shape.set_shape_type(Type::Rect(Rect::default()));
|
||||
shape.set_selrect(l, t, r, b);
|
||||
shape.set_fills(vec![]);
|
||||
shape.add_stroke(stroke);
|
||||
}
|
||||
|
||||
/// Adds a closed rectangular path with a single solid stroke (no fill).
|
||||
pub(super) fn add_stroked_closed_path(
|
||||
pool: &mut ShapesPool,
|
||||
id: Uuid,
|
||||
parent: Uuid,
|
||||
bounds: (f32, f32, f32, f32),
|
||||
stroke: Stroke,
|
||||
) {
|
||||
add_stroked_path(pool, id, parent, bounds, stroke, true);
|
||||
}
|
||||
|
||||
/// Adds an open polyline path with a single solid stroke (no fill).
|
||||
pub(super) fn add_stroked_open_path(
|
||||
pool: &mut ShapesPool,
|
||||
id: Uuid,
|
||||
parent: Uuid,
|
||||
bounds: (f32, f32, f32, f32),
|
||||
stroke: Stroke,
|
||||
) {
|
||||
add_stroked_path(pool, id, parent, bounds, stroke, false);
|
||||
}
|
||||
|
||||
fn add_stroked_path(
|
||||
pool: &mut ShapesPool,
|
||||
id: Uuid,
|
||||
parent: Uuid,
|
||||
(l, t, r, b): (f32, f32, f32, f32),
|
||||
stroke: Stroke,
|
||||
closed: bool,
|
||||
) {
|
||||
let mut segments = vec![
|
||||
Segment::MoveTo((l, t)),
|
||||
Segment::LineTo((r, t)),
|
||||
Segment::LineTo((r, b)),
|
||||
Segment::LineTo((l, b)),
|
||||
];
|
||||
if closed {
|
||||
segments.push(Segment::Close);
|
||||
}
|
||||
let path = Path::new(segments);
|
||||
let shape = pool.add_shape(id);
|
||||
shape.set_parent(parent);
|
||||
shape.set_shape_type(Type::Path(path));
|
||||
shape.set_selrect(l, t, r, b);
|
||||
shape.set_fills(vec![]);
|
||||
shape.add_stroke(stroke);
|
||||
}
|
||||
|
||||
pub(super) fn solid_stroke(kind: StrokeKind, width: f32, color: skia::Color) -> Stroke {
|
||||
stroke_with_style(kind, StrokeStyle::Solid, width, color)
|
||||
}
|
||||
|
||||
pub(super) fn dotted_stroke(kind: StrokeKind, width: f32, color: skia::Color) -> Stroke {
|
||||
stroke_with_style(kind, StrokeStyle::Dotted, width, color)
|
||||
}
|
||||
|
||||
pub(super) fn dashed_stroke(kind: StrokeKind, width: f32, color: skia::Color) -> Stroke {
|
||||
stroke_with_style(kind, StrokeStyle::Dashed, width, color)
|
||||
}
|
||||
|
||||
pub(super) fn mixed_stroke(kind: StrokeKind, width: f32, color: skia::Color) -> Stroke {
|
||||
stroke_with_style(kind, StrokeStyle::Mixed, width, color)
|
||||
}
|
||||
|
||||
fn stroke_with_style(
|
||||
kind: StrokeKind,
|
||||
style: StrokeStyle,
|
||||
width: f32,
|
||||
color: skia::Color,
|
||||
) -> Stroke {
|
||||
let mut stroke = match kind {
|
||||
StrokeKind::Inner => Stroke::new_inner_stroke(width, style, None, None, None, None),
|
||||
StrokeKind::Outer => Stroke::new_outer_stroke(width, style, None, None, None, None),
|
||||
StrokeKind::Center => Stroke::new_center_stroke(width, style, None, None, None, None),
|
||||
};
|
||||
stroke.fill = Fill::Solid(SolidColor(color));
|
||||
stroke
|
||||
}
|
||||
|
||||
/// Text with a linked image fill (register URL via `render_with`).
|
||||
pub(super) fn add_image_text(
|
||||
pool: &mut ShapesPool,
|
||||
id: Uuid,
|
||||
bounds: (f32, f32, f32, f32),
|
||||
text: &str,
|
||||
font_size: f32,
|
||||
image_id: Uuid,
|
||||
) {
|
||||
add_text_with_fills(
|
||||
pool,
|
||||
id,
|
||||
bounds,
|
||||
text,
|
||||
font_size,
|
||||
vec![test_image_fill(image_id)],
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn render(pool: &ShapesPool, root: Uuid) -> String {
|
||||
render_with(pool, root, |_resources| {})
|
||||
}
|
||||
|
||||
/// Like [`render`], but lets the test register extra resources (e.g. image URLs)
|
||||
/// before export.
|
||||
pub(super) fn render_with(
|
||||
pool: &ShapesPool,
|
||||
root: Uuid,
|
||||
setup: impl FnOnce(&mut RenderResources),
|
||||
) -> String {
|
||||
let mut resources = RenderResources::try_new_headless().expect("headless resources");
|
||||
register_test_font_urls(&mut resources.fonts);
|
||||
setup(&mut resources);
|
||||
let _guard = TestRenderResourcesGuard::install(&mut resources);
|
||||
let bytes = render_tree_to_svg(&mut resources, &root, pool, 1.0).expect("svg export");
|
||||
String::from_utf8(bytes).expect("utf8 svg")
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" viewBox="0 0 200 100">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#2563eb"/>
|
||||
<stop offset="100%" stop-color="#7c3aed"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="200" height="100" fill="url(#g)"/>
|
||||
<circle cx="60" cy="50" r="28" fill="#fbbf24"/>
|
||||
<rect x="110" y="22" width="70" height="56" rx="8" fill="#f8fafc" opacity="0.9"/>
|
||||
<text x="145" y="58" text-anchor="middle" font-family="system-ui,sans-serif" font-size="22" font-weight="700" fill="#1e293b">IMG</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 610 B |
@@ -5,7 +5,6 @@ use crate::shapes::{Shape, Stroke};
|
||||
use crate::state::ShapesPoolRef;
|
||||
|
||||
use super::document::{effect_attrs, SvgLayerCanvas};
|
||||
use super::images::emit_fills;
|
||||
use super::render_tree;
|
||||
use crate::render::RenderResources;
|
||||
|
||||
@@ -30,9 +29,14 @@ pub(super) fn render_frame(
|
||||
builder.open_group(&format!("clip-path=\"url(#{clip_id})\""));
|
||||
}
|
||||
|
||||
// Frame background (frame space), with linked `<image>` for image fills.
|
||||
// Frame background (frame space).
|
||||
if !element.fills.is_empty() {
|
||||
emit_fills(builder, shared, element, &element.fills, tree, scale)?;
|
||||
let canvas = builder.canvas();
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
|
||||
renderer.draw_fills(element, &element.fills)?;
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
// Children (absolute coords).
|
||||
@@ -41,14 +45,7 @@ pub(super) fn render_frame(
|
||||
render_tree(builder, shared, child_id, tree, scale)?;
|
||||
}
|
||||
|
||||
// Close content clip before strokes. Outer (and half of center) strokes
|
||||
// extend past the frame bounds; keeping them under clip-path hides them.
|
||||
// Matches GPU: clipped-frame strokes render in exit without the frame clip.
|
||||
if clipped {
|
||||
builder.close_group();
|
||||
}
|
||||
|
||||
// Strokes over children (frame space), outside the content clip.
|
||||
// Strokes over children (frame space).
|
||||
let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect();
|
||||
if !visible_strokes.is_empty() {
|
||||
let canvas = builder.canvas();
|
||||
@@ -59,6 +56,9 @@ pub(super) fn render_frame(
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
if clipped {
|
||||
builder.close_group();
|
||||
}
|
||||
if effects.is_some() {
|
||||
builder.close_group();
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
use crate::error::Result;
|
||||
use crate::render::shape_renderer::ShapeRenderer;
|
||||
use crate::render::vector::VectorRenderer;
|
||||
use crate::shapes::{Fill, ImageFill, Shape};
|
||||
use crate::state::ShapesPoolRef;
|
||||
|
||||
use super::document::SvgLayerCanvas;
|
||||
use crate::render::RenderResources;
|
||||
|
||||
/// Emits fills bottom -> top for SVG export.
|
||||
///
|
||||
/// Non-image fills go through Skia's SVG canvas. Image fills with a registered
|
||||
/// source URL become native linked `<image>` elements (see `store_image_url`);
|
||||
/// without a URL they fall back to Skia (base64-embed) when a CPU image exists.
|
||||
pub(super) fn emit_fills(
|
||||
builder: &mut SvgLayerCanvas,
|
||||
shared: &mut RenderResources,
|
||||
shape: &Shape,
|
||||
fills: &[Fill],
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
) -> Result<()> {
|
||||
if fills.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// fills[0] is the topmost layer; draw bottom → top.
|
||||
for fill in fills.iter().rev() {
|
||||
match fill {
|
||||
Fill::Image(image_fill) if shared.images.source_url(&image_fill.id()).is_some() => {
|
||||
emit_image_fill(builder, shared, shape, image_fill, tree)?;
|
||||
}
|
||||
fill => {
|
||||
let matrix = shape.centered_transform();
|
||||
let canvas = builder.canvas();
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
|
||||
renderer.draw_fills(shape, std::slice::from_ref(fill))?;
|
||||
canvas.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Emits a linked SVG `<image>` clipped to the shape geometry.
|
||||
///
|
||||
/// Skia's SVG backend would base64-embed a PNG from `draw_image_rect`; we emit
|
||||
/// a native `<image href="...">` instead so the export stays linked to the
|
||||
/// registered media URL (see `store_image_url`).
|
||||
fn emit_image_fill(
|
||||
builder: &mut SvgLayerCanvas,
|
||||
shared: &RenderResources,
|
||||
shape: &Shape,
|
||||
image_fill: &ImageFill,
|
||||
tree: ShapesPoolRef,
|
||||
) -> Result<()> {
|
||||
let Some(url) = shared.images.source_url(&image_fill.id()) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let clip_id = builder.unique("imgclip");
|
||||
builder.push_clip_path(&clip_id, shape, tree);
|
||||
let href = xml_escape_attr(url);
|
||||
emit_linked_image_element(builder, shape, image_fill, &href, &clip_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Emits `<g clip-path>` + `<image href>` using the shape selrect and page CTM.
|
||||
pub(super) fn emit_linked_image_element(
|
||||
builder: &mut SvgLayerCanvas,
|
||||
shape: &Shape,
|
||||
image_fill: &ImageFill,
|
||||
href: &str,
|
||||
clip_id: &str,
|
||||
) {
|
||||
let selrect = shape.selrect();
|
||||
let opacity = image_fill.opacity() as f32 / 255.0;
|
||||
let preserve = if image_fill.keep_aspect_ratio() {
|
||||
"xMidYMid slice"
|
||||
} else {
|
||||
"none"
|
||||
};
|
||||
let transform = builder.page_shape_matrix_attr(shape);
|
||||
|
||||
let opacity_attr = if (opacity - 1.0).abs() < f32::EPSILON {
|
||||
String::new()
|
||||
} else {
|
||||
format!(r#" opacity="{opacity}""#)
|
||||
};
|
||||
|
||||
builder.open_group(&format!("clip-path=\"url(#{clip_id})\""));
|
||||
builder.push_raw(&format!(
|
||||
r#"<image href="{href}" x="{}" y="{}" width="{}" height="{}" preserveAspectRatio="{preserve}"{opacity_attr} transform="{transform}"/>"#,
|
||||
selrect.left(),
|
||||
selrect.top(),
|
||||
selrect.width(),
|
||||
selrect.height(),
|
||||
));
|
||||
builder.close_group();
|
||||
}
|
||||
|
||||
pub(super) fn xml_escape_attr(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('"', """)
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
@@ -8,8 +8,7 @@ use crate::shapes::{Shape, Type};
|
||||
use crate::state::ShapesPoolRef;
|
||||
use crate::uuid::Uuid;
|
||||
|
||||
use super::shape_renderer::ShapeRenderer;
|
||||
use super::vector::VectorRenderer;
|
||||
use super::vector::{render_leaf_content, VectorRenderer};
|
||||
use super::RenderResources;
|
||||
|
||||
/// Collects the registered font aliases used by every text span in the subtree
|
||||
@@ -61,8 +60,7 @@ fn svg_page_bounds(shape: &Shape, tree: ShapesPoolRef, scale: f32) -> skia::Rect
|
||||
/// `<clipPath>`.
|
||||
///
|
||||
/// Special-case re-emission for shadows, layer blur, masks, text strokes, and
|
||||
/// image strokes is intentionally out of scope for this cut. Solid Inner/Outer
|
||||
/// and dotted/dashed strokes are emitted as filled outlines.
|
||||
/// deferred strokes is intentionally out of scope for this cut.
|
||||
pub fn render_to_svg(
|
||||
shared: &mut RenderResources,
|
||||
id: &Uuid,
|
||||
@@ -126,7 +124,6 @@ pub(crate) fn render_tree_to_svg(
|
||||
mod document;
|
||||
mod frames;
|
||||
mod groups;
|
||||
mod images;
|
||||
mod text;
|
||||
|
||||
use document::SvgLayerCanvas;
|
||||
@@ -135,7 +132,6 @@ use groups::render_group;
|
||||
use text::render_text_fill;
|
||||
|
||||
use document::effect_attrs;
|
||||
use images::emit_fills;
|
||||
|
||||
/// Renders `id`'s subtree to an SVG body, returning `(defs, body)`.
|
||||
fn render_body(
|
||||
@@ -175,7 +171,7 @@ fn render_tree(
|
||||
| Type::Path(_)
|
||||
| Type::Bool(_)
|
||||
| Type::Text(_)
|
||||
| Type::SVGRaw(_) => render_leaf(builder, shared, element, tree, scale),
|
||||
| Type::SVGRaw(_) => render_leaf(builder, shared, element, scale),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +179,6 @@ fn render_leaf(
|
||||
builder: &mut SvgLayerCanvas,
|
||||
shared: &mut RenderResources,
|
||||
element: &Shape,
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
) -> Result<()> {
|
||||
let effects = effect_attrs(element);
|
||||
@@ -193,26 +188,14 @@ fn render_leaf(
|
||||
|
||||
{
|
||||
if matches!(element.shape_type, Type::Text(_)) {
|
||||
render_text_fill(builder, shared, element)?;
|
||||
render_text_fill(builder, element)?;
|
||||
} else {
|
||||
emit_fills(builder, shared, element, &element.fills, tree, scale)?;
|
||||
|
||||
let matrix = element.centered_transform();
|
||||
let canvas = builder.canvas();
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
|
||||
renderer.draw_fill_inner_shadows(element)?;
|
||||
|
||||
let visible_strokes: Vec<_> = element.visible_strokes().collect();
|
||||
if !visible_strokes.is_empty() {
|
||||
renderer.draw_strokes(element, &visible_strokes)?;
|
||||
if !element.has_fills() {
|
||||
for stroke in &visible_strokes {
|
||||
renderer.draw_stroke_inner_shadows(element, stroke)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
render_leaf_content(&mut renderer, element)?;
|
||||
canvas.restore();
|
||||
}
|
||||
}
|
||||
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="164" height="124" viewBox="0 0 164 124"><defs><clipPath id="clip0" clipPathUnits="userSpaceOnUse">
|
||||
<rect transform="translate(12 12)" width="140" height="100"/>
|
||||
</clipPath></defs><g clip-path="url(#clip0)">
|
||||
<rect fill="#EEE" transform="translate(12 12)" width="140" height="100"/>
|
||||
</g>
|
||||
<path fill="#1040FF" transform="translate(12 12)" d="M-12 -12L152 -12L152 112L-12 112L-12 -12ZM140 0L0 0L0 100L140 100L140 0Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80" viewBox="0 0 100 80">
|
||||
<path fill="green" d="M18 0L0 0L0 8L18 8L18 0ZM0 36L0 18L8 18L8 36L0 36ZM0 72L0 54L8 54L8 72L0 72ZM28 80L10 80L10 72L28 72L28 80ZM64 80L45.999996 80L45.999996 72L64 72L64 80ZM100 80L100 72L82 72L82 80L100 80ZM100 44L100 62L92 62L92 44L100 44ZM100 8L100 26L92 26L92 8L100 8ZM72 0L90 0L90 8L72 8L72 0ZM36 0L54.000004 0L54.000004 8L36 8L36 0Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="116" height="96" viewBox="0 0 116 96">
|
||||
<path fill="green" transform="translate(8 8)" d="M0 4C2.6666667 4 4 2.6666667 4 0C4 -2.6666667 2.6666667 -4 0 -4C-2.6666667 -4 -4 -2.6666667 -4 0C-4 2.6666667 -2.6666667 4 0 4ZM13 4C15.666667 4 17 2.6666667 17 0C17 -2.6666667 15.666667 -4 13 -4C10.333333 -4 9 -2.6666667 9 0C9 2.6666667 10.333333 4 13 4ZM26 4C28.666666 4 30 2.6666667 30 0C30 -2.6666667 28.666666 -4 26 -4C23.333334 -4 22 -2.6666667 22 0C22 2.6666667 23.333334 4 26 4ZM39 4C41.666668 4 43 2.6666667 43 0C43 -2.6666667 41.666668 -4 39 -4C36.333332 -4 35 -2.6666667 35 0C35 2.6666667 36.333332 4 39 4ZM52 4C54.666668 4 56 2.6666667 56 0C56 -2.6666667 54.666668 -4 52 -4C49.333332 -4 48 -2.6666667 48 0C48 2.6666667 49.333332 4 52 4ZM65 4C67.666664 4 69 2.6666667 69 0C69 -2.6666667 67.666664 -4 65 -4C62.333332 -4 61 -2.6666667 61 0C61 2.6666667 62.333332 4 65 4ZM78 4C80.666664 4 82 2.6666667 82 0C82 -2.6666667 80.666664 -4 78 -4C75.333336 -4 74 -2.6666667 74 0C74 2.6666667 75.333336 4 78 4ZM91 4C93.666664 4 95 2.6666667 95 0C95 -2.6666667 93.666664 -4 91 -4C88.333336 -4 87 -2.6666667 87 0C87 2.6666667 88.333336 4 91 4ZM100 8C102.66666 8 104 6.666667 104 4C104 1.3333333 102.66666 0 100 0C97.333336 0 96 1.3333333 96 4C96 6.666667 97.333336 8 100 8ZM0 13C2.6666667 13 4 11.666667 4 9C4 6.333333 2.6666667 5 0 5C-2.6666667 5 -4 6.333333 -4 9C-4 11.666667 -2.6666667 13 0 13ZM100 21C102.66666 21 104 19.666666 104 17C104 14.333333 102.66666 13 100 13C97.333336 13 96 14.333333 96 17C96 19.666666 97.333336 21 100 21ZM0 26C2.6666667 26 4 24.666666 4 22C4 19.333334 2.6666667 18 0 18C-2.6666667 18 -4 19.333334 -4 22C-4 24.666666 -2.6666667 26 0 26ZM100 34C102.66666 34 104 32.666668 104 30C104 27.333334 102.66666 26 100 26C97.333336 26 96 27.333334 96 30C96 32.666668 97.333336 34 100 34ZM0 39C2.6666667 39 4 37.666668 4 35C4 32.333332 2.6666667 31 0 31C-2.6666667 31 -4 32.333332 -4 35C-4 37.666668 -2.6666667 39 0 39ZM100 47C102.66666 47 104 45.666668 104 43C104 40.333332 102.66666 39 100 39C97.333336 39 96 40.333332 96 43C96 45.666668 97.333336 47 100 47ZM0 52C2.6666667 52 4 50.666668 4 48C4 45.333332 2.6666667 44 0 44C-2.6666667 44 -4 45.333332 -4 48C-4 50.666668 -2.6666667 52 0 52ZM100 60C102.66666 60 104 58.666668 104 56C104 53.333332 102.66666 52 100 52C97.333336 52 96 53.333332 96 56C96 58.666668 97.333336 60 100 60ZM0 65C2.6666667 65 4 63.666668 4 61C4 58.333332 2.6666667 57 0 57C-2.6666667 57 -4 58.333332 -4 61C-4 63.666668 -2.6666667 65 0 65ZM100 73C102.66666 73 104 71.666664 104 69C104 66.333336 102.66666 65 100 65C97.333336 65 96 66.333336 96 69C96 71.666664 97.333336 73 100 73ZM0 78C2.6666667 78 4 76.666664 4 74C4 71.333336 2.6666667 70 0 70C-2.6666667 70 -4 71.333336 -4 74C-4 76.666664 -2.6666667 78 0 78ZM7 84C9.666667 84 11 82.666664 11 80C11 77.333336 9.666667 76 7 76C4.333333 76 3 77.333336 3 80C3 82.666664 4.333333 84 7 84ZM20 84C22.666666 84 24 82.666664 24 80C24 77.333336 22.666666 76 20 76C17.333334 76 16 77.333336 16 80C16 82.666664 17.333334 84 20 84ZM33 84C35.666668 84 37 82.666664 37 80C37 77.333336 35.666668 76 33 76C30.333334 76 29 77.333336 29 80C29 82.666664 30.333334 84 33 84ZM45.999996 84C48.666664 84 49.999996 82.666664 49.999996 80C49.999996 77.333336 48.666664 76 45.999996 76C43.333328 76 41.999996 77.333336 41.999996 80C41.999996 82.666664 43.333328 84 45.999996 84ZM59 84C61.666668 84 63 82.666664 63 80C63 77.333336 61.666668 76 59 76C56.333332 76 55 77.333336 55 80C55 82.666664 56.333332 84 59 84ZM72 84C74.666664 84 76 82.666664 76 80C76 77.333336 74.666664 76 72 76C69.333336 76 68 77.333336 68 80C68 82.666664 69.333336 84 72 84ZM85 84C87.666664 84 89 82.666664 89 80C89 77.333336 87.666664 76 85 76C82.333336 76 81 77.333336 81 80C81 82.666664 82.333336 84 85 84ZM98 84C100.66666 84 102 82.666664 102 80C102 77.333336 100.66666 76 98 76C95.333336 76 94 77.333336 94 80C94 82.666664 95.333336 84 98 84Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80" viewBox="0 0 100 80">
|
||||
<path fill="blue" d="M100 0L0 0L0 80L100 80L100 0ZM8 9C8 7.7741446 7.7323785 6.6098022 7.197135 5.5069714C8.7744055 7.1689906 10.708694 8 13 8C15.737486 8 17.904152 6.8878965 19.5 4.6636891C21.095848 6.8878965 23.262514 8 26 8C28.737486 8 30.904152 6.8878965 32.5 4.6636891C34.095848 6.8878965 36.262516 8 39 8C41.737484 8 43.904152 6.8878965 45.5 4.6636891C47.095848 6.8878965 49.262516 8 52 8C54.737484 8 56.904152 6.8878965 58.5 4.6636891C60.095848 6.8878965 62.262516 8 65 8C67.737488 8 69.904152 6.8878965 71.5 4.6636891C73.095848 6.8878965 75.262512 8 78 8C80.737488 8 82.904152 6.8878965 84.5 4.6636891C86.095848 6.8878965 88.262512 8 91 8C91.656258 8 92.302719 7.9204535 92.939392 7.7613606C93.525131 8.8608704 94.324104 9.7737494 95.336311 10.5C93.112106 12.095847 92 14.262514 92 17C92 19.737486 93.112106 21.904152 95.336311 23.5C93.112106 25.095848 92 27.262514 92 30C92 32.737484 93.112106 34.904152 95.336311 36.5C93.112106 38.095848 92 40.262516 92 43C92 45.737484 93.112106 47.904152 95.336311 49.5C93.112106 51.095848 92 53.262516 92 56C92 58.737484 93.112106 60.904152 95.336311 62.5C93.112106 64.095848 92 66.262512 92 69C92 70.631767 92.456512 72.123863 93.369545 73.47628C92.642754 73.992149 92.019569 74.61216 91.5 75.336311C89.904152 73.112106 87.737488 72 85 72C82.262512 72 80.095848 73.112106 78.5 75.336311C76.904152 73.112106 74.737488 72 72 72C69.262512 72 67.095848 73.112106 65.5 75.336311C63.904152 73.112106 61.737484 72 59 72C56.262512 72 54.095844 73.112106 52.5 75.336311C50.904152 73.112106 48.737484 72 45.999996 72C43.262512 72 41.095844 73.112106 39.5 75.336311C37.904152 73.112106 35.737484 72 33 72C30.262514 72 28.095848 73.112106 26.5 75.336311C24.904152 73.112106 22.737486 72 20 72C17.262514 72 15.095847 73.112106 13.5 75.336311C12.085551 73.364929 10.170585 72.264732 7.7551012 72.035721C7.276824 70.147453 6.2463531 68.635551 4.6636891 67.5C6.8878965 65.904152 8 63.737488 8 61C8 58.262516 6.8878965 56.095848 4.6636891 54.5C6.8878965 52.904152 8 50.737484 8 48C8 45.262516 6.8878965 43.095848 4.6636891 41.5C6.8878965 39.904152 8 37.737484 8 35C8 32.262516 6.8878965 30.095848 4.6636891 28.5C6.8878965 26.904152 8 24.737486 8 22C8 19.262514 6.8878965 17.095848 4.6636891 15.5C6.8878965 13.904153 8 11.737486 8 9Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="132" height="112" viewBox="0 0 132 112">
|
||||
<path fill="green" transform="translate(16 16)" d="M-8 0C-8 1.6422184 -7.538126 3.1422181 -6.6143785 4.5C-7.538126 5.8577819 -8 7.3577814 -8 9C-8 11.737486 -6.8878965 13.904153 -4.6636891 15.5C-6.8878965 17.095848 -8 19.262514 -8 22C-8 24.737486 -6.8878965 26.904152 -4.6636891 28.5C-6.8878965 30.095848 -8 32.262516 -8 35C-8 37.737484 -6.8878965 39.904152 -4.6636891 41.5C-6.8878965 43.095848 -8 45.262516 -8 48C-8 50.737484 -6.8878965 52.904152 -4.6636891 54.5C-6.8878965 56.095848 -8 58.262516 -8 61C-8 63.737488 -6.8878965 65.904152 -4.6636891 67.5C-6.8878965 69.095848 -8 71.262512 -8 74C-8 78.851593 -5.5850339 81.506355 -0.75510108 81.964279C0.26408672 85.988091 2.8491201 88 7 88C9.7374859 88 11.904153 86.887894 13.5 84.663689C15.095847 86.887894 17.262514 88 20 88C22.737486 88 24.904152 86.887894 26.5 84.663689C28.095848 86.887894 30.262514 88 33 88C35.737484 88 37.904152 86.887894 39.499996 84.663689C41.095844 86.887894 43.262512 88 45.999996 88C48.737484 88 50.904152 86.887894 52.499996 84.663689C54.095844 86.887894 56.262512 88 59 88C61.737484 88 63.904152 86.887894 65.5 84.663689C67.095848 86.887894 69.262512 88 72 88C74.737488 88 76.904152 86.887894 78.5 84.663689C80.095848 86.887894 82.262512 88 85 88C87.737488 88 89.904152 86.887894 91.5 84.663689C93.095848 86.887894 95.262512 88 98 88C103.33334 88 106 85.333336 106 80C106 78.368233 105.54349 76.876137 104.63046 75.52372C106.87682 73.929276 108 71.754707 108 69C108 66.262512 106.88789 64.095848 104.66369 62.5C106.88789 60.904152 108 58.737484 108 56C108 53.262516 106.88789 51.095848 104.66369 49.5C106.88789 47.904152 108 45.737484 108 43C108 40.262516 106.88789 38.095848 104.66369 36.5C106.88789 34.904152 108 32.737484 108 30C108 27.262514 106.88789 25.095848 104.66369 23.5C106.88789 21.904152 108 19.737486 108 17C108 14.262514 106.88789 12.095847 104.66369 10.5C106.88789 8.9041529 108 6.7374859 108 4C108 -1.3333335 105.33334 -4 100 -4C99.343742 -4 98.697281 -3.9204535 98.060608 -3.7613606C96.55526 -6.5871201 94.201721 -8 91 -8C88.262512 -8 86.095848 -6.8878965 84.5 -4.6636891C82.904152 -6.8878965 80.737488 -8 78 -8C75.262512 -8 73.095848 -6.8878965 71.5 -4.6636891C69.904152 -6.8878965 67.737488 -8 65 -8C62.262516 -8 60.095848 -6.8878965 58.5 -4.6636891C56.904152 -6.8878965 54.737484 -8 52 -8C49.262516 -8 47.095848 -6.8878965 45.5 -4.6636891C43.904152 -6.8878965 41.737484 -8 39 -8C36.262516 -8 34.095848 -6.8878965 32.5 -4.6636891C30.904152 -6.8878965 28.737486 -8 26 -8C23.262514 -8 21.095848 -6.8878965 19.5 -4.6636891C17.904152 -6.8878965 15.737486 -8 13 -8C10.262514 -8 8.0958471 -6.8878965 6.5 -4.6636891C4.9041519 -6.8878965 2.7374856 -8 0 -8C-5.3333335 -8 -8 -5.3333335 -8 0ZM100 0L0 0L0 80L100 80L100 0Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="132" height="112" viewBox="0 0 132 112">
|
||||
<path fill="red" transform="translate(16 16)" d="M0 -8L13 -8L13 0L0 0L0 -8ZM35 0L26 0L26 -8L35 -8L35 0ZM61 0L48 0L48 -8L61 -8L61 0ZM83 0L74 0L74 -8L83 -8L83 0ZM100 31L100 22L108 22L108 31L100 31ZM100 57L100 44L108 44L108 57L100 57ZM100 79L100 70L108 70L108 79L100 79ZM75 80L88 80L88 88L75 88L75 80ZM53 80L62 80L62 88L53 88L53 80ZM27 80L39.999996 80L39.999996 88L27 88L27 80ZM0 37L0 46L-8 46L-8 37L0 37ZM0 11L0 24L-8 24L-8 11L0 11ZM0 59L0 72L-8 72L-8 59L0 59ZM5 80L14 80L14 88L5 88L5 80ZM100 9L100 0L96 0L96 -8L108 -8L108 9L100 9Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="116" height="96" viewBox="0 0 116 96">
|
||||
<path fill="none" stroke="green" stroke-width="8" stroke-miterlimit="4" transform="translate(8 8)" d="M0 0L100 0L100 80L0 80L0 0Z"/>
|
||||
</svg>
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80" viewBox="0 0 100 80">
|
||||
<path fill="blue" d="M100 0L0 0L0 80L100 80L100 0ZM8 8L8 72L92 72L92 8L8 8Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="132" height="112" viewBox="0 0 132 112">
|
||||
<path fill="green" transform="translate(16 16)" d="M-8 -8L108 -8L108 88L-8 88L-8 -8ZM100 0L0 0L0 80L100 80L100 0Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80" viewBox="0 0 100 80"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
|
||||
<rect width="100" height="80"/>
|
||||
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="100" height="80" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 0 0)"/></g></svg>
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="80" height="80" viewBox="0 0 80 80"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
|
||||
<path transform="translate(-10 -10)" d="M10 90L50 10L90 90L10 90Z"/>
|
||||
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="100" height="100" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 -10 -10)"/></g></svg>
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="120" viewBox="0 0 200 120"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
|
||||
<rect width="200" height="120"/>
|
||||
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="200" height="120" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 0 0)"/></g></svg>
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="80" height="80" viewBox="0 0 80 80"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
|
||||
<path transform="translate(-10 -10)" d="M10 90L50 10L90 90"/>
|
||||
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="100" height="100" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 -10 -10)"/></g></svg>
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
assertion_line: 306
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="560" height="240" viewBox="0 0 560 240"><defs><style type="text/css"><![CDATA[@font-face{font-family:"Source Sans Pro";font-style:normal;font-weight:400;src:url("fonts/sourcesanspro-regular.ttf") format("truetype");}]]></style><clipPath id="txtimgclip0" clipPathUnits="userSpaceOnUse">
|
||||
<text font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181">
|
||||
HOLA
|
||||
</text>
|
||||
</clipPath></defs><g clip-path="url(#txtimgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="560" height="240" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 0 0)"/></g></svg>
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="408" height="128" viewBox="0 0 408 128"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
|
||||
<rect transform="translate(-100 -50)" x="100" y="50" width="408" height="128"/>
|
||||
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="100" y="50" width="408" height="128" preserveAspectRatio="none" opacity="0.5019608" transform="matrix(1 0 0 1 -100 -50)"/></g>
|
||||
<rect fill="#003FFF" fill-opacity="0.50196081" transform="translate(-100 -50)" x="100" y="50" width="408" height="128"/>
|
||||
</svg>
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="116" height="96" viewBox="0 0 116 96">
|
||||
<path fill="red" transform="translate(8 8)" d="M0 4C2.6666667 4 4 2.6666667 4 0C4 -2.6666667 2.6666667 -4 0 -4C-2.6666667 -4 -4 -2.6666667 -4 0C-4 2.6666667 -2.6666667 4 0 4ZM13 4C15.666667 4 17 2.6666667 17 0C17 -2.6666667 15.666667 -4 13 -4C10.333333 -4 9 -2.6666667 9 0C9 2.6666667 10.333333 4 13 4ZM26 4C28.666666 4 30 2.6666667 30 0C30 -2.6666667 28.666666 -4 26 -4C23.333334 -4 22 -2.6666667 22 0C22 2.6666667 23.333334 4 26 4ZM39 4C41.666668 4 43 2.6666667 43 0C43 -2.6666667 41.666668 -4 39 -4C36.333332 -4 35 -2.6666667 35 0C35 2.6666667 36.333332 4 39 4ZM52 4C54.666668 4 56 2.6666667 56 0C56 -2.6666667 54.666668 -4 52 -4C49.333332 -4 48 -2.6666667 48 0C48 2.6666667 49.333332 4 52 4ZM65 4C67.666664 4 69 2.6666667 69 0C69 -2.6666667 67.666664 -4 65 -4C62.333332 -4 61 -2.6666667 61 0C61 2.6666667 62.333332 4 65 4ZM78 4C80.666664 4 82 2.6666667 82 0C82 -2.6666667 80.666664 -4 78 -4C75.333336 -4 74 -2.6666667 74 0C74 2.6666667 75.333336 4 78 4ZM91 4C93.666664 4 95 2.6666667 95 0C95 -2.6666667 93.666664 -4 91 -4C88.333336 -4 87 -2.6666667 87 0C87 2.6666667 88.333336 4 91 4ZM100 8C102.66666 8 104 6.666667 104 4C104 1.3333333 102.66666 0 100 0C97.333336 0 96 1.3333333 96 4C96 6.666667 97.333336 8 100 8ZM100 21C102.66666 21 104 19.666666 104 17C104 14.333333 102.66666 13 100 13C97.333336 13 96 14.333333 96 17C96 19.666666 97.333336 21 100 21ZM100 34C102.66666 34 104 32.666668 104 30C104 27.333334 102.66666 26 100 26C97.333336 26 96 27.333334 96 30C96 32.666668 97.333336 34 100 34ZM100 47C102.66666 47 104 45.666668 104 43C104 40.333332 102.66666 39 100 39C97.333336 39 96 40.333332 96 43C96 45.666668 97.333336 47 100 47ZM100 60C102.66666 60 104 58.666668 104 56C104 53.333332 102.66666 52 100 52C97.333336 52 96 53.333332 96 56C96 58.666668 97.333336 60 100 60ZM100 73C102.66666 73 104 71.666664 104 69C104 66.333336 102.66666 65 100 65C97.333336 65 96 66.333336 96 69C96 71.666664 97.333336 73 100 73ZM7 84C9.666667 84 11 82.666664 11 80C11 77.333336 9.666667 76 7 76C4.333333 76 3 77.333336 3 80C3 82.666664 4.333333 84 7 84ZM20 84C22.666666 84 24 82.666664 24 80C24 77.333336 22.666666 76 20 76C17.333334 76 16 77.333336 16 80C16 82.666664 17.333334 84 20 84ZM33 84C35.666668 84 37 82.666664 37 80C37 77.333336 35.666668 76 33 76C30.333334 76 29 77.333336 29 80C29 82.666664 30.333334 84 33 84ZM45.999996 84C48.666664 84 49.999996 82.666664 49.999996 80C49.999996 77.333336 48.666664 76 45.999996 76C43.333328 76 41.999996 77.333336 41.999996 80C41.999996 82.666664 43.333328 84 45.999996 84ZM59 84C61.666668 84 63 82.666664 63 80C63 77.333336 61.666668 76 59 76C56.333332 76 55 77.333336 55 80C55 82.666664 56.333332 84 59 84ZM72 84C74.666664 84 76 82.666664 76 80C76 77.333336 74.666664 76 72 76C69.333336 76 68 77.333336 68 80C68 82.666664 69.333336 84 72 84ZM85 84C87.666664 84 89 82.666664 89 80C89 77.333336 87.666664 76 85 76C82.333336 76 81 77.333336 81 80C81 82.666664 82.333336 84 85 84ZM98 84C100.66666 84 102 82.666664 102 80C102 77.333336 100.66666 76 98 76C95.333336 76 94 77.333336 94 80C94 82.666664 95.333336 84 98 84Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
-11
File diff suppressed because one or more lines are too long.
-8
@@ -1,8 +0,0 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="116" height="96" viewBox="0 0 116 96">
|
||||
<path fill="none" stroke="red" stroke-width="8" stroke-miterlimit="4" transform="translate(8 8)" d="M0 0L100 0L100 80L0 80"/>
|
||||
</svg>
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="116" height="96" viewBox="0 0 116 96">
|
||||
<path fill="none" stroke="blue" stroke-width="8" stroke-miterlimit="4" transform="translate(8 8)" d="M0 0L100 0L100 80L0 80"/>
|
||||
</svg>
|
||||
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user