Compare commits

..

6 Commits

Author SHA1 Message Date
Pablo Alba
82df9994f7 🐛 Fix migration sync-component-id-with-near-main (loop added) 2025-12-12 10:30:51 +01:00
Pablo Alba
58b298b3e0 🐛 Fix migration and repair for nil ids 2025-12-11 17:10:09 +01:00
Pablo Alba
c2ace7c529 🐛 Fix detach fails on nested components 2025-12-11 15:51:02 +01:00
Andrés Moya
7bba793db3 🔧 Validate only after propagation in tests 2025-12-11 15:51:02 +01:00
Andrés Moya
4010136bf5 🔧 Avoid copies with wrong component-id and repair if needed 2025-12-11 15:51:02 +01:00
Andrés Moya
1537162653 🔧 Validate components with unneeded objects 2025-12-11 14:36:07 +01:00
106 changed files with 818 additions and 2367 deletions

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

@@ -115,7 +115,6 @@ example. It's still usable as before, we just removed the example.
- 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

@@ -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

@@ -1766,6 +1766,59 @@
(update :pages-index d/update-vals update-container)
(d/update-when :components d/update-vals update-container))))
(defmethod migrate-data "0017-remove-unneeded-objects-from-components"
[data _]
;; Some components have an `:objects` attribute, despite not being
;; deleted. This migration removes it.
(letfn [(check-component [component]
(if (and (not (:deleted component))
(contains? component :objects))
(dissoc component :objects)
component))]
(d/update-when data :components d/update-vals check-component)))
(defmethod migrate-data "0018-sync-component-id-with-near-main"
[data _]
(let [libs (some-> (:libs data) deref)]
(letfn [(fix-shape
[data page shape]
(if (and (ctk/subcopy-head? shape)
(nil? (ctk/get-swap-slot shape)))
(let [file {:id (:id data) :data data}
ref-shape (ctf/find-ref-shape file page libs shape {:include-deleted? true :with-context? true})]
(if (and (some? ref-shape)
(or (not= (:component-id shape) (:component-id ref-shape))
(not= (:component-file shape) (:component-file ref-shape))))
(cond-> shape
(some? (:component-id ref-shape))
(assoc :component-id (:component-id ref-shape))
(nil? (:component-id ref-shape))
(dissoc :component-id)
(some? (:component-file ref-shape))
(assoc :component-file (:component-file ref-shape))
(nil? (:component-file ref-shape))
(dissoc :component-file))
shape))
shape))
(update-page
[data page]
(d/update-when page :objects d/update-vals (partial fix-shape data page)))
(fix-data [data]
(loop [current-data data
iteration 0]
(let [next-data (update current-data :pages-index d/update-vals (partial update-page current-data))]
(if (or (= current-data next-data)
(> iteration 20)) ;; safety bound
next-data
(recur next-data (inc iteration))))))]
(fix-data data))))
(def available-migrations
(into (d/ordered-set)
["legacy-2"
@@ -1839,4 +1892,6 @@
"0014-clear-components-nil-objects"
"0015-fix-text-attrs-blank-strings"
"0015-clean-shadow-color"
"0016-copy-fills-from-position-data-to-text-node"]))
"0016-copy-fills-from-position-data-to-text-node"
"0017-remove-unneeded-objects-from-components"
"0018-sync-component-id-with-near-main"]))

View File

@@ -333,6 +333,31 @@
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
(defmethod repair-error :component-id-mismatch
[_ {:keys [shape page-id args] :as error} file-data _]
(let [repair-shape
(fn [shape]
; Set the component-id and component-file to the ones of the near main
(log/debug :hint (str " -> set component-id to " (:component-id args)))
(log/debug :hint (str " -> set component-file to " (:component-file args)))
(cond-> shape
(some? (:component-id args))
(assoc :component-id (:component-id args))
(nil? (:component-id args))
(dissoc :component-id)
(some? (:component-file args))
(assoc :component-file (:component-file args))
(nil? (:component-file args))
(dissoc :component-file)))]
(log/dbg :hint "repairing shape :component-id-mismatch" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
(defmethod repair-error :ref-shape-is-head
[_ {:keys [shape page-id args] :as error} file-data _]
(let [repair-shape
@@ -499,7 +524,7 @@
(pcb/update-shapes [(:id shape)] repair-shape))))
(defmethod repair-error :component-nil-objects-not-allowed
[_ {:keys [shape] :as error} file-data _]
[_ {component :shape} file-data _] ; in this error the :shape argument is the component
(let [repair-component
(fn [component]
; Remove the objects key, or set it to {} if the component is deleted
@@ -511,10 +536,26 @@
(log/debug :hint " -> remove :objects")
(dissoc component :objects))))]
(log/dbg :hint "repairing component :component-nil-objects-not-allowed" :id (:id shape) :name (:name shape))
(log/dbg :hint "repairing component :component-nil-objects-not-allowed" :id (:id component) :name (:name component))
(-> (pcb/empty-changes nil)
(pcb/with-library-data file-data)
(pcb/update-component (:id shape) repair-component))))
(pcb/update-component (:id component) repair-component))))
(defmethod repair-error :non-deleted-component-cannot-have-objects
[_ {component :shape} file-data _] ; in this error the :shape argument is the component
(let [repair-component
(fn [component]
; Remove the :objects field
(if-not (:deleted component)
(do
(log/debug :hint " -> remove :objects")
(dissoc component :objects))
component))]
(log/dbg :hint "repairing component :non-deleted-component-cannot-have-objects" :id (:id component) :name (:name component))
(-> (pcb/empty-changes nil)
(pcb/with-library-data file-data)
(pcb/update-component (:id component) repair-component))))
(defmethod repair-error :invalid-text-touched
[_ {:keys [shape page-id] :as error} file-data _]

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

@@ -51,6 +51,7 @@
:ref-shape-is-head
:ref-shape-is-not-head
:shape-ref-in-main
:component-id-mismatch
:root-main-not-allowed
:nested-main-not-allowed
:root-copy-not-allowed
@@ -59,6 +60,7 @@
:not-head-copy-not-allowed
:not-component-not-allowed
:component-nil-objects-not-allowed
:non-deleted-component-cannot-have-objects
:instance-head-not-frame
:invalid-text-touched
:misplaced-slot
@@ -326,6 +328,20 @@
:component-file (:component-file ref-shape)
:component-id (:component-id ref-shape)))))
(defn- check-ref-component-id
"Validate that if the copy has not been swwpped, the component-id and component-file are
the same as in the referenced shape in the near main."
[shape file page libraries]
(when (nil? (ctk/get-swap-slot shape))
(when-let [ref-shape (ctf/find-ref-shape file page libraries shape :include-deleted? true)]
(when (or (not= (:component-id shape) (:component-id ref-shape))
(not= (:component-file shape) (:component-file ref-shape)))
(report-error :component-id-mismatch
"Nested copy component-id and component-file must be the same as the near main"
shape file page
:component-id (:component-id ref-shape)
:component-file (:component-file ref-shape))))))
(defn- check-empty-swap-slot
"Validate that this shape does not have any swap slot."
[shape file page]
@@ -418,6 +434,7 @@
(check-component-not-main-head shape file page libraries)
(check-component-not-root shape file page)
(check-valid-touched shape file page)
(check-ref-component-id shape file page libraries)
;; We can have situations where the nested copy and the ancestor copy come from different libraries and some of them have been dettached
;; so we only validate the shape-ref if the ancestor is from a valid library
(when library-exists
@@ -648,6 +665,13 @@
"Component main not allowed inside other component"
main-instance file component-page))))
(defn- check-not-objects
[component file]
(when (d/not-empty? (:objects component))
(report-error :non-deleted-component-cannot-have-objects
"A non-deleted component cannot have shapes inside"
component file nil)))
(defn- check-component
"Validate semantic coherence of a component. Report all errors found."
[component file]
@@ -656,7 +680,8 @@
"Objects list cannot be nil"
component file nil))
(when-not (:deleted component)
(check-main-inside-main component file))
(check-main-inside-main component file)
(check-not-objects component file))
(when (:deleted component)
(check-component-duplicate-swap-slot component file)
(check-ref-cycles component file))

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

@@ -1769,6 +1769,23 @@
(pcb/update-shapes changes [(:id dest-shape)] ctk/unhead-shape {:ignore-touched true})
changes))
(defn- check-swapped-main
[changes dest-shape origin-shape]
;; Only for direct updates (from main to copy). Check if the main shape
;; has been swapped. If so, the new component-id and component-file must
;; be put into the copy.
(if (and (= (:shape-ref dest-shape) (:id origin-shape))
(ctk/instance-head? dest-shape)
(ctk/instance-head? origin-shape)
(or (not= (:component-id dest-shape) (:component-id origin-shape))
(not= (:component-file dest-shape) (:component-file origin-shape))))
(pcb/update-shapes changes [(:id dest-shape)]
#(assoc %
:component-id (:component-id origin-shape)
:coponent-file (:component-file origin-shape))
{:ignore-touched true})
changes))
(defn- update-attrs
"The main function that implements the attribute sync algorithm. Copy
attributes that have changed in the origin shape to the dest shape.
@@ -1811,6 +1828,8 @@
:always
(check-detached-main dest-shape origin-shape)
:always
(check-swapped-main dest-shape origin-shape)
:always
(generate-update-tokens container dest-shape origin-shape touched omit-touched? nil))
(let [attr-group (get ctk/sync-attrs attr)

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

@@ -274,7 +274,7 @@
file-id
{file-id file}
file-id))]
(thf/apply-changes file changes)))
(thf/apply-changes file changes :validate? false)))
(defn swap-component
"Swap the specified shape by the component specified by component-tag"
@@ -305,12 +305,13 @@
[changes nil])
file' (thf/apply-changes file changes)]
file' (thf/apply-changes file changes :validate? (not propagate-fn))]
(when new-shape-label
(thi/rm-id! (:id new-shape))
(thi/set-id! new-shape-label (:id new-shape)))
(if propagate-fn
(propagate-fn file')
(-> (propagate-fn file')
(thf/validate-file!))
file')))
(defn swap-component-in-shape [file shape-tag component-tag & {:keys [page-label propagate-fn]}]
@@ -339,9 +340,10 @@
(assoc shape :fills (ths/sample-fills-color :fill-color color)))
(:objects page)
{})
file' (thf/apply-changes file changes)]
file' (thf/apply-changes file changes :validate? (not propagate-fn))]
(if propagate-fn
(propagate-fn file')
(-> (propagate-fn file')
(thf/validate-file!))
file')))
(defn update-bottom-color
@@ -357,9 +359,10 @@
(assoc shape :fills (ths/sample-fills-color :fill-color color)))
(:objects page)
{})
file' (thf/apply-changes file changes)]
file' (thf/apply-changes file changes :validate? (not propagate-fn))]
(if propagate-fn
(propagate-fn file')
(-> (propagate-fn file')
(thf/validate-file!))
file')))
(defn reset-overrides [file shape & {:keys [page-label propagate-fn]}]
@@ -374,9 +377,10 @@
{file-id file}
(ctn/make-container container :page)
(:id shape)))
file' (thf/apply-changes file changes)]
file' (thf/apply-changes file changes :validate? (not propagate-fn))]
(if propagate-fn
(propagate-fn file')
(-> (propagate-fn file')
(thf/validate-file!))
file')))
(defn reset-overrides-in-first-child [file shape-tag & {:keys [page-label propagate-fn]}]
@@ -398,9 +402,10 @@
#{(-> (ths/get-shape file shape-tag :page-label page-label)
:id)}
{})
file' (thf/apply-changes file changes)]
file' (thf/apply-changes file changes :validate? (not propagate-fn))]
(if propagate-fn
(propagate-fn file')
(-> (propagate-fn file')
(thf/validate-file!))
file')))
(defn duplicate-shape [file shape-tag & {:keys [page-label propagate-fn]}]
@@ -419,8 +424,9 @@
(:id file)) ;; file-id
(cll/generate-duplicate-changes-update-indices (:objects page) ;; objects
#{(:id shape)}))
file' (thf/apply-changes file changes)]
file' (thf/apply-changes file changes :validate? (not propagate-fn))]
(if propagate-fn
(propagate-fn file')
(-> (propagate-fn file')
(thf/validate-file!))
file')))

View File

@@ -54,12 +54,14 @@
([file] (validate-file! file {}))
([file libraries]
(cfv/validate-file-schema! file)
(cfv/validate-file! file libraries)))
(cfv/validate-file! file libraries)
file))
(defn apply-changes
[file changes]
[file changes & {:keys [validate?] :or {validate? true}}]
(let [file' (ctf/update-file-data file #(cfc/process-changes % (:redo-changes changes) true))]
(validate-file! file')
(when validate?
(validate-file! file'))
file'))
(defn apply-undo-changes

View File

@@ -146,12 +146,15 @@
"Check if some attribute is one that is involved in component syncrhonization.
Note that design tokens also are involved, although they go by an alternate
route and thus they are not part of :sync-attrs.
Also when detaching a nested copy it also needs to trigger a synchronization,
even though :shape-ref is not a synced attribute per se"
Also when detaching a nested copy or it also needs to trigger a synchronization,
even though :shape-ref or :component-id are not synced attribute per se"
[attr]
(or (get sync-attrs attr)
(= :shape-ref attr)
(= :applied-tokens attr)))
(= :applied-tokens attr)
(= :component-id attr)
(= :component-file attr)
(= :component-root attr)))
(defn instance-root?
"Check if this shape is the head of a top instance."

View File

@@ -60,6 +60,9 @@
(some? objects)
(assoc :objects objects)
(nil? objects)
(dissoc :objects)
(some? modified-at)
(assoc :modified-at modified-at)

View File

@@ -465,9 +465,10 @@
page
{(:id file) file}
(thi/id :nested-h-ellipse))
file' (-> (thf/apply-changes file changes)
file' (-> (thf/apply-changes file changes :validate? false)
(tho/propagate-component-changes :c-board-with-ellipse)
(tho/propagate-component-changes :c-big-board))
(tho/propagate-component-changes :c-big-board)
(thf/validate-file!))
;; ==== Get
nested2-h-ellipse (ths/get-shape file' :nested-h-ellipse)

View File

@@ -64,9 +64,8 @@
(reset-all-overrides [file]
(-> file
(tho/reset-overrides-in-first-child :frame-board-1 :page-label :page-1)
(tho/reset-overrides-in-first-child :copy-board-1 :page-label :page-2)
(propagate-all-component-changes)))
(tho/reset-overrides-in-first-child :frame-board-1 :page-label :page-1 :propagate-fn propagate-all-component-changes)
(tho/reset-overrides-in-first-child :copy-board-1 :page-label :page-2 :propagate-fn propagate-all-component-changes)))
(fill-colors [file]
[(tho/bottom-fill-color file :frame-ellipse-1 :page-label :page-1)

View File

@@ -56,10 +56,9 @@
(reset-all-overrides [file]
(-> file
(tho/reset-overrides (ths/get-shape file :copy-simple-1))
(tho/reset-overrides (ths/get-shape file :copy-frame-composed-1))
(tho/reset-overrides (ths/get-shape file :composed-1-composed-2-copy))
(propagate-all-component-changes)))
(tho/reset-overrides (ths/get-shape file :copy-simple-1 :propagate-fn propagate-all-component-changes))
(tho/reset-overrides (ths/get-shape file :copy-frame-composed-1 :propagate-fn propagate-all-component-changes))
(tho/reset-overrides (ths/get-shape file :composed-1-composed-2-copy :propagate-fn propagate-all-component-changes))))
(fill-colors [file]
[(tho/bottom-fill-color file :frame-simple-1)

View File

@@ -6,20 +6,12 @@
(ns common-tests.logic.swap-as-override-test
(:require
[app.common.files.changes :as ch]
[app.common.files.changes-builder :as pcb]
[app.common.logic.libraries :as cll]
[app.common.logic.shapes :as cls]
[app.common.pprint :as pp]
[app.common.data :as d]
[app.common.test-helpers.components :as thc]
[app.common.test-helpers.compositions :as tho]
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as ths]
[app.common.types.component :as ctk]
[app.common.types.container :as ctn]
[app.common.types.file :as ctf]
[app.common.uuid :as uuid]
[clojure.test :as t]))
(t/use-fixtures :each thi/test-fixture)
@@ -27,23 +19,40 @@
(defn- setup []
(-> (thf/sample-file :file1)
(tho/add-simple-component :component-1 :frame-component-1 :child-component-1 :child-params {:name "child-component-1" :type :rect :fills (ths/sample-fills-color :fill-color "#111111")})
(tho/add-simple-component :component-2 :frame-component-2 :child-component-2 :child-params {:name "child-component-2" :type :rect :fills (ths/sample-fills-color :fill-color "#222222")})
(tho/add-simple-component :component-3 :frame-component-3 :child-component-3 :child-params {:name "child-component-3" :type :rect :fills (ths/sample-fills-color :fill-color "#333333")})
(tho/add-simple-component :component-1 :frame-component-1 :child-component-1
:root-params {:name "component-1"}
:child-params {:name "child-component-1"
:type :rect
:fills (ths/sample-fills-color :fill-color "#111111")})
(tho/add-simple-component :component-2 :frame-component-2 :child-component-2
:root-params {:name "component-2"}
:child-params {:name "child-component-2"
:type :rect
:fills (ths/sample-fills-color :fill-color "#222222")})
(tho/add-simple-component :component-3 :frame-component-3 :child-component-3
:root-params {:name "component-3"}
:child-params {:name "child-component-3"
:type :rect
:fills (ths/sample-fills-color :fill-color "#333333")})
(tho/add-frame :frame-icon-and-text)
(thc/instantiate-component :component-1 :copy-component-1 :parent-label :frame-icon-and-text :children-labels [:component-1-icon-and-text])
(tho/add-frame :frame-icon-and-text :name "copy-component-1")
(thc/instantiate-component :component-1 :copy-component-1
:parent-label :frame-icon-and-text
:children-labels [:component-1-icon-and-text])
(ths/add-sample-shape :text
{:type :text
:name "icon+text"
:parent-label :frame-icon-and-text})
(thc/make-component :icon-and-text :frame-icon-and-text)
(tho/add-frame :frame-panel)
(thc/instantiate-component :icon-and-text :copy-icon-and-text :parent-label :frame-panel :children-labels [:icon-and-text-panel])
(tho/add-frame :frame-panel :name "icon-and-text")
(thc/instantiate-component :icon-and-text :copy-icon-and-text
:parent-label :frame-panel
:children-labels [:icon-and-text-panel])
(thc/make-component :panel :frame-panel)
(thc/instantiate-component :panel :copy-panel :children-labels [:copy-icon-and-text-panel])))
(thc/instantiate-component :panel :copy-panel
:children-labels [:copy-icon-and-text-panel])))
(defn- propagate-all-component-changes [file]
(-> file

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

@@ -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

@@ -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

@@ -102,7 +102,7 @@
[:> deprecated-input/numeric-input*
{:placeholder (cond
(not all-equal?)
(tr "settings.multiple")
"Mixed"
(= :multiple (:r1 values))
(tr "settings.multiple")
:else

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

@@ -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

View File

@@ -384,7 +384,7 @@ pub fn propagate_modifiers(
if math::identitish(&entry.transform) {
Modifier::Reflow(entry.id)
} else {
Modifier::Transform(*entry)
Modifier::Transform(entry.clone())
}
})
.collect();

View File

@@ -63,50 +63,10 @@ fn make_corner(
Segment::CurveTo((h1, h2, to))
}
// Calculates the minimum of five f32 values
fn min_5(a: f32, b: f32, c: f32, d: f32, e: f32) -> f32 {
f32::min(a, f32::min(b, f32::min(c, f32::min(d, e))))
}
/*
https://www.w3.org/TR/css-backgrounds-3/#corner-overlap
> Corner curves must not overlap: When the sum of any two adjacent border radii exceeds the size of the border box,
> UAs must proportionally reduce the used values of all border radii until none of them overlap.
> The algorithm for reducing radii is as follows: Let f = min(Li/Si), where i ∈ {top, right, bottom, left}, Si is
> the sum of the two corresponding radii of the corners on side i, and Ltop = Lbottom = the width of the box, and
> Lleft = Lright = the height of the box. If f < 1, then all corner radii are reduced by multiplying them by f.
*/
fn fix_radius(
r1: math::Point,
r2: math::Point,
r3: math::Point,
r4: math::Point,
width: f32,
height: f32,
) -> (math::Point, math::Point, math::Point, math::Point) {
let f = min_5(
1.0,
width / (r1.x + r2.x),
height / (r2.y + r3.y),
width / (r3.x + r4.x),
height / (r4.y + r1.y),
);
if f < 1.0 {
(r1 * f, r2 * f, r3 * f, r4 * f)
} else {
(r1, r2, r3, r4)
}
}
pub fn rect_segments(shape: &Shape, corners: Option<Corners>) -> Vec<Segment> {
let sr = shape.selrect;
let segments = if let Some([r1, r2, r3, r4]) = corners {
let (r1, r2, r3, r4) = fix_radius(r1, r2, r3, r4, sr.width(), sr.height());
let p1 = (sr.x(), sr.y() + r1.y);
let p2 = (sr.x() + r1.x, sr.y());
let p3 = (sr.x() + sr.width() - r2.x, sr.y());

View File

@@ -1,4 +1,3 @@
use crate::render::text::calculate_decoration_metrics;
use crate::{
math::{Bounds, Matrix, Rect},
render::{default_font, DEFAULT_EMOJI_FONT},
@@ -186,17 +185,6 @@ impl TextContentLayout {
}
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct TextDecorationSegment {
pub kind: skia::textlayout::TextDecoration,
pub text_style: skia::textlayout::TextStyle,
pub y: f32,
pub thickness: f32,
pub left: f32,
pub width: f32,
}
/*
* Check if the current x,y (in paragraph relative coordinates) is inside
* the paragraph
@@ -216,48 +204,6 @@ fn intersects(paragraph: &skia_safe::textlayout::Paragraph, x: f32, y: f32) -> b
rects.iter().any(|r| r.rect.contains(&Point::new(x, y)))
}
// Performs a text auto layout without width limits.
// This should be the same as text_auto_layout.
pub fn build_paragraphs_from_paragraph_builders(
paragraph_builders: &mut [ParagraphBuilderGroup],
width: f32,
) -> Vec<Vec<skia::textlayout::Paragraph>> {
let paragraphs = paragraph_builders
.iter_mut()
.map(|builders| {
builders
.iter_mut()
.map(|builder| {
let mut paragraph = builder.build();
// For auto-width, always layout with infinite width first to get intrinsic width
paragraph.layout(width);
paragraph
})
.collect()
})
.collect();
paragraphs
}
/// Calculate the normalized line height from paragraph builders
pub fn calculate_normalized_line_height(
paragraph_builders: &mut [ParagraphBuilderGroup],
width: f32,
) -> f32 {
let mut normalized_line_height = 0.0;
for paragraph_builder_group in paragraph_builders.iter_mut() {
for paragraph_builder in paragraph_builder_group.iter_mut() {
let mut paragraph = paragraph_builder.build();
paragraph.layout(width);
let baseline = paragraph.ideographic_baseline();
if baseline > normalized_line_height {
normalized_line_height = baseline;
}
}
}
normalized_line_height
}
#[derive(Debug, PartialEq, Clone)]
pub struct TextContent {
pub paragraphs: Vec<Paragraph>,
@@ -494,15 +440,59 @@ impl TextContent {
paragraph_group
}
/// Performs a text auto layout without width limits.
/// This should be the same as text_auto_layout.
fn build_paragraphs_from_paragraph_builders(
&self,
paragraph_builders: &mut [ParagraphBuilderGroup],
width: f32,
) -> Vec<Vec<skia::textlayout::Paragraph>> {
let paragraphs = paragraph_builders
.iter_mut()
.map(|builders| {
builders
.iter_mut()
.map(|builder| {
let mut paragraph = builder.build();
// For auto-width, always layout with infinite width first to get intrinsic width
paragraph.layout(width);
paragraph
})
.collect()
})
.collect();
paragraphs
}
/// Calculate the normalized line height from paragraph builders
fn calculate_normalized_line_height(
&self,
paragraph_builders: &mut [ParagraphBuilderGroup],
width: f32,
) -> f32 {
let mut normalized_line_height = 0.0;
for paragraph_builder_group in paragraph_builders.iter_mut() {
for paragraph_builder in paragraph_builder_group.iter_mut() {
let mut paragraph = paragraph_builder.build();
paragraph.layout(width);
let baseline = paragraph.ideographic_baseline();
if baseline > normalized_line_height {
normalized_line_height = baseline;
}
}
}
normalized_line_height
}
/// Performs an Auto Width text layout.
fn text_layout_auto_width(&self) -> TextContentLayoutResult {
let mut paragraph_builders = self.paragraph_builder_group_from_text(None);
let normalized_line_height =
calculate_normalized_line_height(&mut paragraph_builders, f32::MAX);
self.calculate_normalized_line_height(&mut paragraph_builders, f32::MAX);
let paragraphs =
build_paragraphs_from_paragraph_builders(&mut paragraph_builders, f32::MAX);
self.build_paragraphs_from_paragraph_builders(&mut paragraph_builders, f32::MAX);
let (width, height) =
paragraphs
@@ -531,9 +521,10 @@ impl TextContent {
let mut paragraph_builders = self.paragraph_builder_group_from_text(None);
let normalized_line_height =
calculate_normalized_line_height(&mut paragraph_builders, width);
self.calculate_normalized_line_height(&mut paragraph_builders, width);
let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width);
let paragraphs =
self.build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width);
let height = paragraphs
.iter()
.flatten()
@@ -555,9 +546,10 @@ impl TextContent {
let mut paragraph_builders = self.paragraph_builder_group_from_text(None);
let normalized_line_height =
calculate_normalized_line_height(&mut paragraph_builders, width);
self.calculate_normalized_line_height(&mut paragraph_builders, width);
let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width);
let paragraphs =
self.build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width);
let paragraph_height = paragraphs
.iter()
.flatten()
@@ -584,7 +576,8 @@ impl TextContent {
pub fn get_height(&self, width: f32) -> f32 {
let mut paragraph_builders = self.paragraph_builder_group_from_text(None);
let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width);
let paragraphs =
self.build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width);
let paragraph_height = paragraphs
.iter()
.flatten()
@@ -740,7 +733,8 @@ impl TextContent {
let width = self.width();
let mut paragraph_builders = self.paragraph_builder_group_from_text(None);
let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width);
let paragraphs =
self.build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width);
paragraphs
.iter()
@@ -869,17 +863,17 @@ impl Paragraph {
#[derive(Debug, PartialEq, Clone)]
pub struct TextSpan {
pub text: String,
pub font_family: FontFamily,
pub font_size: f32,
pub line_height: f32,
pub letter_spacing: f32,
pub font_weight: i32,
pub font_variant_id: Uuid,
pub text_decoration: Option<TextDecoration>,
pub text_transform: Option<TextTransform>,
pub text_direction: TextDirection,
pub fills: Vec<shapes::Fill>,
text: String,
font_family: FontFamily,
font_size: f32,
line_height: f32,
letter_spacing: f32,
font_weight: i32,
font_variant_id: Uuid,
text_decoration: Option<TextDecoration>,
text_transform: Option<TextTransform>,
text_direction: TextDirection,
fills: Vec<shapes::Fill>,
}
impl TextSpan {
@@ -1051,251 +1045,3 @@ impl TextSpan {
})
}
}
#[allow(dead_code)]
#[derive(Debug, Copy, Clone)]
pub struct PositionData {
pub paragraph: u32,
pub span: u32,
pub start_pos: u32,
pub end_pos: u32,
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
pub direction: u32,
}
#[allow(dead_code)]
#[derive(Debug)]
pub struct ParagraphLayout {
pub paragraph: skia::textlayout::Paragraph,
pub x: f32,
pub y: f32,
pub spans: Vec<crate::shapes::TextSpan>,
pub decorations: Vec<TextDecorationSegment>,
}
#[allow(dead_code)]
#[derive(Debug)]
pub struct TextLayoutData {
pub position_data: Vec<PositionData>,
pub content_rect: Rect,
pub paragraphs: Vec<ParagraphLayout>,
}
fn direction_to_int(direction: TextDirection) -> u32 {
match direction {
TextDirection::RTL => 0,
TextDirection::LTR => 1,
}
}
pub fn calculate_text_layout_data(
shape: &Shape,
text_content: &TextContent,
paragraph_builder_groups: &mut [ParagraphBuilderGroup],
skip_position_data: bool,
) -> TextLayoutData {
let selrect_width = shape.selrect().width();
let text_width = text_content.get_width(selrect_width);
let selrect_height = shape.selrect().height();
let x = shape.selrect.x();
let base_y = shape.selrect.y();
let mut position_data: Vec<PositionData> = Vec::new();
let mut previous_line_height = text_content.normalized_line_height();
let text_paragraphs = text_content.paragraphs();
// 1. Calculate paragraph heights
let mut paragraph_heights: Vec<f32> = Vec::new();
for paragraph_builder_group in paragraph_builder_groups.iter_mut() {
let group_len = paragraph_builder_group.len();
let mut paragraph_offset_y = previous_line_height;
for (builder_index, paragraph_builder) in paragraph_builder_group.iter_mut().enumerate() {
let mut skia_paragraph = paragraph_builder.build();
skia_paragraph.layout(text_width);
if builder_index == group_len - 1 {
if skia_paragraph.get_line_metrics().is_empty() {
paragraph_offset_y = skia_paragraph.ideographic_baseline();
} else {
paragraph_offset_y = skia_paragraph.height();
}
}
if builder_index == 0 {
paragraph_heights.push(skia_paragraph.height());
}
}
previous_line_height = paragraph_offset_y;
}
// 2. Calculate vertical offset and build paragraphs with positions
let total_text_height: f32 = paragraph_heights.iter().sum();
let vertical_offset = match shape.vertical_align() {
VerticalAlign::Center => (selrect_height - total_text_height) / 2.0,
VerticalAlign::Bottom => selrect_height - total_text_height,
_ => 0.0,
};
let mut paragraph_layouts: Vec<ParagraphLayout> = Vec::new();
let mut y_accum = base_y + vertical_offset;
for (i, paragraph_builder_group) in paragraph_builder_groups.iter_mut().enumerate() {
// For each paragraph in the group (e.g., fill, stroke, etc.)
for paragraph_builder in paragraph_builder_group.iter_mut() {
let mut skia_paragraph = paragraph_builder.build();
skia_paragraph.layout(text_width);
let spans = if let Some(text_para) = text_paragraphs.get(i) {
text_para.children().to_vec()
} else {
Vec::new()
};
// Calculate text decorations for this paragraph
let mut decorations = Vec::new();
let line_metrics = skia_paragraph.get_line_metrics();
for line in &line_metrics {
let style_metrics: Vec<_> = line
.get_style_metrics(line.start_index..line.end_index)
.into_iter()
.collect();
let line_baseline = y_accum + line.baseline as f32;
let (max_underline_thickness, underline_y, max_strike_thickness, strike_y) =
calculate_decoration_metrics(&style_metrics, line_baseline);
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.end_index);
let seg_start = (*style_start).max(line.start_index);
let seg_end = style_end.min(line.end_index);
if seg_start >= seg_end {
continue;
}
let rects = skia_paragraph.get_rects_for_range(
seg_start..seg_end,
skia::textlayout::RectHeightStyle::Tight,
skia::textlayout::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.left as f32)
.unwrap_or(0.0);
(total_width, skia_x_offset)
} else {
(0.0, 0.0)
};
let text_left = x + line.left as f32 + actual_x_offset;
let text_width = segment_width;
use skia::textlayout::TextDecoration;
if text_style.decoration().ty == TextDecoration::UNDERLINE {
decorations.push(TextDecorationSegment {
kind: TextDecoration::UNDERLINE,
text_style: (*text_style).clone(),
y: underline_y.unwrap_or(line_baseline),
thickness: max_underline_thickness,
left: text_left,
width: text_width,
});
}
if text_style.decoration().ty == TextDecoration::LINE_THROUGH {
decorations.push(TextDecorationSegment {
kind: TextDecoration::LINE_THROUGH,
text_style: (*text_style).clone(),
y: strike_y.unwrap_or(line_baseline),
thickness: max_strike_thickness,
left: text_left,
width: text_width,
});
}
}
}
paragraph_layouts.push(ParagraphLayout {
paragraph: skia_paragraph,
x,
y: y_accum,
spans: spans.clone(),
decorations,
});
}
y_accum += paragraph_heights[i];
}
// Calculate position data from paragraph_layouts
if !skip_position_data {
for (paragraph_index, para_layout) in paragraph_layouts.iter().enumerate() {
let current_y = para_layout.y;
let text_paragraph = text_paragraphs.get(paragraph_index);
if let Some(text_para) = text_paragraph {
let mut span_ranges: Vec<(usize, usize, usize)> = vec![];
let mut cur = 0;
for (span_index, span) in text_para.children().iter().enumerate() {
let text: String = span.apply_text_transform();
span_ranges.push((cur, cur + text.len(), span_index));
cur += text.len();
}
for (start, end, span_index) in span_ranges {
let rects = para_layout.paragraph.get_rects_for_range(
start..end,
RectHeightStyle::Tight,
RectWidthStyle::Tight,
);
for textbox in rects {
let direction = textbox.direct;
let mut rect = textbox.rect;
let cy = rect.top + rect.height() / 2.0;
let start_pos = para_layout
.paragraph
.get_glyph_position_at_coordinate((rect.left + 0.1, cy))
.position as usize;
let end_pos = para_layout
.paragraph
.get_glyph_position_at_coordinate((rect.right - 0.1, cy))
.position as usize;
let start_pos = start_pos.saturating_sub(start);
let end_pos = end_pos.saturating_sub(start);
rect.offset((x, current_y));
position_data.push(PositionData {
paragraph: paragraph_index as u32,
span: span_index as u32,
start_pos: start_pos as u32,
end_pos: end_pos as u32,
x: rect.x(),
y: rect.y(),
width: rect.width(),
height: rect.height(),
direction: direction_to_int(direction),
});
}
}
}
}
}
let content_rect = Rect::from_xywh(x, base_y + vertical_offset, text_width, total_text_height);
TextLayoutData {
position_data,
content_rect,
paragraphs: paragraph_layouts,
}
}
pub fn calculate_position_data(
shape: &Shape,
text_content: &TextContent,
skip_position_data: bool,
) -> Vec<PositionData> {
let mut text_content = text_content.clone();
text_content.update_layout(shape.selrect);
let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None);
let layout_info = calculate_text_layout_data(
shape,
&text_content,
&mut paragraph_builders,
skip_position_data,
);
layout_info.position_data
}

View File

@@ -23,13 +23,13 @@ impl Modifier {
}
}
#[derive(PartialEq, Debug, Clone, Copy)]
#[derive(PartialEq, Debug, Clone)]
pub enum TransformEntrySource {
Input,
Propagate,
}
#[derive(PartialEq, Debug, Clone, Copy)]
#[derive(PartialEq, Debug, Clone)]
#[repr(C)]
pub struct TransformEntry {
pub id: Uuid,
@@ -65,8 +65,10 @@ impl TransformEntry {
}
}
impl From<[u8; 40]> for TransformEntry {
fn from(bytes: [u8; 40]) -> Self {
impl SerializableResult for TransformEntry {
type BytesType = [u8; 40];
fn from_bytes(bytes: Self::BytesType) -> Self {
let id = uuid_from_u32_quartet(
u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]),
@@ -87,46 +89,29 @@ impl From<[u8; 40]> for TransformEntry {
);
TransformEntry::from_input(id, transform)
}
}
impl TryFrom<&[u8]> for TransformEntry {
type Error = String;
fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
let bytes: [u8; 40] = bytes
.try_into()
.map_err(|_| "Invalid transform entry bytes".to_string())?;
Ok(TransformEntry::from(bytes))
}
}
impl From<TransformEntry> for [u8; 40] {
fn from(value: TransformEntry) -> Self {
let mut result = [0; 40];
let (a, b, c, d) = uuid_to_u32_quartet(&value.id);
fn as_bytes(&self) -> Self::BytesType {
let mut result: Self::BytesType = [0; 40];
let (a, b, c, d) = uuid_to_u32_quartet(&self.id);
result[0..4].clone_from_slice(&a.to_le_bytes());
result[4..8].clone_from_slice(&b.to_le_bytes());
result[8..12].clone_from_slice(&c.to_le_bytes());
result[12..16].clone_from_slice(&d.to_le_bytes());
result[16..20].clone_from_slice(&value.transform[0].to_le_bytes());
result[20..24].clone_from_slice(&value.transform[3].to_le_bytes());
result[24..28].clone_from_slice(&value.transform[1].to_le_bytes());
result[28..32].clone_from_slice(&value.transform[4].to_le_bytes());
result[32..36].clone_from_slice(&value.transform[2].to_le_bytes());
result[36..40].clone_from_slice(&value.transform[5].to_le_bytes());
result[16..20].clone_from_slice(&self.transform[0].to_le_bytes());
result[20..24].clone_from_slice(&self.transform[3].to_le_bytes());
result[24..28].clone_from_slice(&self.transform[1].to_le_bytes());
result[28..32].clone_from_slice(&self.transform[4].to_le_bytes());
result[32..36].clone_from_slice(&self.transform[2].to_le_bytes());
result[36..40].clone_from_slice(&self.transform[5].to_le_bytes());
result
}
}
impl SerializableResult for TransformEntry {
type BytesType = [u8; 40];
// The generic trait doesn't know the size of the array. This is why the
// clone needs to be here even if it could be generic.
fn clone_to_slice(&self, slice: &mut [u8]) {
let bytes = Self::BytesType::from(*self);
slice.clone_from_slice(&bytes);
slice.clone_from_slice(&self.as_bytes());
}
}
@@ -213,8 +198,8 @@ mod tests {
Matrix::new_all(1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 0.0, 0.0, 1.0),
);
let bytes: [u8; 40] = entry.into();
let bytes = entry.as_bytes();
assert_eq!(entry, TransformEntry::from(bytes));
assert_eq!(entry, TransformEntry::from_bytes(bytes));
}
}

View File

@@ -49,8 +49,10 @@ impl fmt::Display for Uuid {
}
}
impl From<[u8; 16]> for Uuid {
fn from(bytes: [u8; 16]) -> Self {
impl SerializableResult for Uuid {
type BytesType = [u8; 16];
fn from_bytes(bytes: Self::BytesType) -> Self {
Self(*uuid_from_u32_quartet(
u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]),
@@ -58,22 +60,10 @@ impl From<[u8; 16]> for Uuid {
u32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]),
))
}
}
impl TryFrom<&[u8]> for Uuid {
type Error = String;
fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
let bytes: [u8; 16] = bytes
.try_into()
.map_err(|_| "Invalid UUID bytes".to_string())?;
Ok(Self::from(bytes))
}
}
impl From<Uuid> for [u8; 16] {
fn from(value: Uuid) -> Self {
let mut result = [0; 16];
let (a, b, c, d) = uuid_to_u32_quartet(&value);
fn as_bytes(&self) -> Self::BytesType {
let mut result: Self::BytesType = [0; 16];
let (a, b, c, d) = uuid_to_u32_quartet(self);
result[0..4].clone_from_slice(&a.to_le_bytes());
result[4..8].clone_from_slice(&b.to_le_bytes());
result[8..12].clone_from_slice(&c.to_le_bytes());
@@ -81,15 +71,10 @@ impl From<Uuid> for [u8; 16] {
result
}
}
impl SerializableResult for Uuid {
type BytesType = [u8; 16];
// The generic trait doesn't know the size of the array. This is why the
// clone needs to be here even if it could be generic.
fn clone_to_slice(&self, slice: &mut [u8]) {
let bytes = Self::BytesType::from(*self);
slice.clone_from_slice(&bytes);
slice.clone_from_slice(&self.as_bytes());
}
}

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