mirror of
https://github.com/penpot/penpot.git
synced 2025-12-24 15:08:43 -05:00
Compare commits
99 Commits
staging-te
...
staging-re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a57011ec7b | ||
|
|
cb325282ec | ||
|
|
01ecde3bfa | ||
|
|
4000ec8762 | ||
|
|
6a1854f180 | ||
|
|
bd580ab159 | ||
|
|
5780a43fe0 | ||
|
|
737eceda3a | ||
|
|
923c3c2dbd | ||
|
|
a14b4561e7 | ||
|
|
bb5568e15a | ||
|
|
5cbcec3db6 | ||
|
|
fe44c14bac | ||
|
|
20061067ad | ||
|
|
336173645e | ||
|
|
08267de242 | ||
|
|
83bb4bf221 | ||
|
|
15ed25ca79 | ||
|
|
9aa387a473 | ||
|
|
67ba91b4b9 | ||
|
|
f67f1a6a0e | ||
|
|
82d3e2024e | ||
|
|
4bd846c16d | ||
|
|
8fde6b28ed | ||
|
|
63325ec796 | ||
|
|
84415476d0 | ||
|
|
94f95ca6b8 | ||
|
|
5a922c6bd6 | ||
|
|
507bf7445b | ||
|
|
81b72c5acd | ||
|
|
1388865cfc | ||
|
|
1738847694 | ||
|
|
ca1c3c799d | ||
|
|
974495e08f | ||
|
|
2ed39e43c3 | ||
|
|
ce5006ae84 | ||
|
|
50dbe6ab12 | ||
|
|
0a7a65af5d | ||
|
|
ea4d0e1238 | ||
|
|
b705cf953a | ||
|
|
90ce1f56e7 | ||
|
|
ab0438cc6f | ||
|
|
c6aa9cc4b7 | ||
|
|
5779adef33 | ||
|
|
2f46cbc0d4 | ||
|
|
ebf1758958 | ||
|
|
e94c56bfa7 | ||
|
|
53be6f996b | ||
|
|
89d9591011 | ||
|
|
5501a2815f | ||
|
|
1066438b02 | ||
|
|
3b23a3ad19 | ||
|
|
5cf51f3d26 | ||
|
|
25acad5154 | ||
|
|
0a212b6291 | ||
|
|
a78477592b | ||
|
|
a661b2564f | ||
|
|
2c3732f3f4 | ||
|
|
45665a3c21 | ||
|
|
81bc1bb0af | ||
|
|
7d36bc4025 | ||
|
|
b8feb6374d | ||
|
|
0889df8e08 | ||
|
|
7be8ac3fd7 | ||
|
|
9216d965ef | ||
|
|
520e979363 | ||
|
|
a38f425dd3 | ||
|
|
4e84deca44 | ||
|
|
0d21e52068 | ||
|
|
1b29e9a50f | ||
|
|
9f567c3bf4 | ||
|
|
1ba15e5d10 | ||
|
|
37e45a8bbf | ||
|
|
3471d40f46 | ||
|
|
c6b64a8e39 | ||
|
|
511e80c948 | ||
|
|
f5a640d104 | ||
|
|
3ae7c514e4 | ||
|
|
fad9ed1c48 | ||
|
|
0caaefefea | ||
|
|
b179aa79b1 | ||
|
|
405ddb60d8 | ||
|
|
95c0d42d5b | ||
|
|
721b337511 | ||
|
|
359379be09 | ||
|
|
876d5783cf | ||
|
|
786f73767b | ||
|
|
4282cdcd2c | ||
|
|
e889413f26 | ||
|
|
115273b478 | ||
|
|
fdddd3284a | ||
|
|
51385a04a0 | ||
|
|
f96ed8ccd6 | ||
|
|
bda5de5c1b | ||
|
|
59f3b4db4c | ||
|
|
7ee03ad911 | ||
|
|
130b8c8214 | ||
|
|
0198d41757 | ||
|
|
567a955151 |
@@ -62,6 +62,7 @@ example. It's still usable as before, we just removed the example.
|
||||
|
||||
- Ensure consistent snap behavior across all zoom levels [Github #7774](https://github.com/penpot/penpot/pull/7774) by [@Tokytome](https://github.com/Tokytome)
|
||||
- Fix crash in token grid view due to tooltip validation (by @dfelinto) [Github #7887](https://github.com/penpot/penpot/pull/7887)
|
||||
- Enable Hindi translations on the application
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
@@ -94,6 +95,8 @@ example. It's still usable as before, we just removed the example.
|
||||
- Fix incorrect interaction betwen hower and scroll on assets sidebar [Taiga #12389](https://tree.taiga.io/project/penpot/issue/12389)
|
||||
- Fix switch variants with paths [Taiga #12841](https://tree.taiga.io/project/penpot/issue/12841)
|
||||
- Fix referencing typography tokens on font-family tokens [Taiga #12492](https://tree.taiga.io/project/penpot/issue/12492)
|
||||
- Fix horizontal scroll on layer panel [Taiga #12843](https://tree.taiga.io/project/penpot/issue/12843)
|
||||
- Fix unicode handling on email template abbreviation filter [Github #7966](https://github.com/penpot/penpot/pull/7966)
|
||||
|
||||
## 2.11.1
|
||||
|
||||
|
||||
@@ -240,4 +240,4 @@
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -821,9 +821,10 @@
|
||||
entries (keep (match-storage-entry-fn) entries)]
|
||||
|
||||
(doseq [{:keys [id entry]} entries]
|
||||
(let [object (->> (read-entry input entry)
|
||||
(decode-storage-object)
|
||||
(validate-storage-object))
|
||||
(let [object (-> (read-entry input entry)
|
||||
(decode-storage-object)
|
||||
(update :bucket d/nilv sto/default-bucket)
|
||||
(validate-storage-object))
|
||||
|
||||
ext (cmedia/mtype->extension (:content-type object))
|
||||
path (str "objects/" id ext)
|
||||
|
||||
@@ -307,7 +307,8 @@
|
||||
:content-type (:mtype input)})]
|
||||
(:id sobject))
|
||||
(catch Throwable cause
|
||||
(l/err :hint "unable to import profile picture"
|
||||
(l/wrn :hint "unable to import profile picture"
|
||||
:uri uri
|
||||
:cause cause)
|
||||
nil)))
|
||||
|
||||
|
||||
@@ -104,28 +104,29 @@
|
||||
(def ^:private schema:limit
|
||||
[:and
|
||||
[:map
|
||||
[::name :any]
|
||||
[::name :keyword]
|
||||
[::strategy schema:strategy]
|
||||
[::key :string]
|
||||
[::opts :string]]
|
||||
[:or
|
||||
[:map
|
||||
[::capacity ::sm/int]
|
||||
[::rate ::sm/int]
|
||||
[::internal ::ct/duration]
|
||||
[::params [::sm/vec :any]]]
|
||||
[:map
|
||||
[::nreq ::sm/int]
|
||||
[::unit [:enum :days :hours :minutes :seconds :weeks]]]]])
|
||||
[::opts :string]
|
||||
[::capacity {:optional true} ::sm/int]
|
||||
[::rate {:optional true} ::sm/int]
|
||||
[::interval {:optional true} ::ct/duration]
|
||||
[::params {:optional true} [::sm/vec :any]]
|
||||
[::permits {:optional true} ::sm/int]
|
||||
[::unit {:optional true} [:enum :days :hours :minutes :seconds :weeks]]]
|
||||
[:fn (fn [attrs]
|
||||
(let [contains-fn (partial contains? attrs)]
|
||||
(or (every? contains-fn [::capacity ::rate ::interval])
|
||||
(every? contains-fn [::permits ::unit]))))]])
|
||||
|
||||
(def ^:private schema:limits
|
||||
[:map-of :keyword [::sm/vec schema:limit]])
|
||||
|
||||
(def ^:private valid-limit-tuple?
|
||||
(sm/lazy-validator schema:limit-tuple))
|
||||
(sm/validator schema:limit-tuple))
|
||||
|
||||
(def ^:private valid-rlimit-instance?
|
||||
(sm/lazy-validator ::rpc/rlimit))
|
||||
(sm/validator ::rpc/rlimit))
|
||||
|
||||
(defmethod parse-limit :window
|
||||
[[name strategy opts :as vlimit]]
|
||||
@@ -134,16 +135,16 @@
|
||||
(merge
|
||||
{::name name
|
||||
::strategy strategy}
|
||||
(if-let [[_ nreq unit] (re-find window-opts-re opts)]
|
||||
(let [nreq (parse-long nreq)]
|
||||
{::nreq nreq
|
||||
(if-let [[_ permits unit] (re-find window-opts-re opts)]
|
||||
(let [permits (parse-long permits)]
|
||||
{::permits permits
|
||||
::unit (case unit
|
||||
"d" :days
|
||||
"h" :hours
|
||||
"m" :minutes
|
||||
"s" :seconds
|
||||
"w" :weeks)
|
||||
::key (str "ratelimit.window." (d/name name))
|
||||
::key (str "penpot.rlimit." (cf/get :tenant) ".window." (d/name name))
|
||||
::opts opts})
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-window-limit-opts
|
||||
@@ -164,15 +165,15 @@
|
||||
::interval interval
|
||||
::opts opts
|
||||
::params [(->seconds interval) rate capacity]
|
||||
::key (str "ratelimit.bucket." (d/name name))})
|
||||
::key (str "penpot.rlimit." (cf/get :tenant) ".bucket." (d/name name))})
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-bucket-limit-opts
|
||||
:hint (str/ffmt "looks like '%' does not have a valid format" opts))))
|
||||
|
||||
(defmethod process-limit :bucket
|
||||
[rconn user-id now {:keys [::key ::params ::service ::capacity ::interval ::rate] :as limit}]
|
||||
[rconn profile-id now {:keys [::key ::params ::service ::capacity ::interval ::rate] :as limit}]
|
||||
(let [script (-> bucket-rate-limit-script
|
||||
(assoc ::rscript/keys [(str key "." service "." user-id)])
|
||||
(assoc ::rscript/keys [(str key "." service "." profile-id)])
|
||||
(assoc ::rscript/vals (conj params (->seconds now))))
|
||||
result (rds/eval rconn script)
|
||||
allowed? (boolean (nth result 0))
|
||||
@@ -192,18 +193,18 @@
|
||||
(assoc ::lresult/remaining remaining))))
|
||||
|
||||
(defmethod process-limit :window
|
||||
[rconn user-id now {:keys [::nreq ::unit ::key ::service] :as limit}]
|
||||
[rconn profile-id now {:keys [::permits ::unit ::key ::service] :as limit}]
|
||||
(let [ts (ct/truncate now unit)
|
||||
ttl (ct/diff now (ct/plus ts {unit 1}))
|
||||
script (-> window-rate-limit-script
|
||||
(assoc ::rscript/keys [(str key "." service "." user-id "." (ct/format-inst ts))])
|
||||
(assoc ::rscript/vals [nreq (->seconds ttl)]))
|
||||
(assoc ::rscript/keys [(str key "." service "." profile-id "." (ct/format-inst ts))])
|
||||
(assoc ::rscript/vals [permits (->seconds ttl)]))
|
||||
result (rds/eval rconn script)
|
||||
allowed? (boolean (nth result 0))
|
||||
remaining (nth result 1)]
|
||||
(l/trace :hint "limit processed"
|
||||
:service service
|
||||
:limit (name (::name limit))
|
||||
:name (name (::name limit))
|
||||
:strategy (name (::strategy limit))
|
||||
:opts (::opts limit)
|
||||
:allowed allowed?
|
||||
@@ -214,8 +215,8 @@
|
||||
(assoc ::lresult/reset (ct/plus ts {unit 1})))))
|
||||
|
||||
(defn- process-limits
|
||||
[rconn user-id limits now]
|
||||
(let [results (into [] (map (partial process-limit rconn user-id now)) limits)
|
||||
[rconn profile-id limits now]
|
||||
(let [results (into [] (map (partial process-limit rconn profile-id now)) limits)
|
||||
remaining (->> results
|
||||
(d/index-by ::name ::lresult/remaining)
|
||||
(uri/map->query-string))
|
||||
@@ -227,7 +228,7 @@
|
||||
|
||||
(when rejected
|
||||
(l/warn :hint "rejected rate limit"
|
||||
:user-id (str user-id)
|
||||
:profile-id (str profile-id)
|
||||
:limit-service (-> rejected ::service name)
|
||||
:limit-name (-> rejected ::name name)
|
||||
:limit-strategy (-> rejected ::strategy name)))
|
||||
@@ -371,12 +372,9 @@
|
||||
(defn- on-refresh-error
|
||||
[_ cause]
|
||||
(when-not (instance? java.util.concurrent.RejectedExecutionException cause)
|
||||
(if-let [explain (-> cause ex-data ex/explain)]
|
||||
(l/warn ::l/raw (str "unable to refresh config, invalid format:\n" explain)
|
||||
::l/sync? true)
|
||||
(l/warn :hint "unexpected exception on loading config"
|
||||
:cause cause
|
||||
::l/sync? true))))
|
||||
(l/warn :hint "unexpected exception on loading config"
|
||||
:cause cause
|
||||
::l/sync? true)))
|
||||
|
||||
(defn- get-config-path
|
||||
[]
|
||||
|
||||
@@ -25,9 +25,9 @@ local allowed = filled >= requested
|
||||
local newTokens = filled
|
||||
if allowed then
|
||||
newTokens = filled - requested
|
||||
redis.call("hset", tokensKey, "tokens", newTokens, "timestamp", timestamp)
|
||||
end
|
||||
|
||||
redis.call("hset", tokensKey, "tokens", newTokens, "timestamp", timestamp)
|
||||
redis.call("expire", tokensKey, ttl)
|
||||
|
||||
return { allowed, newTokens }
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
:assets-s3 :s3
|
||||
nil)))
|
||||
|
||||
(def default-bucket
|
||||
"file-media-object")
|
||||
|
||||
(def valid-buckets
|
||||
#{"file-media-object"
|
||||
"team-font-variant"
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
[app.common.time :as ct]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.storage :as-alias sto]
|
||||
[app.storage :as sto]
|
||||
[app.storage.impl :as impl]
|
||||
[integrant.core :as ig]))
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
[{:keys [metadata]}]
|
||||
(or (some-> metadata :bucket)
|
||||
(some-> metadata :reference d/name)
|
||||
"file-media-object"))
|
||||
sto/default-bucket))
|
||||
|
||||
(defn- process-objects!
|
||||
[conn has-refs? bucket objects]
|
||||
|
||||
@@ -7,10 +7,18 @@
|
||||
(ns app.util.template
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[cuerdas.core :as str]
|
||||
[selmer.filters :as sf]
|
||||
[selmer.parser :as sp]))
|
||||
|
||||
;; (sp/cache-off!)
|
||||
|
||||
(sf/add-filter! :abbreviate
|
||||
(fn [s n]
|
||||
(let [n (parse-long n)]
|
||||
(str/abbreviate s n))))
|
||||
|
||||
|
||||
(defn render
|
||||
[path context]
|
||||
(try
|
||||
|
||||
@@ -137,33 +137,34 @@ RETURNING task.id, task.queue")
|
||||
::wait)))
|
||||
|
||||
(run-batch []
|
||||
(let [rconn (rds/connect cfg)]
|
||||
(try
|
||||
(-> cfg
|
||||
(assoc ::rds/conn rconn)
|
||||
(db/tx-run! run-batch'))
|
||||
(try
|
||||
(let [rconn (rds/connect cfg)]
|
||||
(try
|
||||
(-> cfg
|
||||
(assoc ::rds/conn rconn)
|
||||
(db/tx-run! run-batch'))
|
||||
(finally
|
||||
(.close ^AutoCloseable rconn))))
|
||||
|
||||
(catch InterruptedException cause
|
||||
(throw cause))
|
||||
(catch Exception cause
|
||||
(cond
|
||||
(rds/exception? cause)
|
||||
(do
|
||||
(l/wrn :hint "redis exception (will retry in an instant)" :cause cause)
|
||||
(px/sleep timeout))
|
||||
(catch InterruptedException cause
|
||||
(throw cause))
|
||||
|
||||
(db/sql-exception? cause)
|
||||
(do
|
||||
(l/wrn :hint "database exception (will retry in an instant)" :cause cause)
|
||||
(px/sleep timeout))
|
||||
(catch Exception cause
|
||||
(cond
|
||||
(rds/exception? cause)
|
||||
(do
|
||||
(l/wrn :hint "redis exception (will retry in an instant)" :cause cause)
|
||||
(px/sleep timeout))
|
||||
|
||||
:else
|
||||
(do
|
||||
(l/err :hint "unhandled exception (will retry in an instant)" :cause cause)
|
||||
(px/sleep timeout))))
|
||||
(db/sql-exception? cause)
|
||||
(do
|
||||
(l/wrn :hint "database exception (will retry in an instant)" :cause cause)
|
||||
(px/sleep timeout))
|
||||
|
||||
(finally
|
||||
(.close ^AutoCloseable rconn)))))
|
||||
:else
|
||||
(do
|
||||
(l/err :hint "unhandled exception (will retry in an instant)" :cause cause)
|
||||
(px/sleep timeout))))))
|
||||
|
||||
(dispatcher []
|
||||
(l/inf :hint "started")
|
||||
@@ -176,7 +177,7 @@ RETURNING task.id, task.queue")
|
||||
(catch InterruptedException _
|
||||
(l/trc :hint "interrupted"))
|
||||
(catch Throwable cause
|
||||
(l/err :hint " unexpected exception" :cause cause))
|
||||
(l/err :hint "unexpected exception" :cause cause))
|
||||
(finally
|
||||
(l/inf :hint "terminated"))))]
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
integrant/integrant {:mvn/version "1.0.0"}
|
||||
|
||||
funcool/tubax {:mvn/version "2021.05.20-0"}
|
||||
funcool/cuerdas {:mvn/version "2025.06.16-414"}
|
||||
funcool/cuerdas {:mvn/version "2026.415"}
|
||||
funcool/promesa
|
||||
{:git/sha "46048fc0d4bf5466a2a4121f5d52aefa6337f2e8"
|
||||
:git/url "https://github.com/funcool/promesa"}
|
||||
|
||||
@@ -82,6 +82,113 @@
|
||||
(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?))
|
||||
@@ -112,6 +219,9 @@
|
||||
(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
|
||||
@@ -142,12 +252,23 @@
|
||||
(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 %)))))
|
||||
|
||||
[root-shape children])))
|
||||
;; 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])))
|
||||
|
||||
(defn create-raw-svg
|
||||
[name frame-id {:keys [x y width height offset-x offset-y]} {:keys [attrs] :as data}]
|
||||
[name frame-id {:keys [x y width height offset-x offset-y defs] :as svg-data} {:keys [attrs] :as data}]
|
||||
(let [props (csvg/attrs->props attrs)
|
||||
vbox (grc/make-rect offset-x offset-y width height)]
|
||||
(cts/setup-shape
|
||||
@@ -160,10 +281,11 @@
|
||||
:y y
|
||||
:content data
|
||||
:svg-attrs props
|
||||
:svg-viewbox vbox})))
|
||||
:svg-viewbox vbox
|
||||
:svg-defs defs})))
|
||||
|
||||
(defn create-svg-root
|
||||
[id frame-id parent-id {:keys [name x y width height offset-x offset-y attrs]}]
|
||||
[id frame-id parent-id {:keys [name x y width height offset-x offset-y attrs defs] :as svg-data}]
|
||||
(let [props (-> (dissoc attrs :viewBox :view-box :xmlns)
|
||||
(d/without-keys csvg/inheritable-props)
|
||||
(csvg/attrs->props))]
|
||||
@@ -177,7 +299,8 @@
|
||||
:height height
|
||||
:x (+ x offset-x)
|
||||
:y (+ y offset-y)
|
||||
:svg-attrs props})))
|
||||
:svg-attrs props
|
||||
:svg-defs defs})))
|
||||
|
||||
(defn create-svg-children
|
||||
[objects selected frame-id parent-id svg-data [unames children] [_index svg-element]]
|
||||
@@ -198,7 +321,7 @@
|
||||
|
||||
|
||||
(defn create-group
|
||||
[name frame-id {:keys [x y width height offset-x offset-y] :as svg-data} {:keys [attrs]}]
|
||||
[name frame-id {:keys [x y width height offset-x offset-y defs] :as svg-data} {:keys [attrs]}]
|
||||
(let [transform (csvg/parse-transform (:transform attrs))
|
||||
attrs (-> attrs
|
||||
(d/without-keys csvg/inheritable-props)
|
||||
@@ -214,7 +337,8 @@
|
||||
:height height
|
||||
:svg-transform transform
|
||||
:svg-attrs attrs
|
||||
:svg-viewbox vbox})))
|
||||
:svg-viewbox vbox
|
||||
:svg-defs defs})))
|
||||
|
||||
(defn create-path-shape [name frame-id svg-data {:keys [attrs] :as data}]
|
||||
(when (and (contains? attrs :d) (seq (:d attrs)))
|
||||
@@ -523,6 +647,21 @@
|
||||
: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]
|
||||
@@ -534,7 +673,11 @@
|
||||
(let [name (or (:id attrs) (tag->name tag))
|
||||
att-refs (csvg/find-attr-references attrs)
|
||||
defs (get svg-data :defs)
|
||||
references (csvg/find-def-references defs att-refs)
|
||||
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)
|
||||
|
||||
href-id (or (:href attrs) (:xlink:href attrs) " ")
|
||||
href-id (if (and (string? href-id)
|
||||
|
||||
@@ -546,9 +546,19 @@
|
||||
filter-values)))
|
||||
|
||||
(defn extract-ids [val]
|
||||
(when (some? 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)
|
||||
(->> (re-seq xml-id-regex val)
|
||||
(mapv second))))
|
||||
(mapv second))
|
||||
|
||||
(sequential? val)
|
||||
(mapcat extract-ids val)
|
||||
|
||||
:else
|
||||
[]))
|
||||
|
||||
(defn fix-dot-number
|
||||
"Fixes decimal numbers starting in dot but without leading 0"
|
||||
|
||||
@@ -362,24 +362,24 @@
|
||||
component (ctkl/get-component component-file (:component-id top-instance) true)
|
||||
remote-shape (get-ref-shape component-file component shape)
|
||||
component-container (get-component-container component-file component)
|
||||
[remote-shape component-container]
|
||||
[remote-shape component-container component-file]
|
||||
(if (some? remote-shape)
|
||||
[remote-shape component-container]
|
||||
[remote-shape component-container component-file]
|
||||
;; If not found, try the case of this being a fostered or swapped children
|
||||
(let [head-instance (ctn/get-head-shape (:objects container) shape)
|
||||
component-file (get-in libraries [(:component-file head-instance) :data])
|
||||
head-component (ctkl/get-component component-file (:component-id head-instance) true)
|
||||
remote-shape' (get-ref-shape component-file head-component shape)
|
||||
component-container (get-component-container component-file component)]
|
||||
[remote-shape' component-container]))]
|
||||
(let [head-instance (ctn/get-head-shape (:objects container) shape)
|
||||
component-file (get-in libraries [(:component-file head-instance) :data])
|
||||
head-component (ctkl/get-component component-file (:component-id head-instance) true)
|
||||
remote-shape' (get-ref-shape component-file head-component shape)
|
||||
component-container' (get-component-container component-file head-component)]
|
||||
[remote-shape' component-container' component-file]))]
|
||||
|
||||
(if (nil? remote-shape)
|
||||
nil
|
||||
(if (nil? (:shape-ref remote-shape))
|
||||
(cond-> remote-shape
|
||||
(and remote-shape with-context?)
|
||||
(with-meta {:file {:id (:id file-data)
|
||||
:data file-data}
|
||||
(with-meta {:file {:id (:id component-file)
|
||||
:data component-file}
|
||||
:container component-container}))
|
||||
(find-remote-shape component-container libraries remote-shape :with-context? with-context?)))))
|
||||
|
||||
|
||||
@@ -234,16 +234,15 @@
|
||||
"Calculate the boolean content from shape and objects. Returns a
|
||||
packed PathData instance"
|
||||
[shape objects]
|
||||
(let [content (if (fn? wasm:calc-bool-content)
|
||||
(wasm:calc-bool-content (get shape :bool-type)
|
||||
(get shape :shapes))
|
||||
(calc-bool-content* shape objects))]
|
||||
(let [content (calc-bool-content* shape objects)]
|
||||
(impl/path-data content)))
|
||||
|
||||
(defn update-bool-shape
|
||||
"Calculates the selrect+points for the boolean shape"
|
||||
[shape objects]
|
||||
(let [content (calc-bool-content shape objects)
|
||||
(let [content (if (fn? wasm:calc-bool-content)
|
||||
(wasm:calc-bool-content shape objects)
|
||||
(calc-bool-content shape objects))
|
||||
shape (assoc shape :content content)]
|
||||
(update-geometry shape)))
|
||||
|
||||
|
||||
@@ -1410,8 +1410,8 @@ Will return a value that matches this schema:
|
||||
;; NOTE: we can't assign statically at eval time the value of a
|
||||
;; function that is declared but not defined; so we need to pass
|
||||
;; an anonymous function and delegate the resolution to runtime
|
||||
{:encode/json #(export-dtcg-json %)
|
||||
:decode/json #(read-multi-set-dtcg %)
|
||||
{:encode/json #(some-> % export-dtcg-json)
|
||||
:decode/json #(some-> % read-multi-set-dtcg)
|
||||
;; FIXME: add better, more reallistic generator
|
||||
:gen/gen (->> (sg/small-int)
|
||||
(sg/fmap (fn [_]
|
||||
|
||||
@@ -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 -w",
|
||||
"fmt:js:check": "yarn run prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
58
frontend/playwright/data/text-editor/get-file-blank.json
Normal file
58
frontend/playwright/data/text-editor/get-file-blank.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"~:features": {
|
||||
"~#set": [
|
||||
"layout/grid",
|
||||
"styles/v2",
|
||||
"fdata/pointer-map",
|
||||
"fdata/objects-map",
|
||||
"components/v2",
|
||||
"fdata/shape-data-type"
|
||||
]
|
||||
},
|
||||
"~:permissions": {
|
||||
"~:type": "~:membership",
|
||||
"~:is-owner": true,
|
||||
"~:is-admin": true,
|
||||
"~:can-edit": true,
|
||||
"~:can-read": true,
|
||||
"~:is-logged": true
|
||||
},
|
||||
"~:has-media-trimmed": false,
|
||||
"~:comment-thread-seqn": 0,
|
||||
"~:name": "New File 1",
|
||||
"~:revn": 11,
|
||||
"~:modified-at": "~m1713873823633",
|
||||
"~:id": "~uc7ce0794-0992-8105-8004-38f280443849",
|
||||
"~:is-shared": false,
|
||||
"~:version": 46,
|
||||
"~:project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b",
|
||||
"~:created-at": "~m1713536343369",
|
||||
"~:data": {
|
||||
"~:pages": [
|
||||
"~u66697432-c33d-8055-8006-2c62cc084cad"
|
||||
],
|
||||
"~:pages-index": {
|
||||
"~u66697432-c33d-8055-8006-2c62cc084cad": {
|
||||
"~#penpot/pointer": [
|
||||
"~ude58c8f6-c5c2-8196-8004-3df9e2e52d88",
|
||||
{
|
||||
"~:created-at": "~m1713873823636"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"~:id": "~uc7ce0794-0992-8105-8004-38f280443849",
|
||||
"~:options": {
|
||||
"~:components-v2": true
|
||||
},
|
||||
"~:recent-colors": [
|
||||
{
|
||||
"~:color": "#0000ff",
|
||||
"~:opacity": 1,
|
||||
"~:id": null,
|
||||
"~:file-id": null,
|
||||
"~:image": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
345
frontend/playwright/data/text-editor/get-file-lorem-ipsum.json
Normal file
345
frontend/playwright/data/text-editor/get-file-lorem-ipsum.json
Normal file
@@ -0,0 +1,345 @@
|
||||
{
|
||||
"~:features": {
|
||||
"~#set": [
|
||||
"fdata/path-data",
|
||||
"plugins/runtime",
|
||||
"design-tokens/v1",
|
||||
"layout/grid",
|
||||
"styles/v2",
|
||||
"fdata/pointer-map",
|
||||
"fdata/objects-map",
|
||||
"components/v2",
|
||||
"fdata/shape-data-type",
|
||||
"text-editor/v2"
|
||||
]
|
||||
},
|
||||
"~:team-id": "~u9e6e22b2-db76-81d6-8006-75d7cdbb8bad",
|
||||
"~:permissions": {
|
||||
"~:type": "~:membership",
|
||||
"~:is-owner": true,
|
||||
"~:is-admin": true,
|
||||
"~:can-edit": true,
|
||||
"~:can-read": true,
|
||||
"~:is-logged": true
|
||||
},
|
||||
"~:has-media-trimmed": false,
|
||||
"~:comment-thread-seqn": 0,
|
||||
"~:name": "Bug 11552",
|
||||
"~:revn": 3,
|
||||
"~:modified-at": "~m1753957736516",
|
||||
"~:vern": 0,
|
||||
"~:id": "~u238a17e0-75ff-8075-8006-934586ea2230",
|
||||
"~:is-shared": false,
|
||||
"~:migrations": {
|
||||
"~#ordered-set": [
|
||||
"legacy-2",
|
||||
"legacy-3",
|
||||
"legacy-5",
|
||||
"legacy-6",
|
||||
"legacy-7",
|
||||
"legacy-8",
|
||||
"legacy-9",
|
||||
"legacy-10",
|
||||
"legacy-11",
|
||||
"legacy-12",
|
||||
"legacy-13",
|
||||
"legacy-14",
|
||||
"legacy-16",
|
||||
"legacy-17",
|
||||
"legacy-18",
|
||||
"legacy-19",
|
||||
"legacy-25",
|
||||
"legacy-26",
|
||||
"legacy-27",
|
||||
"legacy-28",
|
||||
"legacy-29",
|
||||
"legacy-31",
|
||||
"legacy-32",
|
||||
"legacy-33",
|
||||
"legacy-34",
|
||||
"legacy-36",
|
||||
"legacy-37",
|
||||
"legacy-38",
|
||||
"legacy-39",
|
||||
"legacy-40",
|
||||
"legacy-41",
|
||||
"legacy-42",
|
||||
"legacy-43",
|
||||
"legacy-44",
|
||||
"legacy-45",
|
||||
"legacy-46",
|
||||
"legacy-47",
|
||||
"legacy-48",
|
||||
"legacy-49",
|
||||
"legacy-50",
|
||||
"legacy-51",
|
||||
"legacy-52",
|
||||
"legacy-53",
|
||||
"legacy-54",
|
||||
"legacy-55",
|
||||
"legacy-56",
|
||||
"legacy-57",
|
||||
"legacy-59",
|
||||
"legacy-62",
|
||||
"legacy-65",
|
||||
"legacy-66",
|
||||
"legacy-67",
|
||||
"0001-remove-tokens-from-groups",
|
||||
"0002-normalize-bool-content-v2",
|
||||
"0002-clean-shape-interactions",
|
||||
"0003-fix-root-shape",
|
||||
"0003-convert-path-content-v2",
|
||||
"0004-clean-shadow-color",
|
||||
"0005-deprecate-image-type",
|
||||
"0006-fix-old-texts-fills",
|
||||
"0007-clear-invalid-strokes-and-fills-v2",
|
||||
"0008-fix-library-colors-v4",
|
||||
"0009-clean-library-colors",
|
||||
"0009-add-partial-text-touched-flags"
|
||||
]
|
||||
},
|
||||
"~:version": 67,
|
||||
"~:project-id": "~u9e6e22b2-db76-81d6-8006-75d7cdc30669",
|
||||
"~:created-at": "~m1753957644225",
|
||||
"~:data": {
|
||||
"~:pages": ["~u238a17e0-75ff-8075-8006-934586ea2231"],
|
||||
"~:pages-index": {
|
||||
"~u238a17e0-75ff-8075-8006-934586ea2231": {
|
||||
"~:objects": {
|
||||
"~u00000000-0000-0000-0000-000000000000": {
|
||||
"~#shape": {
|
||||
"~:y": 0,
|
||||
"~:hide-fill-on-export": false,
|
||||
"~:transform": {
|
||||
"~#matrix": {
|
||||
"~:a": 1.0,
|
||||
"~:b": 0.0,
|
||||
"~:c": 0.0,
|
||||
"~:d": 1.0,
|
||||
"~:e": 0.0,
|
||||
"~:f": 0.0
|
||||
}
|
||||
},
|
||||
"~:rotation": 0,
|
||||
"~:name": "Root Frame",
|
||||
"~:width": 0.01,
|
||||
"~:type": "~:frame",
|
||||
"~:points": [
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 0.0,
|
||||
"~:y": 0.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 0.01,
|
||||
"~:y": 0.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 0.01,
|
||||
"~:y": 0.01
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 0.0,
|
||||
"~:y": 0.01
|
||||
}
|
||||
}
|
||||
],
|
||||
"~:r2": 0,
|
||||
"~:proportion-lock": false,
|
||||
"~:transform-inverse": {
|
||||
"~#matrix": {
|
||||
"~:a": 1.0,
|
||||
"~:b": 0.0,
|
||||
"~:c": 0.0,
|
||||
"~:d": 1.0,
|
||||
"~:e": 0.0,
|
||||
"~:f": 0.0
|
||||
}
|
||||
},
|
||||
"~:r3": 0,
|
||||
"~:r1": 0,
|
||||
"~:id": "~u00000000-0000-0000-0000-000000000000",
|
||||
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
|
||||
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
|
||||
"~:strokes": [],
|
||||
"~:x": 0,
|
||||
"~:proportion": 1.0,
|
||||
"~:r4": 0,
|
||||
"~:selrect": {
|
||||
"~#rect": {
|
||||
"~:x": 0,
|
||||
"~:y": 0,
|
||||
"~:width": 0.01,
|
||||
"~:height": 0.01,
|
||||
"~:x1": 0,
|
||||
"~:y1": 0,
|
||||
"~:x2": 0.01,
|
||||
"~:y2": 0.01
|
||||
}
|
||||
},
|
||||
"~:fills": [
|
||||
{
|
||||
"~:fill-color": "#FFFFFF",
|
||||
"~:fill-opacity": 1
|
||||
}
|
||||
],
|
||||
"~:flip-x": null,
|
||||
"~:height": 0.01,
|
||||
"~:flip-y": null,
|
||||
"~:shapes": ["~ucc6f0580-449c-8019-8006-9345db077fa0"]
|
||||
}
|
||||
},
|
||||
"~ucc6f0580-449c-8019-8006-9345db077fa0": {
|
||||
"~#shape": {
|
||||
"~:y": 438,
|
||||
"~:transform": {
|
||||
"~#matrix": {
|
||||
"~:a": 1.0,
|
||||
"~:b": 0.0,
|
||||
"~:c": 0.0,
|
||||
"~:d": 1.0,
|
||||
"~:e": 0.0,
|
||||
"~:f": 0.0
|
||||
}
|
||||
},
|
||||
"~:rotation": 0,
|
||||
"~:grow-type": "~:auto-width",
|
||||
"~:content": {
|
||||
"~:type": "root",
|
||||
"~:key": "1s4am1jl24s",
|
||||
"~:children": [
|
||||
{
|
||||
"~:type": "paragraph-set",
|
||||
"~:children": [
|
||||
{
|
||||
"~:line-height": "1.2",
|
||||
"~:font-style": "normal",
|
||||
"~:children": [
|
||||
{
|
||||
"~:line-height": "1.2",
|
||||
"~:font-style": "normal",
|
||||
"~:typography-ref-id": null,
|
||||
"~:text-transform": "none",
|
||||
"~:font-id": "sourcesanspro",
|
||||
"~:key": "13p0zwl2yhc",
|
||||
"~:font-size": "14",
|
||||
"~:font-weight": "400",
|
||||
"~:typography-ref-file": null,
|
||||
"~:font-variant-id": "regular",
|
||||
"~:text-decoration": "none",
|
||||
"~:letter-spacing": "0",
|
||||
"~:fills": [
|
||||
{
|
||||
"~:fill-color": "#000000",
|
||||
"~:fill-opacity": 1
|
||||
}
|
||||
],
|
||||
"~:font-family": "sourcesanspro",
|
||||
"~:text": "Lorem ipsum"
|
||||
}
|
||||
],
|
||||
"~:typography-ref-id": null,
|
||||
"~:text-transform": "none",
|
||||
"~:text-align": "left",
|
||||
"~:font-id": "sourcesanspro",
|
||||
"~:key": "20hf3kmyoub",
|
||||
"~:font-size": "14",
|
||||
"~:font-weight": "400",
|
||||
"~:typography-ref-file": null,
|
||||
"~:text-direction": "ltr",
|
||||
"~:type": "paragraph",
|
||||
"~:font-variant-id": "regular",
|
||||
"~:text-decoration": "none",
|
||||
"~:letter-spacing": "0",
|
||||
"~:fills": [
|
||||
{
|
||||
"~:fill-color": "#000000",
|
||||
"~:fill-opacity": 1
|
||||
}
|
||||
],
|
||||
"~:font-family": "sourcesanspro"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"~:vertical-align": "top"
|
||||
},
|
||||
"~:hide-in-viewer": false,
|
||||
"~:name": "Lorem ipsum",
|
||||
"~:width": 77,
|
||||
"~:type": "~:text",
|
||||
"~:points": [
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 404,
|
||||
"~:y": 438
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 481,
|
||||
"~:y": 438
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 481,
|
||||
"~:y": 455
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 404,
|
||||
"~:y": 455
|
||||
}
|
||||
}
|
||||
],
|
||||
"~:transform-inverse": {
|
||||
"~#matrix": {
|
||||
"~:a": 1.0,
|
||||
"~:b": 0.0,
|
||||
"~:c": 0.0,
|
||||
"~:d": 1.0,
|
||||
"~:e": 0.0,
|
||||
"~:f": 0.0
|
||||
}
|
||||
},
|
||||
"~:id": "~ucc6f0580-449c-8019-8006-9345db077fa0",
|
||||
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
|
||||
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
|
||||
"~:x": 404,
|
||||
"~:selrect": {
|
||||
"~#rect": {
|
||||
"~:x": 404,
|
||||
"~:y": 438,
|
||||
"~:width": 77,
|
||||
"~:height": 17,
|
||||
"~:x1": 404,
|
||||
"~:y1": 438,
|
||||
"~:x2": 481,
|
||||
"~:y2": 455
|
||||
}
|
||||
},
|
||||
"~:flip-x": null,
|
||||
"~:height": 17,
|
||||
"~:flip-y": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"~:id": "~u238a17e0-75ff-8075-8006-934586ea2231",
|
||||
"~:name": "Page 1"
|
||||
}
|
||||
},
|
||||
"~:id": "~u238a17e0-75ff-8075-8006-934586ea2230",
|
||||
"~:options": {
|
||||
"~:components-v2": true,
|
||||
"~:base-font-size": "16px"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1 @@
|
||||
{
|
||||
"~:revn": 2,
|
||||
"~:lagged": []
|
||||
|
||||
}
|
||||
w
|
||||
|
||||
4
frontend/playwright/data/text-editor/update-file.json
Normal file
4
frontend/playwright/data/text-editor/update-file.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"~:revn": 2,
|
||||
"~:lagged": []
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"~:id": "~u088df3d4-d383-80f6-8004-527e50ea4f1f",
|
||||
"~:revn": 21,
|
||||
"~:file-id": "~uc7ce0794-0992-8105-8004-38f280443849",
|
||||
"~:session-id": "~u1dc6d4fa-7bd3-803a-8004-527dd9df2c62",
|
||||
"~:changes": []
|
||||
}
|
||||
]
|
||||
36
frontend/playwright/helpers/Clipboard.js
Normal file
36
frontend/playwright/helpers/Clipboard.js
Normal file
@@ -0,0 +1,36 @@
|
||||
export class Clipboard {
|
||||
static Permission = {
|
||||
ONLY_READ: ["clipboard-read"],
|
||||
ONLY_WRITE: ["clipboard-write"],
|
||||
ALL: ["clipboard-read", "clipboard-write"],
|
||||
};
|
||||
|
||||
static enable(context, permissions) {
|
||||
return context.grantPermissions(permissions);
|
||||
}
|
||||
|
||||
static writeText(page, text) {
|
||||
return page.evaluate((text) => navigator.clipboard.writeText(text), text);
|
||||
}
|
||||
|
||||
static readText(page) {
|
||||
return page.evaluate(() => navigator.clipboard.readText());
|
||||
}
|
||||
|
||||
constructor(page, context) {
|
||||
this.page = page;
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
enable(permissions) {
|
||||
return Clipboard.enable(this.context, permissions);
|
||||
}
|
||||
|
||||
writeText(text) {
|
||||
return Clipboard.writeText(this.page, text);
|
||||
}
|
||||
|
||||
readText() {
|
||||
return Clipboard.readText(this.page);
|
||||
}
|
||||
}
|
||||
28
frontend/playwright/helpers/Transit.js
Normal file
28
frontend/playwright/helpers/Transit.js
Normal file
@@ -0,0 +1,28 @@
|
||||
export class Transit {
|
||||
static parse(value) {
|
||||
if (typeof value !== "string") return value;
|
||||
|
||||
if (value.startsWith("~")) return value.slice(2);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
static get(object, ...path) {
|
||||
let aux = object;
|
||||
for (const name of path) {
|
||||
if (typeof name !== "string") {
|
||||
if (!(name in aux)) {
|
||||
return undefined;
|
||||
}
|
||||
aux = aux[name];
|
||||
} else {
|
||||
const transitName = `~:${name}`;
|
||||
if (!(transitName in aux)) {
|
||||
return undefined;
|
||||
}
|
||||
aux = aux[transitName];
|
||||
}
|
||||
}
|
||||
return this.parse(aux);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,27 @@
|
||||
export class BasePage {
|
||||
/**
|
||||
* Mocks multiple RPC calls in a single call.
|
||||
*
|
||||
* @param {Page} page
|
||||
* @param {object<string, string>} paths
|
||||
* @param {*} options
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async mockRPCs(page, paths, options) {
|
||||
for (const [path, jsonFilename] of Object.entries(paths)) {
|
||||
await this.mockRPC(page, path, jsonFilename, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mocks an RPC call using a file.
|
||||
*
|
||||
* @param {Page} page
|
||||
* @param {string} path
|
||||
* @param {string} jsonFilename
|
||||
* @param {*} options
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async mockRPC(page, path, jsonFilename, options) {
|
||||
if (!page) {
|
||||
throw new TypeError("Invalid page argument. Must be a Playwright page.");
|
||||
@@ -93,6 +116,10 @@ export class BasePage {
|
||||
return this.#page;
|
||||
}
|
||||
|
||||
async mockRPCs(paths, options) {
|
||||
return BasePage.mockRPCs(this.page, paths, options);
|
||||
}
|
||||
|
||||
async mockRPC(path, jsonFilename, options) {
|
||||
return BasePage.mockRPC(this.page, path, jsonFilename, options);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,146 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { BaseWebSocketPage } from "./BaseWebSocketPage";
|
||||
import { Transit } from "../../helpers/Transit";
|
||||
|
||||
export class WorkspacePage extends BaseWebSocketPage {
|
||||
static TextEditor = class TextEditor {
|
||||
constructor(workspacePage) {
|
||||
this.workspacePage = workspacePage;
|
||||
|
||||
// locators.
|
||||
this.fontSize = this.workspacePage.rightSidebar.getByRole("textbox", {
|
||||
name: "Font Size",
|
||||
});
|
||||
this.lineHeight = this.workspacePage.rightSidebar.getByRole("textbox", {
|
||||
name: "Line Height",
|
||||
});
|
||||
this.letterSpacing = this.workspacePage.rightSidebar.getByRole(
|
||||
"textbox",
|
||||
{
|
||||
name: "Letter Spacing",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
get page() {
|
||||
return this.workspacePage.page;
|
||||
}
|
||||
|
||||
async waitForStyle(locator, styleName) {
|
||||
return locator.evaluate(
|
||||
(element, styleName) => element.style.getPropertyValue(styleName),
|
||||
styleName,
|
||||
);
|
||||
}
|
||||
|
||||
async waitForEditor() {
|
||||
return this.page.waitForSelector('[data-itype="editor"]');
|
||||
}
|
||||
|
||||
async waitForRoot() {
|
||||
return this.page.waitForSelector('[data-itype="root"]');
|
||||
}
|
||||
|
||||
async waitForParagraph(nth) {
|
||||
if (!nth) {
|
||||
return this.page.waitForSelector('[data-itype="paragraph"]');
|
||||
}
|
||||
return this.page.waitForSelector(
|
||||
`[data-itype="paragraph"]:nth-child(${nth})`,
|
||||
);
|
||||
}
|
||||
|
||||
async waitForParagraphStyle(nth, styleName) {
|
||||
const paragraph = await this.waitForParagraph(nth);
|
||||
return this.waitForStyle(paragraph, styleName);
|
||||
}
|
||||
|
||||
async waitForTextSpan(nth = 0) {
|
||||
if (!nth) {
|
||||
return this.page.waitForSelector('[data-itype="inline"]');
|
||||
}
|
||||
return this.page.waitForSelector(
|
||||
`[data-itype="inline"]:nth-child(${nth})`,
|
||||
);
|
||||
}
|
||||
|
||||
async waitForTextSpanContent(nth = 0) {
|
||||
const textSpan = await this.waitForTextSpan(nth);
|
||||
const textContent = await textSpan.textContent();
|
||||
return textContent;
|
||||
}
|
||||
|
||||
async waitForTextSpanStyle(nth, styleName) {
|
||||
const textSpan = await this.waitForTextSpan(nth);
|
||||
return this.waitForStyle(textSpan, styleName);
|
||||
}
|
||||
|
||||
async startEditing() {
|
||||
await this.page.keyboard.press("Enter");
|
||||
return this.waitForEditor();
|
||||
}
|
||||
|
||||
stopEditing() {
|
||||
return this.page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
async moveToLeft(amount = 0) {
|
||||
for (let i = 0; i < amount; i++) {
|
||||
await this.page.keyboard.press("ArrowLeft");
|
||||
}
|
||||
}
|
||||
|
||||
async moveToRight(amount = 0) {
|
||||
for (let i = 0; i < amount; i++) {
|
||||
await this.page.keyboard.press("ArrowRight");
|
||||
}
|
||||
}
|
||||
|
||||
async moveFromStart(offset = 0) {
|
||||
await this.page.keyboard.press("ArrowLeft");
|
||||
await this.moveToRight(offset);
|
||||
}
|
||||
|
||||
async moveFromEnd(offset = 0) {
|
||||
await this.page.keyboard.press("ArrowRight");
|
||||
await this.moveToLeft(offset);
|
||||
}
|
||||
|
||||
async selectFromStart(length, offset = 0) {
|
||||
await this.moveFromStart(offset);
|
||||
await this.page.keyboard.down("Shift");
|
||||
await this.moveToRight(length);
|
||||
await this.page.keyboard.up("Shift");
|
||||
}
|
||||
|
||||
async selectFromEnd(length, offset = 0) {
|
||||
await this.moveFromEnd(offset);
|
||||
await this.page.keyboard.down("Shift");
|
||||
await this.moveToLeft(length);
|
||||
await this.page.keyboard.up("Shift");
|
||||
}
|
||||
|
||||
async changeNumericInput(locator, newValue) {
|
||||
await expect(locator).toBeVisible();
|
||||
await locator.focus();
|
||||
await locator.fill(`${newValue}`);
|
||||
await locator.blur();
|
||||
}
|
||||
|
||||
changeFontSize(newValue) {
|
||||
return this.changeNumericInput(this.fontSize, newValue);
|
||||
}
|
||||
|
||||
changeLineHeight(newValue) {
|
||||
return this.changeNumericInput(this.lineHeight, newValue);
|
||||
}
|
||||
|
||||
changeLetterSpacing(newValue) {
|
||||
return this.changeNumericInput(this.letterSpacing, newValue);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This should be called on `test.beforeEach`.
|
||||
*
|
||||
@@ -11,50 +150,21 @@ export class WorkspacePage extends BaseWebSocketPage {
|
||||
static async init(page) {
|
||||
await BaseWebSocketPage.initWebSockets(page);
|
||||
|
||||
await BaseWebSocketPage.mockRPC(
|
||||
page,
|
||||
"get-profile",
|
||||
"logged-in-user/get-profile-logged-in.json",
|
||||
);
|
||||
await BaseWebSocketPage.mockRPC(
|
||||
page,
|
||||
"get-team-users?file-id=*",
|
||||
"logged-in-user/get-team-users-single-user.json",
|
||||
);
|
||||
await BaseWebSocketPage.mockRPC(
|
||||
page,
|
||||
"get-comment-threads?file-id=*",
|
||||
"workspace/get-comment-threads-empty.json",
|
||||
);
|
||||
await BaseWebSocketPage.mockRPC(
|
||||
page,
|
||||
"get-project?id=*",
|
||||
"workspace/get-project-default.json",
|
||||
);
|
||||
await BaseWebSocketPage.mockRPC(
|
||||
page,
|
||||
"get-team?id=*",
|
||||
"workspace/get-team-default.json",
|
||||
);
|
||||
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams.json");
|
||||
|
||||
await BaseWebSocketPage.mockRPC(
|
||||
page,
|
||||
"get-team-members?team-id=*",
|
||||
"logged-in-user/get-team-members-your-penpot.json",
|
||||
);
|
||||
|
||||
await BaseWebSocketPage.mockRPC(
|
||||
page,
|
||||
"get-profiles-for-file-comments?file-id=*",
|
||||
"workspace/get-profile-for-file-comments.json",
|
||||
);
|
||||
|
||||
await BaseWebSocketPage.mockRPC(
|
||||
page,
|
||||
"update-profile-props",
|
||||
"workspace/update-profile-empty.json",
|
||||
);
|
||||
await BaseWebSocketPage.mockRPCs(page, {
|
||||
"get-profile": "logged-in-user/get-profile-logged-in.json",
|
||||
"get-team-users?file-id=*":
|
||||
"logged-in-user/get-team-users-single-user.json",
|
||||
"get-comment-threads?file-id=*":
|
||||
"workspace/get-comment-threads-empty.json",
|
||||
"get-project?id=*": "workspace/get-project-default.json",
|
||||
"get-team?id=*": "workspace/get-team-default.json",
|
||||
"get-teams": "get-teams.json",
|
||||
"get-team-members?team-id=*":
|
||||
"logged-in-user/get-team-members-your-penpot.json",
|
||||
"get-profiles-for-file-comments?file-id=*":
|
||||
"workspace/get-profile-for-file-comments.json",
|
||||
"update-profile-props": "workspace/update-profile-empty.json",
|
||||
});
|
||||
}
|
||||
|
||||
static anyTeamId = "c7ce0794-0992-8105-8004-38e630f7920a";
|
||||
@@ -62,9 +172,20 @@ export class WorkspacePage extends BaseWebSocketPage {
|
||||
static anyFileId = "c7ce0794-0992-8105-8004-38f280443849";
|
||||
static anyPageId = "c7ce0794-0992-8105-8004-38f28044384a";
|
||||
|
||||
/**
|
||||
* WebSocket mock
|
||||
*
|
||||
* @type {MockWebSocketHelper}
|
||||
*/
|
||||
#ws = null;
|
||||
|
||||
constructor(page) {
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param {Page} page
|
||||
* @param {} [options]
|
||||
*/
|
||||
constructor(page, options) {
|
||||
super(page);
|
||||
this.pageName = page.getByTestId("page-name");
|
||||
|
||||
@@ -112,11 +233,14 @@ export class WorkspacePage extends BaseWebSocketPage {
|
||||
"tokens-context-menu-for-set",
|
||||
);
|
||||
this.contextMenuForShape = page.getByTestId("context-menu");
|
||||
if (options?.textEditor) {
|
||||
this.textEditor = new WorkspacePage.TextEditor(this);
|
||||
}
|
||||
}
|
||||
|
||||
async goToWorkspace({
|
||||
fileId = WorkspacePage.anyFileId,
|
||||
pageId = WorkspacePage.anyPageId,
|
||||
fileId = this.fileId ?? WorkspacePage.anyFileId,
|
||||
pageId = this.pageId ?? WorkspacePage.anyPageId,
|
||||
} = {}) {
|
||||
await this.page.goto(
|
||||
`/#/workspace?team-id=${WorkspacePage.anyTeamId}&file-id=${fileId}&page-id=${pageId}`,
|
||||
@@ -141,48 +265,59 @@ export class WorkspacePage extends BaseWebSocketPage {
|
||||
}
|
||||
|
||||
async setupEmptyFile() {
|
||||
await this.mockRPC(
|
||||
"get-profile",
|
||||
"logged-in-user/get-profile-logged-in.json",
|
||||
);
|
||||
await this.mockRPC(
|
||||
"get-team-users?file-id=*",
|
||||
"logged-in-user/get-team-users-single-user.json",
|
||||
);
|
||||
await this.mockRPC(
|
||||
"get-comment-threads?file-id=*",
|
||||
"workspace/get-comment-threads-empty.json",
|
||||
);
|
||||
await this.mockRPC(
|
||||
"get-project?id=*",
|
||||
"workspace/get-project-default.json",
|
||||
);
|
||||
await this.mockRPC("get-team?id=*", "workspace/get-team-default.json");
|
||||
await this.mockRPC(
|
||||
"get-profiles-for-file-comments?file-id=*",
|
||||
"workspace/get-profile-for-file-comments.json",
|
||||
);
|
||||
await this.mockRPC(/get\-file\?/, "workspace/get-file-blank.json");
|
||||
await this.mockRPC(
|
||||
"get-file-object-thumbnails?file-id=*",
|
||||
"workspace/get-file-object-thumbnails-blank.json",
|
||||
);
|
||||
await this.mockRPC(
|
||||
"get-font-variants?team-id=*",
|
||||
"workspace/get-font-variants-empty.json",
|
||||
);
|
||||
await this.mockRPC(
|
||||
"get-file-fragment?file-id=*",
|
||||
"workspace/get-file-fragment-blank.json",
|
||||
);
|
||||
await this.mockRPC(
|
||||
"get-file-libraries?file-id=*",
|
||||
"workspace/get-file-libraries-empty.json",
|
||||
);
|
||||
await this.mockRPCs({
|
||||
"get-profile": "logged-in-user/get-profile-logged-in.json",
|
||||
"get-team-users?file-id=*":
|
||||
"logged-in-user/get-team-users-single-user.json ",
|
||||
"get-comment-threads?file-id=*":
|
||||
"workspace/get-comment-threads-empty.json",
|
||||
"get-project?id=*": "workspace/get-project-default.json",
|
||||
"get-team?id=*": "workspace/get-team-default.json",
|
||||
"get-profiles-for-file-comments?file-id=*":
|
||||
"workspace/get-profile-for-file-comments.json",
|
||||
"get-file-object-thumbnails?file-id=*":
|
||||
"workspace/get-file-object-thumbnails-blank.json",
|
||||
"get-font-variants?team-id=*": "workspace/get-font-variants-empty.json",
|
||||
"get-file-fragment?file-id=*": "workspace/get-file-fragment-blank.json",
|
||||
"get-file-libraries?file-id=*": "workspace/get-file-libraries-empty.json",
|
||||
});
|
||||
|
||||
if (this.textEditor) {
|
||||
await this.mockRPC("update-file?id=*", "text-editor/update-file.json");
|
||||
}
|
||||
|
||||
// by default we mock the blank file.
|
||||
await this.mockGetFile("workspace/get-file-blank.json");
|
||||
}
|
||||
|
||||
async mockGetFile(jsonFile) {
|
||||
await this.mockRPC(/get\-file\?/, jsonFile);
|
||||
async mockGetFile(jsonFilename, options) {
|
||||
const page = this.page;
|
||||
const jsonPath = `playwright/data/${jsonFilename}`;
|
||||
const body = await readFile(jsonPath, "utf-8");
|
||||
const payload = JSON.parse(body);
|
||||
|
||||
const fileId = Transit.get(payload, "id");
|
||||
const pageId = Transit.get(payload, "data", "pages", 0);
|
||||
const teamId = Transit.get(payload, "team-id");
|
||||
|
||||
this.fileId = fileId ?? this.anyFileId;
|
||||
this.pageId = pageId ?? this.anyPageId;
|
||||
this.teamId = teamId ?? this.anyTeamId;
|
||||
|
||||
const path = /get\-file\?/;
|
||||
const url = typeof path === "string" ? `**/api/main/methods/${path}` : path;
|
||||
const interceptConfig = {
|
||||
status: 200,
|
||||
contentType: "application/transit+json",
|
||||
...options,
|
||||
};
|
||||
return page.route(url, (route) =>
|
||||
route.fulfill({
|
||||
...interceptConfig,
|
||||
body,
|
||||
}),
|
||||
);
|
||||
// await this.mockRPC(/get\-file\?/, jsonFile);
|
||||
}
|
||||
|
||||
async mockGetAsset(regex, asset) {
|
||||
@@ -190,22 +325,15 @@ export class WorkspacePage extends BaseWebSocketPage {
|
||||
}
|
||||
|
||||
async setupFileWithComments() {
|
||||
await this.mockRPC(
|
||||
"get-comment-threads?file-id=*",
|
||||
"workspace/get-comment-threads-unread.json",
|
||||
);
|
||||
await this.mockRPC(
|
||||
"get-file-fragment?file-id=*&fragment-id=*",
|
||||
"viewer/get-file-fragment-single-board.json",
|
||||
);
|
||||
await this.mockRPC(
|
||||
"get-comments?thread-id=*",
|
||||
"workspace/get-thread-comments.json",
|
||||
);
|
||||
await this.mockRPC(
|
||||
"update-comment-thread-status",
|
||||
"workspace/update-comment-thread-status.json",
|
||||
);
|
||||
await this.mockRPCs({
|
||||
"get-comment-threads?file-id=*":
|
||||
"workspace/get-comment-threads-unread.json",
|
||||
"get-file-fragment?file-id=*&fragment-id=*":
|
||||
"viewer/get-file-fragment-single-board.json",
|
||||
"get-comments?thread-id=*": "workspace/get-thread-comments.json",
|
||||
"update-comment-thread-status":
|
||||
"workspace/update-comment-thread-status.json",
|
||||
});
|
||||
}
|
||||
|
||||
async clickWithDragViewportAt(x, y, width, height) {
|
||||
@@ -223,6 +351,67 @@ export class WorkspacePage extends BaseWebSocketPage {
|
||||
await this.page.mouse.up();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks and moves from the coordinates x1,y1 to x2,y2
|
||||
*
|
||||
* @param {number} x1
|
||||
* @param {number} y1
|
||||
* @param {number} x2
|
||||
* @param {number} y2
|
||||
*/
|
||||
async clickAndMove(x1, y1, x2, y2) {
|
||||
await this.page.waitForTimeout(100);
|
||||
await this.viewport.hover({ position: { x: x1, y: y1 } });
|
||||
await this.page.mouse.down();
|
||||
await this.viewport.hover({ position: { x: x2, y: y2 } });
|
||||
await this.page.mouse.up();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Text Shape in the specified coordinates
|
||||
* with an initial text.
|
||||
*
|
||||
* @param {number} x1
|
||||
* @param {number} y1
|
||||
* @param {number} x2
|
||||
* @param {number} y2
|
||||
* @param {string} initialText
|
||||
* @param {*} [options]
|
||||
*/
|
||||
async createTextShape(x1, y1, x2, y2, initialText, options) {
|
||||
const timeToWait = options?.timeToWait ?? 100;
|
||||
await this.page.keyboard.press("T");
|
||||
await this.page.waitForTimeout(timeToWait);
|
||||
await this.clickAndMove(x1, y1, x2, y2);
|
||||
await this.page.waitForTimeout(timeToWait);
|
||||
if (initialText) {
|
||||
await this.page.keyboard.type(initialText);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the selected element into the clipboard.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async copy() {
|
||||
return this.page.keyboard.press("Control+C");
|
||||
}
|
||||
|
||||
/**
|
||||
* Pastes something from the clipboard.
|
||||
*
|
||||
* @param {"keyboard"|"context-menu"} [kind="keyboard"]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async paste(kind = "keyboard") {
|
||||
if (kind === "context-menu") {
|
||||
await this.viewport.click({ button: "right" });
|
||||
return this.page.getByText("PasteCtrlV").click();
|
||||
}
|
||||
return this.page.keyboard.press("Control+V");
|
||||
}
|
||||
|
||||
async panOnViewportAt(x, y, width, height) {
|
||||
await this.page.waitForTimeout(100);
|
||||
await this.viewport.hover({ position: { x, y } });
|
||||
@@ -250,10 +439,15 @@ export class WorkspacePage extends BaseWebSocketPage {
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
async doubleClickLeafLayer(name, clickOptions = {}) {
|
||||
await this.clickLeafLayer(name, clickOptions);
|
||||
await this.clickLeafLayer(name, clickOptions);
|
||||
}
|
||||
|
||||
async clickToggableLayer(name, clickOptions = {}) {
|
||||
const layer = this.layers
|
||||
.getByTestId("layer-row")
|
||||
.filter({ hasText: name });
|
||||
.getByTestId("layer-row")
|
||||
.filter({ hasText: name });
|
||||
const button = layer.getByRole("button");
|
||||
|
||||
await button.waitFor();
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 23 KiB |
@@ -360,7 +360,7 @@ test("Renders a file with texts with paragraphs and breaking lines", async ({
|
||||
id: "a5f238bd-dd8a-8164-8007-1bc3481eaf05",
|
||||
pageId: "a5f238bd-dd8a-8164-8007-1bc3481eaf06",
|
||||
});
|
||||
await workspace.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ const setupFile = async (workspacePage) => {
|
||||
fileId: "7b2da435-6186-815a-8007-0daa95d2f26d",
|
||||
pageId: "ce79274b-11ab-8088-8007-0487ad43f789",
|
||||
});
|
||||
await workspacePage.mockRPC(
|
||||
"update-file?id=*",
|
||||
"workspace/update-file-empty.json",
|
||||
);
|
||||
};
|
||||
|
||||
const shapeToLayerName = {
|
||||
|
||||
@@ -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,12 +1,323 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { Clipboard } from "../../helpers/Clipboard";
|
||||
import { WorkspacePage } from "../pages/WorkspacePage";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const timeToWait = 100;
|
||||
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
await Clipboard.enable(context, Clipboard.Permission.ONLY_WRITE);
|
||||
|
||||
await WorkspacePage.init(page);
|
||||
await WorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-v2"]);
|
||||
});
|
||||
|
||||
test.skip("BUG 11552 - Apply styles to the current caret", async ({ page }) => {
|
||||
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,
|
||||
});
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.goToWorkspace();
|
||||
await workspace.createTextShape(190, 150, 300, 200, initialText);
|
||||
|
||||
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||
expect(textContent).toBe(initialText);
|
||||
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
test("Create a new text shape from pasting text", async ({ page, context }) => {
|
||||
const textToPaste = "Lorem ipsum";
|
||||
const workspace = new WorkspacePage(page, {
|
||||
textEditor: true,
|
||||
});
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockRPC("update-file?id=*", "text-editor/update-file.json");
|
||||
await workspace.goToWorkspace();
|
||||
|
||||
await Clipboard.writeText(page, textToPaste);
|
||||
|
||||
await workspace.clickAt(190, 150);
|
||||
await workspace.paste("keyboard");
|
||||
|
||||
await page.waitForTimeout(timeToWait);
|
||||
|
||||
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||
expect(textContent).toBe(textToPaste);
|
||||
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.goToWorkspace();
|
||||
|
||||
await Clipboard.writeText(page, textToPaste);
|
||||
|
||||
await workspace.clickAt(190, 150);
|
||||
await workspace.paste("context-menu");
|
||||
|
||||
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||
expect(textContent).toBe(textToPaste);
|
||||
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
test("Update an already created text shape by appending text", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = new WorkspacePage(page, {
|
||||
textEditor: true,
|
||||
});
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||
await workspace.goToWorkspace();
|
||||
await workspace.clickLeafLayer("Lorem ipsum");
|
||||
await workspace.textEditor.startEditing();
|
||||
await workspace.textEditor.moveFromEnd(0);
|
||||
await page.keyboard.type(" dolor sit amet");
|
||||
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||
expect(textContent).toBe("Lorem ipsum dolor sit amet");
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
test("Update an already created text shape by prepending text", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = new WorkspacePage(page, {
|
||||
textEditor: true,
|
||||
});
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||
await workspace.goToWorkspace();
|
||||
await workspace.clickLeafLayer("Lorem ipsum");
|
||||
await workspace.textEditor.startEditing();
|
||||
await workspace.textEditor.moveFromStart(0);
|
||||
await page.keyboard.type("Dolor sit amet ");
|
||||
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||
expect(textContent).toBe("Dolor sit amet Lorem ipsum");
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
test("Update an already created text shape by inserting text in between", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = new WorkspacePage(page, {
|
||||
textEditor: true,
|
||||
});
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||
await workspace.goToWorkspace();
|
||||
await workspace.clickLeafLayer("Lorem ipsum");
|
||||
await workspace.textEditor.startEditing();
|
||||
await workspace.textEditor.moveFromStart(5);
|
||||
await page.keyboard.type(" dolor sit amet");
|
||||
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||
expect(textContent).toBe("Lorem dolor sit amet ipsum");
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||
await workspace.goToWorkspace();
|
||||
|
||||
await Clipboard.writeText(page, textToPaste);
|
||||
|
||||
await workspace.clickLeafLayer("Lorem ipsum");
|
||||
await workspace.textEditor.startEditing();
|
||||
await workspace.textEditor.moveFromEnd();
|
||||
await workspace.paste("keyboard");
|
||||
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||
expect(textContent).toBe("Lorem ipsum dolor sit amet");
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
test("Update a new text shape prepending text by pasting text", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const textToPaste = "Dolor sit amet ";
|
||||
const workspace = new WorkspacePage(page, {
|
||||
textEditor: true,
|
||||
});
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||
await workspace.goToWorkspace();
|
||||
|
||||
await Clipboard.writeText(page, textToPaste);
|
||||
|
||||
await workspace.clickLeafLayer("Lorem ipsum");
|
||||
await workspace.textEditor.startEditing();
|
||||
await workspace.textEditor.moveFromStart();
|
||||
await workspace.paste("keyboard");
|
||||
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||
expect(textContent).toBe("Dolor sit amet Lorem ipsum");
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
test("Update a new text shape replacing (starting) text with pasted text", async ({
|
||||
page,
|
||||
}) => {
|
||||
const textToPaste = "Dolor sit amet";
|
||||
const workspace = new WorkspacePage(page, {
|
||||
textEditor: true,
|
||||
});
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||
await workspace.goToWorkspace();
|
||||
await workspace.clickLeafLayer("Lorem ipsum");
|
||||
await workspace.textEditor.startEditing();
|
||||
await workspace.textEditor.selectFromStart(5);
|
||||
|
||||
await Clipboard.writeText(page, textToPaste);
|
||||
|
||||
await workspace.paste("keyboard");
|
||||
|
||||
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||
expect(textContent).toBe("Dolor sit amet ipsum");
|
||||
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
test("Update a new text shape replacing (ending) text with pasted text", async ({
|
||||
page,
|
||||
}) => {
|
||||
const textToPaste = "dolor sit amet";
|
||||
const workspace = new WorkspacePage(page, {
|
||||
textEditor: true,
|
||||
});
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||
await workspace.goToWorkspace();
|
||||
await workspace.clickLeafLayer("Lorem ipsum");
|
||||
await workspace.textEditor.startEditing();
|
||||
await workspace.textEditor.selectFromEnd(5);
|
||||
|
||||
await Clipboard.writeText(page, textToPaste);
|
||||
|
||||
await workspace.paste("keyboard");
|
||||
|
||||
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||
expect(textContent).toBe("Lorem dolor sit amet");
|
||||
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
test("Update a new text shape replacing (in between) text with pasted text", async ({
|
||||
page,
|
||||
}) => {
|
||||
const textToPaste = "dolor sit amet";
|
||||
const workspace = new WorkspacePage(page, {
|
||||
textEditor: true,
|
||||
});
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||
await workspace.goToWorkspace();
|
||||
await workspace.clickLeafLayer("Lorem ipsum");
|
||||
await workspace.textEditor.startEditing();
|
||||
await workspace.textEditor.selectFromStart(5, 3);
|
||||
|
||||
await Clipboard.writeText(page, textToPaste);
|
||||
|
||||
await workspace.paste("keyboard");
|
||||
|
||||
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||
expect(textContent).toBe("Lordolor sit ametsum");
|
||||
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
test("Update text font size selecting a part of it (starting)", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = new WorkspacePage(page, {
|
||||
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.goToWorkspace();
|
||||
await workspace.clickLeafLayer("Lorem ipsum");
|
||||
await workspace.textEditor.startEditing();
|
||||
await workspace.textEditor.selectFromStart(5);
|
||||
await workspace.textEditor.changeFontSize(36);
|
||||
|
||||
const textContent1 = await workspace.textEditor.waitForTextSpanContent(1);
|
||||
expect(textContent1).toBe("Lorem");
|
||||
const textContent2 = await workspace.textEditor.waitForTextSpanContent(2);
|
||||
expect(textContent2).toBe(" ipsum");
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
test.skip("Update text line height selecting a part of it (starting)", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = new WorkspacePage(page, {
|
||||
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.goToWorkspace();
|
||||
await workspace.clickLeafLayer("Lorem ipsum");
|
||||
await workspace.textEditor.startEditing();
|
||||
await workspace.textEditor.selectFromStart(5);
|
||||
await workspace.textEditor.changeLineHeight(1.4);
|
||||
|
||||
const lineHeight = await workspace.textEditor.waitForParagraphStyle(
|
||||
1,
|
||||
"line-height",
|
||||
);
|
||||
expect(lineHeight).toBe("1.4");
|
||||
|
||||
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||
expect(textContent).toBe("Lorem ipsum");
|
||||
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
test.skip("Update text letter spacing selecting a part of it (starting)", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = new WorkspacePage(page, {
|
||||
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.goToWorkspace();
|
||||
await workspace.clickLeafLayer("Lorem ipsum");
|
||||
await workspace.textEditor.startEditing();
|
||||
await workspace.textEditor.selectFromStart(5);
|
||||
await workspace.textEditor.changeLetterSpacing(10);
|
||||
|
||||
const textContent1 = await workspace.textEditor.waitForTextSpanContent(1);
|
||||
expect(textContent1).toBe("Lorem");
|
||||
const textContent2 = await workspace.textEditor.waitForTextSpanContent(2);
|
||||
expect(textContent2).toBe(" ipsum");
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
test("BUG 11552 - Apply styles to the current caret", async ({ page }) => {
|
||||
const workspace = new WorkspacePage(page);
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("text-editor/get-file-11552.json");
|
||||
@@ -14,21 +325,16 @@ test.skip("BUG 11552 - Apply styles to the current caret", async ({ page }) => {
|
||||
"update-file?id=*",
|
||||
"text-editor/update-file-11552.json",
|
||||
);
|
||||
|
||||
await workspace.goToWorkspace({
|
||||
fileId: "238a17e0-75ff-8075-8006-934586ea2230",
|
||||
pageId: "238a17e0-75ff-8075-8006-934586ea2231",
|
||||
});
|
||||
await workspace.clickLeafLayer("Lorem ipsum");
|
||||
await workspace.clickLeafLayer("Lorem ipsum");
|
||||
await workspace.goToWorkspace();
|
||||
await workspace.doubleClickLeafLayer("Lorem ipsum");
|
||||
|
||||
const fontSizeInput = workspace.rightSidebar.getByRole("textbox", {
|
||||
name: "Font Size",
|
||||
});
|
||||
await expect(fontSizeInput).toBeVisible();
|
||||
|
||||
await workspace.page.keyboard.press("Enter");
|
||||
await workspace.page.keyboard.press("ArrowRight");
|
||||
await page.keyboard.press("Enter");
|
||||
await page.keyboard.press("ArrowRight");
|
||||
|
||||
await fontSizeInput.fill("36");
|
||||
|
||||
|
||||
@@ -303,7 +303,7 @@ test.describe("Tokens: Tokens Tab", () => {
|
||||
const nameField = tokensUpdateCreateModal.getByLabel("Name");
|
||||
await nameField.pressSequentially(".changed");
|
||||
|
||||
await tokensUpdateCreateModal.getByRole("button", {name: "Save"}).click();
|
||||
await tokensUpdateCreateModal.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
await expect(tokensUpdateCreateModal).not.toBeVisible();
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -278,6 +278,7 @@ async function readTranslations() {
|
||||
"id",
|
||||
"ru",
|
||||
"tr",
|
||||
"hi",
|
||||
"zh_CN",
|
||||
"zh_Hant",
|
||||
"hr",
|
||||
|
||||
@@ -23,18 +23,21 @@ corepack enable;
|
||||
corepack install;
|
||||
yarn install || exit 1;
|
||||
|
||||
rm -rf resources/public;
|
||||
rm -rf target/dist;
|
||||
rm -rf resources/public;
|
||||
|
||||
mkdir -p resources/public;
|
||||
|
||||
pushd ../render-wasm;
|
||||
./build
|
||||
popd
|
||||
|
||||
yarn run build:app:main --config-merge "{:release-version \"${CURRENT_HASH}-${TS}\"}" $EXTRA_PARAMS;
|
||||
|
||||
if [ "$INCLUDE_WASM" = "yes" ]; then
|
||||
yarn run build:wasm || exit 1;
|
||||
fi
|
||||
|
||||
yarn run build:app:libs || exit 1;
|
||||
yarn run build:app:assets || exit 1;
|
||||
|
||||
sed -i "s/\.\/render.js/.\/render.js?version=$CURRENT_VERSION/g" resources/public/js/worker/main*.js
|
||||
|
||||
mkdir -p target/dist;
|
||||
rsync -avr resources/public/ target/dist/
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
{:main
|
||||
{:entries [app.worker]
|
||||
:web-worker true
|
||||
:prepend-js "importScripts('/js/worker/render.js');"
|
||||
:prepend-js "importScripts('./render.js');"
|
||||
:depends-on #{}}}
|
||||
|
||||
:js-options
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
(map :page-id))
|
||||
|
||||
(defn- apply-changes-localy
|
||||
[{:keys [file-id redo-changes] :as commit} pending]
|
||||
[{:keys [file-id redo-changes ignore-wasm?] :as commit} pending]
|
||||
(ptk/reify ::apply-changes-localy
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
@@ -103,7 +103,7 @@
|
||||
pids (into #{} xf:map-page-id redo-changes)]
|
||||
(reduce #(ctst/update-object-indices %1 %2) fdata pids)))]
|
||||
|
||||
(if (features/active-feature? state "render-wasm/v1")
|
||||
(if (and (not ignore-wasm?) (features/active-feature? state "render-wasm/v1"))
|
||||
;; Update the wasm model
|
||||
(let [shape-changes (volatile! {})
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
(defn commit
|
||||
"Create a commit event instance"
|
||||
[{:keys [commit-id redo-changes undo-changes origin save-undo? features
|
||||
file-id file-revn file-vern undo-group tags stack-undo? source]}]
|
||||
file-id file-revn file-vern undo-group tags stack-undo? source ignore-wasm?]}]
|
||||
|
||||
(assert (cpc/check-changes redo-changes)
|
||||
"expect valid vector of changes for redo-changes")
|
||||
@@ -147,7 +147,8 @@
|
||||
:save-undo? save-undo?
|
||||
:undo-group undo-group
|
||||
:tags tags
|
||||
:stack-undo? stack-undo?}]
|
||||
:stack-undo? stack-undo?
|
||||
:ignore-wasm? ignore-wasm?}]
|
||||
|
||||
(ptk/reify ::commit
|
||||
cljs.core/IDeref
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
(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-alias dps]
|
||||
[app.main.data.persistence :as dps]
|
||||
[app.main.data.plugins :as dp]
|
||||
[app.main.data.profile :as du]
|
||||
[app.main.data.project :as dpj]
|
||||
@@ -67,6 +67,7 @@
|
||||
[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]
|
||||
@@ -269,8 +270,12 @@
|
||||
(ptk/reify ::process-wasm-object
|
||||
ptk/EffectEvent
|
||||
(effect [_ state _]
|
||||
(let [objects (dsh/lookup-page-objects state)]
|
||||
(wasm.api/process-object (get objects id))))))
|
||||
(let [objects (dsh/lookup-page-objects state)
|
||||
shape (get objects id)]
|
||||
;; Only process objects that exist in the current page
|
||||
;; This prevents errors when processing changes from other pages
|
||||
(when shape
|
||||
(wasm.api/process-object shape))))))
|
||||
|
||||
(defn initialize-workspace
|
||||
[team-id file-id]
|
||||
@@ -379,6 +384,59 @@
|
||||
(->> (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)
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
[app.common.types.fills :as types.fills]
|
||||
[app.common.types.library :as ctl]
|
||||
[app.common.types.shape :as shp]
|
||||
[app.common.types.shape.shadow :refer [check-shadow]]
|
||||
[app.common.types.shape.shadow :as types.shadow]
|
||||
[app.common.types.text :as txt]
|
||||
[app.main.broadcast :as mbc]
|
||||
[app.main.data.helpers :as dsh]
|
||||
@@ -406,30 +406,30 @@
|
||||
|
||||
(defn change-shadow
|
||||
[ids attrs index]
|
||||
(ptk/reify ::change-shadow
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(rx/of (dwsh/update-shapes
|
||||
ids
|
||||
(fn [shape]
|
||||
(let [;; If we try to set a gradient to a shadow (for
|
||||
;; example using the color selection from
|
||||
;; multiple shapes) let's use the first stop
|
||||
;; color
|
||||
attrs (cond-> attrs
|
||||
(:gradient attrs)
|
||||
(dm/get-in [:gradient :stops 0]))
|
||||
(letfn [(update-shadow [shape]
|
||||
(let [;; If we try to set a gradient to a shadow (for
|
||||
;; example using the color selection from
|
||||
;; multiple shapes) let's use the first stop
|
||||
;; color
|
||||
attrs (cond-> attrs
|
||||
(:gradient attrs)
|
||||
(-> (dm/get-in [:gradient :stops 0])
|
||||
(select-keys types.shadow/color-attrs)))
|
||||
|
||||
attrs' (-> (dm/get-in shape [:shadow index :color])
|
||||
(merge attrs)
|
||||
(d/without-nils))]
|
||||
(assoc-in shape [:shadow index :color] attrs'))))))))
|
||||
attrs' (-> (dm/get-in shape [:shadow index :color])
|
||||
(merge attrs)
|
||||
(d/without-nils))]
|
||||
(assoc-in shape [:shadow index :color] attrs')))]
|
||||
(ptk/reify ::change-shadow
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(rx/of (dwsh/update-shapes ids update-shadow))))))
|
||||
|
||||
(defn add-shadow
|
||||
[ids shadow]
|
||||
|
||||
(assert
|
||||
(check-shadow shadow)
|
||||
(types.shadow/check-shadow shadow)
|
||||
"expected a valid shadow struct")
|
||||
|
||||
(assert
|
||||
@@ -1146,16 +1146,16 @@
|
||||
(defn- shadow->color-attr
|
||||
"Given a stroke map enriched with :shape-id, :index, and optionally
|
||||
:has-token-applied / :token-name, returns a color attribute map.
|
||||
|
||||
|
||||
If :has-token-applied is true, adds token metadata to :attrs:
|
||||
{:has-token-applied true
|
||||
:token-name <token-name>}
|
||||
|
||||
|
||||
Args:
|
||||
- stroke: map with stroke info, including :shape-id and :index
|
||||
- file-id: current file UUID
|
||||
- libraries: map of shared color libraries
|
||||
|
||||
|
||||
Returns:
|
||||
A map like:
|
||||
{:attrs {...color data...}
|
||||
@@ -1260,12 +1260,12 @@
|
||||
will include extra attributes in its :attrs map:
|
||||
{:has-token-applied true
|
||||
:token-name <token-name>}
|
||||
|
||||
|
||||
Args:
|
||||
- shapes: vector of shape maps
|
||||
- file-id: current file UUID
|
||||
- libraries: map of shared color libraries
|
||||
|
||||
|
||||
Returns:
|
||||
A vector of color attribute maps with metadata for each shape."
|
||||
[shapes file-id libraries]
|
||||
|
||||
@@ -102,7 +102,8 @@
|
||||
{:origin it
|
||||
:redo-changes changes
|
||||
:undo-changes []
|
||||
:save-undo? false})))))))
|
||||
:save-undo? false
|
||||
:ignore-wasm? true})))))))
|
||||
|
||||
;; FIXME: would be nice to not execute this code twice per page in the
|
||||
;; same working session, maybe some local memoization can improve that
|
||||
@@ -119,4 +120,5 @@
|
||||
{:origin it
|
||||
:redo-changes changes
|
||||
:undo-changes []
|
||||
:save-undo? false})))))))
|
||||
:save-undo? false
|
||||
:ignore-wasm? true})))))))
|
||||
|
||||
@@ -649,7 +649,7 @@
|
||||
(propagate-structure-modifiers modif-tree (dsh/lookup-page-objects state))
|
||||
|
||||
ids
|
||||
(into [] xf:without-uuid-zero (keys transforms))
|
||||
(into (set (keys modif-tree)) xf:without-uuid-zero (keys transforms))
|
||||
|
||||
update-shape
|
||||
(fn [shape]
|
||||
|
||||
@@ -554,7 +554,7 @@
|
||||
(when (features/active-feature? state "text-editor/v2")
|
||||
(let [instance (:workspace-editor state)
|
||||
styles (some-> (editor.v2/getCurrentStyle instance)
|
||||
(styles/get-styles-from-style-declaration)
|
||||
(styles/get-styles-from-style-declaration :removed-mixed true)
|
||||
((comp update-node-fn migrate-node))
|
||||
(styles/attrs->styles))]
|
||||
(editor.v2/applyStylesToSelection instance styles)))))))
|
||||
@@ -831,7 +831,8 @@
|
||||
(effect [_ state _]
|
||||
(when (features/active-feature? state "text-editor/v2")
|
||||
(let [instance (:workspace-editor state)
|
||||
attrs-to-override (some-> (editor.v2/getCurrentStyle instance) (styles/get-styles-from-style-declaration))
|
||||
attrs-to-override (some-> (editor.v2/getCurrentStyle instance)
|
||||
(styles/get-styles-from-style-declaration))
|
||||
overriden-attrs (merge attrs-to-override attrs)
|
||||
styles (styles/attrs->styles overriden-attrs)]
|
||||
(editor.v2/applyStylesToSelection instance styles))))))
|
||||
|
||||
@@ -238,12 +238,12 @@
|
||||
:always
|
||||
(ctm/resize scalev resize-origin shape-transform shape-transform-inverse)
|
||||
|
||||
(and (ctl/any-layout-immediate-child? objects shape)
|
||||
(and (or (ctl/any-layout-immediate-child? objects shape) (ctl/any-layout? shape))
|
||||
(not= (:layout-item-h-sizing shape) :fix)
|
||||
^boolean change-width?)
|
||||
(ctm/change-property :layout-item-h-sizing :fix)
|
||||
|
||||
(and (ctl/any-layout-immediate-child? objects shape)
|
||||
(and (or (ctl/any-layout-immediate-child? objects shape) (ctl/any-layout? shape))
|
||||
(not= (:layout-item-v-sizing shape) :fix)
|
||||
^boolean change-height?)
|
||||
(ctm/change-property :layout-item-v-sizing :fix)
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
(log/set-level! :warn)
|
||||
|
||||
(def google-fonts
|
||||
(preload-gfonts "fonts/gfonts.2025.05.19.json"))
|
||||
(preload-gfonts "fonts/gfonts.2025.11.28.json"))
|
||||
|
||||
(def local-fonts
|
||||
[{:id "sourcesanspro"
|
||||
@@ -342,8 +342,8 @@
|
||||
(fn [result {:keys [font-id] :as node}]
|
||||
(let [current-font
|
||||
(if (some? font-id)
|
||||
(select-keys node [:font-id :font-variant-id])
|
||||
(select-keys txt/default-typography [:font-id :font-variant-id]))]
|
||||
(select-keys node [:font-id :font-variant-id :font-weight :font-style])
|
||||
(select-keys txt/default-typography [:font-id :font-variant-id :font-weight :font-style]))]
|
||||
(conj result current-font)))
|
||||
#{})))
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
(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)
|
||||
@@ -372,6 +375,9 @@
|
||||
(def workspace-modifiers
|
||||
(l/derived :workspace-modifiers st/state))
|
||||
|
||||
(def workspace-wasm-modifiers
|
||||
(l/derived :workspace-wasm-modifiers st/state))
|
||||
|
||||
(def ^:private workspace-modifiers-with-objects
|
||||
(l/derived
|
||||
(fn [state]
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
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)
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
(def current-zoom (mf/create-context nil))
|
||||
|
||||
(def workspace-read-only? (mf/create-context nil))
|
||||
(def is-render? (mf/create-context false))
|
||||
(def is-component? (mf/create-context false))
|
||||
|
||||
(def sidebar
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
(ns app.main.ui.flex-controls.gap
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.geom.shapes :as gsh]
|
||||
@@ -16,6 +17,8 @@
|
||||
[app.common.types.shape.layout :as ctl]
|
||||
[app.main.data.helpers :as dsh]
|
||||
[app.main.data.workspace.modifiers :as dwm]
|
||||
[app.main.data.workspace.transforms :as dwt]
|
||||
[app.main.features :as features]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.css-cursors :as cur]
|
||||
@@ -27,10 +30,11 @@
|
||||
(mf/defc gap-display
|
||||
[{:keys [frame-id zoom gap-type gap on-pointer-enter on-pointer-leave
|
||||
rect-data hover? selected? mouse-pos hover-value
|
||||
on-move-selected on-context-menu]}]
|
||||
on-move-selected on-context-menu on-change]}]
|
||||
(let [resizing (mf/use-var nil)
|
||||
start (mf/use-var nil)
|
||||
original-value (mf/use-var 0)
|
||||
last-pos (mf/use-var nil)
|
||||
negate? (:resize-negate? rect-data)
|
||||
axis (:resize-axis rect-data)
|
||||
|
||||
@@ -43,32 +47,55 @@
|
||||
(reset! start (dom/get-client-position event))
|
||||
(reset! original-value (:initial-value rect-data))))
|
||||
|
||||
on-lost-pointer-capture
|
||||
calc-modifiers
|
||||
(mf/use-fn
|
||||
(mf/deps frame-id gap-type gap)
|
||||
(fn [pos]
|
||||
(let [delta
|
||||
(-> (gpt/to-vec @start pos)
|
||||
(cond-> negate? gpt/negate)
|
||||
(get axis))
|
||||
val
|
||||
(int (max (+ @original-value (/ delta zoom)) 0))
|
||||
|
||||
layout-gap (assoc gap gap-type val)]
|
||||
[val
|
||||
(dwm/create-modif-tree
|
||||
[frame-id]
|
||||
(ctm/change-property (ctm/empty) :layout-gap layout-gap))])))
|
||||
|
||||
on-lost-pointer-capture
|
||||
(mf/use-fn
|
||||
(mf/deps calc-modifiers)
|
||||
(fn [event]
|
||||
(dom/release-pointer event)
|
||||
|
||||
(when (and (features/active-feature? @st/state "render-wasm/v1") (= @resizing gap-type))
|
||||
(let [[_ modifiers] (calc-modifiers @last-pos)]
|
||||
(st/emit! (dwm/apply-wasm-modifiers modifiers)
|
||||
(dwt/finish-transform))))
|
||||
|
||||
(reset! resizing nil)
|
||||
(reset! start nil)
|
||||
(reset! original-value 0)
|
||||
(st/emit! (dwm/apply-modifiers))))
|
||||
(when (not (features/active-feature? @st/state "render-wasm/v1"))
|
||||
(st/emit! (dwm/apply-modifiers)))))
|
||||
|
||||
on-pointer-move
|
||||
(mf/use-fn
|
||||
(mf/deps frame-id gap-type gap)
|
||||
(mf/deps calc-modifiers on-change)
|
||||
(fn [event]
|
||||
(let [pos (dom/get-client-position event)]
|
||||
(reset! last-pos pos)
|
||||
(reset! mouse-pos (point->viewport pos))
|
||||
(when (= @resizing gap-type)
|
||||
(let [delta (-> (gpt/to-vec @start pos)
|
||||
(cond-> negate? gpt/negate)
|
||||
(get axis))
|
||||
val (int (max (+ @original-value (/ delta zoom)) 0))
|
||||
layout-gap (assoc gap gap-type val)
|
||||
modifiers (dwm/create-modif-tree [frame-id] (ctm/change-property (ctm/empty) :layout-gap layout-gap))]
|
||||
|
||||
(let [[val modifiers] (calc-modifiers pos)]
|
||||
(reset! hover-value val)
|
||||
(st/emit! (dwm/set-modifiers modifiers)))))))]
|
||||
(if (features/active-feature? @st/state "render-wasm/v1")
|
||||
(st/emit! (dwm/set-wasm-modifiers modifiers))
|
||||
(st/emit! (dwm/set-modifiers modifiers)))
|
||||
(when on-change
|
||||
(on-change modifiers)))))))]
|
||||
|
||||
[:g.gap-rect
|
||||
[:rect.info-area
|
||||
@@ -120,10 +147,17 @@
|
||||
pill-width (/ fcc/flex-display-pill-width zoom)
|
||||
pill-height (/ fcc/flex-display-pill-height zoom)
|
||||
workspace-modifiers (mf/deref refs/workspace-modifiers)
|
||||
workspace-wasm-modifiers (mf/deref refs/workspace-wasm-modifiers)
|
||||
|
||||
gap-selected (mf/deref refs/workspace-gap-selected)
|
||||
hover (mf/use-state nil)
|
||||
hover-value (mf/use-state 0)
|
||||
mouse-pos (mf/use-state nil)
|
||||
current-modifiers (mf/use-state nil)
|
||||
|
||||
frame
|
||||
(ctm/apply-structure-modifiers frame (dm/get-in @current-modifiers [frame-id :modifiers]))
|
||||
|
||||
padding (:layout-padding frame)
|
||||
gap (:layout-gap frame)
|
||||
{:keys [width height x1 y1]} (:selrect frame)
|
||||
@@ -132,6 +166,12 @@
|
||||
(reset! hover-value val))
|
||||
|
||||
on-pointer-leave #(reset! hover nil)
|
||||
|
||||
on-change
|
||||
(mf/use-fn
|
||||
(fn [modifiers]
|
||||
(reset! current-modifiers modifiers)))
|
||||
|
||||
negate {:column-gap (if flip-x true false)
|
||||
:row-gap (if flip-y true false)}
|
||||
|
||||
@@ -143,8 +183,16 @@
|
||||
(= :column-reverse saved-dir))
|
||||
(drop-last children)
|
||||
(rest children))
|
||||
children-to-display (->> children-to-display
|
||||
(map #(gsh/transform-shape % (get-in workspace-modifiers [(:id %) :modifiers]))))
|
||||
children-to-display
|
||||
(if (features/active-feature? @st/state "render-wasm/v1")
|
||||
(let [modifiers (into {} workspace-wasm-modifiers)]
|
||||
(->> children-to-display
|
||||
;;(map #(gsh/transform-shape % (get-in workspace-modifiers [(:id %) :modifiers])))
|
||||
(map (fn [shape]
|
||||
(gsh/apply-transform shape (get modifiers (:id shape)))))))
|
||||
|
||||
(->> children-to-display
|
||||
(map #(gsh/transform-shape % (get-in workspace-modifiers [(:id %) :modifiers])))))
|
||||
|
||||
wrap-blocks
|
||||
(let [block-children (->> children
|
||||
@@ -272,20 +320,22 @@
|
||||
[:g.gaps {:pointer-events "visible"}
|
||||
(for [[index display-item] (d/enumerate (concat display-blocks display-children))]
|
||||
(let [gap-type (:gap-type display-item)]
|
||||
[:& gap-display {:key (str frame-id index)
|
||||
:frame-id frame-id
|
||||
:zoom zoom
|
||||
:gap-type gap-type
|
||||
:gap gap
|
||||
:on-pointer-enter (partial on-pointer-enter gap-type (get gap gap-type))
|
||||
:on-pointer-leave on-pointer-leave
|
||||
:on-move-selected on-move-selected
|
||||
:on-context-menu on-context-menu
|
||||
:rect-data display-item
|
||||
:hover? (= @hover gap-type)
|
||||
:selected? (= gap-selected gap-type)
|
||||
:mouse-pos mouse-pos
|
||||
:hover-value hover-value}]))
|
||||
[:& gap-display
|
||||
{:key (str frame-id index)
|
||||
:frame-id frame-id
|
||||
:zoom zoom
|
||||
:gap-type gap-type
|
||||
:gap gap
|
||||
:on-pointer-enter (partial on-pointer-enter gap-type (get gap gap-type))
|
||||
:on-pointer-leave on-pointer-leave
|
||||
:on-move-selected on-move-selected
|
||||
:on-context-menu on-context-menu
|
||||
:on-change on-change
|
||||
:rect-data display-item
|
||||
:hover? (= @hover gap-type)
|
||||
:selected? (= gap-selected gap-type)
|
||||
:mouse-pos mouse-pos
|
||||
:hover-value hover-value}]))
|
||||
|
||||
(when @hover
|
||||
[:& fcc/flex-display-pill
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
|
||||
(ns app.main.ui.flex-controls.margin
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.types.modifiers :as ctm]
|
||||
[app.main.data.workspace.modifiers :as dwm]
|
||||
[app.main.data.workspace.transforms :as dwt]
|
||||
[app.main.features :as features]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.css-cursors :as cur]
|
||||
@@ -17,11 +20,14 @@
|
||||
[app.util.dom :as dom]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(mf/defc margin-display [{:keys [shape-id zoom hover-all? hover-v? hover-h? margin-num margin on-pointer-enter on-pointer-leave
|
||||
rect-data hover? selected? mouse-pos hover-value]}]
|
||||
(mf/defc margin-display
|
||||
[{:keys [shape-id zoom hover-all? hover-v? hover-h? margin-num margin
|
||||
on-pointer-enter on-pointer-leave on-change
|
||||
rect-data hover? selected? mouse-pos hover-value]}]
|
||||
(let [resizing? (mf/use-var false)
|
||||
start (mf/use-var nil)
|
||||
original-value (mf/use-var 0)
|
||||
last-pos (mf/use-var nil)
|
||||
negate? (true? (:resize-negate? rect-data))
|
||||
axis (:resize-axis rect-data)
|
||||
|
||||
@@ -34,39 +40,69 @@
|
||||
(reset! start (dom/get-client-position event))
|
||||
(reset! original-value (:initial-value rect-data))))
|
||||
|
||||
calc-modifiers
|
||||
(mf/use-fn
|
||||
(mf/deps shape-id margin-num margin hover-all? hover-v? hover-h?)
|
||||
(fn [pos]
|
||||
(let [delta
|
||||
(-> (gpt/to-vec @start pos)
|
||||
(cond-> negate? gpt/negate)
|
||||
(get axis))
|
||||
|
||||
val
|
||||
(int (max (+ @original-value (/ delta zoom)) 0))
|
||||
|
||||
layout-item-margin
|
||||
(cond
|
||||
hover-all? (assoc margin :m1 val :m2 val :m3 val :m4 val)
|
||||
hover-v? (assoc margin :m1 val :m3 val)
|
||||
hover-h? (assoc margin :m2 val :m4 val)
|
||||
:else (assoc margin margin-num val))
|
||||
|
||||
layout-item-margin-type
|
||||
(if (= (:m1 margin) (:m2 margin) (:m3 margin) (:m4 margin)) :simple :multiple)]
|
||||
|
||||
[val
|
||||
(dwm/create-modif-tree
|
||||
[shape-id]
|
||||
(-> (ctm/empty)
|
||||
(ctm/change-property :layout-item-margin layout-item-margin)
|
||||
(ctm/change-property :layout-item-margin-type layout-item-margin-type)))])))
|
||||
|
||||
on-lost-pointer-capture
|
||||
(mf/use-fn
|
||||
(mf/deps shape-id margin-num margin)
|
||||
(mf/deps calc-modifiers)
|
||||
(fn [event]
|
||||
(dom/release-pointer event)
|
||||
|
||||
(when (features/active-feature? @st/state "render-wasm/v1")
|
||||
(let [[_ modifiers] (calc-modifiers @last-pos)]
|
||||
(st/emit! (dwm/apply-wasm-modifiers modifiers)
|
||||
(dwt/finish-transform))))
|
||||
|
||||
(reset! resizing? false)
|
||||
(reset! start nil)
|
||||
(reset! original-value 0)
|
||||
(st/emit! (dwm/apply-modifiers))))
|
||||
|
||||
(when (not (features/active-feature? @st/state "render-wasm/v1"))
|
||||
(st/emit! (dwm/apply-modifiers)))))
|
||||
|
||||
on-pointer-move
|
||||
(mf/use-fn
|
||||
(mf/deps shape-id margin-num margin hover-all? hover-v? hover-h?)
|
||||
(mf/deps calc-modifiers on-change)
|
||||
(fn [event]
|
||||
(let [pos (dom/get-client-position event)]
|
||||
(reset! mouse-pos (point->viewport pos))
|
||||
(reset! last-pos pos)
|
||||
(when @resizing?
|
||||
(let [delta (-> (gpt/to-vec @start pos)
|
||||
(cond-> negate? gpt/negate)
|
||||
(get axis))
|
||||
val (int (max (+ @original-value (/ delta zoom)) 0))
|
||||
layout-item-margin (cond
|
||||
hover-all? (assoc margin :m1 val :m2 val :m3 val :m4 val)
|
||||
hover-v? (assoc margin :m1 val :m3 val)
|
||||
hover-h? (assoc margin :m2 val :m4 val)
|
||||
:else (assoc margin margin-num val))
|
||||
layout-item-margin-type (if (= (:m1 margin) (:m2 margin) (:m3 margin) (:m4 margin)) :simple :multiple)
|
||||
modifiers (dwm/create-modif-tree [shape-id]
|
||||
(-> (ctm/empty)
|
||||
(ctm/change-property :layout-item-margin layout-item-margin)
|
||||
(ctm/change-property :layout-item-margin-type layout-item-margin-type)))]
|
||||
(let [[val modifiers] (calc-modifiers pos)]
|
||||
(reset! hover-value val)
|
||||
(st/emit! (dwm/set-modifiers modifiers)))))))]
|
||||
(if (features/active-feature? @st/state "render-wasm/v1")
|
||||
(st/emit! (dwm/set-wasm-modifiers modifiers))
|
||||
(st/emit! (dwm/set-modifiers modifiers)))
|
||||
|
||||
(when on-change
|
||||
(on-change modifiers)))))))]
|
||||
|
||||
[:rect.margin-rect
|
||||
{:x (:x rect-data)
|
||||
@@ -89,6 +125,11 @@
|
||||
pill-width (/ fcc/flex-display-pill-width zoom)
|
||||
pill-height (/ fcc/flex-display-pill-height zoom)
|
||||
margins-selected (mf/deref refs/workspace-margins-selected)
|
||||
current-modifiers (mf/use-state nil)
|
||||
|
||||
shape
|
||||
(ctm/apply-structure-modifiers shape (dm/get-in @current-modifiers [shape-id :modifiers]))
|
||||
|
||||
hover-value (mf/use-state 0)
|
||||
mouse-pos (mf/use-state nil)
|
||||
hover (mf/use-state nil)
|
||||
@@ -97,50 +138,67 @@
|
||||
hover-h? (and (or (= @hover :m2) (= @hover :m4)) shift?)
|
||||
margin (:layout-item-margin shape)
|
||||
{:keys [width height x1 x2 y1 y2]} (:selrect shape)
|
||||
on-pointer-enter (fn [hover-type val]
|
||||
(reset! hover hover-type)
|
||||
(reset! hover-value val))
|
||||
on-pointer-leave #(reset! hover nil)
|
||||
hover? #(or hover-all?
|
||||
(and (or (= % :m1) (= % :m3)) hover-v?)
|
||||
(and (or (= % :m2) (= % :m4)) hover-h?)
|
||||
(= @hover %))
|
||||
margin-display-data {:m1 {:key (str shape-id "-m1")
|
||||
:x x1
|
||||
:y (if (:flip-y frame) y2 (- y1 (:m1 margin)))
|
||||
:width width
|
||||
:height (:m1 margin)
|
||||
:initial-value (:m1 margin)
|
||||
:resize-type :top
|
||||
:resize-axis :y
|
||||
:resize-negate? (:flip-y frame)}
|
||||
:m2 {:key (str shape-id "-m2")
|
||||
:x (if (:flip-x frame) (- x1 (:m2 margin)) x2)
|
||||
:y y1
|
||||
:width (:m2 margin)
|
||||
:height height
|
||||
:initial-value (:m2 margin)
|
||||
:resize-type :left
|
||||
:resize-axis :x
|
||||
:resize-negate? (:flip-x frame)}
|
||||
:m3 {:key (str shape-id "-m3")
|
||||
:x x1
|
||||
:y (if (:flip-y frame) (- y1 (:m3 margin)) y2)
|
||||
:width width
|
||||
:height (:m3 margin)
|
||||
:initial-value (:m3 margin)
|
||||
:resize-type :top
|
||||
:resize-axis :y
|
||||
:resize-negate? (:flip-y frame)}
|
||||
:m4 {:key (str shape-id "-m4")
|
||||
:x (if (:flip-x frame) x2 (- x1 (:m4 margin)))
|
||||
:y y1
|
||||
:width (:m4 margin)
|
||||
:height height
|
||||
:initial-value (:m4 margin)
|
||||
:resize-type :left
|
||||
:resize-axis :x
|
||||
:resize-negate? (:flip-x frame)}}]
|
||||
|
||||
on-pointer-enter
|
||||
(mf/use-fn
|
||||
(fn [hover-type val]
|
||||
(reset! hover hover-type)
|
||||
(reset! hover-value val)))
|
||||
|
||||
on-pointer-leave
|
||||
(mf/use-fn
|
||||
(fn []
|
||||
(reset! hover nil)))
|
||||
|
||||
on-change
|
||||
(mf/use-fn
|
||||
(fn [modifiers]
|
||||
(reset! current-modifiers modifiers)))
|
||||
|
||||
hover?
|
||||
(fn [value]
|
||||
(or hover-all?
|
||||
(and (or (= value :m1) (= value :m3)) hover-v?)
|
||||
(and (or (= value :m2) (= value :m4)) hover-h?)
|
||||
(= @hover value)))
|
||||
|
||||
margin-display-data
|
||||
{:m1 {:key (str shape-id "-m1")
|
||||
:x x1
|
||||
:y (if (:flip-y frame) y2 (- y1 (:m1 margin)))
|
||||
:width width
|
||||
:height (:m1 margin)
|
||||
:initial-value (:m1 margin)
|
||||
:resize-type :top
|
||||
:resize-axis :y
|
||||
:resize-negate? (:flip-y frame)}
|
||||
:m2 {:key (str shape-id "-m2")
|
||||
:x (if (:flip-x frame) (- x1 (:m2 margin)) x2)
|
||||
:y y1
|
||||
:width (:m2 margin)
|
||||
:height height
|
||||
:initial-value (:m2 margin)
|
||||
:resize-type :left
|
||||
:resize-axis :x
|
||||
:resize-negate? (:flip-x frame)}
|
||||
:m3 {:key (str shape-id "-m3")
|
||||
:x x1
|
||||
:y (if (:flip-y frame) (- y1 (:m3 margin)) y2)
|
||||
:width width
|
||||
:height (:m3 margin)
|
||||
:initial-value (:m3 margin)
|
||||
:resize-type :top
|
||||
:resize-axis :y
|
||||
:resize-negate? (:flip-y frame)}
|
||||
:m4 {:key (str shape-id "-m4")
|
||||
:x (if (:flip-x frame) x2 (- x1 (:m4 margin)))
|
||||
:y y1
|
||||
:width (:m4 margin)
|
||||
:height height
|
||||
:initial-value (:m4 margin)
|
||||
:resize-type :left
|
||||
:resize-axis :x
|
||||
:resize-negate? (:flip-x frame)}}]
|
||||
|
||||
[:g.margins {:pointer-events "visible"}
|
||||
(for [[margin-num rect-data] margin-display-data]
|
||||
@@ -155,6 +213,7 @@
|
||||
:margin margin
|
||||
:on-pointer-enter (partial on-pointer-enter margin-num (get margin margin-num))
|
||||
:on-pointer-leave on-pointer-leave
|
||||
:on-change on-change
|
||||
:rect-data rect-data
|
||||
:hover? (hover? margin-num)
|
||||
:selected? (get margins-selected margin-num)
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
|
||||
(ns app.main.ui.flex-controls.padding
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.types.modifiers :as ctm]
|
||||
[app.main.data.workspace.modifiers :as dwm]
|
||||
[app.main.data.workspace.transforms :as dwt]
|
||||
[app.main.features :as features]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.css-cursors :as cur]
|
||||
@@ -18,11 +21,13 @@
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(mf/defc padding-display
|
||||
[{:keys [frame-id zoom hover-all? hover-v? hover-h? padding-num padding on-pointer-enter on-pointer-leave
|
||||
rect-data hover? selected? mouse-pos hover-value on-move-selected on-context-menu]}]
|
||||
[{:keys [frame-id zoom hover-all? hover-v? hover-h? padding-num padding on-pointer-enter
|
||||
on-pointer-leave rect-data hover? selected? mouse-pos hover-value on-move-selected
|
||||
on-context-menu on-change]}]
|
||||
(let [resizing? (mf/use-var false)
|
||||
start (mf/use-var nil)
|
||||
original-value (mf/use-var 0)
|
||||
last-pos (mf/use-var nil)
|
||||
negate? (true? (:resize-negate? rect-data))
|
||||
axis (:resize-axis rect-data)
|
||||
|
||||
@@ -35,41 +40,69 @@
|
||||
(reset! start (dom/get-client-position event))
|
||||
(reset! original-value (:initial-value rect-data))))
|
||||
|
||||
calc-modifiers
|
||||
(mf/use-fn
|
||||
(mf/deps frame-id padding-num padding hover-all? hover-v? hover-h?)
|
||||
(fn [pos]
|
||||
(let [delta
|
||||
(-> (gpt/to-vec @start pos)
|
||||
(cond-> negate? gpt/negate)
|
||||
(get axis))
|
||||
|
||||
val
|
||||
(int (max (+ @original-value (/ delta zoom)) 0))
|
||||
|
||||
layout-padding
|
||||
(cond
|
||||
hover-all? (assoc padding :p1 val :p2 val :p3 val :p4 val)
|
||||
hover-v? (assoc padding :p1 val :p3 val)
|
||||
hover-h? (assoc padding :p2 val :p4 val)
|
||||
:else (assoc padding padding-num val))
|
||||
|
||||
|
||||
layout-padding-type
|
||||
(if (= (:p1 padding) (:p2 padding) (:p3 padding) (:p4 padding)) :simple :multiple)]
|
||||
[val
|
||||
(dwm/create-modif-tree
|
||||
[frame-id]
|
||||
(-> (ctm/empty)
|
||||
(ctm/change-property :layout-padding layout-padding)
|
||||
(ctm/change-property :layout-padding-type layout-padding-type)))])))
|
||||
|
||||
on-lost-pointer-capture
|
||||
(mf/use-fn
|
||||
(mf/deps frame-id padding-num padding)
|
||||
(mf/deps calc-modifiers)
|
||||
(fn [event]
|
||||
(dom/release-pointer event)
|
||||
|
||||
(when (features/active-feature? @st/state "render-wasm/v1")
|
||||
(let [[_ modifiers] (calc-modifiers @last-pos)]
|
||||
(st/emit! (dwm/apply-wasm-modifiers modifiers)
|
||||
(dwt/finish-transform))))
|
||||
|
||||
(reset! resizing? false)
|
||||
(reset! start nil)
|
||||
(reset! original-value 0)
|
||||
(st/emit! (dwm/apply-modifiers))))
|
||||
|
||||
(when (not (features/active-feature? @st/state "render-wasm/v1"))
|
||||
(st/emit! (dwm/apply-modifiers)))))
|
||||
|
||||
on-pointer-move
|
||||
(mf/use-fn
|
||||
(mf/deps frame-id padding-num padding hover-all? hover-v? hover-h?)
|
||||
(mf/deps calc-modifiers on-change)
|
||||
(fn [event]
|
||||
(let [pos (dom/get-client-position event)]
|
||||
(reset! mouse-pos (point->viewport pos))
|
||||
(reset! last-pos pos)
|
||||
(when @resizing?
|
||||
(let [delta (-> (gpt/to-vec @start pos)
|
||||
(cond-> negate? gpt/negate)
|
||||
(get axis))
|
||||
val (int (max (+ @original-value (/ delta zoom)) 0))
|
||||
layout-padding (cond
|
||||
hover-all? (assoc padding :p1 val :p2 val :p3 val :p4 val)
|
||||
hover-v? (assoc padding :p1 val :p3 val)
|
||||
hover-h? (assoc padding :p2 val :p4 val)
|
||||
:else (assoc padding padding-num val))
|
||||
|
||||
|
||||
layout-padding-type (if (= (:p1 padding) (:p2 padding) (:p3 padding) (:p4 padding)) :simple :multiple)
|
||||
modifiers (dwm/create-modif-tree [frame-id]
|
||||
(-> (ctm/empty)
|
||||
(ctm/change-property :layout-padding layout-padding)
|
||||
(ctm/change-property :layout-padding-type layout-padding-type)))]
|
||||
(let [[val modifiers] (calc-modifiers pos)]
|
||||
(reset! hover-value val)
|
||||
(st/emit! (dwm/set-modifiers modifiers)))))))]
|
||||
(if (features/active-feature? @st/state "render-wasm/v1")
|
||||
(st/emit! (dwm/set-wasm-modifiers modifiers))
|
||||
(st/emit! (dwm/set-modifiers modifiers)))
|
||||
|
||||
(when on-change
|
||||
(on-change modifiers)))))))]
|
||||
|
||||
[:g.padding-rect
|
||||
[:rect.info-area
|
||||
@@ -105,77 +138,108 @@
|
||||
:on-lost-pointer-capture on-lost-pointer-capture
|
||||
:on-pointer-move on-pointer-move
|
||||
:on-context-menu on-context-menu
|
||||
:class (when (or hover? selected?)
|
||||
(if (= (:resize-axis rect-data) :x) (cur/get-dynamic "resize-ew" 0) (cur/get-dynamic "resize-ew" 90)))
|
||||
:style {:fill (if (or hover? selected?) fcc/distance-color "none")
|
||||
:opacity (if selected? 0 1)}}])]))
|
||||
:class
|
||||
(when (or hover? selected?)
|
||||
(if (= (:resize-axis rect-data) :x)
|
||||
(cur/get-dynamic "resize-ew" 0)
|
||||
(cur/get-dynamic "resize-ew" 90)))
|
||||
|
||||
:style
|
||||
{:fill (if (or hover? selected?) fcc/distance-color "none")
|
||||
:opacity (if selected? 0 1)}}])]))
|
||||
|
||||
(mf/defc padding-rects
|
||||
[{:keys [frame zoom alt? shift? on-move-selected on-context-menu]}]
|
||||
(let [frame-id (:id frame)
|
||||
paddings-selected (mf/deref refs/workspace-paddings-selected)
|
||||
current-modifiers (mf/use-state nil)
|
||||
|
||||
frame
|
||||
(ctm/apply-structure-modifiers frame (dm/get-in @current-modifiers [frame-id :modifiers]))
|
||||
|
||||
hover-value (mf/use-state 0)
|
||||
mouse-pos (mf/use-state nil)
|
||||
hover (mf/use-state nil)
|
||||
|
||||
hover-all? (and (not (nil? @hover)) alt?)
|
||||
hover-v? (and (or (= @hover :p1) (= @hover :p3)) shift?)
|
||||
hover-h? (and (or (= @hover :p2) (= @hover :p4)) shift?)
|
||||
padding (:layout-padding frame)
|
||||
{:keys [width height x1 x2 y1 y2]} (:selrect frame)
|
||||
on-pointer-enter (fn [hover-type val]
|
||||
(reset! hover hover-type)
|
||||
(reset! hover-value val))
|
||||
on-pointer-leave #(reset! hover nil)
|
||||
pill-width (/ fcc/flex-display-pill-width zoom)
|
||||
pill-height (/ fcc/flex-display-pill-height zoom)
|
||||
hover? #(or hover-all?
|
||||
(and (or (= % :p1) (= % :p3)) hover-v?)
|
||||
(and (or (= % :p2) (= % :p4)) hover-h?)
|
||||
(= @hover %))
|
||||
negate {:p1 (if (:flip-y frame) true false)
|
||||
:p2 (if (:flip-x frame) true false)
|
||||
:p3 (if (:flip-y frame) true false)
|
||||
:p4 (if (:flip-x frame) true false)}
|
||||
negate (cond-> negate
|
||||
(not= :auto (:layout-item-h-sizing frame)) (assoc :p2 (not (:p2 negate)))
|
||||
(not= :auto (:layout-item-v-sizing frame)) (assoc :p3 (not (:p3 negate))))
|
||||
|
||||
padding-rect-data {:p1 {:key (str frame-id "-p1")
|
||||
:x x1
|
||||
:y (if (:flip-y frame) (- y2 (:p1 padding)) y1)
|
||||
:width width
|
||||
:height (:p1 padding)
|
||||
:initial-value (:p1 padding)
|
||||
:resize-type (if (:flip-y frame) :bottom :top)
|
||||
:resize-axis :y
|
||||
:resize-negate? (:p1 negate)}
|
||||
:p2 {:key (str frame-id "-p2")
|
||||
:x (if (:flip-x frame) x1 (- x2 (:p2 padding)))
|
||||
:y y1
|
||||
:width (:p2 padding)
|
||||
:height height
|
||||
:initial-value (:p2 padding)
|
||||
:resize-type :left
|
||||
:resize-axis :x
|
||||
:resize-negate? (:p2 negate)}
|
||||
:p3 {:key (str frame-id "-p3")
|
||||
:x x1
|
||||
:y (if (:flip-y frame) y1 (- y2 (:p3 padding)))
|
||||
:width width
|
||||
:height (:p3 padding)
|
||||
:initial-value (:p3 padding)
|
||||
:resize-type :bottom
|
||||
:resize-axis :y
|
||||
:resize-negate? (:p3 negate)}
|
||||
:p4 {:key (str frame-id "-p4")
|
||||
:x (if (:flip-x frame) (- x2 (:p4 padding)) x1)
|
||||
:y y1
|
||||
:width (:p4 padding)
|
||||
:height height
|
||||
:initial-value (:p4 padding)
|
||||
:resize-type (if (:flip-x frame) :right :left)
|
||||
:resize-axis :x
|
||||
:resize-negate? (:p4 negate)}}]
|
||||
negate
|
||||
{:p1 (if (:flip-y frame) true false)
|
||||
:p2 (if (:flip-x frame) true false)
|
||||
:p3 (if (:flip-y frame) true false)
|
||||
:p4 (if (:flip-x frame) true false)}
|
||||
|
||||
negate
|
||||
(cond-> negate
|
||||
(not= :auto (:layout-item-h-sizing frame)) (assoc :p2 (not (:p2 negate)))
|
||||
(not= :auto (:layout-item-v-sizing frame)) (assoc :p3 (not (:p3 negate))))
|
||||
|
||||
padding-rect-data
|
||||
{:p1 {:key (str frame-id "-p1")
|
||||
:x x1
|
||||
:y (if (:flip-y frame) (- y2 (:p1 padding)) y1)
|
||||
:width width
|
||||
:height (:p1 padding)
|
||||
:initial-value (:p1 padding)
|
||||
:resize-type (if (:flip-y frame) :bottom :top)
|
||||
:resize-axis :y
|
||||
:resize-negate? (:p1 negate)}
|
||||
:p2 {:key (str frame-id "-p2")
|
||||
:x (if (:flip-x frame) x1 (- x2 (:p2 padding)))
|
||||
:y y1
|
||||
:width (:p2 padding)
|
||||
:height height
|
||||
:initial-value (:p2 padding)
|
||||
:resize-type :left
|
||||
:resize-axis :x
|
||||
:resize-negate? (:p2 negate)}
|
||||
:p3 {:key (str frame-id "-p3")
|
||||
:x x1
|
||||
:y (if (:flip-y frame) y1 (- y2 (:p3 padding)))
|
||||
:width width
|
||||
:height (:p3 padding)
|
||||
:initial-value (:p3 padding)
|
||||
:resize-type :bottom
|
||||
:resize-axis :y
|
||||
:resize-negate? (:p3 negate)}
|
||||
:p4 {:key (str frame-id "-p4")
|
||||
:x (if (:flip-x frame) (- x2 (:p4 padding)) x1)
|
||||
:y y1
|
||||
:width (:p4 padding)
|
||||
:height height
|
||||
:initial-value (:p4 padding)
|
||||
:resize-type (if (:flip-x frame) :right :left)
|
||||
:resize-axis :x
|
||||
:resize-negate? (:p4 negate)}}
|
||||
|
||||
on-pointer-enter
|
||||
(mf/use-fn
|
||||
(fn [hover-type val]
|
||||
(reset! hover hover-type)
|
||||
(reset! hover-value val)))
|
||||
|
||||
on-pointer-leave
|
||||
(mf/use-fn
|
||||
(fn []
|
||||
(reset! hover nil)))
|
||||
|
||||
on-change
|
||||
(mf/use-fn
|
||||
(fn [modifiers]
|
||||
(reset! current-modifiers modifiers)))
|
||||
|
||||
hover?
|
||||
(fn [value]
|
||||
(or hover-all?
|
||||
(and (or (= value :p1) (= value :p3)) hover-v?)
|
||||
(and (or (= value :p2) (= value :p4)) hover-h?)
|
||||
(= @hover value)))]
|
||||
|
||||
[:g.paddings {:pointer-events "visible"}
|
||||
(for [[padding-num rect-data] padding-rect-data]
|
||||
@@ -194,9 +258,11 @@
|
||||
:on-pointer-leave on-pointer-leave
|
||||
:on-move-selected on-move-selected
|
||||
:on-context-menu on-context-menu
|
||||
:on-change on-change
|
||||
:hover? (hover? padding-num)
|
||||
:selected? (get paddings-selected padding-num)
|
||||
:rect-data rect-data}])
|
||||
|
||||
(when @hover
|
||||
[:& fcc/flex-display-pill
|
||||
{:height pill-height
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
{::mf/wrap-props false}
|
||||
[props]
|
||||
(let [{:keys [position-data content] :as shape} (obj/get props "shape")
|
||||
is-render? (mf/use-ctx ctx/is-render?)
|
||||
is-component? (mf/use-ctx ctx/is-component?)]
|
||||
|
||||
(mf/with-memo [content]
|
||||
@@ -41,5 +42,5 @@
|
||||
;; Only use this for component preview, otherwise the dashboard thumbnails
|
||||
;; will give a tainted canvas error because the `foreignObject` cannot be
|
||||
;; rendered.
|
||||
(and (nil? position-data) is-component?)
|
||||
(and (nil? position-data) (or is-component? is-render?))
|
||||
[:> fo/text-shape props])))
|
||||
|
||||
@@ -12,18 +12,20 @@
|
||||
[app.main.features :as features]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.render-wasm.api :as wasm.api]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(mf/defc text-edition-outline
|
||||
[{:keys [shape zoom modifiers]}]
|
||||
(if (features/active-feature? @st/state "render-wasm/v1")
|
||||
(let [selrect-transform (mf/deref refs/workspace-selrect)
|
||||
[{:keys [x y width height]} transform] (dsh/get-selrect selrect-transform shape)]
|
||||
(let [{:keys [width height]} (wasm.api/get-text-dimensions (:id shape))
|
||||
selrect-transform (mf/deref refs/workspace-selrect)
|
||||
[selrect transform] (dsh/get-selrect selrect-transform shape)]
|
||||
[:rect.main.viewport-selrect
|
||||
{:x x
|
||||
:y y
|
||||
:width width
|
||||
:height height
|
||||
{:x (:x selrect)
|
||||
:y (:y selrect)
|
||||
:width (max width (:width selrect))
|
||||
:height (max height (:height selrect))
|
||||
:transform transform
|
||||
:style {:stroke "var(--color-accent-tertiary)"
|
||||
:stroke-width (/ 1 zoom)
|
||||
|
||||
@@ -320,10 +320,12 @@
|
||||
|
||||
[{:keys [x y width height]} transform]
|
||||
(if render-wasm?
|
||||
(let [{:keys [height]} (wasm.api/get-text-dimensions shape-id)
|
||||
(let [{:keys [width height]} (wasm.api/get-text-dimensions shape-id)
|
||||
selrect-transform (mf/deref refs/workspace-selrect)
|
||||
[selrect transform] (dsh/get-selrect selrect-transform shape)
|
||||
selrect-height (:height selrect)
|
||||
selrect-width (:width selrect)
|
||||
max-width (max width selrect-width)
|
||||
max-height (max height selrect-height)
|
||||
valign (-> shape :content :vertical-align)
|
||||
y (:y selrect)
|
||||
@@ -331,9 +333,9 @@
|
||||
(case valign
|
||||
"bottom" (- y (- height selrect-height))
|
||||
"center" (- y (/ (- height selrect-height) 2))
|
||||
"top" y)
|
||||
y)
|
||||
y)]
|
||||
[(assoc selrect :y y :width (:width selrect) :height max-height) transform])
|
||||
[(assoc selrect :y y :width max-width :height max-height) transform])
|
||||
|
||||
(let [bounds (gst/shape->rect shape)
|
||||
x (mth/min (dm/get-prop bounds :x)
|
||||
@@ -352,7 +354,7 @@
|
||||
(obj/merge!
|
||||
#js {"--editor-container-width" (dm/str width "px")
|
||||
"--editor-container-height" (dm/str height "px")
|
||||
"--fallback-families" (dm/str (str/join ", " fallback-families))})
|
||||
"--fallback-families" (if (seq fallback-families) (dm/str (str/join ", " fallback-families)) "sourcesanspro")})
|
||||
|
||||
(not render-wasm?)
|
||||
(obj/merge!
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// 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);
|
||||
@@ -87,7 +88,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,9 +211,7 @@
|
||||
overflow-x: auto;
|
||||
overflow-y: overlay;
|
||||
scrollbar-gutter: stable;
|
||||
|
||||
.element-list {
|
||||
width: var(--left-sidebar-width);
|
||||
display: grid;
|
||||
}
|
||||
}
|
||||
.element-list {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
[:> deprecated-input/numeric-input*
|
||||
{:placeholder (cond
|
||||
(not all-equal?)
|
||||
"Mixed"
|
||||
(tr "settings.multiple")
|
||||
(= :multiple (:r1 values))
|
||||
(tr "settings.multiple")
|
||||
:else
|
||||
|
||||
@@ -264,12 +264,16 @@
|
||||
(mf/deps font on-change)
|
||||
(fn [new-variant-id]
|
||||
(let [variant (d/seek #(= new-variant-id (:id %)) (:variants font))]
|
||||
(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)))))
|
||||
(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)}))
|
||||
;; NOTE: the select component we are using does not fire on-blur event
|
||||
;; so we need to call on-blur manually
|
||||
(when (some? on-blur)
|
||||
(on-blur)))))
|
||||
|
||||
on-font-select
|
||||
(mf/use-fn
|
||||
@@ -303,7 +307,7 @@
|
||||
:title (tr "inspect.attributes.typography.font-family")
|
||||
:on-click #(reset! open-selector? true)}
|
||||
(cond
|
||||
(= :multiple font-id)
|
||||
(or (= :multiple font-id) (= "mixed" font-id))
|
||||
"--"
|
||||
|
||||
(some? font)
|
||||
@@ -341,12 +345,13 @@
|
||||
{:value (:id variant)
|
||||
:key (pr-str variant)
|
||||
:label (:name variant)})))
|
||||
variant-options (if (= font-size :multiple)
|
||||
variant-options (if (= font-variant-id :multiple)
|
||||
(conj basic-variant-options
|
||||
{:value :multiple
|
||||
{:value ""
|
||||
:key :multiple-variants
|
||||
:label "--"})
|
||||
basic-variant-options)]
|
||||
|
||||
;; TODO Add disabled mode
|
||||
[:& select
|
||||
{:class (stl/css :font-variant-select)
|
||||
@@ -378,6 +383,7 @@
|
||||
:step 0.1
|
||||
:default-value "1.2"
|
||||
:class (stl/css :line-height-input)
|
||||
:aria-label (tr "inspect.attributes.typography.line-height")
|
||||
:value (attr->string line-height)
|
||||
:placeholder (if (= :multiple line-height) (tr "settings.multiple") "--")
|
||||
:nillable (= :multiple line-height)
|
||||
@@ -396,6 +402,7 @@
|
||||
:step 0.1
|
||||
:default-value "0"
|
||||
:class (stl/css :letter-spacing-input)
|
||||
:aria-label (tr "inspect.attributes.typography.letter-spacing")
|
||||
:value (attr->string letter-spacing)
|
||||
:placeholder (if (= :multiple letter-spacing) (tr "settings.multiple") "--")
|
||||
:on-change #(handle-change % :letter-spacing)
|
||||
|
||||
@@ -19,5 +19,5 @@
|
||||
}
|
||||
|
||||
.threads {
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
[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]))
|
||||
|
||||
@@ -275,3 +276,26 @@
|
||||
: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"}])))
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
[app.main.data.workspace.grid-layout.editor :as dwge]
|
||||
[app.main.data.workspace.modifiers :as dwm]
|
||||
[app.main.data.workspace.shape-layout :as dwsl]
|
||||
[app.main.data.workspace.transforms :as dwt]
|
||||
[app.main.features :as features]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
@@ -257,7 +258,8 @@
|
||||
(let [modifiers (calculate-drag-modifiers position)
|
||||
modif-tree (dwm/create-modif-tree [(:id shape)] modifiers)]
|
||||
(when on-clear-modifiers (on-clear-modifiers modifiers))
|
||||
(st/emit! (dwm/apply-wasm-modifiers modif-tree)))
|
||||
(st/emit! (dwm/apply-wasm-modifiers modif-tree)
|
||||
(dwt/finish-transform)))
|
||||
(st/emit! (dwm/apply-modifiers)))))
|
||||
|
||||
{:keys [handle-pointer-down handle-lost-pointer-capture handle-pointer-move]}
|
||||
@@ -506,7 +508,8 @@
|
||||
(let [modifiers (calculate-modifiers position)
|
||||
modif-tree (dwm/create-modif-tree [(:id shape)] modifiers)]
|
||||
(when on-clear-modifiers (on-clear-modifiers))
|
||||
(st/emit! (dwm/apply-wasm-modifiers modif-tree)))
|
||||
(st/emit! (dwm/apply-wasm-modifiers modif-tree)
|
||||
(dwt/finish-transform)))
|
||||
(st/emit! (dwm/apply-modifiers)))
|
||||
(reset! start-size-before nil)
|
||||
(reset! start-size-after nil)))]
|
||||
|
||||
@@ -12,10 +12,12 @@
|
||||
[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]
|
||||
@@ -54,15 +56,11 @@
|
||||
[app.util.debug :as dbg]
|
||||
[app.util.text-editor :as ted]
|
||||
[beicon.v2.core :as rx]
|
||||
[okulary.core :as l]
|
||||
[promesa.core :as p]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
;; --- Viewport
|
||||
|
||||
(def workspace-wasm-modifiers
|
||||
(l/derived :workspace-wasm-modifiers st/state))
|
||||
|
||||
(defn apply-modifiers-to-selected
|
||||
[selected objects modifiers]
|
||||
(->> modifiers
|
||||
@@ -98,7 +96,7 @@
|
||||
;; DEREFS
|
||||
drawing (mf/deref refs/workspace-drawing)
|
||||
focus (mf/deref refs/workspace-focus-selected)
|
||||
wasm-modifiers (mf/deref workspace-wasm-modifiers)
|
||||
wasm-modifiers (mf/deref refs/workspace-wasm-modifiers)
|
||||
|
||||
workspace-editor-state (mf/deref refs/workspace-editor-state)
|
||||
|
||||
@@ -261,6 +259,16 @@
|
||||
|
||||
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?
|
||||
@@ -639,6 +647,12 @@
|
||||
: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?
|
||||
@@ -667,6 +681,11 @@
|
||||
{: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
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
[app.plugins.ruler-guides :as rg]
|
||||
[app.plugins.text :as text]
|
||||
[app.plugins.utils :as u]
|
||||
[app.util.http :as http]
|
||||
[app.util.object :as obj]
|
||||
[beicon.v2.core :as rx]
|
||||
[cuerdas.core :as str]))
|
||||
@@ -1196,7 +1197,12 @@
|
||||
(js/Promise.
|
||||
(fn [resolve reject]
|
||||
(->> (rp/cmd! :export payload)
|
||||
(rx/mapcat #(rp/cmd! :export {:cmd :get-resource :wait true :id (:id %) :blob? true}))
|
||||
(rx/mapcat (fn [{:keys [uri]}]
|
||||
(->> (http/send! {:method :get
|
||||
:uri uri
|
||||
:response-type :blob
|
||||
:omit-default-headers true})
|
||||
(rx/map :body))))
|
||||
(rx/mapcat #(.arrayBuffer %))
|
||||
(rx/map #(js/Uint8Array. %))
|
||||
(rx/subs! resolve reject))))))))
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
[app.main.render :as render]
|
||||
[app.main.repo :as repo]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.context :as ctx]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.globals :as glob]
|
||||
[beicon.v2.core :as rx]
|
||||
@@ -76,11 +77,12 @@
|
||||
(mth/ceil height) "px")}))))
|
||||
|
||||
(when objects
|
||||
[:& render/object-svg
|
||||
{:objects objects
|
||||
:object-id object-id
|
||||
:embed embed
|
||||
:skip-children skip-children}])))
|
||||
[:& (mf/provider ctx/is-render?) {:value true}
|
||||
[:& render/object-svg
|
||||
{:objects objects
|
||||
:object-id object-id
|
||||
:embed embed
|
||||
:skip-children skip-children}]])))
|
||||
|
||||
(mf/defc objects-svg
|
||||
{::mf/wrap-props false}
|
||||
@@ -88,12 +90,13 @@
|
||||
(when-let [objects (mf/deref ref:objects)]
|
||||
(for [object-id object-ids]
|
||||
(let [objects (render/adapt-objects-for-shape objects object-id)]
|
||||
[:& render/object-svg
|
||||
{:objects objects
|
||||
:key (str object-id)
|
||||
:object-id object-id
|
||||
:embed embed
|
||||
:skip-children skip-children}]))))
|
||||
[:& (mf/provider ctx/is-render?) {:value true}
|
||||
[:& render/object-svg
|
||||
{:objects objects
|
||||
:key (str object-id)
|
||||
:object-id object-id
|
||||
:embed embed
|
||||
:skip-children skip-children}]]))))
|
||||
|
||||
(defn- fetch-objects-bundle
|
||||
[& {:keys [file-id page-id share-id object-id] :as options}]
|
||||
|
||||
@@ -18,12 +18,13 @@
|
||||
[app.common.types.path :as path]
|
||||
[app.common.types.path.impl :as path.impl]
|
||||
[app.common.types.shape.layout :as ctl]
|
||||
[app.common.types.text :as txt]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.main.fonts :as fonts]
|
||||
[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 +35,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-fills :as svg-fills]
|
||||
[app.render-wasm.svg-filters :as svg-filters]
|
||||
;; FIXME: rename; confunsing name
|
||||
[app.render-wasm.wasm :as wasm]
|
||||
[app.util.debug :as dbg]
|
||||
@@ -42,6 +43,7 @@
|
||||
[app.util.globals :as ug]
|
||||
[app.util.text.content :as tc]
|
||||
[beicon.v2.core :as rx]
|
||||
[cuerdas.core :as str]
|
||||
[promesa.core :as p]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
@@ -60,6 +62,9 @@
|
||||
|
||||
(def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024))
|
||||
|
||||
(def ^:const DEBOUNCE_DELAY_MS 100)
|
||||
(def ^:const THROTTLE_DELAY_MS 10)
|
||||
|
||||
(def dpr
|
||||
(if use-dpr? (if (exists? js/window) js/window.devicePixelRatio 1.0) 1.0))
|
||||
|
||||
@@ -700,7 +705,7 @@
|
||||
(set-grid-layout-columns (get shape :layout-grid-columns))
|
||||
(set-grid-layout-cells (get shape :layout-grid-cells)))
|
||||
|
||||
(defn set-layout-child
|
||||
(defn set-layout-data
|
||||
[shape]
|
||||
(let [margins (get shape :layout-item-margin)
|
||||
margin-top (get margins :m1 0)
|
||||
@@ -723,7 +728,7 @@
|
||||
is-absolute (boolean (get shape :layout-item-absolute))
|
||||
z-index (get shape :layout-item-z-index)]
|
||||
(h/call wasm/internal-module
|
||||
"_set_layout_child_data"
|
||||
"_set_layout_data"
|
||||
margin-top
|
||||
margin-right
|
||||
margin-bottom
|
||||
@@ -743,6 +748,11 @@
|
||||
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"))
|
||||
@@ -750,10 +760,10 @@
|
||||
(defn- set-shape-layout
|
||||
[shape objects]
|
||||
(clear-layout)
|
||||
|
||||
(when (or (ctl/any-layout? shape)
|
||||
(ctl/any-layout-immediate-child? objects shape))
|
||||
(set-layout-child shape))
|
||||
(ctl/any-layout-immediate-child? objects shape)
|
||||
(has-any-layout-prop? shape))
|
||||
(set-layout-data shape))
|
||||
|
||||
(when (ctl/flex-layout? shape)
|
||||
(set-flex-layout shape))
|
||||
@@ -828,7 +838,7 @@
|
||||
|
||||
(set-shape-vertical-align (get content :vertical-align))
|
||||
|
||||
(let [fonts (fonts/get-content-fonts content)
|
||||
(let [fonts (f/get-content-fonts content)
|
||||
fallback-fonts (fonts-from-text-content content true)
|
||||
all-fonts (concat fonts fallback-fonts)
|
||||
result (f/store-fonts shape-id all-fonts)]
|
||||
@@ -872,27 +882,43 @@
|
||||
|
||||
(def render-finish
|
||||
(letfn [(do-render [ts]
|
||||
(perf/begin-measure "render-finish")
|
||||
(h/call wasm/internal-module "_set_view_end")
|
||||
(render ts))]
|
||||
(fns/debounce do-render 100)))
|
||||
(render ts)
|
||||
(perf/end-measure "render-finish"))]
|
||||
(fns/debounce do-render DEBOUNCE_DELAY_MS)))
|
||||
|
||||
(def render-pan
|
||||
(fns/throttle render 10))
|
||||
(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)))
|
||||
|
||||
(defn set-view-box
|
||||
[prev-zoom zoom vbox]
|
||||
(h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y 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)))
|
||||
|
||||
(if (mth/close? prev-zoom zoom)
|
||||
(do (render-pan)
|
||||
(render-finish))
|
||||
(do (h/call wasm/internal-module "_render_from_cache" 0)
|
||||
(render-finish))))
|
||||
(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")))))
|
||||
|
||||
(defn set-object
|
||||
[objects shape]
|
||||
(perf/begin-measure "set-object")
|
||||
(let [id (dm/get-prop shape :id)
|
||||
(let [shape (svg-filters/apply-svg-derived shape)
|
||||
id (dm/get-prop shape :id)
|
||||
type (dm/get-prop shape :type)
|
||||
|
||||
parent-id (get shape :parent-id)
|
||||
@@ -906,14 +932,7 @@
|
||||
rotation (get shape :rotation)
|
||||
transform (get shape :transform)
|
||||
|
||||
;; 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)
|
||||
|
||||
fills (get shape :fills)
|
||||
strokes (if (= type :group)
|
||||
[] (get shape :strokes))
|
||||
children (get shape :shapes)
|
||||
@@ -945,8 +964,8 @@
|
||||
(set-shape-children children)
|
||||
(set-shape-corners corners)
|
||||
(set-shape-blur blur)
|
||||
(when (and (= type :group) masked)
|
||||
(set-masked masked))
|
||||
(when (= type :group)
|
||||
(set-masked (boolean masked)))
|
||||
(when (= type :bool)
|
||||
(set-shape-bool-type bool-type))
|
||||
(when (and (some? content)
|
||||
@@ -957,12 +976,11 @@
|
||||
(set-shape-svg-attrs svg-attrs))
|
||||
(when (and (some? content) (= type :svg-raw))
|
||||
(set-shape-svg-raw-content (get-static-markup shape)))
|
||||
(when (some? shadows) (set-shape-shadows 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
|
||||
@@ -986,10 +1004,7 @@
|
||||
(run!
|
||||
(fn [id]
|
||||
(f/update-text-layout id)
|
||||
(mw/emit! {:cmd :index/update-text-rect
|
||||
:page-id (:current-page-id @st/state)
|
||||
:shape-id id
|
||||
:dimensions (get-text-dimensions id)})))))
|
||||
(update-text-rect! id)))))
|
||||
|
||||
(defn process-pending
|
||||
([shapes thumbnails full on-complete]
|
||||
@@ -1043,6 +1058,7 @@
|
||||
(process-pending shapes thumbnails full noop-fn
|
||||
(fn []
|
||||
(when render-callback (render-callback))
|
||||
(render-finish)
|
||||
(ug/dispatch! (ug/event "penpot:wasm:set-objects")))))))
|
||||
|
||||
(defn clear-focus-mode
|
||||
@@ -1229,6 +1245,8 @@
|
||||
(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")
|
||||
@@ -1246,9 +1264,29 @@
|
||||
|
||||
(defn clear-canvas
|
||||
[]
|
||||
;; TODO: perform corresponding cleaning
|
||||
(set! wasm/context-initialized? false)
|
||||
(h/call wasm/internal-module "_clean_up"))
|
||||
(try
|
||||
;; TODO: perform corresponding cleaning
|
||||
(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
|
||||
(.error js/console error))))
|
||||
|
||||
(defn show-grid
|
||||
[id]
|
||||
@@ -1291,12 +1329,7 @@
|
||||
(mem/free)
|
||||
content))
|
||||
|
||||
(defn- calculate-bool*
|
||||
[bool-type]
|
||||
(-> (h/call wasm/internal-module "_calculate_bool" (sr/translate-bool-type bool-type))
|
||||
(mem/->offset-32)))
|
||||
|
||||
(defn calculate-bool
|
||||
(defn calculate-bool*
|
||||
[bool-type ids]
|
||||
(let [size (mem/get-alloc-size ids UUID-U8-SIZE)
|
||||
heap (mem/get-heap-u32)
|
||||
@@ -1307,7 +1340,10 @@
|
||||
offset
|
||||
(rseq ids))
|
||||
|
||||
(let [offset (calculate-bool* bool-type)
|
||||
(let [offset
|
||||
(-> (h/call wasm/internal-module "_calculate_bool" (sr/translate-bool-type bool-type))
|
||||
(mem/->offset-32))
|
||||
|
||||
length (aget heap offset)
|
||||
data (mem/slice heap
|
||||
(+ offset 1)
|
||||
@@ -1316,6 +1352,86 @@
|
||||
(mem/free)
|
||||
content)))
|
||||
|
||||
(defn calculate-bool
|
||||
[shape objects]
|
||||
|
||||
;; We need to be able to calculate the boolean data but we cannot
|
||||
;; depend on the serialization flow.
|
||||
;; start_temp_object / end_temp_object create a new shapes_pool
|
||||
;; temporary and then we serialize the objects needed to calculate the
|
||||
;; boolean object.
|
||||
;; After the content is returned we discard that temporary context
|
||||
(h/call wasm/internal-module "_start_temp_objects")
|
||||
|
||||
(let [bool-type (get shape :bool-type)
|
||||
ids (get shape :shapes)
|
||||
all-children
|
||||
(->> ids
|
||||
(mapcat #(cfh/get-children-with-self objects %)))]
|
||||
(h/call wasm/internal-module "_init_shapes_pool" (count all-children))
|
||||
(run! (partial set-object objects) all-children)
|
||||
|
||||
(let [content (-> (calculate-bool* bool-type ids)
|
||||
(path.impl/path-data))]
|
||||
(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)]
|
||||
|
||||
(d/patch-object
|
||||
txt/default-text-attrs
|
||||
(d/without-nils
|
||||
{: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")
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.logging :as log]
|
||||
[app.common.types.text :as txt]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.main.fonts :as fonts]
|
||||
@@ -49,10 +50,13 @@
|
||||
:builtin))
|
||||
|
||||
(defn- font-db-data
|
||||
[font-id font-variant-id]
|
||||
[font-id font-variant-id font-weight-fallback font-style-fallback]
|
||||
(let [font (fonts/get-font-data font-id)
|
||||
closest-variant (fonts/find-closest-variant font font-weight-fallback font-style-fallback)
|
||||
variant (fonts/get-variant font font-variant-id)]
|
||||
variant))
|
||||
(if (or (nil? closest-variant) (= closest-variant variant))
|
||||
variant
|
||||
closest-variant)))
|
||||
|
||||
(defn- font-id->uuid [font-id]
|
||||
(case (font-backend font-id)
|
||||
@@ -63,22 +67,22 @@
|
||||
:builtin
|
||||
uuid/zero))
|
||||
|
||||
(defn ^:private font-id->asset-id [font-id font-variant-id]
|
||||
|
||||
(defn ^:private font-id->asset-id [font-id font-variant-id font-weight font-style]
|
||||
(case (font-backend font-id)
|
||||
:google
|
||||
font-id
|
||||
:custom
|
||||
(let [font-uuid (custom-font-id->uuid font-id)
|
||||
matching-font (d/seek (fn [[_ font]]
|
||||
(let [variant-id (or (:font-variant-id font) (dm/str (:font-style font) "-" (:font-weight font)))]
|
||||
(and (= (:font-id font) font-uuid)
|
||||
(or (nil? font-variant-id)
|
||||
(= variant-id font-variant-id)))))
|
||||
(seq @fonts))]
|
||||
matching-font (some (fn [[_ font]]
|
||||
(and (= (:font-id font) font-uuid)
|
||||
(= (str (:font-weight font)) (str font-weight))
|
||||
font))
|
||||
(seq @fonts))]
|
||||
(when matching-font
|
||||
(:ttf-file-id (second matching-font))))
|
||||
(:ttf-file-id matching-font)))
|
||||
:builtin
|
||||
(let [variant (font-db-data font-id font-variant-id)]
|
||||
(let [variant (font-db-data font-id font-variant-id font-weight font-style)]
|
||||
(:ttf-url variant))))
|
||||
|
||||
(defn update-text-layout
|
||||
@@ -100,6 +104,7 @@
|
||||
ptr (h/call wasm/internal-module "_alloc_bytes" size)
|
||||
heap (gobj/get ^js wasm/internal-module "HEAPU8")
|
||||
mem (js/Uint8Array. (.-buffer heap) ptr size)]
|
||||
|
||||
(.set mem (js/Uint8Array. font-array-buffer))
|
||||
(h/call wasm/internal-module "_store_font"
|
||||
(aget shape-id-buffer 0)
|
||||
@@ -134,17 +139,17 @@
|
||||
(rx/empty))))})
|
||||
|
||||
(defn- google-font-ttf-url
|
||||
[font-id font-variant-id]
|
||||
(let [variant (font-db-data font-id font-variant-id)]
|
||||
[font-id font-variant-id font-weight font-style]
|
||||
(let [variant (font-db-data font-id font-variant-id font-weight font-style)]
|
||||
(if-let [ttf-url (:ttf-url variant)]
|
||||
(str/replace ttf-url "https://fonts.gstatic.com/s/" (u/join cf/public-uri "/internal/gfonts/font/"))
|
||||
nil)))
|
||||
|
||||
(defn- font-id->ttf-url
|
||||
[font-id asset-id font-variant-id]
|
||||
[font-id asset-id font-variant-id font-weight font-style]
|
||||
(case (font-backend font-id)
|
||||
:google
|
||||
(google-font-ttf-url font-id font-variant-id)
|
||||
(google-font-ttf-url font-id font-variant-id font-weight font-style)
|
||||
:custom
|
||||
(dm/str (u/join cf/public-uri "assets/by-id/" asset-id))
|
||||
:builtin
|
||||
@@ -153,7 +158,7 @@
|
||||
(defn- store-font-id
|
||||
[shape-id font-data asset-id emoji? fallback?]
|
||||
(when asset-id
|
||||
(let [uri (font-id->ttf-url (:font-id font-data) asset-id (:font-variant-id font-data))
|
||||
(let [uri (font-id->ttf-url (:font-id font-data) asset-id (:font-variant-id font-data) (:weight font-data) (:style-name font-data))
|
||||
id-buffer (uuid/get-u32 (:wasm-id font-data))
|
||||
font-data (assoc font-data :family-id-buffer id-buffer)
|
||||
font-stored? (not= 0 (h/call wasm/internal-module "_is_font_uploaded"
|
||||
@@ -187,6 +192,30 @@
|
||||
(catch :default _e
|
||||
uuid/zero)))
|
||||
|
||||
(defn normalize-span-font
|
||||
[span paragraph]
|
||||
(let [font-id (:font-id span)
|
||||
font-variant-id (:font-variant-id span)
|
||||
font-weight-fallback (or (:font-weight span) (:font-weight paragraph))
|
||||
font-style-fallback (or (:font-style span) (:font-style paragraph))
|
||||
font-data (font-db-data font-id font-variant-id font-weight-fallback font-style-fallback)]
|
||||
(-> span
|
||||
(assoc :font-variant-id (or (:id font-data) (:id font-data) font-variant-id)
|
||||
:font-weight (or (:weight font-data) font-weight-fallback)
|
||||
:font-style (or (:style font-data) font-style-fallback)))))
|
||||
|
||||
(defn normalize-paragraph-font
|
||||
[paragraph]
|
||||
(let [font-id (:font-id paragraph)
|
||||
font-variant-id (:font-variant-id paragraph)
|
||||
font-weight-fallback (:font-weight paragraph)
|
||||
font-style-fallback (:font-style paragraph)
|
||||
font-data (font-db-data font-id font-variant-id font-weight-fallback font-style-fallback)]
|
||||
(-> paragraph
|
||||
(assoc :font-variant-id (or (:id font-data) (:id font-data) font-variant-id)
|
||||
:font-weight (or (:weight font-data) font-weight-fallback)
|
||||
:font-style (or (:style font-data) font-style-fallback)))))
|
||||
|
||||
(defn serialize-font-size
|
||||
[font-size]
|
||||
(cond
|
||||
@@ -244,26 +273,41 @@
|
||||
(string? letter-spacing)
|
||||
(or (d/parse-double letter-spacing) default-letter-spacing)))
|
||||
|
||||
|
||||
(defn normalize-font-variant
|
||||
[font-variant-id]
|
||||
(if (or (nil? font-variant-id) (str/blank? font-variant-id))
|
||||
"regular"
|
||||
font-variant-id))
|
||||
|
||||
(defn store-font
|
||||
[shape-id font]
|
||||
(let [font-id (get font :font-id)
|
||||
font-variant-id (get font :font-variant-id)
|
||||
normalized-variant-id (when font-variant-id
|
||||
(-> font-variant-id
|
||||
(str/lower)
|
||||
(str/replace #"\s+" "")))
|
||||
font-weight-fallback (or (get font :font-weight) 400)
|
||||
font-style-fallback (or (get font :font-style) "normal")
|
||||
emoji? (get font :is-emoji false)
|
||||
fallback? (get font :is-fallback false)
|
||||
font-data (font-db-data font-id normalized-variant-id font-weight-fallback font-style-fallback)
|
||||
wasm-id (font-id->uuid font-id)
|
||||
raw-weight (or (:weight (font-db-data font-id font-variant-id)) 400)
|
||||
raw-weight (or (:weight font-data) font-weight-fallback)
|
||||
weight (serialize-font-weight raw-weight)
|
||||
style (serialize-font-style (cond
|
||||
(str/includes? font-variant-id "italic") "italic"
|
||||
(str/includes? raw-weight "italic") "italic"
|
||||
:else "normal"))
|
||||
asset-id (font-id->asset-id font-id font-variant-id)
|
||||
style (cond
|
||||
(str/includes? (or normalized-variant-id "") "italic") "italic"
|
||||
(str/includes? raw-weight "italic") "italic"
|
||||
:else font-style-fallback)
|
||||
variant-id (or (:id font-data) normalized-variant-id)
|
||||
asset-id (font-id->asset-id font-id variant-id raw-weight style)
|
||||
font-data {:wasm-id wasm-id
|
||||
:font-id font-id
|
||||
:font-variant-id font-variant-id
|
||||
:style style
|
||||
:font-variant-id variant-id
|
||||
:style (serialize-font-style style)
|
||||
:style-name style
|
||||
:weight weight}]
|
||||
|
||||
(store-font-id shape-id font-data asset-id emoji? fallback?)))
|
||||
|
||||
;; FIXME: This is a temporary function to load the fallback fonts for the editor.
|
||||
@@ -273,6 +317,29 @@
|
||||
(doseq [font fonts]
|
||||
(fonts/ensure-loaded! (:font-id font) (:font-variant-id font))))
|
||||
|
||||
|
||||
(defn get-content-fonts
|
||||
"Extends from app.main.fonts/get-content-fonts. Extracts the fonts used by the content of a text shape, resolving the correct font variant info."
|
||||
[content]
|
||||
(let [paragraph-set (first (get content :children))
|
||||
paragraphs (get paragraph-set :children)]
|
||||
(->> paragraphs
|
||||
(mapcat #(get % :children))
|
||||
(filter txt/is-text-node?)
|
||||
(reduce
|
||||
(fn [result {:keys [font-id font-variant-id font-weight font-style] :as node}]
|
||||
(let [resolved-font-id (or font-id (:font-id txt/default-typography))
|
||||
resolved-variant-id (or font-variant-id (:font-variant-id txt/default-typography))
|
||||
font-weight-fallback (or font-weight (:font-weight txt/default-typography) 400)
|
||||
font-style-fallback (or font-style (:font-style txt/default-typography) "normal")
|
||||
font-data (font-db-data resolved-font-id resolved-variant-id font-weight-fallback font-style-fallback)
|
||||
font-ref {:font-id resolved-font-id
|
||||
:font-variant-id (or (:id font-data) (:name font-data) resolved-variant-id)
|
||||
:font-weight (or (:weight font-data) font-weight-fallback)
|
||||
:font-style (or (:style font-data) font-style-fallback)}]
|
||||
(conj result font-ref)))
|
||||
#{}))))
|
||||
|
||||
(defn store-fonts
|
||||
[shape-id fonts]
|
||||
(keep (fn [font] (store-font shape-id font)) fonts))
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
font-size (f/serialize-font-size font-size)
|
||||
|
||||
line-height (f/serialize-line-height (get span :line-height) paragraph-line-height)
|
||||
letter-spacing (f/serialize-letter-spacing (get paragraph :letter-spacing))
|
||||
letter-spacing (f/serialize-letter-spacing (get span :letter-spacing))
|
||||
|
||||
font-weight (get span :font-weight paragraph-font-weight)
|
||||
font-weight (f/serialize-font-weight font-weight)
|
||||
@@ -142,7 +142,9 @@
|
||||
;; buffer has the following format:
|
||||
;; [<num-spans> <paragraph_attributes> <spans_attributes> <text>]
|
||||
[spans paragraph text]
|
||||
(let [num-spans (count spans)
|
||||
(let [normalized-paragraph (f/normalize-paragraph-font paragraph)
|
||||
normalized-spans (map #(f/normalize-span-font % normalized-paragraph) spans)
|
||||
num-spans (count normalized-spans)
|
||||
fills-size (* types.fills.impl/FILL-U8-SIZE MAX-TEXT-FILLS)
|
||||
metadata-size (+ PARAGRAPH-ATTR-U8-SIZE
|
||||
(* num-spans (+ SPAN-ATTR-U8-SIZE fills-size)))
|
||||
@@ -157,8 +159,8 @@
|
||||
|
||||
(-> offset
|
||||
(mem/write-u32 dview num-spans)
|
||||
(write-paragraph dview paragraph)
|
||||
(write-spans dview spans paragraph)
|
||||
(write-paragraph dview normalized-paragraph)
|
||||
(write-spans dview normalized-spans normalized-paragraph)
|
||||
(mem/write-buffer heapu8 text-buffer))
|
||||
|
||||
(h/call wasm/internal-module "_set_shape_text_content")))
|
||||
|
||||
@@ -45,4 +45,29 @@
|
||||
: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-fills :as svg-fills]
|
||||
[app.render-wasm.svg-filters :as svg-filters]
|
||||
[app.render-wasm.wasm :as wasm]
|
||||
[beicon.v2.core :as rx]
|
||||
[cljs.core :as c]
|
||||
@@ -130,7 +130,11 @@
|
||||
(defn- set-wasm-attr!
|
||||
[shape k]
|
||||
(when wasm/context-initialized?
|
||||
(let [v (get shape k)
|
||||
(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)
|
||||
id (get shape :id)]
|
||||
(case k
|
||||
:parent-id
|
||||
@@ -163,8 +167,7 @@
|
||||
(api/set-shape-transform v)
|
||||
|
||||
:fills
|
||||
(let [fills (svg-fills/resolve-shape-fills shape)]
|
||||
(into [] (api/set-shape-fills id fills false)))
|
||||
(api/set-shape-fills id v false)
|
||||
|
||||
:strokes
|
||||
(into [] (api/set-shape-strokes id v false))
|
||||
@@ -222,12 +225,16 @@
|
||||
v])
|
||||
|
||||
:svg-attrs
|
||||
(when (cfh/path-shape? shape)
|
||||
(api/set-shape-svg-attrs v))
|
||||
(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)))
|
||||
|
||||
:masked-group
|
||||
(when (cfh/mask-shape? shape)
|
||||
(api/set-masked (:masked-group shape)))
|
||||
(when (cfh/group-shape? shape)
|
||||
(api/set-masked (boolean (:masked-group shape))))
|
||||
|
||||
:content
|
||||
(cond
|
||||
@@ -262,7 +269,7 @@
|
||||
:layout-item-min-w
|
||||
:layout-item-absolute
|
||||
:layout-item-z-index)
|
||||
(api/set-layout-child shape)
|
||||
(api/set-layout-data shape)
|
||||
|
||||
:layout-grid-rows
|
||||
(api/set-grid-layout-rows v)
|
||||
@@ -291,7 +298,8 @@
|
||||
(api/set-grid-layout-data shape)
|
||||
|
||||
(ctl/flex-layout? shape)
|
||||
(api/set-flex-layout shape)))
|
||||
(api/set-flex-layout shape))
|
||||
(api/set-layout-data shape))
|
||||
|
||||
;; Property not in WASM
|
||||
nil))))
|
||||
@@ -322,7 +330,7 @@
|
||||
(rx/subs! #(api/request-render "set-wasm-attrs"))))
|
||||
|
||||
;; `conj` empty set initialization
|
||||
(def conj* (fnil conj #{}))
|
||||
(def conj* (fnil conj (d/ordered-set)))
|
||||
|
||||
(defn- impl-assoc
|
||||
[self k v]
|
||||
|
||||
@@ -74,6 +74,30 @@
|
||||
: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")
|
||||
@@ -81,9 +105,16 @@
|
||||
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)]
|
||||
(gpt/point (/ (- (dm/get-prop pt :x) origin-x) width)
|
||||
(/ (- (dm/get-prop pt :y) origin-y) height)))
|
||||
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))
|
||||
pt))
|
||||
|
||||
(defn- normalize-attrs
|
||||
@@ -257,18 +288,25 @@
|
||||
(parse-gradient-stop node))))
|
||||
vec)]
|
||||
(when (seq stops)
|
||||
(let [[center radius-point]
|
||||
(let [[center point-x point-y]
|
||||
(let [points (apply-gradient-transform [(gpt/point cx cy)
|
||||
(gpt/point (+ cx r) cy)]
|
||||
(gpt/point (+ cx r) cy)
|
||||
(gpt/point cx (+ cy r))]
|
||||
transform)]
|
||||
(map #(normalize-point % units shape) points))
|
||||
radius (gpt/distance center radius-point)]
|
||||
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))]
|
||||
{: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 radius
|
||||
:width width
|
||||
:stops stops}))))
|
||||
|
||||
(defn- svg-gradient->fill
|
||||
|
||||
98
frontend/src/app/render_wasm/svg_filters.cljs
Normal file
98
frontend/src/app/render_wasm/svg_filters.cljs
Normal file
@@ -0,0 +1,98 @@
|
||||
;; 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,6 +9,8 @@
|
||||
|
||||
(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
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
{:label "Føroyskt mál (community)" :value "fo"}
|
||||
{:label "Korean (community)" :value "ko"}
|
||||
{:label "עִבְרִית (community)" :value "he"}
|
||||
{:label "आधुनिक मानक हिन्दी (community)" :value "hi"}
|
||||
{:label "عربي/عربى (community)" :value "ar"}
|
||||
{:label "فارسی (community)" :value "fa"}
|
||||
{:label "日本語 (Community)" :value "ja_jp"}
|
||||
|
||||
@@ -48,6 +48,9 @@
|
||||
"This function strips units from attr values and un-scapes font-family"
|
||||
[k v]
|
||||
(cond
|
||||
(= v "mixed")
|
||||
:multiple
|
||||
|
||||
(and (or (= k :font-size)
|
||||
(= k :letter-spacing))
|
||||
(= (str/slice v -2) "px"))
|
||||
@@ -184,19 +187,23 @@
|
||||
style-value (normalize-style-value style-name v)]
|
||||
(assoc acc style-name style-value)))) {} style-defaults)))
|
||||
|
||||
(def mixed-values #{:mixed :multiple "mixed" "multiple"})
|
||||
|
||||
(defn get-styles-from-style-declaration
|
||||
"Returns a ClojureScript object compatible with text nodes"
|
||||
[style-declaration]
|
||||
[style-declaration & {:keys [removed-mixed] :or {removed-mixed false}}]
|
||||
(reduce
|
||||
(fn [acc k]
|
||||
(if (contains? mapping k)
|
||||
(let [style-name (get-style-name-as-css-variable k)
|
||||
[_ style-decode] (get mapping k)
|
||||
style-value (.getPropertyValue style-declaration style-name)]
|
||||
(assoc acc k (style-decode style-value)))
|
||||
(when (or (not removed-mixed) (not (contains? mixed-values style-value)))
|
||||
(assoc acc k (style-decode style-value))))
|
||||
(let [style-name (get-style-name k)
|
||||
style-value (normalize-attr-value k (.getPropertyValue style-declaration style-name))]
|
||||
(assoc acc k style-value)))) {} txt/text-style-attrs))
|
||||
(when (or (not removed-mixed) (not (contains? mixed-values style-value)))
|
||||
(assoc acc k style-value))))) {} txt/text-style-attrs))
|
||||
|
||||
(defn get-styles-from-event
|
||||
"Returns a ClojureScript object compatible with text nodes"
|
||||
|
||||
@@ -257,7 +257,7 @@
|
||||
(filter (if clip-children?
|
||||
(comp overlaps-parent? :clip-parents)
|
||||
(constantly true)))
|
||||
(map :id))
|
||||
(keep :id))
|
||||
result)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@@ -42,6 +42,37 @@
|
||||
(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})]
|
||||
|
||||
49
frontend/test/frontend_tests/svg_filters_test.cljs
Normal file
49
frontend/test/frontend_tests/svg_filters_test.cljs
Normal file
@@ -0,0 +1,49 @@
|
||||
;; 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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import LayoutType from "./layout/LayoutType.js";
|
||||
* @typedef {Object} TextEditorOptions
|
||||
* @property {CSSStyleDeclaration|Object.<string,*>} [styleDefaults]
|
||||
* @property {SelectionControllerDebug} [debug]
|
||||
* @property {boolean} [shouldUpdatePositionOnScroll=false]
|
||||
* @property {boolean} [allowHTMLPaste=false]
|
||||
*/
|
||||
|
||||
@@ -92,6 +93,21 @@ export class TextEditor extends EventTarget {
|
||||
*/
|
||||
#canvas = null;
|
||||
|
||||
/**
|
||||
* Text editor options.
|
||||
*
|
||||
* @type {TextEditorOptions}
|
||||
*/
|
||||
#options = {};
|
||||
|
||||
/**
|
||||
* A boolean indicating that this instance was
|
||||
* disposed or not.
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
#isDisposed = false;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
@@ -101,9 +117,9 @@ export class TextEditor extends EventTarget {
|
||||
*/
|
||||
constructor(element, canvas, options) {
|
||||
super();
|
||||
if (!(element instanceof HTMLElement))
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new TypeError("Invalid text editor element");
|
||||
|
||||
}
|
||||
this.#element = element;
|
||||
this.#canvas = canvas;
|
||||
this.#events = {
|
||||
@@ -119,6 +135,7 @@ export class TextEditor extends EventTarget {
|
||||
keydown: this.#onKeyDown,
|
||||
};
|
||||
this.#styleDefaults = options?.styleDefaults;
|
||||
this.#options = options;
|
||||
this.#setup(options);
|
||||
}
|
||||
|
||||
@@ -150,14 +167,18 @@ export class TextEditor extends EventTarget {
|
||||
|
||||
/**
|
||||
* Setups the root element.
|
||||
*
|
||||
* @param {TextEditorOptions} options
|
||||
*/
|
||||
#setupRoot() {
|
||||
#setupRoot(options) {
|
||||
this.#root = createEmptyRoot(this.#styleDefaults);
|
||||
this.#element.appendChild(this.#root);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setups event listeners.
|
||||
*
|
||||
* @param {TextEditorOptions} options
|
||||
*/
|
||||
#setupListeners(options) {
|
||||
this.#changeController.addEventListener("change", this.#onChange);
|
||||
@@ -174,18 +195,61 @@ export class TextEditor extends EventTarget {
|
||||
}
|
||||
|
||||
/**
|
||||
* Setups the elements, the properties and the
|
||||
* initial content.
|
||||
* Disposes everything.
|
||||
*/
|
||||
#setup(options) {
|
||||
this.#setupElementProperties(options);
|
||||
this.#setupRoot(options);
|
||||
dispose() {
|
||||
if (this.#isDisposed) {
|
||||
return this;
|
||||
}
|
||||
this.#isDisposed = true;
|
||||
|
||||
// Dispose change controller.
|
||||
this.#changeController.removeEventListener("change", this.#onChange);
|
||||
this.#changeController.dispose();
|
||||
this.#changeController = null;
|
||||
|
||||
// Disposes selection controller.
|
||||
this.#selectionController.removeEventListener(
|
||||
"stylechange",
|
||||
this.#onStyleChange,
|
||||
);
|
||||
this.#selectionController.dispose();
|
||||
this.#selectionController = null;
|
||||
|
||||
// Disposes the rest of event listeners.
|
||||
removeEventListeners(this.#element, this.#events);
|
||||
if (this.#options.shouldUpdatePositionOnScroll) {
|
||||
window.removeEventListener("scroll", this.#onScroll);
|
||||
}
|
||||
|
||||
// Disposes references to DOM elements.
|
||||
this.#element = null;
|
||||
this.#root = null;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setups controllers.
|
||||
*
|
||||
* @param {TextEditorOptions} options
|
||||
*/
|
||||
#setupControllers(options) {
|
||||
this.#changeController = new ChangeController(this);
|
||||
this.#selectionController = new SelectionController(
|
||||
this,
|
||||
document.getSelection(),
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setups the elements, the properties and the
|
||||
* initial content.
|
||||
*/
|
||||
#setup(options) {
|
||||
this.#setupElementProperties(options);
|
||||
this.#setupRoot(options);
|
||||
this.#setupControllers(options);
|
||||
this.#setupListeners(options);
|
||||
}
|
||||
|
||||
@@ -242,7 +306,9 @@ export class TextEditor extends EventTarget {
|
||||
* @param {CustomEvent} e
|
||||
* @returns {void}
|
||||
*/
|
||||
#onChange = (e) => this.dispatchEvent(new e.constructor(e.type, e));
|
||||
#onChange = (e) => {
|
||||
this.dispatchEvent(new e.constructor(e.type, e));
|
||||
};
|
||||
|
||||
/**
|
||||
* Dispatchs a `stylechange` event.
|
||||
@@ -421,6 +487,15 @@ export class TextEditor extends EventTarget {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates that the TextEditor was disposed.
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isDisposed() {
|
||||
return this.#isDisposed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Root element that contains all the paragraphs.
|
||||
*
|
||||
@@ -478,6 +553,15 @@ export class TextEditor extends EventTarget {
|
||||
return this.#selectionController.currentStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text editor options
|
||||
*
|
||||
* @type {TextEditorOptions}
|
||||
*/
|
||||
get options() {
|
||||
return this.#options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus the element
|
||||
*/
|
||||
@@ -540,7 +624,8 @@ export class TextEditor extends EventTarget {
|
||||
* Applies the current styles to the selection or
|
||||
* the current DOM node at the caret.
|
||||
*
|
||||
* @param {*} styles
|
||||
* @param {Object.<string, *>} styles
|
||||
* @returns {TextEditor}
|
||||
*/
|
||||
applyStylesToSelection(styles) {
|
||||
this.#selectionController.startMutation();
|
||||
@@ -553,6 +638,8 @@ export class TextEditor extends EventTarget {
|
||||
|
||||
/**
|
||||
* Selects all content.
|
||||
*
|
||||
* @returns {TextEditor}
|
||||
*/
|
||||
selectAll() {
|
||||
this.#selectionController.selectAll();
|
||||
@@ -562,30 +649,12 @@ export class TextEditor extends EventTarget {
|
||||
/**
|
||||
* Moves cursor to end.
|
||||
*
|
||||
* @returns
|
||||
* @returns {TextEditor}
|
||||
*/
|
||||
cursorToEnd() {
|
||||
this.#selectionController.cursorToEnd();
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes everything.
|
||||
*/
|
||||
dispose() {
|
||||
this.#changeController.removeEventListener("change", this.#onChange);
|
||||
this.#changeController.dispose();
|
||||
this.#changeController = null;
|
||||
this.#selectionController.removeEventListener(
|
||||
"stylechange",
|
||||
this.#onStyleChange,
|
||||
);
|
||||
this.#selectionController.dispose();
|
||||
this.#selectionController = null;
|
||||
removeEventListeners(this.#element, this.#events);
|
||||
this.#element = null;
|
||||
this.#root = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -595,8 +664,16 @@ 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();
|
||||
@@ -615,47 +692,98 @@ export function createRootFromString(string) {
|
||||
return root;
|
||||
}
|
||||
|
||||
export function isEditor(instance) {
|
||||
/**
|
||||
* Returns true if the passed object is a TextEditor
|
||||
* instance.
|
||||
*
|
||||
* @param {*} instance
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isTextEditor(instance) {
|
||||
return instance instanceof TextEditor;
|
||||
}
|
||||
|
||||
/* Convenience function based API for Text Editor */
|
||||
/**
|
||||
* Returns the root element of a TextEditor
|
||||
* instance.
|
||||
*
|
||||
* @param {TextEditor} instance
|
||||
* @returns {HTMLDivElement}
|
||||
*/
|
||||
export function getRoot(instance) {
|
||||
if (isEditor(instance)) {
|
||||
if (isTextEditor(instance)) {
|
||||
return instance.root;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the root of the text editor.
|
||||
*
|
||||
* @param {TextEditor} instance
|
||||
* @param {HTMLDivElement} root
|
||||
* @returns {TextEditor}
|
||||
*/
|
||||
export function setRoot(instance, root) {
|
||||
if (isEditor(instance)) {
|
||||
if (isTextEditor(instance)) {
|
||||
instance.root = root;
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new TextEditor instance.
|
||||
*
|
||||
* @param {HTMLDivElement} element
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {TextEditorOptions} options
|
||||
* @returns {TextEditor}
|
||||
*/
|
||||
export function create(element, canvas, options) {
|
||||
return new TextEditor(element, canvas, { ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current style of the TextEditor instance.
|
||||
*
|
||||
* @param {TextEditor} instance
|
||||
* @returns {CSSStyleDeclaration|undefined}
|
||||
*/
|
||||
export function getCurrentStyle(instance) {
|
||||
if (isEditor(instance)) {
|
||||
if (isTextEditor(instance)) {
|
||||
return instance.currentStyle;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the specified styles to the TextEditor
|
||||
* passed.
|
||||
*
|
||||
* @param {TextEditor} instance
|
||||
* @param {Object.<string, *>} styles
|
||||
* @returns {TextEditor|null}
|
||||
*/
|
||||
export function applyStylesToSelection(instance, styles) {
|
||||
if (isEditor(instance)) {
|
||||
if (isTextEditor(instance)) {
|
||||
return instance.applyStylesToSelection(styles);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes the current instance resources by nullifying
|
||||
* every property.
|
||||
*
|
||||
* @param {TextEditor} instance
|
||||
* @returns {TextEditor|null}
|
||||
*/
|
||||
export function dispose(instance) {
|
||||
if (isEditor(instance)) {
|
||||
instance.dispose();
|
||||
if (isTextEditor(instance)) {
|
||||
return instance.dispose();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default TextEditor;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
mapContentFragmentFromHTML,
|
||||
mapContentFragmentFromString,
|
||||
} from "../content/dom/Content.js";
|
||||
import { TextEditor } from "../TextEditor.js";
|
||||
|
||||
/**
|
||||
* Returns a DocumentFragment from text/html.
|
||||
@@ -17,7 +18,10 @@ import {
|
||||
* @param {DataTransfer} clipboardData
|
||||
* @returns {DocumentFragment}
|
||||
*/
|
||||
function getFormattedFragmentFromClipboardData(selectionController, clipboardData) {
|
||||
function getFormattedFragmentFromClipboardData(
|
||||
selectionController,
|
||||
clipboardData,
|
||||
) {
|
||||
return mapContentFragmentFromHTML(
|
||||
clipboardData.getData("text/html"),
|
||||
selectionController.currentStyle,
|
||||
@@ -38,19 +42,26 @@ function getPlainFragmentFromClipboardData(selectionController, clipboardData) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a DocumentFragment (or null) if it contains
|
||||
* a compatible clipboardData type.
|
||||
* Returns a document fragment of html data.
|
||||
*
|
||||
* @param {DataTransfer} clipboardData
|
||||
* @returns {DocumentFragment|null}
|
||||
* @returns {DocumentFragment}
|
||||
*/
|
||||
function getFragmentFromClipboardData(selectionController, clipboardData) {
|
||||
function getFormattedOrPlainFragmentFromClipboardData(
|
||||
selectionController,
|
||||
clipboardData,
|
||||
) {
|
||||
if (clipboardData.types.includes("text/html")) {
|
||||
return getFormattedFragmentFromClipboardData(selectionController, clipboardData)
|
||||
return getFormattedFragmentFromClipboardData(
|
||||
selectionController,
|
||||
clipboardData,
|
||||
);
|
||||
} else if (clipboardData.types.includes("text/plain")) {
|
||||
return getPlainFragmentFromClipboardData(selectionController, clipboardData)
|
||||
return getPlainFragmentFromClipboardData(
|
||||
selectionController,
|
||||
clipboardData,
|
||||
);
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,18 +82,40 @@ export function paste(event, editor, selectionController) {
|
||||
|
||||
let fragment = null;
|
||||
if (editor?.options?.allowHTMLPaste) {
|
||||
fragment = getFragmentFromClipboardData(selectionController, event.clipboardData);
|
||||
fragment = getFormattedOrPlainFragmentFromClipboardData(
|
||||
event.clipboardData,
|
||||
);
|
||||
} else {
|
||||
fragment = getPlainFragmentFromClipboardData(selectionController, event.clipboardData);
|
||||
fragment = getPlainFragmentFromClipboardData(
|
||||
selectionController,
|
||||
event.clipboardData,
|
||||
);
|
||||
}
|
||||
|
||||
if (!fragment) {
|
||||
// NOOP
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectionController.isCollapsed) {
|
||||
selectionController.insertPaste(fragment);
|
||||
const hasOnlyOneParagraph = fragment.children.length === 1;
|
||||
const hasOnlyOneTextSpan = fragment.firstElementChild.children.length === 1;
|
||||
const forceTextSpan =
|
||||
fragment.firstElementChild.dataset.textSpan === "force";
|
||||
if (hasOnlyOneParagraph && hasOnlyOneTextSpan && forceTextSpan) {
|
||||
selectionController.insertIntoFocus(fragment.textContent);
|
||||
} else {
|
||||
selectionController.insertPaste(fragment);
|
||||
}
|
||||
} else {
|
||||
selectionController.replaceWithPaste(fragment);
|
||||
const hasOnlyOneParagraph = fragment.children.length === 1;
|
||||
const hasOnlyOneTextSpan = fragment.firstElementChild.children.length === 1;
|
||||
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,22 +28,21 @@ 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,11 +1,17 @@
|
||||
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", () => {
|
||||
@@ -13,7 +19,9 @@ 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)", () => {
|
||||
@@ -26,13 +34,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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -232,12 +232,12 @@ export function mapContentFragmentFromString(string, styleDefaults) {
|
||||
if (line === "") {
|
||||
fragment.appendChild(createEmptyParagraph(styleDefaults));
|
||||
} else {
|
||||
fragment.appendChild(
|
||||
createParagraph(
|
||||
[createTextSpan(new Text(line), styleDefaults)],
|
||||
styleDefaults,
|
||||
),
|
||||
);
|
||||
const textSpan = createTextSpan(new Text(line), styleDefaults);
|
||||
const paragraph = createParagraph([textSpan], styleDefaults);
|
||||
if (lines.length === 1) {
|
||||
paragraph.dataset.textSpan = "force";
|
||||
}
|
||||
fragment.appendChild(paragraph);
|
||||
}
|
||||
}
|
||||
return fragment;
|
||||
|
||||
@@ -112,7 +112,11 @@ 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,5 +1,11 @@
|
||||
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,6 +6,7 @@
|
||||
* Copyright (c) KALEIDOS INC
|
||||
*/
|
||||
|
||||
import StyleDeclaration from "../../controllers/StyleDeclaration.js";
|
||||
import { getFills } from "./Color.js";
|
||||
|
||||
const DEFAULT_FONT_SIZE = "16px";
|
||||
@@ -338,13 +339,13 @@ export function setStylesFromObject(element, allowedStyles, styleObject) {
|
||||
continue;
|
||||
}
|
||||
let styleValue = styleObject[styleName];
|
||||
if (!styleValue) continue;
|
||||
|
||||
if (styleName === "font-family") {
|
||||
styleValue = sanitizeFontFamily(styleValue);
|
||||
}
|
||||
|
||||
if (styleValue) {
|
||||
setStyle(element, styleName, styleValue, styleUnit);
|
||||
}
|
||||
setStyle(element, styleName, styleValue, styleUnit);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
@@ -386,7 +387,10 @@ export function setStylesFromDeclaration(
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
export function setStyles(element, allowedStyles, styleObjectOrDeclaration) {
|
||||
if (styleObjectOrDeclaration instanceof CSSStyleDeclaration) {
|
||||
if (
|
||||
styleObjectOrDeclaration instanceof CSSStyleDeclaration ||
|
||||
styleObjectOrDeclaration instanceof StyleDeclaration
|
||||
) {
|
||||
return setStylesFromDeclaration(
|
||||
element,
|
||||
allowedStyles,
|
||||
@@ -426,13 +430,15 @@ export function mergeStyles(allowedStyles, styleDeclaration, newStyles) {
|
||||
const mergedStyles = {};
|
||||
for (const [styleName, styleUnit] of allowedStyles) {
|
||||
if (styleName in newStyles) {
|
||||
mergedStyles[styleName] = newStyles[styleName];
|
||||
const styleValue = newStyles[styleName];
|
||||
mergedStyles[styleName] = styleValue;
|
||||
} else {
|
||||
mergedStyles[styleName] = getStyleFromDeclaration(
|
||||
const styleValue = getStyleFromDeclaration(
|
||||
styleDeclaration,
|
||||
styleName,
|
||||
styleUnit,
|
||||
);
|
||||
mergedStyles[styleName] = styleValue;
|
||||
}
|
||||
}
|
||||
return mergedStyles;
|
||||
|
||||
@@ -22,8 +22,7 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,8 +32,7 @@ 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,6 +6,8 @@
|
||||
* Copyright (c) KALEIDOS INC
|
||||
*/
|
||||
|
||||
import SafeGuard from "../../controllers/SafeGuard.js";
|
||||
|
||||
/**
|
||||
* Iterator direction.
|
||||
*
|
||||
@@ -56,7 +58,7 @@ export class TextNodeIterator {
|
||||
startNode,
|
||||
rootNode,
|
||||
skipNodes = new Set(),
|
||||
direction = TextNodeIteratorDirection.FORWARD
|
||||
direction = TextNodeIteratorDirection.FORWARD,
|
||||
) {
|
||||
if (startNode === rootNode) {
|
||||
return TextNodeIterator.findDown(
|
||||
@@ -65,7 +67,7 @@ export class TextNodeIterator {
|
||||
: startNode.lastChild,
|
||||
rootNode,
|
||||
skipNodes,
|
||||
direction
|
||||
direction,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,7 +95,7 @@ export class TextNodeIterator {
|
||||
: currentNode.lastChild,
|
||||
rootNode,
|
||||
skipNodes,
|
||||
direction
|
||||
direction,
|
||||
);
|
||||
}
|
||||
currentNode =
|
||||
@@ -117,7 +119,7 @@ export class TextNodeIterator {
|
||||
startNode,
|
||||
rootNode,
|
||||
backTrack = new Set(),
|
||||
direction = TextNodeIteratorDirection.FORWARD
|
||||
direction = TextNodeIteratorDirection.FORWARD,
|
||||
) {
|
||||
backTrack.add(startNode);
|
||||
if (TextNodeIterator.isTextNode(startNode)) {
|
||||
@@ -125,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;
|
||||
@@ -142,7 +144,7 @@ export class TextNodeIterator {
|
||||
startNode.parentNode,
|
||||
rootNode,
|
||||
backTrack,
|
||||
direction
|
||||
direction,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -212,7 +214,7 @@ export class TextNodeIterator {
|
||||
this.#currentNode,
|
||||
this.#rootNode,
|
||||
new Set(),
|
||||
TextNodeIteratorDirection.FORWARD
|
||||
TextNodeIteratorDirection.FORWARD,
|
||||
);
|
||||
|
||||
if (!nextNode) {
|
||||
@@ -235,7 +237,7 @@ export class TextNodeIterator {
|
||||
this.#currentNode,
|
||||
this.#rootNode,
|
||||
new Set(),
|
||||
TextNodeIteratorDirection.BACKWARD
|
||||
TextNodeIteratorDirection.BACKWARD,
|
||||
);
|
||||
|
||||
if (!previousNode) {
|
||||
@@ -245,6 +247,49 @@ export class TextNodeIterator {
|
||||
this.#currentNode = previousNode;
|
||||
return this.#currentNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of text nodes.
|
||||
*
|
||||
* @param {TextNode} startNode
|
||||
* @param {TextNode} endNode
|
||||
* @returns {Array<TextNode>}
|
||||
*/
|
||||
collectFrom(startNode, endNode) {
|
||||
const nodes = [];
|
||||
for (const node of this.iterateFrom(startNode, endNode)) {
|
||||
nodes.push(node);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterates over a list of nodes.
|
||||
*
|
||||
* @param {TextNode} startNode
|
||||
* @param {TextNode} endNode
|
||||
* @yields {TextNode}
|
||||
*/
|
||||
*iterateFrom(startNode, endNode) {
|
||||
const comparedPosition = startNode.compareDocumentPosition(endNode);
|
||||
this.#currentNode = startNode;
|
||||
SafeGuard.start();
|
||||
while (this.#currentNode !== endNode) {
|
||||
yield this.#currentNode;
|
||||
SafeGuard.update();
|
||||
if (comparedPosition === Node.DOCUMENT_POSITION_PRECEDING) {
|
||||
if (!this.previousNode()) {
|
||||
break;
|
||||
}
|
||||
} else if (comparedPosition === Node.DOCUMENT_POSITION_FOLLOWING) {
|
||||
if (!this.nextNode()) {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default TextNodeIterator;
|
||||
|
||||
@@ -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,11 +24,24 @@ 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;
|
||||
export function throwAfter(error, timeout = SAFE_GUARD_TIME) {
|
||||
timeoutId = setTimeout(() => {
|
||||
throw error;
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
export function throwCancel() {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
export default {
|
||||
start,
|
||||
update,
|
||||
}
|
||||
throwAfter,
|
||||
throwCancel,
|
||||
};
|
||||
|
||||
@@ -54,6 +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";
|
||||
|
||||
/**
|
||||
* Supported options for the SelectionController.
|
||||
@@ -64,39 +65,7 @@ import { sanitizeFontFamily } from "../content/dom/Style.js";
|
||||
|
||||
/**
|
||||
* SelectionController uses the same concepts used by the Selection API but extending it to support
|
||||
* our own internal model based on paragraphs (in drafconst textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
|
||||
createParagraph([createTextSpan(new Text("Hello, "))]),
|
||||
createEmptyParagraph(),
|
||||
createParagraph([createTextSpan(new Text("World!"))]),
|
||||
]);
|
||||
const root = textEditorMock.root;
|
||||
const selection = document.getSelection();
|
||||
const selectionController = new SelectionController(
|
||||
textEditorMock,
|
||||
selection
|
||||
);
|
||||
focus(
|
||||
selection,
|
||||
textEditorMock,
|
||||
root.childNodes.item(2).firstChild.firstChild,
|
||||
0
|
||||
);
|
||||
selectionController.mergeBackwardParagraph();
|
||||
expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement);
|
||||
expect(textEditorMock.root.children.length).toBe(2);
|
||||
expect(textEditorMock.root.dataset.itype).toBe("root");
|
||||
expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement);
|
||||
expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph");
|
||||
expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf(
|
||||
HTMLSpanElement
|
||||
);
|
||||
expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe(
|
||||
"span"
|
||||
);
|
||||
expect(textEditorMock.root.textContent).toBe("Hello, World!");
|
||||
expect(textEditorMock.root.firstChild.textContent).toBe("Hello, ");
|
||||
expect(textEditorMock.root.lastChild.textContent).toBe("World!");
|
||||
t.js they were called blocks) and text spans.
|
||||
* our own internal model based on paragraphs (in draft.js they were called blocks) and text spans.
|
||||
*/
|
||||
export class SelectionController extends EventTarget {
|
||||
/**
|
||||
@@ -164,21 +133,12 @@ export class SelectionController extends EventTarget {
|
||||
#textNodeIterator = null;
|
||||
|
||||
/**
|
||||
* CSSStyleDeclaration that we can mutate
|
||||
* StyleDeclaration that we can mutate
|
||||
* to handle style changes.
|
||||
*
|
||||
* @type {CSSStyleDeclaration}
|
||||
* @type {StyleDeclaration}
|
||||
*/
|
||||
#currentStyle = null;
|
||||
|
||||
/**
|
||||
* Element used to have a custom CSSStyleDeclaration
|
||||
* that we can modify to handle style changes when the
|
||||
* selection is changed.
|
||||
*
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#inertElement = null;
|
||||
#currentStyle = new StyleDeclaration();
|
||||
|
||||
/**
|
||||
* @type {SelectionControllerDebug}
|
||||
@@ -275,19 +235,68 @@ export class SelectionController extends EventTarget {
|
||||
*
|
||||
* @param {HTMLElement} element
|
||||
*/
|
||||
#applyStylesToCurrentStyle(element) {
|
||||
#applyStylesFromElementToCurrentStyle(element) {
|
||||
for (let index = 0; index < element.style.length; index++) {
|
||||
const styleName = element.style.item(index);
|
||||
if (styleName === "--fills") {
|
||||
continue;
|
||||
}
|
||||
let styleValue = element.style.getPropertyValue(styleName);
|
||||
|
||||
if (styleName === "font-family") {
|
||||
styleValue = sanitizeFontFamily(styleValue);
|
||||
}
|
||||
this.#currentStyle.setProperty(styleName, styleValue);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies some styles to the currentStyle
|
||||
* CSSStyleDeclaration
|
||||
*
|
||||
* @param {HTMLElement} element
|
||||
*/
|
||||
#mergeStylesFromElementToCurrentStyle(element) {
|
||||
for (let index = 0; index < element.style.length; index++) {
|
||||
const styleName = element.style.item(index);
|
||||
let styleValue = element.style.getPropertyValue(styleName);
|
||||
if (styleName === "font-family") {
|
||||
styleValue = sanitizeFontFamily(styleValue);
|
||||
}
|
||||
|
||||
this.#currentStyle.setProperty(styleName, styleValue);
|
||||
this.#currentStyle.mergeProperty(styleName, styleValue);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates current styles based on the currently selected text spans.
|
||||
*
|
||||
* @param {HTMLSpanElement} startNode
|
||||
* @param {HTMLSpanElement} endNode
|
||||
*/
|
||||
#updateCurrentStyleFrom(startNode, endNode) {
|
||||
this.#applyDefaultStylesToCurrentStyle();
|
||||
const root = startNode.parentElement.parentElement.parentElement;
|
||||
this.#applyStylesFromElementToCurrentStyle(root);
|
||||
// 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,
|
||||
)) {
|
||||
const paragraph = textNode.parentElement.parentElement;
|
||||
this.#applyStylesFromElementToCurrentStyle(paragraph);
|
||||
}
|
||||
for (const textNode of this.#textNodeIterator.iterateFrom(
|
||||
startNode,
|
||||
endNode,
|
||||
)) {
|
||||
const textSpan = textNode.parentElement;
|
||||
this.#mergeStylesFromElementToCurrentStyle(textSpan);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates current styles based on the currently selected text span.
|
||||
*
|
||||
@@ -297,20 +306,14 @@ export class SelectionController extends EventTarget {
|
||||
#updateCurrentStyle(textSpan) {
|
||||
this.#applyDefaultStylesToCurrentStyle();
|
||||
const root = textSpan.parentElement.parentElement;
|
||||
this.#applyStylesToCurrentStyle(root);
|
||||
this.#applyStylesFromElementToCurrentStyle(root);
|
||||
const paragraph = textSpan.parentElement;
|
||||
this.#applyStylesToCurrentStyle(paragraph);
|
||||
this.#applyStylesToCurrentStyle(textSpan);
|
||||
this.#applyStylesFromElementToCurrentStyle(paragraph);
|
||||
this.#applyStylesFromElementToCurrentStyle(textSpan);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is called on every `selectionchange` because it is dispatched
|
||||
* only by the `document` object.
|
||||
*
|
||||
* @param {Event} e
|
||||
*/
|
||||
#onSelectionChange = (e) => {
|
||||
#updateState() {
|
||||
// If we're outside the contenteditable element, then
|
||||
// we return.
|
||||
if (!this.hasFocus) {
|
||||
@@ -320,17 +323,23 @@ export class SelectionController extends EventTarget {
|
||||
let focusNodeChanges = false;
|
||||
let anchorNodeChanges = false;
|
||||
|
||||
if (this.#focusNode !== this.#selection.focusNode) {
|
||||
if (
|
||||
this.#focusNode !== this.#selection.focusNode ||
|
||||
this.#focusOffset !== this.#selection.focusOffset
|
||||
) {
|
||||
this.#focusNode = this.#selection.focusNode;
|
||||
this.#focusOffset = this.#selection.focusOffset;
|
||||
focusNodeChanges = true;
|
||||
}
|
||||
this.#focusOffset = this.#selection.focusOffset;
|
||||
|
||||
if (this.#anchorNode !== this.#selection.anchorNode) {
|
||||
if (
|
||||
this.#anchorNode !== this.#selection.anchorNode ||
|
||||
this.#anchorOffset !== this.#selection.anchorOffset
|
||||
) {
|
||||
this.#anchorNode = this.#selection.anchorNode;
|
||||
this.#anchorOffset = this.#selection.anchorOffset;
|
||||
anchorNodeChanges = true;
|
||||
}
|
||||
this.#anchorOffset = this.#selection.anchorOffset;
|
||||
|
||||
// We need to handle multi selection from firefox
|
||||
// and remove all the old ranges and just keep the
|
||||
@@ -359,7 +368,7 @@ export class SelectionController extends EventTarget {
|
||||
// If focus node changed, we need to retrieve all the
|
||||
// styles of the current text span and dispatch an event
|
||||
// to notify that the styles have changed.
|
||||
if (focusNodeChanges) {
|
||||
if (focusNodeChanges || anchorNodeChanges) {
|
||||
this.#notifyStyleChange();
|
||||
}
|
||||
|
||||
@@ -372,43 +381,42 @@ export class SelectionController extends EventTarget {
|
||||
if (this.#debug) {
|
||||
this.#debug.update(this);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* This is called on every `selectionchange` because it is dispatched
|
||||
* only by the `document` object.
|
||||
*
|
||||
* @param {Event} e
|
||||
*/
|
||||
#onSelectionChange = (_) => this.#updateState();
|
||||
|
||||
/**
|
||||
* Notifies that the styles have changed.
|
||||
*/
|
||||
#notifyStyleChange() {
|
||||
const textSpan = this.focusTextSpan;
|
||||
if (textSpan) {
|
||||
this.#updateCurrentStyle(textSpan);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("stylechange", {
|
||||
detail: this.#currentStyle,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const firstTextSpan =
|
||||
if (this.#selection.isCollapsed) {
|
||||
// CARET
|
||||
const textSpan =
|
||||
this.focusTextSpan ??
|
||||
this.#textEditor.root?.firstElementChild?.firstElementChild;
|
||||
if (firstTextSpan) {
|
||||
this.#updateCurrentStyle(firstTextSpan);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("stylechange", {
|
||||
detail: this.#currentStyle,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
this.#updateCurrentStyle(textSpan);
|
||||
} else {
|
||||
// SELECTION.
|
||||
this.#updateCurrentStyleFrom(this.#anchorNode, this.#focusNode);
|
||||
}
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("stylechange", {
|
||||
detail: this.#currentStyle,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setups
|
||||
*/
|
||||
#setup() {
|
||||
// This element is not attached to the DOM
|
||||
// so it doesn't trigger style or layout calculations.
|
||||
// That's why it's called "inertElement".
|
||||
this.#inertElement = document.createElement("div");
|
||||
this.#currentStyle = this.#inertElement.style;
|
||||
this.#applyDefaultStylesToCurrentStyle();
|
||||
|
||||
if (this.#selection.rangeCount > 0) {
|
||||
@@ -428,6 +436,22 @@ export class SelectionController extends EventTarget {
|
||||
document.addEventListener("selectionchange", this.#onSelectionChange);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes the current resources.
|
||||
*/
|
||||
dispose() {
|
||||
document.removeEventListener("selectionchange", this.#onSelectionChange);
|
||||
this.#textEditor = null;
|
||||
this.#ranges.clear();
|
||||
this.#ranges = null;
|
||||
this.#range = null;
|
||||
this.#selection = null;
|
||||
this.#focusNode = null;
|
||||
this.#anchorNode = null;
|
||||
this.#mutations.dispose();
|
||||
this.#mutations = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Range-like object.
|
||||
*
|
||||
@@ -480,19 +504,12 @@ export class SelectionController extends EventTarget {
|
||||
if (!this.#savedSelection) return false;
|
||||
|
||||
if (this.#savedSelection.anchorNode && this.#savedSelection.focusNode) {
|
||||
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.#selection.setBaseAndExtent(
|
||||
this.#savedSelection.anchorNode,
|
||||
this.#savedSelection.anchorOffset,
|
||||
this.#savedSelection.focusNode,
|
||||
this.#savedSelection.focusOffset,
|
||||
);
|
||||
}
|
||||
this.#savedSelection = null;
|
||||
return true;
|
||||
@@ -502,6 +519,8 @@ export class SelectionController extends EventTarget {
|
||||
* Marks the start of a mutation.
|
||||
*
|
||||
* Clears all the mutations kept in CommandMutations.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
startMutation() {
|
||||
this.#mutations.clear();
|
||||
@@ -512,7 +531,7 @@ export class SelectionController extends EventTarget {
|
||||
/**
|
||||
* Marks the end of a mutation.
|
||||
*
|
||||
* @returns
|
||||
* @returns {CommandMutations}
|
||||
*/
|
||||
endMutation() {
|
||||
return this.#mutations;
|
||||
@@ -520,6 +539,8 @@ export class SelectionController extends EventTarget {
|
||||
|
||||
/**
|
||||
* Selects all content.
|
||||
*
|
||||
* @returns {SelectionController}
|
||||
*/
|
||||
selectAll() {
|
||||
if (this.#textEditor.isEmpty) {
|
||||
@@ -558,23 +579,15 @@ export class SelectionController extends EventTarget {
|
||||
this.#selection.removeAllRanges();
|
||||
this.#selection.addRange(range);
|
||||
|
||||
// Ensure internal state is synchronized
|
||||
this.#focusNode = this.#selection.focusNode;
|
||||
this.#focusOffset = this.#selection.focusOffset;
|
||||
this.#anchorNode = this.#selection.anchorNode;
|
||||
this.#anchorOffset = this.#selection.anchorOffset;
|
||||
this.#range = range;
|
||||
this.#ranges.clear();
|
||||
this.#ranges.add(range);
|
||||
|
||||
// Notify style changes
|
||||
this.#notifyStyleChange();
|
||||
this.#updateState();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves cursor to end.
|
||||
*
|
||||
* @returns {SelectionController}
|
||||
*/
|
||||
cursorToEnd() {
|
||||
const range = document.createRange(); //Create a range (a range is a like the selection but invisible)
|
||||
@@ -662,22 +675,6 @@ export class SelectionController extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes the current resources.
|
||||
*/
|
||||
dispose() {
|
||||
document.removeEventListener("selectionchange", this.#onSelectionChange);
|
||||
this.#textEditor = null;
|
||||
this.#ranges.clear();
|
||||
this.#ranges = null;
|
||||
this.#range = null;
|
||||
this.#selection = null;
|
||||
this.#focusNode = null;
|
||||
this.#anchorNode = null;
|
||||
this.#mutations.dispose();
|
||||
this.#mutations = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current selection.
|
||||
*
|
||||
@@ -1114,8 +1111,8 @@ export class SelectionController extends EventTarget {
|
||||
return isParagraphEnd(this.focusNode, this.focusOffset);
|
||||
}
|
||||
|
||||
#getFragmentInlineTextNode(fragment) {
|
||||
if (isInline(fragment.firstElementChild.lastChild)) {
|
||||
#getFragmentTextSpanTextNode(fragment) {
|
||||
if (isTextSpan(fragment.firstElementChild.lastChild)) {
|
||||
return fragment.firstElementChild.firstElementChild.lastChild;
|
||||
}
|
||||
return fragment.firstElementChild.lastChild;
|
||||
@@ -1131,11 +1128,12 @@ export class SelectionController extends EventTarget {
|
||||
* @param {DocumentFragment} fragment
|
||||
*/
|
||||
insertPaste(fragment) {
|
||||
if (
|
||||
fragment.children.length === 1 &&
|
||||
fragment.firstElementChild?.dataset?.textSpan === "force"
|
||||
) {
|
||||
const collapseNode = fragment.firstElementChild.firstChild;
|
||||
const hasOnlyOneParagraph = fragment.children.length === 1;
|
||||
const forceTextSpan =
|
||||
fragment.firstElementChild?.dataset?.textSpan === "force";
|
||||
if (hasOnlyOneParagraph && forceTextSpan) {
|
||||
// first text span
|
||||
const collapseNode = fragment.firstElementChild.firstElementChild;
|
||||
if (this.isTextSpanStart) {
|
||||
this.focusTextSpan.before(...fragment.firstElementChild.children);
|
||||
} else if (this.isTextSpanEnd) {
|
||||
@@ -1147,7 +1145,9 @@ export class SelectionController extends EventTarget {
|
||||
newTextSpan,
|
||||
);
|
||||
}
|
||||
return this.collapse(collapseNode, collapseNode.nodeValue?.length || 0);
|
||||
// collapseNode could be a <br>, that's why we need to
|
||||
// make `nodeValue` as optional.
|
||||
return this.collapse(collapseNode, collapseNode?.nodeValue?.length || 0);
|
||||
}
|
||||
const collapseNode = this.#getFragmentParagraphTextNode(fragment);
|
||||
if (this.isParagraphStart) {
|
||||
@@ -1393,9 +1393,16 @@ export class SelectionController extends EventTarget {
|
||||
this.focusOffset,
|
||||
newText,
|
||||
);
|
||||
this.collapse(this.focusNode, this.focusOffset + newText.length);
|
||||
} else if (this.isLineBreakFocus) {
|
||||
const textNode = new Text(newText);
|
||||
this.focusNode.replaceWith(textNode);
|
||||
// the focus node is a <span>.
|
||||
if (isTextSpan(this.focusNode)) {
|
||||
this.focusNode.firstElementChild.replaceWith(textNode);
|
||||
// the focus node is a <br>.
|
||||
} else {
|
||||
this.focusNode.replaceWith(textNode);
|
||||
}
|
||||
this.collapse(textNode, newText.length);
|
||||
} else {
|
||||
throw new Error("Unknown node type");
|
||||
@@ -1932,11 +1939,21 @@ export class SelectionController extends EventTarget {
|
||||
const textSpan = this.startTextSpan;
|
||||
const midText = startNode.splitText(startOffset);
|
||||
const endText = midText.splitText(endOffset - startOffset);
|
||||
const midTextSpan = createTextSpanFrom(textSpan, midText, newStyles);
|
||||
textSpan.after(midTextSpan);
|
||||
if (endText.length > 0) {
|
||||
const endTextSpan = createTextSpan(endText, textSpan.style);
|
||||
midTextSpan.after(endTextSpan);
|
||||
|
||||
// Only create text span if midText is not empty
|
||||
if (midText.nodeValue && midText.nodeValue.length > 0) {
|
||||
const midTextSpan = createTextSpanFrom(textSpan, midText, newStyles);
|
||||
textSpan.after(midTextSpan);
|
||||
if (endText.length > 0) {
|
||||
const endTextSpan = createTextSpan(endText, textSpan.style);
|
||||
midTextSpan.after(endTextSpan);
|
||||
}
|
||||
} else {
|
||||
// If midText is empty, just create endTextSpan if needed
|
||||
if (endText.length > 0) {
|
||||
const endTextSpan = createTextSpan(endText, textSpan.style);
|
||||
textSpan.after(endTextSpan);
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: This is necessary because sometimes
|
||||
@@ -1953,7 +1970,7 @@ export class SelectionController extends EventTarget {
|
||||
// the styles are applied to the current caret
|
||||
else if (
|
||||
this.startOffset === this.endOffset &&
|
||||
this.endOffset === endNode.nodeValue.length
|
||||
this.endOffset === endNode.nodeValue?.length
|
||||
) {
|
||||
const newTextSpan = createVoidTextSpan(newStyles);
|
||||
this.endTextSpan.after(newTextSpan);
|
||||
@@ -1963,6 +1980,10 @@ export class SelectionController extends EventTarget {
|
||||
else {
|
||||
const paragraph = this.startParagraph;
|
||||
setParagraphStyles(paragraph, newStyles);
|
||||
// Apply styles to child text spans.
|
||||
for (const textSpan of paragraph.children) {
|
||||
setTextSpanStyles(textSpan, newStyles);
|
||||
}
|
||||
}
|
||||
return this.#notifyStyleChange();
|
||||
|
||||
@@ -1984,7 +2005,8 @@ export class SelectionController extends EventTarget {
|
||||
// new text span.
|
||||
if (
|
||||
this.#textNodeIterator.currentNode === startNode &&
|
||||
startOffset > 0
|
||||
startOffset > 0 &&
|
||||
startOffset < (startNode.nodeValue?.length || 0)
|
||||
) {
|
||||
const newTextSpan = splitTextSpan(textSpan, startOffset);
|
||||
setTextSpanStyles(newTextSpan, newStyles);
|
||||
@@ -1999,14 +2021,15 @@ export class SelectionController extends EventTarget {
|
||||
(this.#textNodeIterator.currentNode !== startNode &&
|
||||
this.#textNodeIterator.currentNode !== endNode) ||
|
||||
(this.#textNodeIterator.currentNode === endNode &&
|
||||
endOffset === endNode.nodeValue.length)
|
||||
endOffset === endNode.nodeValue?.length)
|
||||
) {
|
||||
setTextSpanStyles(textSpan, newStyles);
|
||||
|
||||
// If we're at end node
|
||||
} else if (
|
||||
this.#textNodeIterator.currentNode === endNode &&
|
||||
endOffset < endNode.nodeValue.length
|
||||
endOffset < endNode.nodeValue?.length &&
|
||||
endOffset > 0
|
||||
) {
|
||||
const newTextSpan = splitTextSpan(textSpan, endOffset);
|
||||
setTextSpanStyles(textSpan, newStyles);
|
||||
|
||||
@@ -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,7 +292,12 @@ 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);
|
||||
@@ -315,9 +320,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",
|
||||
);
|
||||
@@ -359,25 +364,21 @@ 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();
|
||||
@@ -415,7 +416,12 @@ 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();
|
||||
@@ -439,9 +445,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");
|
||||
@@ -461,9 +467,7 @@ 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);
|
||||
@@ -486,9 +490,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)", () => {
|
||||
|
||||
113
frontend/text-editor/src/editor/controllers/StyleDeclaration.js
Normal file
113
frontend/text-editor/src/editor/controllers/StyleDeclaration.js
Normal file
@@ -0,0 +1,113 @@
|
||||
export class StyleDeclaration {
|
||||
static Property = class Property {
|
||||
static NULL = '["~#\'",null]';
|
||||
|
||||
static Default = new Property("", "", "");
|
||||
|
||||
name;
|
||||
value = "";
|
||||
priority = "";
|
||||
|
||||
constructor(name, value = "", priority = "") {
|
||||
this.name = name;
|
||||
this.value = value ?? "";
|
||||
this.priority = priority ?? "";
|
||||
}
|
||||
};
|
||||
|
||||
#items = new Map();
|
||||
|
||||
get cssFloat() {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
get cssText() {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
get parentRule() {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
get length() {
|
||||
return this.#items.size;
|
||||
}
|
||||
|
||||
#getProperty(name) {
|
||||
return this.#items.get(name) ?? StyleDeclaration.Property.Default;
|
||||
}
|
||||
|
||||
getPropertyPriority(name) {
|
||||
const { priority } = this.#getProperty(name);
|
||||
return priority ?? "";
|
||||
}
|
||||
|
||||
getPropertyValue(name) {
|
||||
const { value } = this.#getProperty(name);
|
||||
return value ?? "";
|
||||
}
|
||||
|
||||
item(index) {
|
||||
return Array.from(this.#items).at(index).name;
|
||||
}
|
||||
|
||||
removeProperty(name) {
|
||||
const value = this.getPropertyValue(name);
|
||||
this.#items.delete(name);
|
||||
return value;
|
||||
}
|
||||
|
||||
setProperty(name, value, priority) {
|
||||
this.#items.set(name, new StyleDeclaration.Property(name, value, priority));
|
||||
}
|
||||
|
||||
/** Non compatible methods */
|
||||
#isQuotedValue(a, b) {
|
||||
if (a.startsWith('"') && b.startsWith('"')) {
|
||||
return a === b;
|
||||
} else if (a.startsWith('"') && !b.startsWith('"')) {
|
||||
return a.slice(1, -1) === b;
|
||||
} else if (!a.startsWith('"') && b.startsWith('"')) {
|
||||
return a === b.slice(1, -1);
|
||||
}
|
||||
return a === b;
|
||||
}
|
||||
|
||||
mergeProperty(name, value) {
|
||||
const currentValue = this.getPropertyValue(name);
|
||||
if (this.#isQuotedValue(currentValue, value)) {
|
||||
return this.setProperty(name, value);
|
||||
} else if (
|
||||
currentValue === "" &&
|
||||
value === StyleDeclaration.Property.NULL
|
||||
) {
|
||||
return this.setProperty(name, value);
|
||||
} else if (currentValue === "" && ["initial", "none"].includes(value)) {
|
||||
return this.setProperty(name, value);
|
||||
} else if (currentValue !== value && name === "--fills") {
|
||||
return this.setProperty(name, value);
|
||||
} else if (currentValue !== value) {
|
||||
return this.setProperty(name, "mixed");
|
||||
}
|
||||
}
|
||||
|
||||
fromCSSStyleDeclaration(cssStyleDeclaration) {
|
||||
for (let index = 0; index < cssStyleDeclaration.length; index++) {
|
||||
const name = cssStyleDeclaration.item(index);
|
||||
const value = cssStyleDeclaration.getPropertyValue(name);
|
||||
const priority = cssStyleDeclaration.getPropertyPriority(name);
|
||||
this.setProperty(name, value, priority);
|
||||
}
|
||||
}
|
||||
|
||||
toObject() {
|
||||
return Object.fromEntries(
|
||||
Array.from(this.#items.entries(), ([name, property]) => [
|
||||
name,
|
||||
property.value,
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default StyleDeclaration;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { StyleDeclaration } from "./StyleDeclaration.js";
|
||||
|
||||
describe("StyleDeclaration", () => {
|
||||
test("Create a new StyleDeclaration", () => {
|
||||
const styleDeclaration = new StyleDeclaration();
|
||||
expect(styleDeclaration).toBeInstanceOf(StyleDeclaration);
|
||||
});
|
||||
|
||||
test("Uninmplemented getters should throw", () => {
|
||||
expect(() => styleDeclaration.cssFloat).toThrow();
|
||||
expect(() => styleDeclaration.cssText).toThrow();
|
||||
expect(() => styleDeclaration.parentRule).toThrow();
|
||||
});
|
||||
|
||||
test("Set property", () => {
|
||||
const styleDeclaration = new StyleDeclaration();
|
||||
styleDeclaration.setProperty("line-height", "1.2");
|
||||
expect(styleDeclaration.getPropertyValue("line-height")).toBe("1.2");
|
||||
expect(styleDeclaration.getPropertyPriority("line-height")).toBe("");
|
||||
});
|
||||
|
||||
test("Remove property", () => {
|
||||
const styleDeclaration = new StyleDeclaration();
|
||||
styleDeclaration.setProperty("line-height", "1.2");
|
||||
expect(styleDeclaration.getPropertyValue("line-height")).toBe("1.2");
|
||||
expect(styleDeclaration.getPropertyPriority("line-height")).toBe("");
|
||||
styleDeclaration.removeProperty("line-height");
|
||||
expect(styleDeclaration.getPropertyValue("line-height")).toBe("");
|
||||
expect(styleDeclaration.getPropertyPriority("line-height")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -43,33 +43,38 @@ 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
|
||||
selectionController.anchorParagraph,
|
||||
);
|
||||
this.#elements.startContainer.value = this.getNodeDescription(
|
||||
selectionController.startContainer,
|
||||
);
|
||||
this.#elements.endContainer.value = this.getNodeDescription(
|
||||
selectionController.endContainer,
|
||||
);
|
||||
this.#elements.startContainer.value = this.getNodeDescription(selectionController.startContainer);
|
||||
this.#elements.endContainer.value = this.getNodeDescription(selectionController.endContainer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,10 +39,7 @@ 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 }) {
|
||||
@@ -119,10 +116,7 @@ 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;
|
||||
@@ -228,10 +222,7 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user