Compare commits

..

1 Commits

Author SHA1 Message Date
Eva Marco
91b1c210d1 wip 2025-12-11 09:47:11 +01:00
111 changed files with 754 additions and 2400 deletions

View File

@@ -11,7 +11,7 @@ jobs:
secrets: inherit
with:
gh_ref: ${{ github.ref_name }}
build_wasm: "yes"
build_wasm: "no"
build_storybook: "yes"
build-docker:

2
.gitignore vendored
View File

@@ -20,7 +20,6 @@
.rebel_readline_history
.repl
.shadow-cljs
.pnpm-store/
/*.jpg
/*.md
/*.png
@@ -72,7 +71,6 @@
/library/target/
/library/*.zip
/external
/penpot-nitrate
clj-profiler/
node_modules

View File

@@ -114,8 +114,6 @@ example. It's still usable as before, we just removed the example.
- Fix unpublish library modal not scrolling a long file list [Taiga #12285](https://tree.taiga.io/project/penpot/issue/12285)
- Fix incorrect interaction betwen hower and scroll on assets sidebar [Taiga #12389](https://tree.taiga.io/project/penpot/issue/12389)
- Fix switch variants with paths [Taiga #12841](https://tree.taiga.io/project/penpot/issue/12841)
- Fix referencing typography tokens on font-family tokens [Taiga #12492](https://tree.taiga.io/project/penpot/issue/12492)
- Fix horizontal scroll on layer panel [Taiga #12843](https://tree.taiga.io/project/penpot/issue/12843)
## 2.11.1

View File

@@ -3,7 +3,7 @@
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Tokens%20starter%20kit.penpot"}
{:id "penpot-design-system"
:name "Penpot Design System | Pencil"
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Pencil-Penpot-Design-System.penpot"}
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/penpot-app.penpot"}
{:id "wireframing-kit"
:name "Wireframe library"
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Wireframing%20kit%20v1.1.penpot"}

View File

@@ -36,8 +36,7 @@ export PENPOT_FLAGS="\
enable-file-validation \
enable-file-schema-validation \
enable-redis-cache \
enable-subscriptions \
enable-nitrate";
enable-subscriptions";
# Default deletion delay for devenv
export PENPOT_DELETION_DELAY="24h"
@@ -56,8 +55,6 @@ export PENPOT_OBJECTS_STORAGE_BACKEND=s3
export PENPOT_OBJECTS_STORAGE_S3_ENDPOINT=http://minio:9000
export PENPOT_OBJECTS_STORAGE_S3_BUCKET=penpot
export PENPOT_NITRATE_BACKEND_URI=http://localhost:3000
export JAVA_OPTS="\
-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \
-Djdk.attach.allowAttachSelf \

View File

@@ -225,8 +225,6 @@
[:netty-io-threads {:optional true} ::sm/int]
[:executor-threads {:optional true} ::sm/int]
[:nitrate-backend-uri {:optional true} ::sm/uri]
;; DEPRECATED
[:assets-storage-backend {:optional true} :keyword]
[:storage-assets-fs-directory {:optional true} :string]

View File

@@ -323,7 +323,6 @@
{::http.client/client (ig/ref ::http.client/client)
::db/pool (ig/ref ::db/pool)
::rds/pool (ig/ref ::rds/pool)
:app.nitrate/client (ig/ref :app.nitrate/client)
::wrk/executor (ig/ref ::wrk/netty-executor)
::session/manager (ig/ref ::session/manager)
::ldap/provider (ig/ref ::ldap/provider)
@@ -340,9 +339,6 @@
::email/blacklist (ig/ref ::email/blacklist)
::email/whitelist (ig/ref ::email/whitelist)}
:app.nitrate/client
{::http.client/client (ig/ref ::http.client/client)}
:app.rpc/management-methods
{::http.client/client (ig/ref ::http.client/client)
::db/pool (ig/ref ::db/pool)
@@ -352,7 +348,6 @@
::sto/storage (ig/ref ::sto/storage)
::mtx/metrics (ig/ref ::mtx/metrics)
::mbus/msgbus (ig/ref ::mbus/msgbus)
:app.nitrate/client (ig/ref :app.nitrate/client)
::rds/client (ig/ref ::rds/client)
::setup/props (ig/ref ::setup/props)}

View File

@@ -1,123 +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
(ns app.nitrate
"Module that make calls to the external nitrate aplication"
(:require
[app.common.logging :as l]
[app.common.schema :as sm]
[app.config :as cf]
[app.http.client :as http]
[app.rpc :as-alias rpc]
[app.setup :as-alias setup]
[app.util.json :as json]
[clojure.core :as c]
[integrant.core :as ig]))
(def baseuri (cf/get :nitrate-backend-uri))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- coercer
[schema & {:as opts}]
(let [decode-fn (sm/decoder schema sm/json-transformer)
check-fn (sm/check-fn schema opts)]
(fn [data]
(-> data decode-fn check-fn))))
(defn- request-builder
[cfg method uri management-key profile-id]
(fn []
(http/req! cfg {:method method
:headers {"content-type" "application/json"
"accept" "application/json"
"x-shared-key" management-key
"x-profile-id" (str profile-id)}
:uri uri
:version :http1.1})))
(defn- with-retries
[handler max-retries]
(fn []
(loop [attempt 1]
(let [result (try
(handler)
(catch Exception e
(if (< attempt max-retries)
::retry
(do
;; TODO Error handling
(l/error :hint "request fail after multiple retries" :cause e)
nil))))]
(if (= result ::retry)
(recur (inc attempt))
result)))))
(defn- with-validate [handler uri schema]
(fn []
(let [coercer-http (coercer schema
:type :validation
:hint (str "invalid data received calling " uri))]
(try
(coercer-http (-> (handler) :body json/decode))
(catch Exception e
;; TODO Error handling
(l/error :hint "error validating json response" :cause e)
nil)))))
(defn- request-to-nitrate
[{:keys [::management-key] :as cfg} method uri schema {:keys [::rpc/profile-id] :as params}]
(let [full-http-call (-> (request-builder cfg method uri management-key profile-id)
(with-retries 3)
(with-validate uri schema))]
(full-http-call)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn call
[cfg method params]
(when (contains? cf/flags :nitrate)
(let [client (get cfg ::client)
method (get client method)]
(method params))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:organization
[:map
[:id ::sm/text]
[:name ::sm/text]])
(defn- get-team-org
[cfg {:keys [team-id] :as params}]
(request-to-nitrate cfg :get (str baseuri "/api/teams/" (str team-id)) schema:organization params))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; INITIALIZATION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defmethod ig/init-key ::client
[_ {:keys [::setup/props] :as cfg}]
(if (contains? cf/flags :nitrate)
(let [management-key (or (cf/get :management-api-key)
(get props :management-key))
cfg (assoc cfg ::management-key management-key)]
{:get-team-org (partial get-team-org cfg)})
{}))
(defmethod ig/halt-key! ::client
[_ {:keys []}]
(do :stuff))

View File

@@ -296,7 +296,6 @@
(let [cfg (assoc cfg ::type "management" ::metrics-id :rpc-management-timing)]
(->> (sv/scan-ns
'app.rpc.management.subscription
'app.rpc.management.nitrate
'app.rpc.management.exporter)
(map (partial process-method cfg "management" wrap-management))
(into {}))))

View File

@@ -23,7 +23,6 @@
[app.main :as-alias main]
[app.media :as media]
[app.msgbus :as mbus]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as-alias doc]
@@ -173,12 +172,6 @@
(map decode-row)
(map process-permissions)))
(defn- add-org-to-team
[cfg team params]
(let [params (assoc (or params {}) :team-id (:id team))
org (nitrate/call cfg :get-team-org params)]
(assoc team :organization-id (:id org) :organization-name (:name org))))
(defn get-teams
[conn profile-id]
(let [profile (profile/get-profile conn profile-id)
@@ -197,9 +190,7 @@
::sm/params schema:get-teams}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
(dm/with-open [conn (db/open pool)]
(cond->> (get-teams conn profile-id)
(contains? cf/flags :nitrate)
(map #(add-org-to-team cfg % params)))))
(get-teams conn profile-id)))
(def ^:private sql:get-owned-teams
"SELECT t.id, t.name,

View File

@@ -1,112 +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
(ns app.rpc.management.nitrate
"Internal Nitrate HTTP API.
Provides authenticated access to organization management and token validation endpoints.
All requests must include a valid shared key token in the `x-shared-key` header, and
a cookie `auth-token` with the user token.
They will return `401 Unauthorized` if the shared key or user token are invalid."
(:require
[app.common.schema :as sm]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.msgbus :as mbus]
[app.rpc :as-alias rpc]
[app.rpc.commands.files :as files]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as doc]
[app.util.services :as sv]))
;; ---- API: authenticate
(def ^:private schema:profile
[:map
[:id ::sm/uuid]
[:name :string]
[:email :string]
[:photo-url :string]])
(sv/defmethod ::authenticate
"Authenticate an user
@api GET /authenticate
@returns
200 OK: Returns the authenticated user."
{::doc/added "2.12"
::sm/result schema:profile}
[cfg {:keys [::rpc/profile-id] :as params}]
(let [profile (profile/get-profile cfg profile-id)]
{:id (get profile :id)
:name (get profile :fullname)
:email (get profile :email)
:photo-url (files/resolve-public-uri (get profile :photo-id))}))
;; ---- API: get-teams
(def ^:private sql:get-teams
"SELECT t.*
FROM team AS t
JOIN team_profile_rel AS tpr ON t.id = tpr.team_id
WHERE tpr.profile_id = ?
AND tpr.is_owner = 't'
AND t.is_default = 'f'
AND t.deleted_at is null;")
(def ^:private schema:team
[:map
[:id ::sm/uuid]
[:name :string]])
(def ^:private schema:get-teams-result
[:vector schema:team])
(sv/defmethod ::get-teams
"List teams for which current user is owner.
@api GET /get-teams
@returns
200 OK: Returns the list of teams for the user."
{::doc/added "2.12"
::sm/result schema:get-teams-result}
[cfg {:keys [::rpc/profile-id]}]
(when (contains? cf/flags :nitrate)
(let [current-user-id (-> (profile/get-profile cfg profile-id) :id)]
(->> (db/exec! cfg [sql:get-teams current-user-id])
(map #(select-keys % [:id :name]))))))
;; ---- API: notify-team-change
(def ^:private schema:notify-team-change
[:map
[:id ::sm/uuid]
[:organization-id ::sm/text]])
(sv/defmethod ::notify-team-change
"Notify to Penpot a team change from nitrate
@api POST /notify-team-change
@returns
200 OK"
{::doc/added "2.12"
::sm/params schema:notify-team-change
::rpc/auth false}
[cfg {:keys [id organization-id organization-name]}]
(when (contains? cf/flags :nitrate)
(let [msgbus (::mbus/msgbus cfg)]
(mbus/pub! msgbus
;;TODO There is a bug on dashboard with teams notifications.
;;For now we send it to uuid/zero instead of team-id
:topic uuid/zero
:message {:type :team-org-change
:team-id id
:organization-id organization-id
:organization-name organization-name}))))

View File

@@ -82,113 +82,6 @@
(declare create-svg-children)
(declare parse-svg-element)
(defn- process-gradient-stops
"Processes gradient stops to extract stop-color and stop-opacity from style attributes
and convert them to direct attributes. This ensures stops with style='stop-color:#...;stop-opacity:1'
are properly converted to stop-color and stop-opacity attributes."
[stops]
(mapv (fn [stop]
(let [stop-attrs (:attrs stop)
stop-style (get stop-attrs :style)
;; Parse style if it's a string using csvg/parse-style utility
parsed-style (when (and (string? stop-style) (seq stop-style))
(csvg/parse-style stop-style))
;; Extract stop-color and stop-opacity from style
style-stop-color (when parsed-style (:stop-color parsed-style))
style-stop-opacity (when parsed-style (:stop-opacity parsed-style))
;; Merge: use direct attributes first, then style values as fallback
final-attrs (cond-> stop-attrs
(and style-stop-color (not (contains? stop-attrs :stop-color)))
(assoc :stop-color style-stop-color)
(and style-stop-opacity (not (contains? stop-attrs :stop-opacity)))
(assoc :stop-opacity style-stop-opacity)
;; Remove style attribute if we've extracted its values
(or style-stop-color style-stop-opacity)
(dissoc :style))]
(assoc stop :attrs final-attrs)))
stops))
(defn- resolve-gradient-href
"Resolves xlink:href references in gradients by merging the referenced gradient's
stops and attributes with the referencing gradient. This ensures gradients that
reference other gradients (like linearGradient3550 referencing linearGradient3536)
inherit the stops from the base gradient.
According to SVG spec, when a gradient has xlink:href:
- It inherits all attributes from the referenced gradient
- It inherits all stops from the referenced gradient
- The referencing gradient's attributes override the base ones
- If the referencing gradient has stops, they replace the base stops
Returns the defs map with all gradient href references resolved."
[defs]
(letfn [(resolve-gradient [gradient-id gradient-node defs visited]
(if (contains? visited gradient-id)
(do
#?(:cljs (js/console.warn "[resolve-gradient] Circular reference detected for" gradient-id)
:clj nil)
gradient-node) ;; Avoid circular references
(let [attrs (:attrs gradient-node)
href-id (or (:href attrs) (:xlink:href attrs))
href-id (when (and (string? href-id) (pos? (count href-id)))
(subs href-id 1)) ;; Remove leading #
base-gradient (when (and href-id (contains? defs href-id))
(get defs href-id))
resolved-base (when base-gradient (resolve-gradient href-id base-gradient defs (conj visited gradient-id)))]
(if resolved-base
;; Merge: base gradient attributes + referencing gradient attributes
;; Use referencing gradient's stops if present, otherwise use base stops
(let [base-attrs (:attrs resolved-base)
ref-attrs (:attrs gradient-node)
;; Start with base attributes (without id), then merge with ref attributes
;; This ensures ref attributes override base ones
base-attrs-clean (dissoc base-attrs :id)
ref-attrs-clean (dissoc ref-attrs :href :xlink:href :id)
;; Special handling for gradientTransform: if both have it, combine them
base-transform (get base-attrs :gradientTransform)
ref-transform (get ref-attrs :gradientTransform)
combined-transform (cond
(and base-transform ref-transform)
(str base-transform " " ref-transform) ;; Apply base first, then ref
:else (or ref-transform base-transform))
;; Merge attributes: base first, then ref (ref overrides)
merged-attrs (-> (d/deep-merge base-attrs-clean ref-attrs-clean)
(cond-> combined-transform
(assoc :gradientTransform combined-transform)))
;; If referencing gradient has content (stops), use it; otherwise use base content
final-content (if (seq (:content gradient-node))
(:content gradient-node)
(:content resolved-base))
;; Process stops to extract stop-color and stop-opacity from style attributes
processed-content (process-gradient-stops final-content)
result {:tag (:tag gradient-node)
:attrs (assoc merged-attrs :id gradient-id)
:content processed-content}]
result)
;; Process stops even for gradients without references to extract style attributes
(let [processed-content (process-gradient-stops (:content gradient-node))]
(assoc gradient-node :content processed-content))))))]
(let [gradient-tags #{:linearGradient :radialGradient}
result (reduce-kv
(fn [acc id node]
(if (contains? gradient-tags (:tag node))
(assoc acc id (resolve-gradient id node defs #{}))
(assoc acc id node)))
{}
defs)]
result)))
(defn create-svg-shapes
([svg-data pos objects frame-id parent-id selected center?]
(create-svg-shapes (uuid/next) svg-data pos objects frame-id parent-id selected center?))
@@ -219,9 +112,6 @@
(csvg/fix-percents)
(csvg/extract-defs))
;; Resolve gradient href references in all defs before processing shapes
def-nodes (resolve-gradient-href def-nodes)
;; In penpot groups have the size of their children. To
;; respect the imported svg size and empty space let's create
;; a transparent shape as background to respect the imported
@@ -252,23 +142,12 @@
(reduce (partial create-svg-children objects selected frame-id root-id svg-data)
[unames []]
(d/enumerate (->> (:content svg-data)
(mapv #(csvg/inherit-attributes root-attrs %)))))
(mapv #(csvg/inherit-attributes root-attrs %)))))]
;; Collect all defs from children and merge into root shape
all-defs-from-children (reduce (fn [acc child]
(if-let [child-defs (:svg-defs child)]
(merge acc child-defs)
acc))
{}
children)
;; Merge defs from svg-data and children into root shape
root-shape-with-defs (assoc root-shape :svg-defs (merge def-nodes all-defs-from-children))]
[root-shape-with-defs children])))
[root-shape children])))
(defn create-raw-svg
[name frame-id {:keys [x y width height offset-x offset-y defs] :as svg-data} {:keys [attrs] :as data}]
[name frame-id {:keys [x y width height offset-x offset-y]} {:keys [attrs] :as data}]
(let [props (csvg/attrs->props attrs)
vbox (grc/make-rect offset-x offset-y width height)]
(cts/setup-shape
@@ -281,11 +160,10 @@
:y y
:content data
:svg-attrs props
:svg-viewbox vbox
:svg-defs defs})))
:svg-viewbox vbox})))
(defn create-svg-root
[id frame-id parent-id {:keys [name x y width height offset-x offset-y attrs defs] :as svg-data}]
[id frame-id parent-id {:keys [name x y width height offset-x offset-y attrs]}]
(let [props (-> (dissoc attrs :viewBox :view-box :xmlns)
(d/without-keys csvg/inheritable-props)
(csvg/attrs->props))]
@@ -299,8 +177,7 @@
:height height
:x (+ x offset-x)
:y (+ y offset-y)
:svg-attrs props
:svg-defs defs})))
:svg-attrs props})))
(defn create-svg-children
[objects selected frame-id parent-id svg-data [unames children] [_index svg-element]]
@@ -321,7 +198,7 @@
(defn create-group
[name frame-id {:keys [x y width height offset-x offset-y defs] :as svg-data} {:keys [attrs]}]
[name frame-id {:keys [x y width height offset-x offset-y] :as svg-data} {:keys [attrs]}]
(let [transform (csvg/parse-transform (:transform attrs))
attrs (-> attrs
(d/without-keys csvg/inheritable-props)
@@ -337,8 +214,7 @@
:height height
:svg-transform transform
:svg-attrs attrs
:svg-viewbox vbox
:svg-defs defs})))
:svg-viewbox vbox})))
(defn create-path-shape [name frame-id svg-data {:keys [attrs] :as data}]
(when (and (contains? attrs :d) (seq (:d attrs)))
@@ -647,21 +523,6 @@
:else (dm/str tag))]
(dm/str "svg-" suffix)))
(defn- filter-valid-def-references
"Filters out false positive references that are not valid def IDs.
Filters out:
- Colors in style attributes (hex colors like #f9dd67)
- Style fragments that contain CSS keywords (like stop-opacity)
- References that don't exist in defs"
[ref-ids defs]
(let [is-style-fragment? (fn [ref-id]
(or (clr/hex-color-string? (str "#" ref-id))
(str/includes? ref-id ";") ;; Contains CSS separator
(str/includes? ref-id "stop-opacity") ;; CSS keyword
(str/includes? ref-id "stop-color")))] ;; CSS keyword
(->> ref-ids
(remove is-style-fragment?) ;; Filter style fragments and hex colors
(filter #(contains? defs %))))) ;; Only existing defs
(defn parse-svg-element
[frame-id svg-data {:keys [tag attrs hidden] :as element} unames]
@@ -673,11 +534,7 @@
(let [name (or (:id attrs) (tag->name tag))
att-refs (csvg/find-attr-references attrs)
defs (get svg-data :defs)
valid-refs (filter-valid-def-references att-refs defs)
all-refs (csvg/find-def-references defs valid-refs)
;; Filter the final result to ensure all references are valid defs
;; This prevents false positives from style attributes in gradient stops
references (filter-valid-def-references all-refs defs)
references (csvg/find-def-references defs att-refs)
href-id (or (:href attrs) (:xlink:href attrs) " ")
href-id (if (and (string? href-id)

View File

@@ -145,10 +145,7 @@
;; A temporal flag, enables backend code use more extensivelly
;; redis for caching data
:redis-cache
;; Activates the nitrate module
:nitrate})
:redis-cache})
(def all-flags
(set/union email login varia))

View File

@@ -546,19 +546,9 @@
filter-values)))
(defn extract-ids [val]
;; Extract referenced ids from string values like "url(#myId)".
;; Non-string values (maps, numbers, nil, etc.) return an empty seq
;; to avoid re-seq type errors when attributes carry nested structures.
(cond
(string? val)
(when (some? val)
(->> (re-seq xml-id-regex val)
(mapv second))
(sequential? val)
(mapcat extract-ids val)
:else
[]))
(mapv second))))
(defn fix-dot-number
"Fixes decimal numbers starting in dot but without leading 0"

View File

@@ -50,9 +50,4 @@ tmux select-window -t penpot:4
tmux send-keys -t penpot 'cd penpot/backend' enter C-l
tmux send-keys -t penpot './scripts/start-dev' enter
tmux new-window -t penpot:5 -n 'nitrate'
tmux select-window -t penpot:5
tmux send-keys -t penpot 'cd penpot/penpot-nitrate' enter C-l
tmux send-keys -t penpot 'pnpm dev --host' enter
tmux -2 attach-session -t penpot

View File

@@ -29,9 +29,8 @@ update_flags /var/www/app/js/config.js
export PENPOT_BACKEND_URI=${PENPOT_BACKEND_URI:-http://penpot-backend:6060}
export PENPOT_EXPORTER_URI=${PENPOT_EXPORTER_URI:-http://penpot-exporter:6061}
export PENPOT_NITRATE_URI=${PENPOT_NITRATE_URI:-http://penpot-nitrate:3000}
export PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE=${PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE:-367001600} # Default to 350MiB
envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_NITRATE_URI,\$PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE" \
envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE" \
< /tmp/nginx.conf.template > /etc/nginx/nginx.conf
PENPOT_DEFAULT_INTERNAL_RESOLVER="$(awk 'BEGIN{ORS=" "} $1=="nameserver" { sub(/%.*$/,"",$2); print ($2 ~ ":")? "["$2"]": $2}' /etc/resolv.conf)"

View File

@@ -139,14 +139,6 @@ http {
proxy_pass $PENPOT_BACKEND_URI/ws/notifications;
}
location /control-center {
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $http_cf_connecting_ip;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass $PENPOT_NITRATE_URI$request_uri;
}
include /etc/nginx/overrides/server.d/*.conf;
location / {

View File

@@ -21,7 +21,6 @@
"raw-body": "^3.0.1",
"source-map-support": "^0.5.21",
"svgo": "penpot/svgo#v3.1",
"undici": "^7.16.0",
"xml-js": "^1.6.11",
"xregexp": "^5.1.2"
},

View File

@@ -7,4 +7,5 @@ bb -i '(babashka.wait/wait-for-port "localhost" 9630)';
bb -i '(babashka.wait/wait-for-path "target/app.js")';
sleep 2;
export NODE_TLS_REJECT_UNAUTHORIZED=0
exec node target/app.js

View File

@@ -107,12 +107,12 @@
:on-progress on-progress)
append (fn [{:keys [filename path] :as resource}]
(rsc/add-to-zip zip path (str/replace filename sanitize-file-regex "_")))
(rsc/add-to-zip! zip path (str/replace filename sanitize-file-regex "_")))
proc (->> exports
(map (fn [export] (rd/render export append)))
(p/all)
(p/mcat (fn [_] (rsc/close-zip zip)))
(p/fnly (fn [_] (.finalize zip)))
(p/fmap (constantly resource))
(p/mcat (partial rsc/upload-resource auth-token))
(p/fmap (fn [resource]

View File

@@ -11,7 +11,6 @@
["node:fs" :as fs]
["node:fs/promises" :as fsp]
["node:path" :as path]
["undici" :as http]
[app.common.exceptions :as ex]
[app.common.transit :as t]
[app.common.uri :as u]
@@ -54,40 +53,30 @@
(.pipe zip out)
zip))
(defn add-to-zip
(defn add-to-zip!
[zip path name]
(.file ^js zip path #js {:name name}))
(defn close-zip
(defn close-zip!
[zip]
(p/create (fn [resolve]
(.on ^js zip "close" resolve)
(.finalize ^js zip))))
(.finalize ^js zip))
(defn upload-resource
[auth-token resource]
(->> (fsp/readFile (:path resource))
(p/fmap (fn [buffer]
(js/console.log buffer)
(new js/Blob #js [buffer] #js {:type (:mtype resource)})))
(p/mcat (fn [blob]
(let [fdata (new http/FormData)
agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}})
headers #js {"X-Shared-Key" cf/management-key
"Authorization" (str "Bearer " auth-token)}
request #js {:headers headers
:method "POST"
:body fdata
:dispatcher agent}
uri (-> (cf/get :public-uri)
(u/ensure-path-slash)
(u/join "api/management/methods/upload-tempfile")
(str))]
(let [fdata (new js/FormData)
uri (-> (cf/get :public-uri)
(u/ensure-path-slash)
(u/join "api/management/methods/upload-tempfile")
(str))]
(.append fdata "content" blob (:filename resource))
(http/fetch uri request))))
(js/fetch uri #js {:headers #js {"X-Shared-Key" cf/management-key
"Authorization" (str "Bearer " auth-token)}
:method "POST"
:body fdata}))))
(p/mcat (fn [response]
(if (not= (.-status response) 200)
(ex/raise :type :internal

View File

@@ -75,8 +75,7 @@
[path]
(->> (.stat fs/promises path)
(p/fmap (fn [data]
{:path path
:created-at (inst-ms (.-ctime ^js data))
{:created-at (inst-ms (.-ctime ^js data))
:size (.-size data)}))
(p/merr (fn [_cause]
(p/resolved nil)))))

View File

@@ -582,7 +582,6 @@ __metadata:
raw-body: "npm:^3.0.1"
source-map-support: "npm:^0.5.21"
svgo: "penpot/svgo#v3.1"
undici: "npm:^7.16.0"
ws: "npm:^8.18.3"
xml-js: "npm:^1.6.11"
xregexp: "npm:^5.1.2"
@@ -1514,13 +1513,6 @@ __metadata:
languageName: node
linkType: hard
"undici@npm:^7.16.0":
version: 7.16.0
resolution: "undici@npm:7.16.0"
checksum: 10c0/efd867792e9f233facf9efa0a087e2d9c3e4415c0b234061b9b40307ca4fa01d945fee4d43c7b564e1b80e0d519bcc682f9f6e0de13c717146c00a80e2f1fb0f
languageName: node
linkType: hard
"unique-filename@npm:^4.0.0":
version: 4.0.0
resolution: "unique-filename@npm:4.0.0"

View File

@@ -32,8 +32,8 @@
"e2e:server": "node ./scripts/e2e-server.js",
"fmt:clj": "cljfmt fix --parallel=true src/ test/",
"fmt:clj:check": "cljfmt check --parallel=false src/ test/",
"fmt:js": "yarn run prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -c text-editor/**/*.js -w",
"fmt:js:check": "yarn run prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js text-editor/**/*.js",
"fmt:js": "yarn run prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -w",
"fmt:js:check": "yarn run prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js",
"lint:clj": "clj-kondo --parallel --lint src/",
"lint:scss": "yarn run prettier -c resources/styles -c src/**/*.scss",
"lint:scss:fix": "yarn run prettier -c resources/styles -c src/**/*.scss -w",

View File

@@ -1,12 +1,12 @@
export class Clipboard {
static Permission = {
ONLY_READ: ["clipboard-read"],
ONLY_WRITE: ["clipboard-write"],
ALL: ["clipboard-read", "clipboard-write"],
};
ONLY_READ: ['clipboard-read'],
ONLY_WRITE: ['clipboard-write'],
ALL: ['clipboard-read', 'clipboard-write']
}
static enable(context, permissions) {
return context.grantPermissions(permissions);
return context.grantPermissions(permissions)
}
static writeText(page, text) {
@@ -18,8 +18,8 @@ export class Clipboard {
}
constructor(page, context) {
this.page = page;
this.context = context;
this.page = page
this.context = context
}
enable(permissions) {

View File

@@ -1,16 +1,18 @@
export class Transit {
static parse(value) {
if (typeof value !== "string") return value;
if (typeof value !== 'string')
return value
if (value.startsWith("~")) return value.slice(2);
if (value.startsWith('~'))
return value.slice(2)
return value;
return value
}
static get(object, ...path) {
let aux = object;
for (const name of path) {
if (typeof name !== "string") {
if (typeof name !== 'string') {
if (!(name in aux)) {
return undefined;
}

View File

@@ -9,7 +9,7 @@ export class BasePage {
*/
static async mockRPCs(page, paths, options) {
for (const [path, jsonFilename] of Object.entries(paths)) {
await this.mockRPC(page, path, jsonFilename, options);
await this.mockRPC(page, path, jsonFilename, options)
}
}

View File

@@ -1,7 +1,7 @@
import { expect } from "@playwright/test";
import { readFile } from "node:fs/promises";
import { readFile } from 'node:fs/promises';
import { BaseWebSocketPage } from "./BaseWebSocketPage";
import { Transit } from "../../helpers/Transit";
import { Transit } from '../../helpers/Transit';
export class WorkspacePage extends BaseWebSocketPage {
static TextEditor = class TextEditor {

View File

@@ -51,7 +51,7 @@ test.skip("BUG 12164 - Crash when trying to fetch a missing font", async ({
pageId: "2b7f0188-51a1-8193-8006-e05bad87b74d",
});
await workspacePage.page.waitForTimeout(1000);
await workspacePage.page.waitForTimeout(1000)
await workspacePage.waitForFirstRender();
await expect(

View File

@@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test";
import { Clipboard } from "../../helpers/Clipboard";
import { Clipboard } from '../../helpers/Clipboard';
import { WorkspacePage } from "../pages/WorkspacePage";
const timeToWait = 100;
@@ -11,14 +11,14 @@ test.beforeEach(async ({ page, context }) => {
await WorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-v2"]);
});
test.afterEach(async ({ context }) => {
test.afterEach(async ({ context}) => {
context.clearPermissions();
});
})
test("Create a new text shape", async ({ page }) => {
const initialText = "Lorem ipsum";
const workspace = new WorkspacePage(page, {
textEditor: true,
textEditor: true
});
await workspace.setupEmptyFile();
await workspace.goToWorkspace();
@@ -36,7 +36,10 @@ test("Create a new text shape from pasting text", async ({ page, context }) => {
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.mockRPC("update-file?id=*", "text-editor/update-file.json");
await workspace.mockRPC(
"update-file?id=*",
"text-editor/update-file.json",
);
await workspace.goToWorkspace();
await Clipboard.writeText(page, textToPaste);
@@ -52,13 +55,10 @@ test("Create a new text shape from pasting text", async ({ page, context }) => {
await workspace.textEditor.stopEditing();
});
test("Create a new text shape from pasting text using context menu", async ({
page,
context,
}) => {
test("Create a new text shape from pasting text using context menu", async ({ page, context }) => {
const textToPaste = "Lorem ipsum";
const workspace = new WorkspacePage(page, {
textEditor: true,
textEditor: true
});
await workspace.setupEmptyFile();
await workspace.goToWorkspace();
@@ -72,13 +72,11 @@ test("Create a new text shape from pasting text using context menu", async ({
expect(textContent).toBe(textToPaste);
await workspace.textEditor.stopEditing();
});
})
test("Update an already created text shape by appending text", async ({
page,
}) => {
test("Update an already created text shape by appending text", async ({ page }) => {
const workspace = new WorkspacePage(page, {
textEditor: true,
textEditor: true
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
@@ -96,7 +94,7 @@ test("Update an already created text shape by prepending text", async ({
page,
}) => {
const workspace = new WorkspacePage(page, {
textEditor: true,
textEditor: true
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
@@ -114,7 +112,7 @@ test("Update an already created text shape by inserting text in between", async
page,
}) => {
const workspace = new WorkspacePage(page, {
textEditor: true,
textEditor: true
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
@@ -128,13 +126,10 @@ test("Update an already created text shape by inserting text in between", async
await workspace.textEditor.stopEditing();
});
test("Update a new text shape appending text by pasting text", async ({
page,
context,
}) => {
test("Update a new text shape appending text by pasting text", async ({ page, context }) => {
const textToPaste = " dolor sit amet";
const workspace = new WorkspacePage(page, {
textEditor: true,
textEditor: true
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
@@ -152,12 +147,11 @@ test("Update a new text shape appending text by pasting text", async ({
});
test("Update a new text shape prepending text by pasting text", async ({
page,
context,
page, context
}) => {
const textToPaste = "Dolor sit amet ";
const workspace = new WorkspacePage(page, {
textEditor: true,
textEditor: true
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
@@ -179,7 +173,7 @@ test("Update a new text shape replacing (starting) text with pasted text", async
}) => {
const textToPaste = "Dolor sit amet";
const workspace = new WorkspacePage(page, {
textEditor: true,
textEditor: true
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
@@ -203,7 +197,7 @@ test("Update a new text shape replacing (ending) text with pasted text", async (
}) => {
const textToPaste = "dolor sit amet";
const workspace = new WorkspacePage(page, {
textEditor: true,
textEditor: true
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
@@ -227,7 +221,7 @@ test("Update a new text shape replacing (in between) text with pasted text", asy
}) => {
const textToPaste = "dolor sit amet";
const workspace = new WorkspacePage(page, {
textEditor: true,
textEditor: true
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
@@ -250,11 +244,14 @@ test("Update text font size selecting a part of it (starting)", async ({
page,
}) => {
const workspace = new WorkspacePage(page, {
textEditor: true,
textEditor: true
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
await workspace.mockRPC("update-file?id=*", "text-editor/update-file.json");
await workspace.mockRPC(
"update-file?id=*",
"text-editor/update-file.json",
);
await workspace.goToWorkspace();
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing();
@@ -283,10 +280,7 @@ test.skip("Update text line height selecting a part of it (starting)", async ({
await workspace.textEditor.selectFromStart(5);
await workspace.textEditor.changeLineHeight(1.4);
const lineHeight = await workspace.textEditor.waitForParagraphStyle(
1,
"line-height",
);
const lineHeight = await workspace.textEditor.waitForParagraphStyle(1, 'line-height');
expect(lineHeight).toBe("1.4");
const textContent = await workspace.textEditor.waitForTextSpanContent();

View File

@@ -20,26 +20,25 @@ echo $PATH
set -ex
corepack enable;
corepack install;
corepack install || exit 1;
yarn install || exit 1;
rm -rf target/dist;
rm -rf resources/public;
rm -rf target/dist;
mkdir -p resources/public;
mkdir -p target/dist;
pushd ../render-wasm;
./build
popd
yarn run build:app:main $EXTRA_PARAMS;
yarn run build:app:libs;
yarn run build:app:assets;
sed -i "s/\.\/render.js/.\/render.js?version=$CURRENT_VERSION/g" resources/public/js/worker/main*.js
if [ "$INCLUDE_WASM" = "yes" ]; then
yarn run build:wasm || exit 1;
fi
rsync -avr resources/public/ target/dist/
yarn run build:app:libs || exit 1;
yarn run build:app:assets || exit 1;
rsync -avr resources/public/ target/dist/;
if [ "$INCLUDE_STORYBOOK" = "yes" ]; then
# build storybook

View File

@@ -92,7 +92,7 @@
{:main
{:entries [app.worker]
:web-worker true
:prepend-js "importScripts('./render.js');"
:prepend-js "importScripts('/js/worker/render.js');"
:depends-on #{}}}
:js-options

View File

@@ -649,22 +649,10 @@
(rx/of (dcm/change-team-role params)
(modal/hide)))))
(defn handle-change-team-org
[{:keys [team-id organization-id organization-name] :as message}]
(ptk/reify ::handle-change-team-org
ptk/UpdateEvent
(update [_ state]
(if (contains? (:teams state) team-id)
(-> state
(assoc-in [:teams team-id :organization-id] organization-id)
(assoc-in [:teams team-id :organization-name] organization-name))
state))))
(defn- process-message
[{:keys [type] :as msg}]
(case type
:notification (dcm/handle-notification msg)
:team-role-change (handle-change-team-role msg)
:team-membership-change (dcm/team-membership-change msg)
:team-org-change (handle-change-team-org msg)
nil))

View File

@@ -24,8 +24,6 @@
(def revn-data (atom {}))
(def queue-conj (fnil conj #queue []))
(def force-persist? #(= % ::force-persist))
(defn- update-status
[status]
(ptk/reify ::update-status

View File

@@ -32,7 +32,7 @@
[app.main.data.helpers :as dsh]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.data.persistence :as dps]
[app.main.data.persistence :as-alias dps]
[app.main.data.plugins :as dp]
[app.main.data.profile :as du]
[app.main.data.project :as dpj]
@@ -67,7 +67,6 @@
[app.main.errors]
[app.main.features :as features]
[app.main.features.pointer-map :as fpmap]
[app.main.refs :as refs]
[app.main.repo :as rp]
[app.main.router :as rt]
[app.render-wasm :as wasm]
@@ -380,59 +379,6 @@
(->> (rx/from added)
(rx/map process-wasm-object)))))))
(when render-wasm?
(let [local-commits-s
(->> stream
(rx/filter dch/commit?)
(rx/map deref)
(rx/filter #(and (= :local (:source %))
(not (contains? (:tags %) :position-data))))
(rx/filter (complement empty?)))
notifier-s
(rx/merge
(->> local-commits-s (rx/debounce 1000))
(->> stream (rx/filter dps/force-persist?)))
objects-s
(rx/from-atom refs/workspace-page-objects {:emit-current-value? true})
current-page-id-s
(rx/from-atom refs/current-page-id {:emit-current-value? true})]
(->> local-commits-s
(rx/buffer-until notifier-s)
(rx/with-latest-from objects-s)
(rx/map
(fn [[commits objects]]
(->> commits
(mapcat :redo-changes)
(filter #(contains? #{:mod-obj :add-obj} (:type %)))
(filter #(cfh/text-shape? objects (:id %)))
(map #(vector
(:id %)
(wasm.api/calculate-position-data (get objects (:id %))))))))
(rx/with-latest-from current-page-id-s)
(rx/map
(fn [[text-position-data page-id]]
(let [changes
(->> text-position-data
(mapv (fn [[id position-data]]
{:type :mod-obj
:id id
:page-id page-id
:operations
[{:type :set
:attr :position-data
:val position-data
:ignore-touched true
:ignore-geometry true}]})))]
(dch/commit-changes
{:redo-changes changes :undo-changes []
:save-undo? false
:tags #{:position-data}})))))))
(->> stream
(rx/filter dch/commit?)
(rx/map deref)

View File

@@ -30,9 +30,6 @@
(def profile
(l/derived (l/key :profile) st/state))
(def current-page-id
(l/derived (l/key :current-page-id) st/state))
(def team
(l/derived (fn [state]
(let [team-id (:current-team-id state)

View File

@@ -60,7 +60,6 @@
current-id (get state :id)
current-value (get state :current-value)
current-label (get label-index current-value)
is-open? (get state :is-open?)
node-ref (mf/use-ref nil)

View File

@@ -280,8 +280,8 @@
(mf/defc teams-selector-dropdown*
{::mf/private true}
[{:keys [team profile teams show-default-team allow-create-teams allow-create-org] :rest props}]
(let [on-create-team-click
[{:keys [team profile teams] :rest props}]
(let [on-create-click
(mf/use-fn #(st/emit! (modal/show :team-form {})))
on-team-click
@@ -290,25 +290,18 @@
(let [team-id (-> (dom/get-current-target event)
(dom/get-data "value")
(uuid/parse))]
(st/emit! (dcm/go-to-dashboard-recent :team-id team-id)))))
on-create-org-click
(mf/use-fn
(fn []
;; TODO update when org creation route is ready
(dom/open-new-window "localhost:3000/org/create")))]
(st/emit! (dcm/go-to-dashboard-recent :team-id team-id)))))]
[:> dropdown-menu* props
(when show-default-team
[:> dropdown-menu-item* {:on-click on-team-click
:data-value (:default-team-id profile)
:class (stl/css :team-dropdown-item)}
[:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon]
[:> dropdown-menu-item* {:on-click on-team-click
:data-value (:default-team-id profile)
:class (stl/css :team-dropdown-item)}
[:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon]
[:span {:class (stl/css :team-text)} (tr "dashboard.your-penpot")]
(when (= (:default-team-id profile) (:id team))
tick-icon)])
[:span {:class (stl/css :team-text)} (tr "dashboard.your-penpot")]
(when (= (:default-team-id profile) (:id team))
tick-icon)]
(for [team-item (remove :is-default (vals teams))]
[:> dropdown-menu-item* {:on-click on-team-click
@@ -329,19 +322,11 @@
(when (= (:id team-item) (:id team))
tick-icon)])
(when allow-create-teams
[:hr {:role "separator" :class (stl/css :team-separator)}]
[:> dropdown-menu-item* {:on-click on-create-team-click
:class (stl/css :team-dropdown-item :action)}
[:span {:class (stl/css :icon-wrapper)} add-icon]
[:span {:class (stl/css :team-text)} (tr "dashboard.create-new-team")]])
(when allow-create-org
[:hr {:role "separator" :class (stl/css :team-separator)}]
[:> dropdown-menu-item* {:on-click on-create-org-click
:class (stl/css :team-dropdown-item :action)}
[:span {:class (stl/css :icon-wrapper)} add-icon]
[:span {:class (stl/css :team-text)} (tr "dashboard.create-new-org")]])]))
[:hr {:role "separator" :class (stl/css :team-separator)}]
[:> dropdown-menu-item* {:on-click on-create-click
:class (stl/css :team-dropdown-item :action)}
[:span {:class (stl/css :icon-wrapper)} add-icon]
[:span {:class (stl/css :team-text)} (tr "dashboard.create-new-team")]]]))
(mf/defc team-options-dropdown*
{::mf/private true}
@@ -491,80 +476,9 @@
:data-testid "delete-team"}
(tr "dashboard.delete-team")])]))
(mf/defc sidebar-org-switch*
[{:keys [team profile]}]
(let [teams (->> (mf/deref refs/teams)
vals
(group-by :organization-id)
(map (fn [[_group entries]] (first entries)))
vec
(d/index-by :id))
teams (update-vals teams
(fn [t]
(assoc t :name (str "ORG: " (:organization-name t)))))
team (assoc team :name (str "ORG: " (:organization-name team)))
show-teams-menu*
(mf/use-state false)
show-teams-menu?
(deref show-teams-menu*)
on-show-teams-click
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(swap! show-teams-menu* not)))
on-show-teams-keydown
(mf/use-fn
(fn [event]
(when (or (kbd/space? event)
(kbd/enter? event))
(dom/prevent-default event)
(dom/stop-propagation event)
(some-> (dom/get-current-target event)
(dom/click!)))))
close-teams-menu
(mf/use-fn #(reset! show-teams-menu* false))]
[:div {:class (stl/css :sidebar-team-switch)}
[:div {:class (stl/css :switch-content)}
[:button {:class (stl/css :current-team)
:on-click on-show-teams-click
:on-key-down on-show-teams-keydown}
[:div {:class (stl/css :team-name)}
[:img {:src (cf/resolve-team-photo-url team)
:class (stl/css :team-picture)
:alt (:name team)}]
[:span {:class (stl/css :team-text) :title (:name team)} (:name team)]]
arrow-icon]]
;; Teams Dropdown
[:> teams-selector-dropdown* {:show show-teams-menu?
:on-close close-teams-menu
:id "organizations-list"
:class (stl/css :dropdown :teams-dropdown)
:team team
:profile profile
:teams teams
:show-default-team false
:allow-create-teams false
:allow-create-org true}]]))
(mf/defc sidebar-team-switch*
[{:keys [team profile]}]
(let [nitrate? (contains? cf/flags :nitrate)
org-id (when nitrate? (:organization-id team))
teams (cond->> (mf/deref refs/teams)
nitrate?
(filter #(= (-> % val :organization-id) org-id)))
(let [teams (mf/deref refs/teams)
subscription
(get team :subscription)
@@ -672,10 +586,7 @@
:class (stl/css :dropdown :teams-dropdown)
:team team
:profile profile
:teams teams
:show-default-team true
:allow-create-teams true
:allow-create-org false}]
:teams teams}]
[:> team-options-dropdown* {:show show-team-options-menu?
:on-close close-team-options-menu
@@ -792,8 +703,6 @@
[:*
[:div {:class (stl/css-case :sidebar-content true)
:ref container}
(when (contains? cf/flags :nitrate)
[:> sidebar-org-switch* {:team team :profile profile}])
[:> sidebar-team-switch* {:team team :profile profile}]
[:> sidebar-search* {:search-term search-term

View File

@@ -45,7 +45,7 @@
.element-list {
@include t.use-typography("body-large");
color: var(--modal-text-foreground-color);
overflow-y: auto;
overflow-y: scroll;
margin-block: 0;
}

View File

@@ -32,13 +32,19 @@
min-width: var(--sp-l);
}
// TODO: Review if we need other type of button, so we don't need important here
.invisible-button {
position: absolute;
right: 4px;
top: 4px;
opacity: var(--opacity-button);
background-color: var(--color-background-quaternary) !important;
&:hover {
background-color: var(--color-background-quaternary);
--opacity-button: 1;
}
&:focus {
background-color: var(--color-background-quaternary);
--opacity-button: 1;
}
}

View File

@@ -80,7 +80,7 @@
[:div {:class (stl/css :pill-dot)}])]]
(when-not ^boolean disabled
[:> icon-button* {:variant "action"
[:> icon-button* {:variant "ghost"
:class (stl/css :invisible-button)
:icon i/broken-link
:ref token-detach-btn-ref

View File

@@ -8,6 +8,7 @@
@use "ds/_sizes.scss" as *;
@use "ds/typography.scss" as t;
@use "ds/colors.scss" as *;
@use "ds/mixins.scss" as *;
.token-field {
--token-field-bg-color: var(--color-background-tertiary);
@@ -16,9 +17,8 @@
--token-field-outline-color: none;
--token-field-height: var(--sp-xxxl);
--token-field-margin: unset;
display: grid;
grid-template-columns: 1fr auto;
width: inherit;
column-gap: var(--sp-xs);
align-items: center;
position: relative;
@@ -27,6 +27,7 @@
border-radius: $br-8;
padding: var(--sp-xs);
outline: $b-1 solid var(--token-field-outline-color);
position: relative;
&:hover {
--token-field-bg-color: var(--color-background-quaternary);
@@ -39,7 +40,7 @@
}
.with-icon {
grid-template-columns: auto 1fr auto;
grid-template-columns: auto 1fr;
}
.token-field-disabled {
@@ -57,6 +58,8 @@
--pill-bg-color: var(--color-background-tertiary);
--pill-fg-color: var(--color-token-foreground);
@include t.use-typography("code-font");
@include textEllipsis;
display: block;
height: var(--sp-xxl);
width: fit-content;
background: var(--pill-bg-color);
@@ -65,6 +68,7 @@
color: var(--pill-fg-color);
border-radius: $br-6;
padding-inline: $sz-6;
max-width: 100%;
&:hover {
--pill-bg-color: var(--color-token-background);
--pill-fg-color: var(--color-foreground-primary);
@@ -115,6 +119,9 @@
}
.invisible-button {
position: absolute;
right: 0;
top: 0;
opacity: var(--opacity-button);
&:hover {

View File

@@ -159,4 +159,6 @@ $arrow-side: 12px;
block-size: fit-content;
inline-size: fit-content;
line-height: 0;
display: grid;
max-width: 100%;
}

View File

@@ -5,7 +5,6 @@
// Copyright (c) KALEIDOS INC
@use "refactor/common-refactor.scss" as deprecated;
@use "ds/_utils.scss" as *;
.layer-row {
--layer-indentation-size: calc(#{deprecated.$s-4} * 6);
@@ -88,7 +87,7 @@
height: deprecated.$s-32;
width: calc(100% - (var(--depth) * var(--layer-indentation-size)));
cursor: pointer;
min-width: px2rem(140);
&.filtered {
width: calc(100% - deprecated.$s-12);
}

View File

@@ -211,7 +211,9 @@
overflow-x: auto;
overflow-y: overlay;
scrollbar-gutter: stable;
}
.element-list {
display: grid;
.element-list {
width: var(--left-sidebar-width);
display: grid;
}
}

View File

@@ -3,10 +3,15 @@
(:require
[app.common.data.macros :as dm]
[app.common.types.shape.radius :as ctsr]
[app.common.types.token :as tk]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.tokens.application :as dwta]
[app.main.features :as features]
[app.main.store :as st]
[app.main.ui.components.numeric-input :as deprecated-input]
[app.main.ui.context :as muc]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.numeric-input :refer [numeric-input*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.hooks :as hooks]
[app.util.i18n :as i18n :refer [tr]]
@@ -21,11 +26,15 @@
(defn- check-border-radius-menu-props
[old-props new-props]
(let [old-values (unchecked-get old-props "values")
new-values (unchecked-get new-props "values")]
new-values (unchecked-get new-props "values")
old-applied-tokens (unchecked-get old-props "applied-tokens")
new-applied-tokens (unchecked-get new-props "applied-tokens")]
(and (identical? (unchecked-get old-props "class")
(unchecked-get new-props "class"))
(identical? (unchecked-get old-props "ids")
(unchecked-get new-props "ids"))
(identical? old-applied-tokens
new-applied-tokens)
(identical? (get old-values :r1)
(get new-values :r1))
(identical? (get old-values :r2)
@@ -35,13 +44,64 @@
(identical? (get old-values :r4)
(get new-values :r4)))))
(mf/defc numeric-input-wrapper*
{::mf/private true}
[{:keys [values name applied-tokens align on-detach radius] :rest props}]
(let [tokens (mf/use-ctx muc/active-tokens-by-type)
tokens (mf/with-memo [tokens name]
(delay
(-> (deref tokens)
(select-keys (get tk/tokens-by-input name))
(not-empty))))
on-detach-attr
(mf/use-fn
(mf/deps on-detach name)
#(on-detach % name))
r1-value (get applied-tokens :r1)
r2-value (get applied-tokens :r2)
r3-value (get applied-tokens :r3)
r4-value (get applied-tokens :r4)
all-equal? (= r1-value r2-value r3-value r4-value)
applied-token (if (= :all radius)
(if all-equal?
r1-value
:mixed)
:mixed)
props (mf/spread-props props
{:placeholder (if (or (= :multiple (:applied-tokens values))
(= :multiple (get values name)))
(tr "settings.multiple") "--")
:class (stl/css :numeric-input-measures)
:applied-token applied-token
:tokens (if (delay? tokens) @tokens tokens)
:align align
:on-detach on-detach-attr
:value (get values name)})]
[:> numeric-input* props]))
(mf/defc border-radius-menu*
{::mf/wrap [#(mf/memo' % check-border-radius-menu-props)]}
[{:keys [class ids values]}]
(let [all-equal? (all-equal? values)
[{:keys [class ids values applied-tokens]}]
(let [token-numeric-inputs
(features/use-feature "tokens/numeric-input")
all-equal? (all-equal? values)
radius-expanded* (mf/use-state false)
radius-expanded (deref radius-expanded*)
;; DETACH
on-detach-token
(mf/use-fn
(mf/deps ids)
(fn [token attr]
(st/emit! (dwta/unapply-token {:token (first token)
:attributes #{attr}
:shape-ids ids}))))
change-radius
(mf/use-fn
(mf/deps ids)
@@ -94,23 +154,39 @@
[:div {:class (dm/str class " " (stl/css :radius))}
(if (not radius-expanded)
[:div {:class (stl/css :radius-1)
:title (tr "workspace.options.radius")}
[:> icon* {:icon-id i/corner-radius
:size "s"
:class (stl/css :icon)}]
[:> deprecated-input/numeric-input*
{:placeholder (cond
(not all-equal?)
(tr "settings.multiple")
(= :multiple (:r1 values))
(tr "settings.multiple")
:else
"--")
:min 0
:nillable true
:on-change on-single-radius-change
:value (if all-equal? (:r1 values) nil)}]]
(if token-numeric-inputs
[:div {:class (stl/css :radius-1)
:title (tr "workspace.options.radius")}
[:> numeric-input-wrapper*
{:on-change on-single-radius-change
:on-detach on-detach-token
:icon i/corner-radius
:min 0
:name :border-radius
:nillable true
:property (tr "workspace.options.width")
:applied-tokens applied-tokens
:radius :all
:values (if all-equal? (:r1 values) nil)}]]
[:div {:class (stl/css :radius-1)
:title (tr "workspace.options.radius")}
[:> icon* {:icon-id i/corner-radius
:size "s"
:class (stl/css :icon)}]
[:* [:> deprecated-input/numeric-input*
{:placeholder (cond
(not all-equal?)
"Mixed"
(= :multiple (:r1 values))
(tr "settings.multiple")
:else
"--")
:min 0
:nillable true
:on-change on-single-radius-change
:value (if all-equal? (:r1 values) nil)}]]])
[:div {:class (stl/css :radius-4)}
[:div {:class (stl/css :small-input)}

View File

@@ -77,7 +77,7 @@
(nil? (get values name)))
(tr "settings.multiple")
"--")
:class (stl/css :numeric-input-measures)
:class (stl/css :numeric-input-layout)
:applied-token (get applied-tokens name)
:tokens tokens
:align align

View File

@@ -433,6 +433,6 @@
align-items: center;
}
.numeric-input-measures {
.numeric-input-layout {
--dropdown-width: var(--7-columns-dropdown-width);
}

View File

@@ -6,6 +6,7 @@
@use "refactor/common-refactor.scss" as deprecated;
@use "../../../sidebar/common/sidebar.scss" as sidebar;
@use "ds/_utils.scss" as *;
.element-set {
display: grid;
@@ -188,7 +189,6 @@
@extend .button-icon;
}
// TODO: Add a proper variable to this sizing
.numeric-input-measures {
--dropdown-width: var(--7-columns-dropdown-width);
}

View File

@@ -265,13 +265,11 @@
(mf/deps font on-change)
(fn [new-variant-id]
(let [variant (d/seek #(= new-variant-id (:id %)) (:variants font))]
(when-not (nil? variant)
(on-change {:font-id (:id font)
:font-family (:family font)
:font-variant-id new-variant-id
:font-weight (:weight variant)
:font-style (:style variant)}))
(on-change {:font-id (:id font)
:font-family (:family font)
:font-variant-id new-variant-id
:font-weight (:weight variant)
:font-style (:style variant)})
(dom/blur! (dom/get-target new-variant-id)))))
on-font-select
@@ -344,13 +342,12 @@
{:value (:id variant)
:key (pr-str variant)
:label (:name variant)})))
variant-options (if (= font-variant-id :multiple)
variant-options (if (= font-size :multiple)
(conj basic-variant-options
{:value ""
{:value :multiple
:key :multiple-variants
:label "--"})
basic-variant-options)]
;; TODO Add disabled mode
[:& select
{:class (stl/css :font-variant-select)

View File

@@ -68,7 +68,7 @@
(mf/defc color-token-row*
{::mf/private true}
[{:keys [active-tokens applied-token-name color on-swatch-click-token detach-token open-modal-from-token]}]
[{:keys [active-tokens color-token color on-swatch-click-token detach-token open-modal-from-token]}]
(let [;; `active-tokens` may be provided as a `delay` (lazy computation).
;; In that case we must deref it (`@active-tokens`) to force evaluation
;; and obtain the actual value. If its already realized (not a delay),
@@ -77,22 +77,21 @@
@active-tokens
active-tokens)
active-color-tokens (:color active-tokens)
color-tokens (:color active-tokens)
token (some #(when (= (:name %) applied-token-name) %) active-color-tokens)
token (some #(when (= (:name %) color-token) %) color-tokens)
on-detach-token
(mf/use-fn
(mf/deps detach-token token applied-token-name)
(mf/deps detach-token token color-token)
(fn []
(let [token (or token applied-token-name)]
(let [token (or token color-token)]
(detach-token token))))
has-errors (some? (:errors token))
token-name (:name token)
resolved (:resolved-value token)
not-active (and (empty? active-tokens)
(nil? token))
not-active (and (some? active-tokens) (nil? token))
id (dm/str (:id token) "-name")
swatch-tooltip-content (cond
not-active
@@ -110,7 +109,7 @@
#(mf/html
[:div
[:span (dm/str (tr "workspace.tokens.token-name") ": ")]
[:span {:class (stl/css :token-name-tooltip)} applied-token-name]]))]
[:span {:class (stl/css :token-name-tooltip)} color-token]]))]
[:div {:class (stl/css :color-info)}
[:div {:class (stl/css-case :token-color-wrapper true
@@ -129,7 +128,7 @@
:class (stl/css :token-tooltip)}
[:div {:class (stl/css :token-name)
:aria-labelledby id}
(or token-name applied-token-name)]]
(or token-name color-token)]]
[:div {:class (stl/css :token-actions)}
[:> icon-button*
{:variant "action"
@@ -147,11 +146,7 @@
on-change on-reorder on-detach on-open on-close on-remove origin on-detach-token
disable-drag on-focus on-blur select-only select-on-focus on-token-change applied-token]}]
(let [;; TODO: Remove this workaround fixing `get-attrs*` fn on sidebar/options/shapes/multiple.cljs
applied-token (if (= :multiple applied-token)
nil
applied-token)
token-color (contains? cfg/flags :token-color)
(let [token-color (contains? cfg/flags :token-color)
libraries (mf/deref refs/files)
color-without-hash (mf/use-memo
@@ -182,6 +177,7 @@
(-> (deref active-tokens*)
(select-keys (get tk/tokens-by-input origin))
(not-empty)))))
on-focus'
(mf/use-fn
(mf/deps on-focus)
@@ -356,7 +352,7 @@
(cond
(and token-color applied-token)
[:> color-token-row* {:active-tokens tokens
:applied-token-name applied-token
:color-token applied-token
:color (dissoc color :ref-id :ref-file)
:on-swatch-click-token on-swatch-click-token
:detach-token detach-token

View File

@@ -63,8 +63,7 @@
:data {:index index})
[nil nil])
stroke-color-token
(:stroke-color applied-tokens)
stroke-color-token (:stroke-color applied-tokens)
on-color-change-refactor
(mf/use-fn

View File

@@ -268,8 +268,8 @@
:on-click modal/hide!}
(tr "labels.cancel")]
[:> import-type-dropdown*
{:options [{:label (tr "workspace.tokens.import-menu-json-option") :value :file}
{:label (tr "workspace.tokens.import-menu-zip-option") :value :zip}
{:options [{:label (tr "workspace.tokens.import-menu-zip-option") :value :zip}
{:label (tr "workspace.tokens.import-menu-json-option") :value :file}
{:label (tr "workspace.tokens.import-menu-folder-option") :value :folder}]
:on-click handle-import-action
:text-render render-button-text

View File

@@ -16,7 +16,6 @@
[app.common.geom.shapes.points :as gpo]
[app.common.types.shape.layout :as ctl]
[app.common.uuid :as uuid]
[app.render-wasm.api :as wasm.api]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
@@ -276,26 +275,3 @@
:y2 (:y end-p)
:style {:stroke "red"
:stroke-width (/ 1 zoom)}}]))]))))
(mf/defc debug-text-wasm-position-data
{::mf/wrap-props false}
[props]
(let [zoom (unchecked-get props "zoom")
selected-shapes (unchecked-get props "selected-shapes")
selected-text
(when (and (= (count selected-shapes) 1) (= :text (-> selected-shapes first :type)))
(first selected-shapes))
position-data
(when selected-text
(wasm.api/calculate-position-data selected-text))]
(for [{:keys [x y width height]} position-data]
[:rect {:x x
:y (- y height)
:width width
:height height
:fill "none"
:strokeWidth (/ 1 zoom)
:stroke "red"}])))

View File

@@ -12,12 +12,10 @@
[app.common.files.helpers :as cfh]
[app.common.geom.shapes :as gsh]
[app.common.types.color :as clr]
[app.common.types.component :as ctk]
[app.common.types.path :as path]
[app.common.types.shape :as cts]
[app.common.types.shape.layout :as ctl]
[app.main.data.workspace.transforms :as dwt]
[app.main.data.workspace.variants :as dwv]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
@@ -259,16 +257,6 @@
first-shape (first selected-shapes)
show-add-variant? (and single-select?
(or (ctk/is-variant-container? first-shape)
(ctk/is-variant? first-shape)))
add-variant
(mf/use-fn
(mf/deps first-shape)
#(st/emit!
(dwv/add-new-variant (:id first-shape))))
show-padding?
(and (nil? transform)
single-select?
@@ -647,12 +635,6 @@
:hover-top-frame-id @hover-top-frame-id
:zoom zoom}])
(when (dbg/enabled? :text-outline)
[:& wvd/debug-text-wasm-position-data
{:selected-shapes selected-shapes
:objects base-objects
:zoom zoom}])
(when show-selection-handlers?
[:g.selection-handlers {:clipPath "url(#clip-handlers)"}
(when-not text-editing?
@@ -681,11 +663,6 @@
{:id (first selected)
:zoom zoom}])
(when show-add-variant?
[:> widgets/button-add* {:shape first-shape
:zoom zoom
:on-click add-variant}])
[:g.grid-layout-editor {:clipPath "url(#clip-handlers)"}
(when show-grid-editor?
[:& grid-layout/editor

View File

@@ -23,7 +23,6 @@
[app.main.refs :as refs]
[app.main.render :as render]
[app.main.store :as st]
[app.main.ui.shapes.text]
[app.main.worker :as mw]
[app.render-wasm.api.fonts :as f]
[app.render-wasm.api.texts :as t]
@@ -34,7 +33,7 @@
[app.render-wasm.performance :as perf]
[app.render-wasm.serializers :as sr]
[app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.svg-filters :as svg-filters]
[app.render-wasm.svg-fills :as svg-fills]
;; FIXME: rename; confunsing name
[app.render-wasm.wasm :as wasm]
[app.util.debug :as dbg]
@@ -43,7 +42,6 @@
[app.util.modules :as mod]
[app.util.text.content :as tc]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[promesa.core :as p]
[rumext.v2 :as mf]))
@@ -705,7 +703,7 @@
(set-grid-layout-columns (get shape :layout-grid-columns))
(set-grid-layout-cells (get shape :layout-grid-cells)))
(defn set-layout-data
(defn set-layout-child
[shape]
(let [margins (get shape :layout-item-margin)
margin-top (get margins :m1 0)
@@ -728,7 +726,7 @@
is-absolute (boolean (get shape :layout-item-absolute))
z-index (get shape :layout-item-z-index)]
(h/call wasm/internal-module
"_set_layout_data"
"_set_layout_child_data"
margin-top
margin-right
margin-bottom
@@ -748,11 +746,6 @@
is-absolute
(d/nilv z-index 0))))
(defn has-any-layout-prop? [shape]
(some #(and (keyword? %)
(str/starts-with? (name %) "layout-"))
(keys shape)))
(defn clear-layout
[]
(h/call wasm/internal-module "_clear_shape_layout"))
@@ -760,10 +753,10 @@
(defn- set-shape-layout
[shape objects]
(clear-layout)
(when (or (ctl/any-layout? shape)
(ctl/any-layout-immediate-child? objects shape)
(has-any-layout-prop? shape))
(set-layout-data shape))
(ctl/any-layout-immediate-child? objects shape))
(set-layout-child shape))
(when (ctl/flex-layout? shape)
(set-flex-layout shape))
@@ -882,43 +875,27 @@
(def render-finish
(letfn [(do-render [ts]
(perf/begin-measure "render-finish")
(h/call wasm/internal-module "_set_view_end")
(render ts)
(perf/end-measure "render-finish"))]
(render ts))]
(fns/debounce do-render DEBOUNCE_DELAY_MS)))
(def render-pan
(letfn [(do-render-pan [ts]
(perf/begin-measure "render-pan")
(render ts)
(perf/end-measure "render-pan"))]
(fns/throttle do-render-pan THROTTLE_DELAY_MS)))
(fns/throttle render THROTTLE_DELAY_MS))
(defn set-view-box
[prev-zoom zoom vbox]
(let [is-pan (mth/close? prev-zoom zoom)]
(perf/begin-measure "set-view-box")
(h/call wasm/internal-module "_set_view_start")
(h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox)))
(h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox)))
(if is-pan
(do (perf/end-measure "set-view-box")
(perf/begin-measure "set-view-box::pan")
(render-pan)
(render-finish)
(perf/end-measure "set-view-box::pan"))
(do (perf/end-measure "set-view-box")
(perf/begin-measure "set-view-box::zoom")
(h/call wasm/internal-module "_render_from_cache" 0)
(render-finish)
(perf/end-measure "set-view-box::zoom")))))
(if (mth/close? prev-zoom zoom)
(do (render-pan)
(render-finish))
(do (h/call wasm/internal-module "_render_from_cache" 0)
(render-finish))))
(defn set-object
[objects shape]
(perf/begin-measure "set-object")
(let [shape (svg-filters/apply-svg-derived shape)
id (dm/get-prop shape :id)
(let [id (dm/get-prop shape :id)
type (dm/get-prop shape :type)
parent-id (get shape :parent-id)
@@ -932,7 +909,14 @@
rotation (get shape :rotation)
transform (get shape :transform)
fills (get shape :fills)
;; If the shape comes from an imported SVG (we know this because
;; it has the :svg-attrs attribute) and it does not have its
;; own fill, we set a default black fill. This fill will be
;; inherited by child nodes and emulates the behavior of
;; standard SVG, where a node without an explicit fill
;; defaults to black.
fills (svg-fills/resolve-shape-fills shape)
strokes (if (= type :group)
[] (get shape :strokes))
children (get shape :shapes)
@@ -976,11 +960,12 @@
(set-shape-svg-attrs svg-attrs))
(when (and (some? content) (= type :svg-raw))
(set-shape-svg-raw-content (get-static-markup shape)))
(set-shape-shadows shadows)
(when (some? shadows) (set-shape-shadows shadows))
(when (= type :text)
(set-shape-grow-type grow-type))
(set-shape-layout shape objects)
(set-shape-selrect selrect)
(let [pending_thumbnails (into [] (concat
@@ -1004,7 +989,10 @@
(run!
(fn [id]
(f/update-text-layout id)
(update-text-rect! id)))))
(mw/emit! {:cmd :index/update-text-rect
:page-id (:current-page-id @st/state)
:shape-id id
:dimensions (get-text-dimensions id)})))))
(defn process-pending
([shapes thumbnails full on-complete]
@@ -1245,8 +1233,6 @@
(when-not (nil? context)
(let [handle (.registerContext ^js gl context #js {"majorVersion" 2})]
(.makeContextCurrent ^js gl handle)
(set! wasm/gl-context-handle handle)
(set! wasm/gl-context context)
;; Force the WEBGL_debug_renderer_info extension as emscripten does not enable it
(.getExtension context "WEBGL_debug_renderer_info")
@@ -1269,20 +1255,6 @@
(set! wasm/context-initialized? false)
(h/call wasm/internal-module "_clean_up")
;; Ensure the WebGL context is properly disposed so browsers do not keep
;; accumulating active contexts between page switches.
(when-let [gl (unchecked-get wasm/internal-module "GL")]
(when-let [handle wasm/gl-context-handle]
(try
;; Ask the browser to release resources explicitly if available.
(when-let [ctx wasm/gl-context]
(when-let [lose-ext (.getExtension ^js ctx "WEBGL_lose_context")]
(.loseContext ^js lose-ext)))
(.deleteContext ^js gl handle)
(finally
(set! wasm/gl-context-handle nil)
(set! wasm/gl-context nil)))))
;; If this calls panics we don't want to crash. This happens sometimes
;; with hot-reload in develop
(catch :default error
@@ -1376,59 +1348,6 @@
(h/call wasm/internal-module "_end_temp_objects")
content)))
(def POSITION-DATA-U8-SIZE 36)
(def POSITION-DATA-U32-SIZE (/ POSITION-DATA-U8-SIZE 4))
(defn calculate-position-data
[shape]
(when wasm/context-initialized?
(use-shape (:id shape))
(let [heapf32 (mem/get-heap-f32)
heapu32 (mem/get-heap-u32)
offset (-> (h/call wasm/internal-module "_calculate_position_data")
(mem/->offset-32))
length (aget heapu32 offset)
max-offset (+ offset 1 (* length POSITION-DATA-U32-SIZE))
result
(loop [result (transient [])
offset (inc offset)]
(if (< offset max-offset)
(let [entry (dr/read-position-data-entry heapu32 heapf32 offset)]
(recur (conj! result entry)
(+ offset POSITION-DATA-U32-SIZE)))
(persistent! result)))
result
(->> result
(mapv
(fn [{:keys [paragraph span start-pos end-pos direction x y width height]}]
(let [content (:content shape)
element (-> content :children
(get 0) :children ;; paragraph-set
(get paragraph) :children ;; paragraph
(get span))
text (subs (:text element) start-pos end-pos)]
{:x x
:y (+ y height)
:width width
:height height
:direction (dr/translate-direction direction)
:font-family (get element :font-family)
:font-size (get element :font-size)
:font-weight (get element :font-weight)
:text-transform (get element :text-transform)
:text-decoration (get element :text-decoration)
:letter-spacing (get element :letter-spacing)
:font-style (get element :font-style)
:fills (get element :fills)
:text text}))))]
(mem/free)
result)))
(defn init-wasm-module
[module]
(let [default-fn (unchecked-get module "default")

View File

@@ -45,29 +45,4 @@
:center (gpt/point cx cy)
:transform (gmt/matrix a b c d e f)}))
(defn read-position-data-entry
[heapu32 heapf32 offset]
(let [paragraph (aget heapu32 (+ offset 0))
span (aget heapu32 (+ offset 1))
start-pos (aget heapu32 (+ offset 2))
end-pos (aget heapu32 (+ offset 3))
x (aget heapf32 (+ offset 4))
y (aget heapf32 (+ offset 5))
width (aget heapf32 (+ offset 6))
height (aget heapf32 (+ offset 7))
direction (aget heapu32 (+ offset 8))]
{:paragraph paragraph
:span span
:start-pos start-pos
:end-pos end-pos
:x x
:y y
:width width
:height height
:direction direction}))
(defn translate-direction
[direction]
(case direction
0 "rtl"
"ltr"))

View File

@@ -14,7 +14,7 @@
[app.common.types.shape.layout :as ctl]
[app.main.refs :as refs]
[app.render-wasm.api :as api]
[app.render-wasm.svg-filters :as svg-filters]
[app.render-wasm.svg-fills :as svg-fills]
[app.render-wasm.wasm :as wasm]
[beicon.v2.core :as rx]
[cljs.core :as c]
@@ -130,11 +130,7 @@
(defn- set-wasm-attr!
[shape k]
(when wasm/context-initialized?
(let [shape (case k
:svg-attrs (svg-filters/apply-svg-derived (assoc shape :svg-attrs (get shape :svg-attrs)))
(:fills :blur :shadow) (svg-filters/apply-svg-derived shape)
shape)
v (get shape k)
(let [v (get shape k)
id (get shape :id)]
(case k
:parent-id
@@ -167,7 +163,8 @@
(api/set-shape-transform v)
:fills
(api/set-shape-fills id v false)
(let [fills (svg-fills/resolve-shape-fills shape)]
(into [] (api/set-shape-fills id fills false)))
:strokes
(into [] (api/set-shape-strokes id v false))
@@ -225,12 +222,8 @@
v])
:svg-attrs
(do
(api/set-shape-svg-attrs v)
;; Always update fills/blur/shadow to clear previous state if filters disappear
(api/set-shape-fills id (:fills shape) false)
(api/set-shape-blur (:blur shape))
(api/set-shape-shadows (:shadow shape)))
(when (cfh/path-shape? shape)
(api/set-shape-svg-attrs v))
:masked-group
(when (cfh/mask-shape? shape)
@@ -269,7 +262,7 @@
:layout-item-min-w
:layout-item-absolute
:layout-item-z-index)
(api/set-layout-data shape)
(api/set-layout-child shape)
:layout-grid-rows
(api/set-grid-layout-rows v)
@@ -299,7 +292,7 @@
(ctl/flex-layout? shape)
(api/set-flex-layout shape))
(api/set-layout-data shape))
(api/set-layout-child shape))
;; Property not in WASM
nil))))

View File

@@ -74,30 +74,6 @@
:width (max 0.01 (or (dm/get-prop shape :width) 1))
:height (max 0.01 (or (dm/get-prop shape :height) 1))}))))
(defn- apply-svg-transform
"Applies SVG transform to a point if present."
[pt svg-transform]
(if svg-transform
(gpt/transform pt svg-transform)
pt))
(defn- apply-viewbox-transform
"Transforms a point from viewBox space to selrect space."
[pt viewbox rect]
(if viewbox
(let [{svg-x :x svg-y :y svg-width :width svg-height :height} viewbox
rect-width (max 0.01 (dm/get-prop rect :width))
rect-height (max 0.01 (dm/get-prop rect :height))
origin-x (or (dm/get-prop rect :x) (dm/get-prop rect :x1) 0)
origin-y (or (dm/get-prop rect :y) (dm/get-prop rect :y1) 0)
scale-x (/ rect-width svg-width)
scale-y (/ rect-height svg-height)
;; Transform from viewBox space to selrect space
transformed-x (+ origin-x (* (- (dm/get-prop pt :x) svg-x) scale-x))
transformed-y (+ origin-y (* (- (dm/get-prop pt :y) svg-y) scale-y))]
(gpt/point transformed-x transformed-y))
pt))
(defn- normalize-point
[pt units shape]
(if (= units "userspaceonuse")
@@ -105,16 +81,9 @@
width (max 0.01 (dm/get-prop rect :width))
height (max 0.01 (dm/get-prop rect :height))
origin-x (or (dm/get-prop rect :x) (dm/get-prop rect :x1) 0)
origin-y (or (dm/get-prop rect :y) (dm/get-prop rect :y1) 0)
svg-transform (:svg-transform shape)
viewbox (:svg-viewbox shape)
;; For userSpaceOnUse, coordinates are in SVG user space
;; We need to transform them to shape space before normalizing
pt-after-svg-transform (apply-svg-transform pt svg-transform)
transformed-pt (apply-viewbox-transform pt-after-svg-transform viewbox rect)
normalized-x (/ (- (dm/get-prop transformed-pt :x) origin-x) width)
normalized-y (/ (- (dm/get-prop transformed-pt :y) origin-y) height)]
(gpt/point normalized-x normalized-y))
origin-y (or (dm/get-prop rect :y) (dm/get-prop rect :y1) 0)]
(gpt/point (/ (- (dm/get-prop pt :x) origin-x) width)
(/ (- (dm/get-prop pt :y) origin-y) height)))
pt))
(defn- normalize-attrs
@@ -288,25 +257,18 @@
(parse-gradient-stop node))))
vec)]
(when (seq stops)
(let [[center point-x point-y]
(let [[center radius-point]
(let [points (apply-gradient-transform [(gpt/point cx cy)
(gpt/point (+ cx r) cy)
(gpt/point cx (+ cy r))]
(gpt/point (+ cx r) cy)]
transform)]
(map #(normalize-point % units shape) points))
radius-x (gpt/distance center point-x)
radius-y (gpt/distance center point-y)
;; Prefer Y as the base radius so width becomes the X/Y ratio.
base-radius (if (pos? radius-y) radius-y radius-x)
radius-point (if (pos? radius-y) point-y point-x)
width (let [safe-radius (max base-radius 1.0e-6)]
(/ radius-x safe-radius))]
radius (gpt/distance center radius-point)]
{:type :radial
:start-x (dm/get-prop center :x)
:start-y (dm/get-prop center :y)
:end-x (dm/get-prop radius-point :x)
:end-y (dm/get-prop radius-point :y)
:width width
:width radius
:stops stops}))))
(defn- svg-gradient->fill

View File

@@ -1,98 +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
(ns app.render-wasm.svg-filters
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.svg :as csvg]
[app.common.uuid :as uuid]
[app.render-wasm.svg-fills :as svg-fills]))
(def ^:private drop-shadow-tags
#{:feOffset :feGaussianBlur :feColorMatrix})
(defn- find-filter-element
"Finds a filter element by tag in filter content."
[filter-content tag]
(some #(when (= tag (:tag %)) %) filter-content))
(defn- find-filter-def
[shape]
(let [filter-attr (or (dm/get-in shape [:svg-attrs :filter])
(dm/get-in shape [:svg-attrs :style :filter]))
svg-defs (dm/get-prop shape :svg-defs)]
(when (and filter-attr svg-defs)
(let [filter-ids (csvg/extract-ids filter-attr)]
(some #(get svg-defs %) filter-ids)))))
(defn- build-blur
[gaussian-blur]
(when gaussian-blur
{:id (uuid/next)
:type :layer-blur
;; For layer blur the value matches stdDeviation directly
:value (-> (dm/get-in gaussian-blur [:attrs :stdDeviation])
(d/parse-double 0))
:hidden false}))
(defn- build-drop-shadow
[filter-content drop-shadow-elements]
(let [offset-elem (find-filter-element filter-content :feOffset)]
(when (and offset-elem (seq drop-shadow-elements))
(let [blur-elem (find-filter-element drop-shadow-elements :feGaussianBlur)
dx (-> (dm/get-in offset-elem [:attrs :dx])
(d/parse-double 0))
dy (-> (dm/get-in offset-elem [:attrs :dy])
(d/parse-double 0))
blur-value (if blur-elem
(-> (dm/get-in blur-elem [:attrs :stdDeviation])
(d/parse-double 0)
(* 2))
0)]
[{:id (uuid/next)
:style :drop-shadow
:offset-x dx
:offset-y dy
:blur blur-value
:spread 0
:hidden false
;; TODO: parse feColorMatrix to extract color/opacity
:color {:color "#000000" :opacity 1}}]))))
(defn apply-svg-filters
"Derives native blur/shadow from SVG filter definitions when the shape does
not already have them. The SVG attributes are left untouched so SVG fallback
rendering keeps working the same way as gradient fills."
[shape]
(let [existing-blur (:blur shape)
existing-shadow (:shadow shape)]
(if-let [filter-def (find-filter-def shape)]
(let [content (:content filter-def)
gaussian-blur (find-filter-element content :feGaussianBlur)
drop-shadow-elements (filter #(contains? drop-shadow-tags (:tag %)) content)
blur (or existing-blur (build-blur gaussian-blur))
shadow (if (seq existing-shadow)
existing-shadow
(build-drop-shadow content drop-shadow-elements))]
(cond-> shape
blur (assoc :blur blur)
(seq shadow) (assoc :shadow shadow)))
shape)))
(defn apply-svg-derived
"Applies SVG-derived effects (fills, blur, shadows) uniformly.
- Keeps user fills if present; otherwise derives from SVG.
- Converts SVG filters into native blur/shadow when needed.
- Always returns shape with :fills (possibly []) and blur/shadow keys."
[shape]
(let [shape' (apply-svg-filters shape)
fills (or (svg-fills/resolve-shape-fills shape') [])]
(assoc shape'
:fills fills
:blur (:blur shape')
:shadow (:shadow shape'))))

View File

@@ -9,8 +9,6 @@
(defonce internal-frame-id nil)
(defonce internal-module #js {})
(defonce gl-context-handle nil)
(defonce gl-context nil)
(defonce serializers
#js {:blur-type shared/RawBlurType
:blend-mode shared/RawBlendMode

View File

@@ -42,37 +42,6 @@
(deftest skips-when-no-svg-fill
(is (nil? (svg-fills/svg-fill->fills {:svg-attrs {:fill "none"}}))))
(def elliptical-shape
{:selrect {:x 0 :y 0 :width 200 :height 100}
:svg-attrs {:style {:fill "url(#grad-ellipse)"}}
:svg-defs {"grad-ellipse"
{:tag :radialGradient
:attrs {:id "grad-ellipse"
:gradientUnits "userSpaceOnUse"
:cx "50"
:cy "50"
:r "50"
:gradientTransform "matrix(2 0 0 1 0 0)"}
:content [{:tag :stop
:attrs {:offset "0"
:style "stop-color:#000000;stop-opacity:1"}}
{:tag :stop
:attrs {:offset "1"
:style "stop-color:#ffffff;stop-opacity:1"}}]}}})
(deftest builds-elliptical-radial-gradient-with-transform
(let [fills (svg-fills/svg-fill->fills elliptical-shape)
gradient (get-in (first fills) [:fill-color-gradient])]
(testing "ellipse from gradientTransform is preserved"
(is (= 1 (count fills)))
(is (= :radial (:type gradient)))
(is (= 0.5 (:start-x gradient)))
(is (= 0.5 (:start-y gradient)))
(is (= 0.5 (:end-x gradient)))
(is (= 1.0 (:end-y gradient)))
;; Scaling the X axis in the gradientTransform should reflect on width.
(is (= 1.0 (:width gradient))))))
(deftest resolve-shape-fills-prefers-existing-fills
(let [fills [{:fill-color "#ff00ff" :fill-opacity 0.75}]
resolved (svg-fills/resolve-shape-fills {:fills fills})]

View File

@@ -1,49 +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
(ns frontend-tests.svg-filters-test
(:require
[app.render-wasm.svg-filters :as svg-filters]
[cljs.test :refer [deftest is testing]]))
(def sample-filter-shape
{:svg-attrs {:filter "url(#simple-filter)"}
:svg-defs {"simple-filter"
{:tag :filter
:content [{:tag :feOffset :attrs {:dx "2" :dy "3"}}
{:tag :feGaussianBlur :attrs {:stdDeviation "4"}}]}}})
(deftest derives-blur-and-shadow-from-svg-filter
(let [shape (svg-filters/apply-svg-filters sample-filter-shape)
blur (:blur shape)
shadow (:shadow shape)]
(testing "layer blur derived from feGaussianBlur"
(is (= :layer-blur (:type blur)))
(is (= 4.0 (:value blur))))
(testing "drop shadow derived from filter chain"
(is (= [{:style :drop-shadow
:offset-x 2.0
:offset-y 3.0
:blur 8.0
:spread 0
:hidden false
:color {:color "#000000" :opacity 1}}]
(map #(dissoc % :id) shadow))))
(testing "svg attrs remain intact"
(is (= "url(#simple-filter)" (get-in shape [:svg-attrs :filter]))))))
(deftest keeps-existing-native-filters
(let [existing {:blur {:id :existing :type :layer-blur :value 1.0}
:shadow [{:id :shadow :style :drop-shadow}]}
shape (svg-filters/apply-svg-filters (merge sample-filter-shape existing))]
(is (= (:blur existing) (:blur shape)))
(is (= (:shadow existing) (:shadow shape)))))
(deftest skips-when-no-filter-definition
(let [shape {:svg-attrs {:fill "#fff"}}
result (svg-filters/apply-svg-filters shape)]
(is (= shape result))))

View File

@@ -15,7 +15,7 @@
*/
export function addEventListeners(target, object, options) {
Object.entries(object).forEach(([type, listener]) =>
target.addEventListener(type, listener, options),
target.addEventListener(type, listener, options)
);
}
@@ -27,6 +27,6 @@ export function addEventListeners(target, object, options) {
*/
export function removeEventListeners(target, object) {
Object.entries(object).forEach(([type, listener]) =>
target.removeEventListener(type, listener),
target.removeEventListener(type, listener)
);
}

View File

@@ -664,16 +664,8 @@ export class TextEditor extends EventTarget {
* @param {boolean} allowHTMLPaste
* @returns {Root}
*/
export function createRootFromHTML(
html,
style = undefined,
allowHTMLPaste = undefined,
) {
const fragment = mapContentFragmentFromHTML(
html,
style || undefined,
allowHTMLPaste || undefined,
);
export function createRootFromHTML(html, style = undefined, allowHTMLPaste = undefined) {
const fragment = mapContentFragmentFromHTML(html, style || undefined, allowHTMLPaste || undefined);
const root = createRoot([], style);
root.replaceChildren(fragment);
resetInertElement();

View File

@@ -18,10 +18,7 @@ import { TextEditor } from "../TextEditor.js";
* @param {DataTransfer} clipboardData
* @returns {DocumentFragment}
*/
function getFormattedFragmentFromClipboardData(
selectionController,
clipboardData,
) {
function getFormattedFragmentFromClipboardData(selectionController, clipboardData) {
return mapContentFragmentFromHTML(
clipboardData.getData("text/html"),
selectionController.currentStyle,
@@ -82,14 +79,9 @@ export function paste(event, editor, selectionController) {
let fragment = null;
if (editor?.options?.allowHTMLPaste) {
fragment = getFormattedOrPlainFragmentFromClipboardData(
event.clipboardData,
);
fragment = getFormattedOrPlainFragmentFromClipboardData(event.clipboardData);
} else {
fragment = getPlainFragmentFromClipboardData(
selectionController,
event.clipboardData,
);
fragment = getPlainFragmentFromClipboardData(selectionController, event.clipboardData);
}
if (!fragment) {
@@ -100,9 +92,10 @@ export function paste(event, editor, selectionController) {
if (selectionController.isCollapsed) {
const hasOnlyOneParagraph = fragment.children.length === 1;
const hasOnlyOneTextSpan = fragment.firstElementChild.children.length === 1;
const forceTextSpan =
fragment.firstElementChild.dataset.textSpan === "force";
if (hasOnlyOneParagraph && hasOnlyOneTextSpan && forceTextSpan) {
const forceTextSpan = fragment.firstElementChild.dataset.textSpan === "force";
if (hasOnlyOneParagraph
&& hasOnlyOneTextSpan
&& forceTextSpan) {
selectionController.insertIntoFocus(fragment.textContent);
} else {
selectionController.insertPaste(fragment);
@@ -110,9 +103,10 @@ export function paste(event, editor, selectionController) {
} else {
const hasOnlyOneParagraph = fragment.children.length === 1;
const hasOnlyOneTextSpan = fragment.firstElementChild.children.length === 1;
const forceTextSpan =
fragment.firstElementChild.dataset.textSpan === "force";
if (hasOnlyOneParagraph && hasOnlyOneTextSpan && forceTextSpan) {
const forceTextSpan = fragment.firstElementChild.dataset.textSpan === "force";
if (hasOnlyOneParagraph
&& hasOnlyOneTextSpan
&& forceTextSpan) {
selectionController.replaceText(fragment.textContent);
} else {
selectionController.replaceWithPaste(fragment);

View File

@@ -23,7 +23,7 @@ export function deleteContentBackward(event, editor, selectionController) {
// If not is collapsed AKA is a selection, then
// we removeSelected.
if (!selectionController.isCollapsed) {
return selectionController.removeSelected({ direction: "backward" });
return selectionController.removeSelected({ direction: 'backward' });
}
// If we're in a text node and the offset is
@@ -32,18 +32,18 @@ export function deleteContentBackward(event, editor, selectionController) {
if (selectionController.isTextFocus && selectionController.focusOffset > 0) {
return selectionController.removeBackwardText();
// If we're in a text node but we're at the end of the
// paragraph, we should merge the current paragraph
// with the following paragraph.
// If we're in a text node but we're at the end of the
// paragraph, we should merge the current paragraph
// with the following paragraph.
} else if (
selectionController.isTextFocus &&
selectionController.focusAtStart
) {
return selectionController.mergeBackwardParagraph();
// If we're at an text span or a line break paragraph
// and there's more than one paragraph, then we should
// remove the next paragraph.
// If we're at an text span or a line break paragraph
// and there's more than one paragraph, then we should
// remove the next paragraph.
} else if (
selectionController.isTextSpanFocus ||
selectionController.isLineBreakFocus

View File

@@ -28,21 +28,22 @@ export function deleteContentForward(event, editor, selectionController) {
// If we're in a text node and the offset is
// greater than 0 (not at the start of the text span)
// we simple remove a character from the text.
if (selectionController.isTextFocus && selectionController.focusAtEnd) {
if (selectionController.isTextFocus
&& selectionController.focusAtEnd) {
return selectionController.mergeForwardParagraph();
// If we're in a text node but we're at the end of the
// paragraph, we should merge the current paragraph
// with the following paragraph.
// If we're in a text node but we're at the end of the
// paragraph, we should merge the current paragraph
// with the following paragraph.
} else if (
selectionController.isTextFocus &&
selectionController.focusOffset >= 0
) {
return selectionController.removeForwardText();
// If we're at a text span or a line break paragraph
// and there's more than one paragraph, then we should
// remove the next paragraph.
// If we're at a text span or a line break paragraph
// and there's more than one paragraph, then we should
// remove the next paragraph.
} else if (
(selectionController.isTextSpanFocus ||
selectionController.isLineBreakFocus) &&

View File

@@ -1,17 +1,11 @@
import { describe, test, expect } from "vitest";
import { insertInto, removeBackward, removeForward, replaceWith } from "./Text";
import { describe, test, expect } from 'vitest'
import { insertInto, removeBackward, removeForward, replaceWith } from './Text';
describe("Text", () => {
test("* should throw when passed wrong parameters", () => {
expect(() => insertInto(Infinity, Infinity, Infinity)).toThrowError(
"Invalid string",
);
expect(() => insertInto("Hello", Infinity, Infinity)).toThrowError(
"Invalid offset",
);
expect(() => insertInto("Hello", 0, Infinity)).toThrowError(
"Invalid string",
);
expect(() => insertInto(Infinity, Infinity, Infinity)).toThrowError('Invalid string');
expect(() => insertInto('Hello', Infinity, Infinity)).toThrowError('Invalid offset');
expect(() => insertInto('Hello', 0, Infinity)).toThrowError('Invalid string');
});
test("`insertInto` should insert a string into an offset", () => {
@@ -19,9 +13,7 @@ describe("Text", () => {
});
test("`replaceWith` should replace a string into a string", () => {
expect(replaceWith("Hello, Something!", 7, 16, "World")).toBe(
"Hello, World!",
);
expect(replaceWith("Hello, Something!", 7, 16, "World")).toBe("Hello, World!");
});
test("`removeBackward` should remove string backward from start (offset 0)", () => {
@@ -34,13 +26,13 @@ describe("Text", () => {
test("`removeBackward` should remove string backward from end", () => {
expect(removeBackward("Hello, World!", "Hello, World!".length)).toBe(
"Hello, World",
"Hello, World"
);
});
test("`removeForward` should remove string forward from end", () => {
expect(removeForward("Hello, World!", "Hello, World!".length)).toBe(
"Hello, World!",
"Hello, World!"
);
});

View File

@@ -24,7 +24,7 @@ function getContext() {
if (!context) {
context = canvas.getContext("2d");
}
return context;
return context
}
/**

View File

@@ -230,10 +230,15 @@ export function mapContentFragmentFromString(string, styleDefaults) {
const fragment = document.createDocumentFragment();
for (const line of lines) {
if (line === "") {
fragment.appendChild(createEmptyParagraph(styleDefaults));
fragment.appendChild(
createEmptyParagraph(styleDefaults)
);
} else {
const textSpan = createTextSpan(new Text(line), styleDefaults);
const paragraph = createParagraph([textSpan], styleDefaults);
const paragraph = createParagraph(
[textSpan],
styleDefaults,
);
if (lines.length === 1) {
paragraph.dataset.textSpan = "force";
}

View File

@@ -112,11 +112,7 @@ describe("Paragraph", () => {
const helloTextSpan = createTextSpan(new Text("Hello, "));
const worldTextSpan = createTextSpan(new Text("World"));
const exclTextSpan = createTextSpan(new Text("!"));
const paragraph = createParagraph([
helloTextSpan,
worldTextSpan,
exclTextSpan,
]);
const paragraph = createParagraph([helloTextSpan, worldTextSpan, exclTextSpan]);
const newParagraph = splitParagraphAtNode(paragraph, 1);
expect(newParagraph).toBeInstanceOf(HTMLDivElement);
expect(newParagraph.nodeName).toBe(TAG);

View File

@@ -1,11 +1,5 @@
import { describe, test, expect } from "vitest";
import {
createEmptyRoot,
createRoot,
setRootStyles,
TAG,
TYPE,
} from "./Root.js";
import { createEmptyRoot, createRoot, setRootStyles, TAG, TYPE } from "./Root.js";
/* @vitest-environment jsdom */
describe("Root", () => {

View File

@@ -6,7 +6,7 @@
* Copyright (c) KALEIDOS INC
*/
import StyleDeclaration from "../../controllers/StyleDeclaration.js";
import StyleDeclaration from '../../controllers/StyleDeclaration.js';
import { getFills } from "./Color.js";
const DEFAULT_FONT_SIZE = "16px";
@@ -339,7 +339,8 @@ export function setStylesFromObject(element, allowedStyles, styleObject) {
continue;
}
let styleValue = styleObject[styleName];
if (!styleValue) continue;
if (!styleValue)
continue;
if (styleName === "font-family") {
styleValue = sanitizeFontFamily(styleValue);
@@ -387,10 +388,8 @@ export function setStylesFromDeclaration(
* @returns {HTMLElement}
*/
export function setStyles(element, allowedStyles, styleObjectOrDeclaration) {
if (
styleObjectOrDeclaration instanceof CSSStyleDeclaration ||
styleObjectOrDeclaration instanceof StyleDeclaration
) {
if (styleObjectOrDeclaration instanceof CSSStyleDeclaration
|| styleObjectOrDeclaration instanceof StyleDeclaration) {
return setStylesFromDeclaration(
element,
allowedStyles,

View File

@@ -22,7 +22,8 @@ import { isRoot } from "./Root.js";
*/
export function isTextNode(node) {
if (!node) throw new TypeError("Invalid text node");
return node.nodeType === Node.TEXT_NODE || isLineBreak(node);
return node.nodeType === Node.TEXT_NODE
|| isLineBreak(node);
}
/**
@@ -32,7 +33,8 @@ export function isTextNode(node) {
* @returns {boolean}
*/
export function isEmptyTextNode(node) {
return node.nodeType === Node.TEXT_NODE && node.nodeValue === "";
return node.nodeType === Node.TEXT_NODE
&& node.nodeValue === "";
}
/**

View File

@@ -6,7 +6,7 @@
* Copyright (c) KALEIDOS INC
*/
import SafeGuard from "../../controllers/SafeGuard.js";
import SafeGuard from '../../controllers/SafeGuard.js';
/**
* Iterator direction.
@@ -58,7 +58,7 @@ export class TextNodeIterator {
startNode,
rootNode,
skipNodes = new Set(),
direction = TextNodeIteratorDirection.FORWARD,
direction = TextNodeIteratorDirection.FORWARD
) {
if (startNode === rootNode) {
return TextNodeIterator.findDown(
@@ -67,7 +67,7 @@ export class TextNodeIterator {
: startNode.lastChild,
rootNode,
skipNodes,
direction,
direction
);
}
@@ -95,7 +95,7 @@ export class TextNodeIterator {
: currentNode.lastChild,
rootNode,
skipNodes,
direction,
direction
);
}
currentNode =
@@ -119,7 +119,7 @@ export class TextNodeIterator {
startNode,
rootNode,
backTrack = new Set(),
direction = TextNodeIteratorDirection.FORWARD,
direction = TextNodeIteratorDirection.FORWARD
) {
backTrack.add(startNode);
if (TextNodeIterator.isTextNode(startNode)) {
@@ -127,14 +127,14 @@ export class TextNodeIterator {
startNode.parentNode,
rootNode,
backTrack,
direction,
direction
);
} else if (TextNodeIterator.isContainerNode(startNode)) {
const found = TextNodeIterator.findDown(
startNode,
rootNode,
backTrack,
direction,
direction
);
if (found) {
return found;
@@ -144,7 +144,7 @@ export class TextNodeIterator {
startNode.parentNode,
rootNode,
backTrack,
direction,
direction
);
}
}
@@ -214,7 +214,7 @@ export class TextNodeIterator {
this.#currentNode,
this.#rootNode,
new Set(),
TextNodeIteratorDirection.FORWARD,
TextNodeIteratorDirection.FORWARD
);
if (!nextNode) {
@@ -237,7 +237,7 @@ export class TextNodeIterator {
this.#currentNode,
this.#rootNode,
new Set(),
TextNodeIteratorDirection.BACKWARD,
TextNodeIteratorDirection.BACKWARD
);
if (!previousNode) {
@@ -270,8 +270,10 @@ export class TextNodeIterator {
* @param {TextNode} endNode
* @yields {TextNode}
*/
*iterateFrom(startNode, endNode) {
const comparedPosition = startNode.compareDocumentPosition(endNode);
* iterateFrom(startNode, endNode) {
const comparedPosition = startNode.compareDocumentPosition(
endNode
);
this.#currentNode = startNode;
SafeGuard.start();
while (this.#currentNode !== endNode) {

View File

@@ -38,7 +38,7 @@ export class ChangeController extends EventTarget {
* @param {number} [time=500]
*/
constructor(time = 500) {
super();
super()
if (typeof time === "number" && (!Number.isInteger(time) || time <= 0)) {
throw new TypeError("Invalid time");
}

View File

@@ -24,19 +24,19 @@ export function start() {
*/
export function update() {
if (Date.now - startTime >= SAFE_GUARD_TIME) {
throw new Error("Safe guard timeout");
throw new Error('Safe guard timeout');
}
}
let timeoutId = 0;
let timeoutId = 0
export function throwAfter(error, timeout = SAFE_GUARD_TIME) {
timeoutId = setTimeout(() => {
throw error;
}, timeout);
throw error
}, timeout)
}
export function throwCancel() {
clearTimeout(timeoutId);
clearTimeout(timeoutId)
}
export default {

View File

@@ -54,7 +54,7 @@ import { isRoot, setRootStyles } from "../content/dom/Root.js";
import { SelectionDirection } from "./SelectionDirection.js";
import SafeGuard from "./SafeGuard.js";
import { sanitizeFontFamily } from "../content/dom/Style.js";
import StyleDeclaration from "./StyleDeclaration.js";
import StyleDeclaration from './StyleDeclaration.js';
/**
* Supported options for the SelectionController.
@@ -280,17 +280,11 @@ export class SelectionController extends EventTarget {
// FIXME: I don't like this approximation. Having to iterate nodes twice
// is bad for performance. I think we need another way of "computing"
// the cascade.
for (const textNode of this.#textNodeIterator.iterateFrom(
startNode,
endNode,
)) {
for (const textNode of this.#textNodeIterator.iterateFrom(startNode, endNode)) {
const paragraph = textNode.parentElement.parentElement;
this.#applyStylesFromElementToCurrentStyle(paragraph);
}
for (const textNode of this.#textNodeIterator.iterateFrom(
startNode,
endNode,
)) {
for (const textNode of this.#textNodeIterator.iterateFrom(startNode, endNode)) {
const textSpan = textNode.parentElement;
this.#mergeStylesFromElementToCurrentStyle(textSpan);
}
@@ -504,12 +498,19 @@ export class SelectionController extends EventTarget {
if (!this.#savedSelection) return false;
if (this.#savedSelection.anchorNode && this.#savedSelection.focusNode) {
this.#selection.setBaseAndExtent(
this.#savedSelection.anchorNode,
this.#savedSelection.anchorOffset,
this.#savedSelection.focusNode,
this.#savedSelection.focusOffset,
);
if (this.#savedSelection.anchorNode === this.#savedSelection.focusNode) {
this.#selection.setPosition(
this.#savedSelection.focusNode,
this.#savedSelection.focusOffset,
);
} else {
this.#selection.setBaseAndExtent(
this.#savedSelection.anchorNode,
this.#savedSelection.anchorOffset,
this.#savedSelection.focusNode,
this.#savedSelection.focusOffset,
);
}
}
this.#savedSelection = null;
return true;
@@ -1131,7 +1132,10 @@ export class SelectionController extends EventTarget {
const hasOnlyOneParagraph = fragment.children.length === 1;
const forceTextSpan =
fragment.firstElementChild?.dataset?.textSpan === "force";
if (hasOnlyOneParagraph && forceTextSpan) {
if (
hasOnlyOneParagraph &&
forceTextSpan
) {
// first text span
const collapseNode = fragment.firstElementChild.firstElementChild;
if (this.isTextSpanStart) {
@@ -1399,7 +1403,7 @@ export class SelectionController extends EventTarget {
// the focus node is a <span>.
if (isTextSpan(this.focusNode)) {
this.focusNode.firstElementChild.replaceWith(textNode);
// the focus node is a <br>.
// the focus node is a <br>.
} else {
this.focusNode.replaceWith(textNode);
}
@@ -1977,7 +1981,8 @@ export class SelectionController extends EventTarget {
this.setSelection(newTextSpan.firstChild, 0, newTextSpan.firstChild, 0);
}
// The styles are applied to the paragraph
else {
else
{
const paragraph = this.startParagraph;
setParagraphStyles(paragraph, newStyles);
// Apply styles to child text spans.

View File

@@ -278,9 +278,9 @@ describe("SelectionController", () => {
expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe(
"Hello",
);
expect(textEditorMock.root.lastChild.firstChild.firstChild.nodeValue).toBe(
", World!",
);
expect(
textEditorMock.root.lastChild.firstChild.firstChild.nodeValue,
).toBe(", World!");
});
test("`insertPaste` should insert a paragraph from a pasted fragment (at middle)", () => {
@@ -292,12 +292,7 @@ describe("SelectionController", () => {
textEditorMock,
selection,
);
focus(
selection,
textEditorMock,
root.firstChild.firstChild.firstChild,
"Lorem ".length,
);
focus(selection, textEditorMock, root.firstChild.firstChild.firstChild, "Lorem ".length);
const paragraph = createParagraph([createTextSpan(new Text("ipsum "))]);
const fragment = document.createDocumentFragment();
fragment.append(paragraph);
@@ -320,9 +315,9 @@ describe("SelectionController", () => {
expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe(
"Lorem ",
);
expect(
textEditorMock.root.children.item(1).firstChild.firstChild.nodeValue,
).toBe("ipsum ");
expect(textEditorMock.root.children.item(1).firstChild.firstChild.nodeValue).toBe(
"ipsum ",
);
expect(textEditorMock.root.lastChild.firstChild.firstChild.nodeValue).toBe(
"dolor",
);
@@ -364,21 +359,25 @@ describe("SelectionController", () => {
expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe(
"Hello",
);
expect(textEditorMock.root.lastChild.firstChild.firstChild.nodeValue).toBe(
", World!",
);
expect(
textEditorMock.root.lastChild.firstChild.firstChild.nodeValue,
).toBe(", World!");
});
test("`insertPaste` should insert a text span from a pasted fragment (at start)", () => {
const textEditorMock =
TextEditorMock.createTextEditorMockWithText(", World!");
const textEditorMock = TextEditorMock.createTextEditorMockWithText(", World!");
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(
textEditorMock,
selection,
);
focus(selection, textEditorMock, root.firstChild.firstChild.firstChild, 0);
focus(
selection,
textEditorMock,
root.firstChild.firstChild.firstChild,
0,
);
const paragraph = createParagraph([createTextSpan(new Text("Hello"))]);
paragraph.dataset.textSpan = "force";
const fragment = document.createDocumentFragment();
@@ -416,12 +415,7 @@ describe("SelectionController", () => {
textEditorMock,
selection,
);
focus(
selection,
textEditorMock,
root.firstChild.firstChild.firstChild,
"Lorem ".length,
);
focus(selection, textEditorMock, root.firstChild.firstChild.firstChild, "Lorem ".length);
const paragraph = createParagraph([createTextSpan(new Text("ipsum "))]);
paragraph.dataset.textSpan = "force";
const fragment = document.createDocumentFragment();
@@ -445,9 +439,9 @@ describe("SelectionController", () => {
expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe(
"Lorem ",
);
expect(
textEditorMock.root.firstChild.children.item(1).firstChild.nodeValue,
).toBe("ipsum ");
expect(textEditorMock.root.firstChild.children.item(1).firstChild.nodeValue).toBe(
"ipsum ",
);
expect(
textEditorMock.root.firstChild.children.item(2).firstChild.nodeValue,
).toBe("dolor");
@@ -467,7 +461,9 @@ describe("SelectionController", () => {
root.firstChild.firstChild.firstChild,
"Hello".length,
);
const paragraph = createParagraph([createTextSpan(new Text(", World!"))]);
const paragraph = createParagraph([
createTextSpan(new Text(", World!"))
]);
paragraph.dataset.textSpan = "force";
const fragment = document.createDocumentFragment();
fragment.append(paragraph);
@@ -490,9 +486,9 @@ describe("SelectionController", () => {
expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe(
"Hello",
);
expect(
textEditorMock.root.firstChild.children.item(1).firstChild.nodeValue,
).toBe(", World!");
expect(textEditorMock.root.firstChild.children.item(1).firstChild.nodeValue).toBe(
", World!",
);
});
test("`removeBackwardText` should remove text in backward direction (backspace)", () => {

View File

@@ -77,10 +77,7 @@ export class StyleDeclaration {
const currentValue = this.getPropertyValue(name);
if (this.#isQuotedValue(currentValue, value)) {
return this.setProperty(name, value);
} else if (
currentValue === "" &&
value === StyleDeclaration.Property.NULL
) {
} else if (currentValue === "" && value === StyleDeclaration.Property.NULL) {
return this.setProperty(name, value);
} else if (currentValue === "" && ["initial", "none"].includes(value)) {
return this.setProperty(name, value);
@@ -110,4 +107,4 @@ export class StyleDeclaration {
}
}
export default StyleDeclaration;
export default StyleDeclaration

View File

@@ -43,38 +43,33 @@ export class SelectionControllerDebug {
this.#elements.isParagraphStart.checked =
selectionController.isParagraphStart;
this.#elements.isParagraphEnd.checked = selectionController.isParagraphEnd;
this.#elements.isTextSpanStart.checked =
selectionController.isTextSpanStart;
this.#elements.isTextSpanStart.checked = selectionController.isTextSpanStart;
this.#elements.isTextSpanEnd.checked = selectionController.isTextSpanEnd;
this.#elements.isTextAnchor.checked = selectionController.isTextAnchor;
this.#elements.isTextFocus.checked = selectionController.isTextFocus;
this.#elements.focusNode.value = this.getNodeDescription(
selectionController.focusNode,
selectionController.focusOffset,
selectionController.focusOffset
);
this.#elements.focusOffset.value = selectionController.focusOffset;
this.#elements.anchorNode.value = this.getNodeDescription(
selectionController.anchorNode,
selectionController.anchorOffset,
selectionController.anchorOffset
);
this.#elements.anchorOffset.value = selectionController.anchorOffset;
this.#elements.focusTextSpan.value = this.getNodeDescription(
selectionController.focusTextSpan,
selectionController.focusTextSpan
);
this.#elements.anchorTextSpan.value = this.getNodeDescription(
selectionController.anchorTextSpan,
selectionController.anchorTextSpan
);
this.#elements.focusParagraph.value = this.getNodeDescription(
selectionController.focusParagraph,
selectionController.focusParagraph
);
this.#elements.anchorParagraph.value = this.getNodeDescription(
selectionController.anchorParagraph,
);
this.#elements.startContainer.value = this.getNodeDescription(
selectionController.startContainer,
);
this.#elements.endContainer.value = this.getNodeDescription(
selectionController.endContainer,
selectionController.anchorParagraph
);
this.#elements.startContainer.value = this.getNodeDescription(selectionController.startContainer);
this.#elements.endContainer.value = this.getNodeDescription(selectionController.endContainer);
}
}

View File

@@ -39,7 +39,10 @@ export class Point {
}
polar(angle, length = 1.0) {
return this.set(Math.cos(angle) * length, Math.sin(angle) * length);
return this.set(
Math.cos(angle) * length,
Math.sin(angle) * length
);
}
add({ x, y }) {
@@ -116,7 +119,10 @@ export class Point {
export class Rect {
static create(x, y, width, height) {
return new Rect(new Point(width, height), new Point(x, y));
return new Rect(
new Point(width, height),
new Point(x, y),
);
}
#size;
@@ -222,7 +228,10 @@ export class Rect {
}
clone() {
return new Rect(this.#size.clone(), this.#position.clone());
return new Rect(
this.#size.clone(),
this.#position.clone(),
);
}
toFixed(fractionDigits = 0) {

View File

@@ -82,13 +82,13 @@ export class Shape {
}
get rotation() {
return this.#rotation;
return this.#rotation
}
set rotation(newRotation) {
if (!Number.isFinite(newRotation)) {
throw new TypeError("Invalid rotation");
throw new TypeError('Invalid rotation')
}
this.#rotation = newRotation;
this.#rotation = newRotation
}
}

View File

@@ -6,7 +6,8 @@ export function fromStyle(style) {
const entry = Object.entries(this).find(([name, value]) =>
name === fromStyleValue(style) ? value : 0,
);
if (!entry) return;
if (!entry)
return;
const [name] = entry;
return name;

View File

@@ -1,4 +1,4 @@
import { Point } from "./geom";
import { Point } from './geom';
export class Viewport {
#zoom;
@@ -38,7 +38,7 @@ export class Viewport {
}
pan(dx, dy) {
this.#position.x += dx / this.#zoom;
this.#position.y += dy / this.#zoom;
this.#position.x += dx / this.#zoom
this.#position.y += dy / this.#zoom
}
}

View File

@@ -1,9 +1,6 @@
import { createRoot } from "../editor/content/dom/Root.js";
import { createParagraph } from "../editor/content/dom/Paragraph.js";
import {
createEmptyTextSpan,
createTextSpan,
} from "../editor/content/dom/TextSpan.js";
import { createEmptyTextSpan, createTextSpan } from "../editor/content/dom/TextSpan.js";
import { createLineBreak } from "../editor/content/dom/LineBreak.js";
export class TextEditorMock extends EventTarget {
@@ -41,14 +38,14 @@ export class TextEditorMock extends EventTarget {
static createTextEditorMockWithRoot(root) {
const container = TextEditorMock.getTemplate();
const selectionImposterElement = container.querySelector(
".text-editor-selection-imposter",
".text-editor-selection-imposter"
);
const textEditorMock = new TextEditorMock(
container.querySelector(".text-editor-content"),
{
root,
selectionImposterElement,
},
}
);
return textEditorMock;
}
@@ -89,8 +86,8 @@ export class TextEditorMock extends EventTarget {
return this.createTextEditorMockWithParagraphs([
createParagraph([
text.length === 0
? createEmptyTextSpan()
: createTextSpan(new Text(text)),
? createEmptyTextSpan()
: createTextSpan(new Text(text))
]),
]);
}
@@ -103,9 +100,7 @@ export class TextEditorMock extends EventTarget {
* @returns
*/
static createTextEditorMockWithParagraph(textSpans) {
return this.createTextEditorMockWithParagraphs([
createParagraph(textSpans),
]);
return this.createTextEditorMockWithParagraphs([createParagraph(textSpans)]);
}
#element = null;

View File

@@ -1,28 +1,30 @@
import path from "node:path";
import fs from "node:fs/promises";
import fs from 'node:fs/promises';
import { defineConfig } from "vite";
import { coverageConfigDefaults } from "vitest/config";
async function waitFor(timeInMillis) {
return new Promise((resolve) => setTimeout((_) => resolve(), timeInMillis));
return new Promise(resolve =>
setTimeout(_ => resolve(), timeInMillis)
);
}
const wasmWatcherPlugin = (options = {}) => {
return {
name: "vite-wasm-watcher-plugin",
configureServer(server) {
server.watcher.add("../resources/public/js/render_wasm.wasm");
server.watcher.add("../resources/public/js/render_wasm.js");
server.watcher.add("../resources/public/js/render_wasm.wasm")
server.watcher.add("../resources/public/js/render_wasm.js")
server.watcher.on("change", async (file) => {
if (file.includes("../resources/")) {
// If we copy the files immediately, we end
// up with an empty .js file (I don't know why).
await waitFor(100);
await waitFor(100)
// copy files.
await fs.copyFile(
path.resolve(file),
path.resolve("./src/wasm/", path.basename(file)),
);
path.resolve('./src/wasm/', path.basename(file))
)
console.log(`${file} changed`);
}
});
@@ -47,7 +49,9 @@ const wasmWatcherPlugin = (options = {}) => {
};
export default defineConfig({
plugins: [wasmWatcherPlugin()],
plugins: [
wasmWatcherPlugin()
],
root: "./src",
resolve: {
alias: {

View File

@@ -421,9 +421,6 @@ msgstr "(copy)"
msgid "dashboard.create-new-team"
msgstr "Create new team"
msgid "dashboard.create-new-org"
msgstr "Create new org"
#: src/app/main/ui/workspace/main_menu.cljs:664
msgid "dashboard.create-version-menu"
msgstr "Pin this version"

View File

@@ -430,9 +430,6 @@ msgstr "(copia)"
msgid "dashboard.create-new-team"
msgstr "Crear nuevo equipo"
msgid "dashboard.create-new-org"
msgstr "Crear nueva organización"
#: src/app/main/ui/workspace/main_menu.cljs:664
msgid "dashboard.create-version-menu"
msgstr "Guardar esta versión"

View File

@@ -63,12 +63,6 @@ function clean {
cargo clean;
}
function setup {
corepack enable;
corepack install;
yarn install;
}
function build {
cargo build $CARGO_PARAMS;
}
@@ -76,14 +70,12 @@ function build {
function copy_artifacts {
DEST=$1;
mkdir -p $DEST;
cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.js $DEST/$BUILD_NAME.js;
cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm $DEST/$BUILD_NAME.wasm;
sed -i "s/render_wasm.wasm/$BUILD_NAME.wasm?version=$CURRENT_VERSION/g" $DEST/$BUILD_NAME.js;
yarn esbuild target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.js \
npx esbuild target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.js \
--log-level=error \
--outfile=$DEST/worker/render.js \
--platform=neutral \

View File

@@ -7,9 +7,8 @@ pushd $_SCRIPT_DIR;
. ./_build_env
set -ex;
set -x;
setup;
build;
copy_artifacts "../frontend/resources/public/js";
copy_shared_artifact;

View File

@@ -1,17 +0,0 @@
{
"name": "penpot-render-wasm",
"version": "1.20.0",
"license": "MPL-2.0",
"author": "Kaleidos INC",
"private": true,
"packageManager": "yarn@4.10.3+sha512.c38cafb5c7bb273f3926d04e55e1d8c9dfa7d9c3ea1f36a4868fa028b9e5f72298f0b7f401ad5eb921749eb012eb1c3bb74bf7503df3ee43fd600d14a018266f",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"
},
"type": "module",
"devDependencies": {
"@types/node": "^20.12.7",
"esbuild": "^0.25.9"
}
}

View File

@@ -230,62 +230,20 @@ pub extern "C" fn resize_viewbox(width: i32, height: i32) {
#[no_mangle]
pub extern "C" fn set_view(zoom: f32, x: f32, y: f32) {
with_state_mut!(state, {
performance::begin_measure!("set_view");
let render_state = state.render_state_mut();
render_state.set_view(zoom, x, y);
performance::end_measure!("set_view");
});
}
#[cfg(feature = "profile-macros")]
static mut VIEW_INTERACTION_START: i32 = 0;
#[no_mangle]
pub extern "C" fn set_view_start() {
with_state_mut!(state, {
#[cfg(feature = "profile-macros")]
unsafe {
VIEW_INTERACTION_START = performance::get_time();
}
performance::begin_measure!("set_view_start");
state.render_state.options.set_fast_mode(true);
performance::end_measure!("set_view_start");
});
}
#[no_mangle]
pub extern "C" fn set_view_end() {
with_state_mut!(state, {
let _end_start = performance::begin_timed_log!("set_view_end");
performance::begin_measure!("set_view_end");
state.render_state.options.set_fast_mode(false);
// We can have renders in progress
state.render_state.cancel_animation_frame();
let zoom_changed = state.render_state.zoom_changed();
// Only rebuild tile indices when zoom has changed.
// During pan-only operations, shapes stay in the same tiles
// because tile_size = 1/scale * TILE_SIZE (depends only on zoom).
if zoom_changed {
let _rebuild_start = performance::begin_timed_log!("rebuild_tiles");
performance::begin_measure!("set_view_end::rebuild_tiles");
if state.render_state.options.is_profile_rebuild_tiles() {
state.rebuild_tiles();
} else {
state.rebuild_tiles_shallow();
}
performance::end_measure!("set_view_end::rebuild_tiles");
performance::end_timed_log!("rebuild_tiles", _rebuild_start);
}
performance::end_measure!("set_view_end");
performance::end_timed_log!("set_view_end", _end_start);
#[cfg(feature = "profile-macros")]
{
let total_time = performance::get_time() - unsafe { VIEW_INTERACTION_START };
performance::console_log!(
"[PERF] view_interaction (zoom_changed={}): {}ms",
zoom_changed,
total_time
);
if state.render_state.options.is_profile_rebuild_tiles() {
state.rebuild_tiles();
} else {
state.rebuild_tiles_shallow();
}
});
}
@@ -303,7 +261,7 @@ pub extern "C" fn set_focus_mode() {
let entries: Vec<Uuid> = bytes
.chunks(size_of::<<Uuid as SerializableResult>::BytesType>())
.map(|data| Uuid::try_from(data).unwrap())
.map(|data| Uuid::from_bytes(data.try_into().unwrap()))
.collect();
with_state_mut!(state, {
@@ -523,7 +481,7 @@ pub extern "C" fn set_children() {
let entries: Vec<Uuid> = bytes
.chunks(size_of::<<Uuid as SerializableResult>::BytesType>())
.map(|data| Uuid::try_from(data).unwrap())
.map(|data| Uuid::from_bytes(data.try_into().unwrap()))
.collect();
set_children_set(entries);
@@ -679,7 +637,7 @@ pub extern "C" fn propagate_modifiers(pixel_precision: bool) -> *mut u8 {
let entries: Vec<_> = bytes
.chunks(size_of::<<TransformEntry as SerializableResult>::BytesType>())
.map(|data| TransformEntry::try_from(data).unwrap())
.map(|data| TransformEntry::from_bytes(data.try_into().unwrap()))
.collect();
with_state!(state, {
@@ -694,7 +652,7 @@ pub extern "C" fn set_modifiers() {
let entries: Vec<_> = bytes
.chunks(size_of::<<TransformEntry as SerializableResult>::BytesType>())
.map(|data| TransformEntry::try_from(data).unwrap())
.map(|data| TransformEntry::from_bytes(data.try_into().unwrap()))
.collect();
let mut modifiers = HashMap::new();

View File

@@ -57,8 +57,10 @@ pub fn bytes_or_empty() -> Vec<u8> {
guard.take().unwrap_or_default()
}
pub trait SerializableResult: From<Self::BytesType> + Into<Self::BytesType> {
pub trait SerializableResult {
type BytesType;
fn from_bytes(bytes: Self::BytesType) -> Self;
fn as_bytes(&self) -> Self::BytesType;
fn clone_to_slice(&self, slice: &mut [u8]);
}

View File

@@ -1,3 +1,2 @@
pub const DEBUG_VISIBLE: u32 = 0x01;
pub const PROFILE_REBUILD_TILES: u32 = 0x02;
pub const FAST_MODE: u32 = 0x04;

View File

@@ -1,3 +1,7 @@
#[allow(unused_imports)]
#[cfg(target_arch = "wasm32")]
use crate::get_now;
#[allow(dead_code)]
#[cfg(target_arch = "wasm32")]
pub fn get_time() -> i32 {
@@ -11,68 +15,6 @@ pub fn get_time() -> i32 {
now.elapsed().as_millis() as i32
}
/// Log a message to the browser console (only when profile-macros feature is enabled)
#[macro_export]
macro_rules! console_log {
($($arg:tt)*) => {
#[cfg(all(feature = "profile-macros", target_arch = "wasm32"))]
{
use $crate::run_script;
run_script!(format!("console.log('{}')", format!($($arg)*)));
}
#[cfg(all(feature = "profile-macros", not(target_arch = "wasm32")))]
{
println!($($arg)*);
}
};
}
/// Begin a timed section with logging (only when profile-macros feature is enabled)
/// Returns the start time - store it and pass to end_timed_log!
#[macro_export]
macro_rules! begin_timed_log {
($name:expr) => {{
#[cfg(feature = "profile-macros")]
{
$crate::performance::get_time()
}
#[cfg(not(feature = "profile-macros"))]
{
0.0
}
}};
}
/// End a timed section and log the duration (only when profile-macros feature is enabled)
#[macro_export]
macro_rules! end_timed_log {
($name:expr, $start:expr) => {{
#[cfg(all(feature = "profile-macros", target_arch = "wasm32"))]
{
let duration = $crate::performance::get_time() - $start;
use $crate::run_script;
run_script!(format!(
"console.log('[PERF] {}: {:.2}ms')",
$name, duration
));
}
#[cfg(all(feature = "profile-macros", not(target_arch = "wasm32")))]
{
let duration = $crate::performance::get_time() - $start;
println!("[PERF] {}: {:.2}ms", $name, duration);
}
}};
}
#[allow(unused_imports)]
pub use console_log;
#[allow(unused_imports)]
pub use begin_timed_log;
#[allow(unused_imports)]
pub use end_timed_log;
#[macro_export]
macro_rules! mark {
($name:expr) => {

View File

@@ -9,8 +9,7 @@ mod options;
mod shadows;
mod strokes;
mod surfaces;
pub mod text;
mod text;
mod ui;
use skia_safe::{self as skia, Matrix, RRect, Rect};
@@ -929,8 +928,6 @@ impl RenderState {
}
pub fn render_from_cache(&mut self, shapes: ShapesPoolRef) {
let _start = performance::begin_timed_log!("render_from_cache");
performance::begin_measure!("render_from_cache");
let scale = self.get_cached_scale();
if let Some(snapshot) = &self.cached_target_snapshot {
let canvas = self.surfaces.canvas(SurfaceId::Target);
@@ -968,8 +965,6 @@ impl RenderState {
self.flush_and_submit();
}
performance::end_measure!("render_from_cache");
performance::end_timed_log!("render_from_cache", _start);
}
pub fn start_render_loop(
@@ -979,7 +974,6 @@ impl RenderState {
timestamp: i32,
sync_render: bool,
) -> Result<(), String> {
let _start = performance::begin_timed_log!("start_render_loop");
let scale = self.get_scale();
self.tile_viewbox.update(self.viewbox, scale);
@@ -1010,12 +1004,10 @@ impl RenderState {
// FIXME - review debug
// debug::render_debug_tiles_for_viewbox(self);
let _tile_start = performance::begin_timed_log!("tile_cache_update");
performance::begin_measure!("tile_cache");
self.pending_tiles
.update(&self.tile_viewbox, &self.surfaces);
performance::end_measure!("tile_cache");
performance::end_timed_log!("tile_cache_update", _tile_start);
self.pending_nodes.clear();
if self.pending_nodes.capacity() < tree.len() {
@@ -1039,7 +1031,6 @@ impl RenderState {
}
performance::end_measure!("start_render_loop");
performance::end_timed_log!("start_render_loop", _start);
Ok(())
}
@@ -1488,11 +1479,8 @@ impl RenderState {
.surfaces
.get_render_context_translation(self.render_area, scale);
// Skip expensive drop shadow rendering in fast mode (during pan/zoom)
let skip_shadows = self.options.is_fast_mode();
// For text shapes, render drop shadow using text rendering logic
if !skip_shadows && !matches!(element.shape_type, Type::Text(_)) {
if !matches!(element.shape_type, Type::Text(_)) {
// Shadow rendering technique: Two-pass approach for proper opacity handling
//
// The shadow rendering uses a two-pass technique to ensure that overlapping
@@ -2066,10 +2054,6 @@ impl RenderState {
self.cached_viewbox.zoom() * self.options.dpr()
}
pub fn zoom_changed(&self) -> bool {
(self.viewbox.zoom - self.cached_viewbox.zoom).abs() > f32::EPSILON
}
pub fn mark_touched(&mut self, uuid: Uuid) {
self.touched_ids.insert(uuid);
}

View File

@@ -15,19 +15,6 @@ impl RenderOptions {
self.flags & options::PROFILE_REBUILD_TILES == options::PROFILE_REBUILD_TILES
}
/// Use fast mode to enable / disable expensive operations
pub fn is_fast_mode(&self) -> bool {
self.flags & options::FAST_MODE == options::FAST_MODE
}
pub fn set_fast_mode(&mut self, enabled: bool) {
if enabled {
self.flags |= options::FAST_MODE;
} else {
self.flags &= !options::FAST_MODE;
}
}
pub fn dpr(&self) -> f32 {
self.dpr.unwrap_or(1.0)
}

View File

@@ -2,15 +2,18 @@ use super::{filters, RenderState, Shape, SurfaceId};
use crate::{
math::Rect,
shapes::{
calculate_position_data, calculate_text_layout_data, merge_fills, set_paint_fill,
ParagraphBuilderGroup, Stroke, StrokeKind, TextContent,
merge_fills, set_paint_fill, ParagraphBuilderGroup, Stroke, StrokeKind, TextContent,
VerticalAlign,
},
utils::{get_fallback_fonts, get_font_collection},
};
use skia_safe::{
self as skia,
canvas::SaveLayerRec,
textlayout::{ParagraphBuilder, StyleMetrics, TextDecoration, TextStyle},
textlayout::{
LineMetrics, Paragraph, ParagraphBuilder, RectHeightStyle, RectWidthStyle, StyleMetrics,
TextDecoration, TextStyle,
},
Canvas, ImageFilter, Paint, Path,
};
@@ -238,24 +241,48 @@ fn draw_text(
paragraph_builder_groups: &mut [Vec<ParagraphBuilder>],
) {
let text_content = shape.get_text_content();
let layout_info =
calculate_text_layout_data(shape, text_content, paragraph_builder_groups, true);
let selrect_width = shape.selrect().width();
let text_width = text_content.get_width(selrect_width);
let text_height = text_content.get_height(selrect_width);
let selrect_height = shape.selrect().height();
let mut global_offset_y = match shape.vertical_align() {
VerticalAlign::Center => (selrect_height - text_height) / 2.0,
VerticalAlign::Bottom => selrect_height - text_height,
_ => 0.0,
};
let layer_rec = SaveLayerRec::default();
canvas.save_layer(&layer_rec);
let mut previous_line_height = text_content.normalized_line_height();
for para in &layout_info.paragraphs {
para.paragraph.paint(canvas, (para.x, para.y));
for deco in &para.decorations {
draw_text_decorations(
canvas,
&deco.text_style,
Some(deco.y),
deco.thickness,
deco.left,
deco.width,
);
for paragraph_builder_group in paragraph_builder_groups {
let group_offset_y = global_offset_y;
let group_len = paragraph_builder_group.len();
let mut paragraph_offset_y = previous_line_height;
for (paragraph_index, paragraph_builder) in paragraph_builder_group.iter_mut().enumerate() {
let mut paragraph = paragraph_builder.build();
paragraph.layout(text_width);
let xy = (shape.selrect().x(), shape.selrect().y() + group_offset_y);
paragraph.paint(canvas, xy);
let line_metrics = paragraph.get_line_metrics();
if paragraph_index == group_len - 1 {
if line_metrics.is_empty() {
paragraph_offset_y = paragraph.ideographic_baseline();
} else {
paragraph_offset_y = paragraph.height();
previous_line_height = paragraph.ideographic_baseline();
}
}
for line_metrics in paragraph.get_line_metrics().iter() {
render_text_decoration(canvas, &paragraph, paragraph_builder, line_metrics, xy);
}
}
global_offset_y += paragraph_offset_y;
}
}
@@ -280,7 +307,7 @@ fn draw_text_decorations(
}
}
pub fn calculate_decoration_metrics(
fn calculate_decoration_metrics(
style_metrics: &Vec<(usize, &StyleMetrics)>,
line_baseline: f32,
) -> (f32, Option<f32>, f32, Option<f32>) {
@@ -330,6 +357,106 @@ pub fn calculate_decoration_metrics(
)
}
fn render_text_decoration(
canvas: &Canvas,
skia_paragraph: &Paragraph,
builder: &mut ParagraphBuilder,
line_metrics: &LineMetrics,
xy: (f32, f32),
) {
let style_metrics: Vec<_> = line_metrics
.get_style_metrics(line_metrics.start_index..line_metrics.end_index)
.into_iter()
.collect();
let mut current_x_offset = 0.0;
let total_chars = line_metrics.end_index - line_metrics.start_index;
let line_start_offset = line_metrics.left as f32;
if total_chars == 0 || style_metrics.is_empty() {
return;
}
let line_baseline = xy.1 + line_metrics.baseline as f32;
let full_text = builder.get_text();
// Calculate decoration metrics
let (max_underline_thickness, underline_y, max_strike_thickness, strike_y) =
calculate_decoration_metrics(&style_metrics, line_baseline);
// Draw decorations per segment (text span)
for (i, (style_start, style_metric)) in style_metrics.iter().enumerate() {
let text_style = &style_metric.text_style;
let style_end = style_metrics
.get(i + 1)
.map(|(next_i, _)| *next_i)
.unwrap_or(line_metrics.end_index);
let seg_start = (*style_start).max(line_metrics.start_index);
let seg_end = style_end.min(line_metrics.end_index);
if seg_start >= seg_end {
continue;
}
let start_byte = full_text
.char_indices()
.nth(seg_start)
.map(|(i, _)| i)
.unwrap_or(0);
let end_byte = full_text
.char_indices()
.nth(seg_end)
.map(|(i, _)| i)
.unwrap_or(full_text.len());
let segment_text = &full_text[start_byte..end_byte];
let rects = skia_paragraph.get_rects_for_range(
seg_start..seg_end,
RectHeightStyle::Tight,
RectWidthStyle::Tight,
);
let (segment_width, actual_x_offset) = if !rects.is_empty() {
let total_width: f32 = rects.iter().map(|r| r.rect.width()).sum();
let skia_x_offset = rects
.first()
.map(|r| r.rect.left - line_start_offset)
.unwrap_or(0.0);
(total_width, skia_x_offset)
} else {
let font = skia_paragraph.get_font_at(seg_start);
let measured_width = font.measure_text(segment_text, None).0;
(measured_width, current_x_offset)
};
let text_left = xy.0 + line_start_offset + actual_x_offset;
let text_width = segment_width;
// Underline
if text_style.decoration().ty == TextDecoration::UNDERLINE {
draw_text_decorations(
canvas,
text_style,
underline_y,
max_underline_thickness,
text_left,
text_width,
);
}
// Strikethrough
if text_style.decoration().ty == TextDecoration::LINE_THROUGH {
draw_text_decorations(
canvas,
text_style,
strike_y,
max_strike_thickness,
text_left,
text_width,
);
}
current_x_offset += segment_width;
}
}
#[allow(dead_code)]
fn calculate_total_paragraphs_height(paragraphs: &mut [ParagraphBuilder], width: f32) -> f32 {
paragraphs
@@ -379,29 +506,6 @@ pub fn render_as_path(
}
}
#[allow(dead_code)]
pub fn render_position_data(
render_state: &mut RenderState,
surface_id: SurfaceId,
shape: &Shape,
text_content: &TextContent,
) {
let position_data = calculate_position_data(shape, text_content, false);
let mut paint = skia::Paint::default();
paint.set_style(skia::PaintStyle::Stroke);
paint.set_color(skia::Color::from_argb(255, 255, 0, 0));
paint.set_stroke_width(2.);
for pd in position_data {
let rect = Rect::from_xywh(pd.x, pd.y, pd.width, pd.height);
render_state
.surfaces
.canvas(surface_id)
.draw_rect(rect, &paint);
}
}
// How to use it?
// Type::Text(text_content) => {
// self.surfaces

Some files were not shown because too many files have changed in this diff Show More