mirror of
https://github.com/penpot/penpot.git
synced 2025-12-24 06:58:34 -05:00
Compare commits
1 Commits
nitrate-mo
...
eva-replac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91b1c210d1 |
2
.github/workflows/build-tag.yml
vendored
2
.github/workflows/build-tag.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: ${{ github.ref_name }}
|
||||
build_wasm: "yes"
|
||||
build_wasm: "no"
|
||||
build_storybook: "yes"
|
||||
|
||||
build-docker:
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -114,8 +114,6 @@ example. It's still usable as before, we just removed the example.
|
||||
- Fix unpublish library modal not scrolling a long file list [Taiga #12285](https://tree.taiga.io/project/penpot/issue/12285)
|
||||
- Fix incorrect interaction betwen hower and scroll on assets sidebar [Taiga #12389](https://tree.taiga.io/project/penpot/issue/12389)
|
||||
- Fix switch variants with paths [Taiga #12841](https://tree.taiga.io/project/penpot/issue/12841)
|
||||
- Fix referencing typography tokens on font-family tokens [Taiga #12492](https://tree.taiga.io/project/penpot/issue/12492)
|
||||
- Fix horizontal scroll on layer panel [Taiga #12843](https://tree.taiga.io/project/penpot/issue/12843)
|
||||
|
||||
## 2.11.1
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Tokens%20starter%20kit.penpot"}
|
||||
{:id "penpot-design-system"
|
||||
:name "Penpot Design System | Pencil"
|
||||
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Pencil-Penpot-Design-System.penpot"}
|
||||
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/penpot-app.penpot"}
|
||||
{:id "wireframing-kit"
|
||||
:name "Wireframe library"
|
||||
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Wireframing%20kit%20v1.1.penpot"}
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)}
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -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 {}))))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}))))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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 / {
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
"raw-body": "^3.0.1",
|
||||
"source-map-support": "^0.5.21",
|
||||
"svgo": "penpot/svgo#v3.1",
|
||||
"undici": "^7.16.0",
|
||||
"xml-js": "^1.6.11",
|
||||
"xregexp": "^5.1.2"
|
||||
},
|
||||
|
||||
@@ -7,4 +7,5 @@ bb -i '(babashka.wait/wait-for-port "localhost" 9630)';
|
||||
bb -i '(babashka.wait/wait-for-path "target/app.js")';
|
||||
sleep 2;
|
||||
|
||||
export NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
exec node target/app.js
|
||||
|
||||
@@ -107,12 +107,12 @@
|
||||
:on-progress on-progress)
|
||||
|
||||
append (fn [{:keys [filename path] :as resource}]
|
||||
(rsc/add-to-zip zip path (str/replace filename sanitize-file-regex "_")))
|
||||
(rsc/add-to-zip! zip path (str/replace filename sanitize-file-regex "_")))
|
||||
|
||||
proc (->> exports
|
||||
(map (fn [export] (rd/render export append)))
|
||||
(p/all)
|
||||
(p/mcat (fn [_] (rsc/close-zip zip)))
|
||||
(p/fnly (fn [_] (.finalize zip)))
|
||||
(p/fmap (constantly resource))
|
||||
(p/mcat (partial rsc/upload-resource auth-token))
|
||||
(p/fmap (fn [resource]
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
["node:fs" :as fs]
|
||||
["node:fs/promises" :as fsp]
|
||||
["node:path" :as path]
|
||||
["undici" :as http]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.transit :as t]
|
||||
[app.common.uri :as u]
|
||||
@@ -54,40 +53,30 @@
|
||||
(.pipe zip out)
|
||||
zip))
|
||||
|
||||
(defn add-to-zip
|
||||
(defn add-to-zip!
|
||||
[zip path name]
|
||||
(.file ^js zip path #js {:name name}))
|
||||
|
||||
(defn close-zip
|
||||
(defn close-zip!
|
||||
[zip]
|
||||
(p/create (fn [resolve]
|
||||
(.on ^js zip "close" resolve)
|
||||
(.finalize ^js zip))))
|
||||
(.finalize ^js zip))
|
||||
|
||||
(defn upload-resource
|
||||
[auth-token resource]
|
||||
(->> (fsp/readFile (:path resource))
|
||||
(p/fmap (fn [buffer]
|
||||
(js/console.log buffer)
|
||||
(new js/Blob #js [buffer] #js {:type (:mtype resource)})))
|
||||
(p/mcat (fn [blob]
|
||||
(let [fdata (new http/FormData)
|
||||
agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}})
|
||||
headers #js {"X-Shared-Key" cf/management-key
|
||||
"Authorization" (str "Bearer " auth-token)}
|
||||
|
||||
request #js {:headers headers
|
||||
:method "POST"
|
||||
:body fdata
|
||||
:dispatcher agent}
|
||||
uri (-> (cf/get :public-uri)
|
||||
(u/ensure-path-slash)
|
||||
(u/join "api/management/methods/upload-tempfile")
|
||||
(str))]
|
||||
|
||||
(let [fdata (new js/FormData)
|
||||
uri (-> (cf/get :public-uri)
|
||||
(u/ensure-path-slash)
|
||||
(u/join "api/management/methods/upload-tempfile")
|
||||
(str))]
|
||||
(.append fdata "content" blob (:filename resource))
|
||||
(http/fetch uri request))))
|
||||
|
||||
(js/fetch uri #js {:headers #js {"X-Shared-Key" cf/management-key
|
||||
"Authorization" (str "Bearer " auth-token)}
|
||||
:method "POST"
|
||||
:body fdata}))))
|
||||
(p/mcat (fn [response]
|
||||
(if (not= (.-status response) 200)
|
||||
(ex/raise :type :internal
|
||||
|
||||
@@ -75,8 +75,7 @@
|
||||
[path]
|
||||
(->> (.stat fs/promises path)
|
||||
(p/fmap (fn [data]
|
||||
{:path path
|
||||
:created-at (inst-ms (.-ctime ^js data))
|
||||
{:created-at (inst-ms (.-ctime ^js data))
|
||||
:size (.-size data)}))
|
||||
(p/merr (fn [_cause]
|
||||
(p/resolved nil)))))
|
||||
|
||||
@@ -582,7 +582,6 @@ __metadata:
|
||||
raw-body: "npm:^3.0.1"
|
||||
source-map-support: "npm:^0.5.21"
|
||||
svgo: "penpot/svgo#v3.1"
|
||||
undici: "npm:^7.16.0"
|
||||
ws: "npm:^8.18.3"
|
||||
xml-js: "npm:^1.6.11"
|
||||
xregexp: "npm:^5.1.2"
|
||||
@@ -1514,13 +1513,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"undici@npm:^7.16.0":
|
||||
version: 7.16.0
|
||||
resolution: "undici@npm:7.16.0"
|
||||
checksum: 10c0/efd867792e9f233facf9efa0a087e2d9c3e4415c0b234061b9b40307ca4fa01d945fee4d43c7b564e1b80e0d519bcc682f9f6e0de13c717146c00a80e2f1fb0f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"unique-filename@npm:^4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "unique-filename@npm:4.0.0"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,13 +32,19 @@
|
||||
min-width: var(--sp-l);
|
||||
}
|
||||
|
||||
// TODO: Review if we need other type of button, so we don't need important here
|
||||
.invisible-button {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 4px;
|
||||
opacity: var(--opacity-button);
|
||||
|
||||
background-color: var(--color-background-quaternary) !important;
|
||||
&:hover {
|
||||
background-color: var(--color-background-quaternary);
|
||||
--opacity-button: 1;
|
||||
}
|
||||
&:focus {
|
||||
background-color: var(--color-background-quaternary);
|
||||
--opacity-button: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
[:div {:class (stl/css :pill-dot)}])]]
|
||||
|
||||
(when-not ^boolean disabled
|
||||
[:> icon-button* {:variant "action"
|
||||
[:> icon-button* {:variant "ghost"
|
||||
:class (stl/css :invisible-button)
|
||||
:icon i/broken-link
|
||||
:ref token-detach-btn-ref
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
@use "ds/_sizes.scss" as *;
|
||||
@use "ds/typography.scss" as t;
|
||||
@use "ds/colors.scss" as *;
|
||||
@use "ds/mixins.scss" as *;
|
||||
|
||||
.token-field {
|
||||
--token-field-bg-color: var(--color-background-tertiary);
|
||||
@@ -16,9 +17,8 @@
|
||||
--token-field-outline-color: none;
|
||||
--token-field-height: var(--sp-xxxl);
|
||||
--token-field-margin: unset;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
width: inherit;
|
||||
column-gap: var(--sp-xs);
|
||||
align-items: center;
|
||||
position: relative;
|
||||
@@ -27,6 +27,7 @@
|
||||
border-radius: $br-8;
|
||||
padding: var(--sp-xs);
|
||||
outline: $b-1 solid var(--token-field-outline-color);
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
--token-field-bg-color: var(--color-background-quaternary);
|
||||
@@ -39,7 +40,7 @@
|
||||
}
|
||||
|
||||
.with-icon {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.token-field-disabled {
|
||||
@@ -57,6 +58,8 @@
|
||||
--pill-bg-color: var(--color-background-tertiary);
|
||||
--pill-fg-color: var(--color-token-foreground);
|
||||
@include t.use-typography("code-font");
|
||||
@include textEllipsis;
|
||||
display: block;
|
||||
height: var(--sp-xxl);
|
||||
width: fit-content;
|
||||
background: var(--pill-bg-color);
|
||||
@@ -65,6 +68,7 @@
|
||||
color: var(--pill-fg-color);
|
||||
border-radius: $br-6;
|
||||
padding-inline: $sz-6;
|
||||
max-width: 100%;
|
||||
&:hover {
|
||||
--pill-bg-color: var(--color-token-background);
|
||||
--pill-fg-color: var(--color-foreground-primary);
|
||||
@@ -115,6 +119,9 @@
|
||||
}
|
||||
|
||||
.invisible-button {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
opacity: var(--opacity-button);
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -159,4 +159,6 @@ $arrow-side: 12px;
|
||||
block-size: fit-content;
|
||||
inline-size: fit-content;
|
||||
line-height: 0;
|
||||
display: grid;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.types.shape.radius :as ctsr]
|
||||
[app.common.types.token :as tk]
|
||||
[app.main.data.workspace.shapes :as dwsh]
|
||||
[app.main.data.workspace.tokens.application :as dwta]
|
||||
[app.main.features :as features]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.components.numeric-input :as deprecated-input]
|
||||
[app.main.ui.context :as muc]
|
||||
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
|
||||
[app.main.ui.ds.controls.numeric-input :refer [numeric-input*]]
|
||||
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
|
||||
[app.main.ui.hooks :as hooks]
|
||||
[app.util.i18n :as i18n :refer [tr]]
|
||||
@@ -21,11 +26,15 @@
|
||||
(defn- check-border-radius-menu-props
|
||||
[old-props new-props]
|
||||
(let [old-values (unchecked-get old-props "values")
|
||||
new-values (unchecked-get new-props "values")]
|
||||
new-values (unchecked-get new-props "values")
|
||||
old-applied-tokens (unchecked-get old-props "applied-tokens")
|
||||
new-applied-tokens (unchecked-get new-props "applied-tokens")]
|
||||
(and (identical? (unchecked-get old-props "class")
|
||||
(unchecked-get new-props "class"))
|
||||
(identical? (unchecked-get old-props "ids")
|
||||
(unchecked-get new-props "ids"))
|
||||
(identical? old-applied-tokens
|
||||
new-applied-tokens)
|
||||
(identical? (get old-values :r1)
|
||||
(get new-values :r1))
|
||||
(identical? (get old-values :r2)
|
||||
@@ -35,13 +44,64 @@
|
||||
(identical? (get old-values :r4)
|
||||
(get new-values :r4)))))
|
||||
|
||||
(mf/defc numeric-input-wrapper*
|
||||
{::mf/private true}
|
||||
[{:keys [values name applied-tokens align on-detach radius] :rest props}]
|
||||
(let [tokens (mf/use-ctx muc/active-tokens-by-type)
|
||||
tokens (mf/with-memo [tokens name]
|
||||
(delay
|
||||
(-> (deref tokens)
|
||||
(select-keys (get tk/tokens-by-input name))
|
||||
(not-empty))))
|
||||
on-detach-attr
|
||||
(mf/use-fn
|
||||
(mf/deps on-detach name)
|
||||
#(on-detach % name))
|
||||
|
||||
r1-value (get applied-tokens :r1)
|
||||
r2-value (get applied-tokens :r2)
|
||||
r3-value (get applied-tokens :r3)
|
||||
r4-value (get applied-tokens :r4)
|
||||
all-equal? (= r1-value r2-value r3-value r4-value)
|
||||
|
||||
applied-token (if (= :all radius)
|
||||
(if all-equal?
|
||||
r1-value
|
||||
:mixed)
|
||||
:mixed)
|
||||
|
||||
|
||||
props (mf/spread-props props
|
||||
{:placeholder (if (or (= :multiple (:applied-tokens values))
|
||||
(= :multiple (get values name)))
|
||||
(tr "settings.multiple") "--")
|
||||
:class (stl/css :numeric-input-measures)
|
||||
:applied-token applied-token
|
||||
:tokens (if (delay? tokens) @tokens tokens)
|
||||
:align align
|
||||
:on-detach on-detach-attr
|
||||
:value (get values name)})]
|
||||
[:> numeric-input* props]))
|
||||
|
||||
(mf/defc border-radius-menu*
|
||||
{::mf/wrap [#(mf/memo' % check-border-radius-menu-props)]}
|
||||
[{:keys [class ids values]}]
|
||||
(let [all-equal? (all-equal? values)
|
||||
[{:keys [class ids values applied-tokens]}]
|
||||
(let [token-numeric-inputs
|
||||
(features/use-feature "tokens/numeric-input")
|
||||
|
||||
all-equal? (all-equal? values)
|
||||
radius-expanded* (mf/use-state false)
|
||||
radius-expanded (deref radius-expanded*)
|
||||
|
||||
;; DETACH
|
||||
on-detach-token
|
||||
(mf/use-fn
|
||||
(mf/deps ids)
|
||||
(fn [token attr]
|
||||
(st/emit! (dwta/unapply-token {:token (first token)
|
||||
:attributes #{attr}
|
||||
:shape-ids ids}))))
|
||||
|
||||
change-radius
|
||||
(mf/use-fn
|
||||
(mf/deps ids)
|
||||
@@ -94,23 +154,39 @@
|
||||
|
||||
[:div {:class (dm/str class " " (stl/css :radius))}
|
||||
(if (not radius-expanded)
|
||||
[:div {:class (stl/css :radius-1)
|
||||
:title (tr "workspace.options.radius")}
|
||||
[:> icon* {:icon-id i/corner-radius
|
||||
:size "s"
|
||||
:class (stl/css :icon)}]
|
||||
[:> deprecated-input/numeric-input*
|
||||
{:placeholder (cond
|
||||
(not all-equal?)
|
||||
(tr "settings.multiple")
|
||||
(= :multiple (:r1 values))
|
||||
(tr "settings.multiple")
|
||||
:else
|
||||
"--")
|
||||
:min 0
|
||||
:nillable true
|
||||
:on-change on-single-radius-change
|
||||
:value (if all-equal? (:r1 values) nil)}]]
|
||||
(if token-numeric-inputs
|
||||
[:div {:class (stl/css :radius-1)
|
||||
:title (tr "workspace.options.radius")}
|
||||
[:> numeric-input-wrapper*
|
||||
{:on-change on-single-radius-change
|
||||
:on-detach on-detach-token
|
||||
:icon i/corner-radius
|
||||
:min 0
|
||||
:name :border-radius
|
||||
:nillable true
|
||||
:property (tr "workspace.options.width")
|
||||
:applied-tokens applied-tokens
|
||||
:radius :all
|
||||
:values (if all-equal? (:r1 values) nil)}]]
|
||||
|
||||
[:div {:class (stl/css :radius-1)
|
||||
:title (tr "workspace.options.radius")}
|
||||
[:> icon* {:icon-id i/corner-radius
|
||||
:size "s"
|
||||
:class (stl/css :icon)}]
|
||||
|
||||
[:* [:> deprecated-input/numeric-input*
|
||||
{:placeholder (cond
|
||||
(not all-equal?)
|
||||
"Mixed"
|
||||
(= :multiple (:r1 values))
|
||||
(tr "settings.multiple")
|
||||
:else
|
||||
"--")
|
||||
:min 0
|
||||
:nillable true
|
||||
:on-change on-single-radius-change
|
||||
:value (if all-equal? (:r1 values) nil)}]]])
|
||||
|
||||
[:div {:class (stl/css :radius-4)}
|
||||
[:div {:class (stl/css :small-input)}
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
(nil? (get values name)))
|
||||
(tr "settings.multiple")
|
||||
"--")
|
||||
:class (stl/css :numeric-input-measures)
|
||||
:class (stl/css :numeric-input-layout)
|
||||
:applied-token (get applied-tokens name)
|
||||
:tokens tokens
|
||||
:align align
|
||||
|
||||
@@ -433,6 +433,6 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.numeric-input-measures {
|
||||
.numeric-input-layout {
|
||||
--dropdown-width: var(--7-columns-dropdown-width);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
@use "refactor/common-refactor.scss" as deprecated;
|
||||
@use "../../../sidebar/common/sidebar.scss" as sidebar;
|
||||
@use "ds/_utils.scss" as *;
|
||||
|
||||
.element-set {
|
||||
display: grid;
|
||||
@@ -188,7 +189,6 @@
|
||||
@extend .button-icon;
|
||||
}
|
||||
|
||||
// TODO: Add a proper variable to this sizing
|
||||
.numeric-input-measures {
|
||||
--dropdown-width: var(--7-columns-dropdown-width);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
|
||||
(mf/defc color-token-row*
|
||||
{::mf/private true}
|
||||
[{:keys [active-tokens applied-token-name color on-swatch-click-token detach-token open-modal-from-token]}]
|
||||
[{:keys [active-tokens color-token color on-swatch-click-token detach-token open-modal-from-token]}]
|
||||
(let [;; `active-tokens` may be provided as a `delay` (lazy computation).
|
||||
;; In that case we must deref it (`@active-tokens`) to force evaluation
|
||||
;; and obtain the actual value. If it’s already realized (not a delay),
|
||||
@@ -77,22 +77,21 @@
|
||||
@active-tokens
|
||||
active-tokens)
|
||||
|
||||
active-color-tokens (:color active-tokens)
|
||||
color-tokens (:color active-tokens)
|
||||
|
||||
token (some #(when (= (:name %) applied-token-name) %) active-color-tokens)
|
||||
token (some #(when (= (:name %) color-token) %) color-tokens)
|
||||
|
||||
on-detach-token
|
||||
(mf/use-fn
|
||||
(mf/deps detach-token token applied-token-name)
|
||||
(mf/deps detach-token token color-token)
|
||||
(fn []
|
||||
(let [token (or token applied-token-name)]
|
||||
(let [token (or token color-token)]
|
||||
(detach-token token))))
|
||||
|
||||
has-errors (some? (:errors token))
|
||||
token-name (:name token)
|
||||
resolved (:resolved-value token)
|
||||
not-active (and (empty? active-tokens)
|
||||
(nil? token))
|
||||
not-active (and (some? active-tokens) (nil? token))
|
||||
id (dm/str (:id token) "-name")
|
||||
swatch-tooltip-content (cond
|
||||
not-active
|
||||
@@ -110,7 +109,7 @@
|
||||
#(mf/html
|
||||
[:div
|
||||
[:span (dm/str (tr "workspace.tokens.token-name") ": ")]
|
||||
[:span {:class (stl/css :token-name-tooltip)} applied-token-name]]))]
|
||||
[:span {:class (stl/css :token-name-tooltip)} color-token]]))]
|
||||
|
||||
[:div {:class (stl/css :color-info)}
|
||||
[:div {:class (stl/css-case :token-color-wrapper true
|
||||
@@ -129,7 +128,7 @@
|
||||
:class (stl/css :token-tooltip)}
|
||||
[:div {:class (stl/css :token-name)
|
||||
:aria-labelledby id}
|
||||
(or token-name applied-token-name)]]
|
||||
(or token-name color-token)]]
|
||||
[:div {:class (stl/css :token-actions)}
|
||||
[:> icon-button*
|
||||
{:variant "action"
|
||||
@@ -147,11 +146,7 @@
|
||||
on-change on-reorder on-detach on-open on-close on-remove origin on-detach-token
|
||||
disable-drag on-focus on-blur select-only select-on-focus on-token-change applied-token]}]
|
||||
|
||||
(let [;; TODO: Remove this workaround fixing `get-attrs*` fn on sidebar/options/shapes/multiple.cljs
|
||||
applied-token (if (= :multiple applied-token)
|
||||
nil
|
||||
applied-token)
|
||||
token-color (contains? cfg/flags :token-color)
|
||||
(let [token-color (contains? cfg/flags :token-color)
|
||||
libraries (mf/deref refs/files)
|
||||
|
||||
color-without-hash (mf/use-memo
|
||||
@@ -182,6 +177,7 @@
|
||||
(-> (deref active-tokens*)
|
||||
(select-keys (get tk/tokens-by-input origin))
|
||||
(not-empty)))))
|
||||
|
||||
on-focus'
|
||||
(mf/use-fn
|
||||
(mf/deps on-focus)
|
||||
@@ -356,7 +352,7 @@
|
||||
(cond
|
||||
(and token-color applied-token)
|
||||
[:> color-token-row* {:active-tokens tokens
|
||||
:applied-token-name applied-token
|
||||
:color-token applied-token
|
||||
:color (dissoc color :ref-id :ref-file)
|
||||
:on-swatch-click-token on-swatch-click-token
|
||||
:detach-token detach-token
|
||||
|
||||
@@ -63,8 +63,7 @@
|
||||
:data {:index index})
|
||||
[nil nil])
|
||||
|
||||
stroke-color-token
|
||||
(:stroke-color applied-tokens)
|
||||
stroke-color-token (:stroke-color applied-tokens)
|
||||
|
||||
on-color-change-refactor
|
||||
(mf/use-fn
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}])))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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))))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'))))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})]
|
||||
|
||||
@@ -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))))
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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!"
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ function getContext() {
|
||||
if (!context) {
|
||||
context = canvas.getContext("2d");
|
||||
}
|
||||
return context;
|
||||
return context
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 === "";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -7,9 +7,8 @@ pushd $_SCRIPT_DIR;
|
||||
|
||||
. ./_build_env
|
||||
|
||||
set -ex;
|
||||
set -x;
|
||||
|
||||
setup;
|
||||
build;
|
||||
copy_artifacts "../frontend/resources/public/js";
|
||||
copy_shared_artifact;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
pub const DEBUG_VISIBLE: u32 = 0x01;
|
||||
pub const PROFILE_REBUILD_TILES: u32 = 0x02;
|
||||
pub const FAST_MODE: u32 = 0x04;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 ¶.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, ¶graph, paragraph_builder, line_metrics, xy);
|
||||
}
|
||||
}
|
||||
|
||||
global_offset_y += paragraph_offset_y;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,7 +307,7 @@ fn draw_text_decorations(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn calculate_decoration_metrics(
|
||||
fn calculate_decoration_metrics(
|
||||
style_metrics: &Vec<(usize, &StyleMetrics)>,
|
||||
line_baseline: f32,
|
||||
) -> (f32, Option<f32>, f32, Option<f32>) {
|
||||
@@ -330,6 +357,106 @@ pub fn calculate_decoration_metrics(
|
||||
)
|
||||
}
|
||||
|
||||
fn render_text_decoration(
|
||||
canvas: &Canvas,
|
||||
skia_paragraph: &Paragraph,
|
||||
builder: &mut ParagraphBuilder,
|
||||
line_metrics: &LineMetrics,
|
||||
xy: (f32, f32),
|
||||
) {
|
||||
let style_metrics: Vec<_> = line_metrics
|
||||
.get_style_metrics(line_metrics.start_index..line_metrics.end_index)
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let mut current_x_offset = 0.0;
|
||||
let total_chars = line_metrics.end_index - line_metrics.start_index;
|
||||
let line_start_offset = line_metrics.left as f32;
|
||||
|
||||
if total_chars == 0 || style_metrics.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let line_baseline = xy.1 + line_metrics.baseline as f32;
|
||||
let full_text = builder.get_text();
|
||||
|
||||
// Calculate decoration metrics
|
||||
let (max_underline_thickness, underline_y, max_strike_thickness, strike_y) =
|
||||
calculate_decoration_metrics(&style_metrics, line_baseline);
|
||||
|
||||
// Draw decorations per segment (text span)
|
||||
for (i, (style_start, style_metric)) in style_metrics.iter().enumerate() {
|
||||
let text_style = &style_metric.text_style;
|
||||
let style_end = style_metrics
|
||||
.get(i + 1)
|
||||
.map(|(next_i, _)| *next_i)
|
||||
.unwrap_or(line_metrics.end_index);
|
||||
|
||||
let seg_start = (*style_start).max(line_metrics.start_index);
|
||||
let seg_end = style_end.min(line_metrics.end_index);
|
||||
if seg_start >= seg_end {
|
||||
continue;
|
||||
}
|
||||
|
||||
let start_byte = full_text
|
||||
.char_indices()
|
||||
.nth(seg_start)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(0);
|
||||
let end_byte = full_text
|
||||
.char_indices()
|
||||
.nth(seg_end)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(full_text.len());
|
||||
let segment_text = &full_text[start_byte..end_byte];
|
||||
|
||||
let rects = skia_paragraph.get_rects_for_range(
|
||||
seg_start..seg_end,
|
||||
RectHeightStyle::Tight,
|
||||
RectWidthStyle::Tight,
|
||||
);
|
||||
let (segment_width, actual_x_offset) = if !rects.is_empty() {
|
||||
let total_width: f32 = rects.iter().map(|r| r.rect.width()).sum();
|
||||
let skia_x_offset = rects
|
||||
.first()
|
||||
.map(|r| r.rect.left - line_start_offset)
|
||||
.unwrap_or(0.0);
|
||||
(total_width, skia_x_offset)
|
||||
} else {
|
||||
let font = skia_paragraph.get_font_at(seg_start);
|
||||
let measured_width = font.measure_text(segment_text, None).0;
|
||||
(measured_width, current_x_offset)
|
||||
};
|
||||
|
||||
let text_left = xy.0 + line_start_offset + actual_x_offset;
|
||||
let text_width = segment_width;
|
||||
|
||||
// Underline
|
||||
if text_style.decoration().ty == TextDecoration::UNDERLINE {
|
||||
draw_text_decorations(
|
||||
canvas,
|
||||
text_style,
|
||||
underline_y,
|
||||
max_underline_thickness,
|
||||
text_left,
|
||||
text_width,
|
||||
);
|
||||
}
|
||||
// Strikethrough
|
||||
if text_style.decoration().ty == TextDecoration::LINE_THROUGH {
|
||||
draw_text_decorations(
|
||||
canvas,
|
||||
text_style,
|
||||
strike_y,
|
||||
max_strike_thickness,
|
||||
text_left,
|
||||
text_width,
|
||||
);
|
||||
}
|
||||
current_x_offset += segment_width;
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn calculate_total_paragraphs_height(paragraphs: &mut [ParagraphBuilder], width: f32) -> f32 {
|
||||
paragraphs
|
||||
@@ -379,29 +506,6 @@ pub fn render_as_path(
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn render_position_data(
|
||||
render_state: &mut RenderState,
|
||||
surface_id: SurfaceId,
|
||||
shape: &Shape,
|
||||
text_content: &TextContent,
|
||||
) {
|
||||
let position_data = calculate_position_data(shape, text_content, false);
|
||||
|
||||
let mut paint = skia::Paint::default();
|
||||
paint.set_style(skia::PaintStyle::Stroke);
|
||||
paint.set_color(skia::Color::from_argb(255, 255, 0, 0));
|
||||
paint.set_stroke_width(2.);
|
||||
|
||||
for pd in position_data {
|
||||
let rect = Rect::from_xywh(pd.x, pd.y, pd.width, pd.height);
|
||||
render_state
|
||||
.surfaces
|
||||
.canvas(surface_id)
|
||||
.draw_rect(rect, &paint);
|
||||
}
|
||||
}
|
||||
|
||||
// How to use it?
|
||||
// Type::Text(text_content) => {
|
||||
// self.surfaces
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user