mirror of
https://github.com/penpot/penpot.git
synced 2025-12-30 09:58:55 -05:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ca98ddf21 | ||
|
|
15157c54b1 | ||
|
|
232b29cd89 | ||
|
|
da0704081f | ||
|
|
066b1235a6 | ||
|
|
141694dc8d | ||
|
|
151aedcf91 | ||
|
|
5513daf17d | ||
|
|
fde0f3c182 | ||
|
|
7b408e4db1 | ||
|
|
b8fd829f9d | ||
|
|
089a66881c | ||
|
|
667b5fb6ee | ||
|
|
f0f89151c5 | ||
|
|
1221d60357 | ||
|
|
f553fa10d8 | ||
|
|
96947b0219 | ||
|
|
e2900d9012 | ||
|
|
1f0e470419 | ||
|
|
079a945c2f | ||
|
|
542d709541 | ||
|
|
4f1d5a19e4 | ||
|
|
91b0c47244 | ||
|
|
a7a49e4b39 | ||
|
|
ba81b2b14d | ||
|
|
423c237d42 | ||
|
|
5a55884b9f | ||
|
|
38fd343c53 | ||
|
|
94976aa2b1 | ||
|
|
5247d217ab | ||
|
|
40693e6857 | ||
|
|
5c428b5aa5 | ||
|
|
e92ddee33a | ||
|
|
c121f459ba | ||
|
|
698a258290 | ||
|
|
aa023d847d | ||
|
|
53f57dad0b | ||
|
|
d7d7535ab4 | ||
|
|
accc662e1c | ||
|
|
1efc1516e2 | ||
|
|
b5d731ca72 | ||
|
|
e380289e34 | ||
|
|
b22323a484 | ||
|
|
58dd23f9c7 | ||
|
|
54e7551d56 | ||
|
|
404297f837 | ||
|
|
33f853ff2e | ||
|
|
d16513be9d | ||
|
|
ad077696b0 | ||
|
|
1cbeafe85c | ||
|
|
76c8523f44 | ||
|
|
f277d8b125 |
17
CHANGES.md
17
CHANGES.md
@@ -1,5 +1,22 @@
|
||||
# CHANGELOG
|
||||
|
||||
## 2.4.2
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix detach when top copy is dangling and nested copy is not [Taiga #9699](https://tree.taiga.io/project/penpot/issue/9699)
|
||||
- Fix problem in plugins with `replaceColor` method [#174](https://github.com/penpot/penpot-plugins/issues/174)
|
||||
- Fix issue with recursive commponents [Taiga #9903](https://tree.taiga.io/project/penpot/issue/9903)
|
||||
- Fix missing methods reference on API Docs
|
||||
- Fix memory usage issue on file-gc asynchronous task (related to snapshots feature)
|
||||
|
||||
## 2.4.1
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix error when importing files with touched components [Taiga #9625](https://tree.taiga.io/project/penpot/issue/9625)
|
||||
- Fix problem when changing color libraries [Plugins #184](https://github.com/penpot/penpot-plugins/issues/184)
|
||||
|
||||
## 2.4.0
|
||||
|
||||
### :rocket: Epics and highlights
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
export PENPOT_SECRET_KEY=super-secret-devenv-key
|
||||
export PENPOT_HOST=devenv
|
||||
export PENPOT_FLAGS="\
|
||||
$PENPOT_FLAGS \
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
export PENPOT_SECRET_KEY=super-secret-devenv-key
|
||||
export PENPOT_HOST=devenv
|
||||
export PENPOT_FLAGS="\
|
||||
$PENPOT_FLAGS \
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
(:require
|
||||
[buddy.hashers :as hashers]))
|
||||
|
||||
(def default-params
|
||||
(def ^:private default-options
|
||||
{:alg :argon2id
|
||||
:memory 32768 ;; 32 MiB
|
||||
:iterations 3
|
||||
@@ -16,12 +16,12 @@
|
||||
|
||||
(defn derive-password
|
||||
[password]
|
||||
(hashers/derive password default-params))
|
||||
(hashers/derive password default-options))
|
||||
|
||||
(defn verify-password
|
||||
[attempt password]
|
||||
(try
|
||||
(hashers/verify attempt password)
|
||||
(hashers/verify attempt password default-options)
|
||||
(catch Throwable _
|
||||
{:update false
|
||||
:valid false})))
|
||||
|
||||
@@ -270,19 +270,17 @@
|
||||
:else (throw (IllegalArgumentException. "unable to resolve connectable"))))
|
||||
|
||||
(def ^:private params-mapping
|
||||
{::return-keys? :return-keys
|
||||
::return-keys :return-keys})
|
||||
{::return-keys :return-keys})
|
||||
|
||||
(defn rename-opts
|
||||
[opts]
|
||||
(set/rename-keys opts params-mapping))
|
||||
|
||||
(def ^:private default-insert-opts
|
||||
{:builder-fn sql/as-kebab-maps
|
||||
:return-keys true})
|
||||
(assoc sql/default-opts :return-keys true))
|
||||
|
||||
(def ^:private default-opts
|
||||
{:builder-fn sql/as-kebab-maps})
|
||||
sql/default-opts)
|
||||
|
||||
(defn exec!
|
||||
([ds sv] (exec! ds sv nil))
|
||||
@@ -333,7 +331,7 @@
|
||||
(defn update!
|
||||
"A helper that build an UPDATE SQL statement and executes it.
|
||||
|
||||
Given a connectable object, a table name, a hash map of columns and
|
||||
Given a connectable object, a table name, a hash map of columns and
|
||||
values to set, and either a hash map of columns and values to search
|
||||
on or a vector of a SQL where clause and parameters, perform an
|
||||
update on the table.
|
||||
@@ -413,10 +411,20 @@
|
||||
:hint "database object not found"))
|
||||
row))
|
||||
|
||||
(def ^:private default-plan-opts
|
||||
(-> default-opts
|
||||
(assoc :fetch-size 1)
|
||||
(assoc :concurrency :read-only)
|
||||
(assoc :cursors :close)
|
||||
(assoc :result-type :forward-only)))
|
||||
|
||||
(defn plan
|
||||
[ds sql]
|
||||
(-> (get-connectable ds)
|
||||
(jdbc/plan sql sql/default-opts)))
|
||||
([ds sql]
|
||||
(-> (get-connectable ds)
|
||||
(jdbc/plan sql default-plan-opts)))
|
||||
([ds sql opts]
|
||||
(-> (get-connectable ds)
|
||||
(jdbc/plan sql (merge default-plan-opts opts)))))
|
||||
|
||||
(defn cursor
|
||||
"Return a lazy seq of rows using server side cursors"
|
||||
|
||||
@@ -15,14 +15,15 @@
|
||||
(defn kebab-case [s] (str/replace s #"_" "-"))
|
||||
(defn snake-case [s] (str/replace s #"-" "_"))
|
||||
|
||||
(def default-opts
|
||||
{:table-fn snake-case
|
||||
:column-fn snake-case})
|
||||
|
||||
(defn as-kebab-maps
|
||||
[rs opts]
|
||||
(jdbc-opt/as-unqualified-modified-maps rs (assoc opts :label-fn kebab-case)))
|
||||
|
||||
(def default-opts
|
||||
{:table-fn snake-case
|
||||
:column-fn snake-case
|
||||
:builder-fn as-kebab-maps})
|
||||
|
||||
(defn insert
|
||||
([table key-map]
|
||||
(insert table key-map nil))
|
||||
|
||||
@@ -29,10 +29,11 @@
|
||||
;; --- GENERAL PURPOSE INTERNAL HELPERS
|
||||
|
||||
(defn- decode-row
|
||||
[{:keys [participants position] :as row}]
|
||||
[{:keys [participants position mentions] :as row}]
|
||||
(cond-> row
|
||||
(db/pgpoint? position) (assoc :position (db/decode-pgpoint position))
|
||||
(db/pgobject? participants) (assoc :participants (db/decode-transit-pgobject participants))))
|
||||
(db/pgobject? participants) (assoc :participants (db/decode-transit-pgobject participants))
|
||||
(db/pgarray? mentions) (assoc :mentions (db/decode-pgarray mentions #{}))))
|
||||
|
||||
(def xf-decode-row
|
||||
(map decode-row))
|
||||
@@ -461,8 +462,9 @@
|
||||
:thread-id thread-id
|
||||
:owner-id profile-id
|
||||
:content content})
|
||||
props {:file-id file-id
|
||||
:share-id nil}]
|
||||
comment (decode-row comment)
|
||||
props {:file-id file-id
|
||||
:share-id nil}]
|
||||
|
||||
;; Update thread modified-at attribute and assoc the current
|
||||
;; profile to the participant set.
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
:is-owner true
|
||||
:is-admin true
|
||||
:can-edit true}
|
||||
{::db/return-keys? false}))
|
||||
{::db/return-keys false}))
|
||||
|
||||
(doseq [params (sequence (comp
|
||||
(map #(bfc/remap-id % :file-id))
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
(let [params (:query-params request)
|
||||
pstyle (:type params "js")
|
||||
context (assoc context :param-style pstyle)]
|
||||
|
||||
{::yres/status 200
|
||||
::yres/body (-> (io/resource "app/templates/api-doc.tmpl")
|
||||
(tmpl/render context))}))
|
||||
@@ -207,7 +208,7 @@
|
||||
(assert (sm/valid? ::rpc/methods (::rpc/methods params)) "expected valid methods"))
|
||||
|
||||
(defmethod ig/init-key ::routes
|
||||
[_ {:keys [methods] :as cfg}]
|
||||
[_ {:keys [::rpc/methods] :as cfg}]
|
||||
[(let [context (prepare-doc-context methods)]
|
||||
[["/_doc"
|
||||
{:handler (doc-handler context)
|
||||
|
||||
@@ -74,8 +74,7 @@
|
||||
|
||||
(defmethod ig/assert-key ::props
|
||||
[_ params]
|
||||
(assert (db/pool? (::db/pool params)) "expected valid database pool")
|
||||
(assert (string? (::key params)) "expected valid key string"))
|
||||
(assert (db/pool? (::db/pool params)) "expected valid database pool"))
|
||||
|
||||
(defmethod ig/init-key ::props
|
||||
[_ {:keys [::db/pool ::key] :as cfg}]
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
[app.util.pointer-map :as pmap]
|
||||
[app.util.time :as dt]
|
||||
[app.worker :as wrk]
|
||||
[clojure.set :as set]
|
||||
[integrant.core :as ig]))
|
||||
|
||||
(declare ^:private get-file)
|
||||
@@ -53,16 +52,21 @@
|
||||
RETURNING id")
|
||||
|
||||
(def ^:private xf:collect-used-media
|
||||
(comp (map :data) (mapcat bfc/collect-used-media)))
|
||||
(comp
|
||||
(map :data)
|
||||
(mapcat bfc/collect-used-media)))
|
||||
|
||||
(defn- clean-file-media!
|
||||
"Performs the garbage collection of file media objects."
|
||||
[{:keys [::db/conn] :as cfg} {:keys [id] :as file}]
|
||||
(let [used (into #{}
|
||||
xf:collect-used-media
|
||||
(cons file
|
||||
(->> (db/cursor conn [sql:get-snapshots id])
|
||||
(map (partial decode-file cfg)))))
|
||||
(let [xform (comp
|
||||
(map (partial decode-file cfg))
|
||||
xf:collect-used-media)
|
||||
|
||||
used (->> (db/plan conn [sql:get-snapshots id])
|
||||
(transduce xform conj #{}))
|
||||
used (into used xf:collect-used-media [file])
|
||||
|
||||
ids (db/create-array conn "uuid" used)
|
||||
unused (->> (db/exec! conn [sql:mark-file-media-object-deleted id ids])
|
||||
(into #{} (map :id)))]
|
||||
@@ -145,51 +149,47 @@
|
||||
AND f.deleted_at IS null
|
||||
ORDER BY f.modified_at ASC")
|
||||
|
||||
(def ^:private xf:map-id (map :id))
|
||||
|
||||
(defn- get-used-components
|
||||
"Given a file and a set of components marked for deletion, return a
|
||||
filtered set of component ids that are still un use"
|
||||
[components library-id {:keys [data]}]
|
||||
(filter #(ctf/used-in? data library-id % :component) components))
|
||||
|
||||
(defn- clean-deleted-components!
|
||||
"Performs the garbage collection of unreferenced deleted components."
|
||||
[{:keys [::db/conn] :as cfg} {:keys [data] :as file}]
|
||||
(let [file-id (:id file)
|
||||
|
||||
get-used-components
|
||||
(fn [data components]
|
||||
;; Find which of the components are used in the file.
|
||||
(into #{}
|
||||
(filter #(ctf/used-in? data file-id % :component))
|
||||
components))
|
||||
deleted-components
|
||||
(ctkl/deleted-components-seq data)
|
||||
|
||||
get-unused-components
|
||||
(fn [components files]
|
||||
;; Find and return a set of unused components (on all files).
|
||||
(reduce (fn [components {:keys [data]}]
|
||||
(if (seq components)
|
||||
(->> (get-used-components data components)
|
||||
(set/difference components))
|
||||
(reduced components)))
|
||||
xform
|
||||
(mapcat (partial get-used-components deleted-components file-id))
|
||||
|
||||
components
|
||||
files))
|
||||
used-remote
|
||||
(->> (db/plan conn [sql:get-files-for-library file-id])
|
||||
(transduce (comp (map (partial decode-file cfg)) xform) conj #{}))
|
||||
|
||||
process-fdata
|
||||
(fn [data unused]
|
||||
(reduce (fn [data id]
|
||||
(l/trc :hint "delete component"
|
||||
:component-id (str id)
|
||||
:file-id (str file-id))
|
||||
(ctkl/delete-component data id))
|
||||
data
|
||||
unused))
|
||||
used-local
|
||||
(into #{} xform [file])
|
||||
|
||||
deleted (into #{} (ctkl/deleted-components-seq data))
|
||||
|
||||
unused (->> (db/cursor conn [sql:get-files-for-library file-id] {:chunk-size 1})
|
||||
(map (partial decode-file cfg))
|
||||
(cons file)
|
||||
(get-unused-components deleted)
|
||||
(mapv :id)
|
||||
(set))
|
||||
|
||||
file (update file :data process-fdata unused)]
|
||||
unused
|
||||
(transduce xf:map-id disj
|
||||
(into #{} xf:map-id deleted-components)
|
||||
(concat used-remote used-local))
|
||||
|
||||
file
|
||||
(update file :data
|
||||
(fn [data]
|
||||
(reduce (fn [data id]
|
||||
(l/trc :hint "delete component"
|
||||
:component-id (str id)
|
||||
:file-id (str file-id))
|
||||
(ctkl/delete-component data id))
|
||||
data
|
||||
unused)))]
|
||||
|
||||
(l/dbg :hint "clean" :rel "components" :file-id (str file-id) :total (count unused))
|
||||
file))
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
(def default
|
||||
{:database-uri "postgresql://postgres/penpot_test"
|
||||
:redis-uri "redis://redis/1"
|
||||
:file-snapshot-every 1})
|
||||
:auto-file-snapshot-every 1})
|
||||
|
||||
(def config
|
||||
(cf/read-config :prefix "penpot-test"
|
||||
|
||||
@@ -383,8 +383,19 @@
|
||||
;; as deleted.
|
||||
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||
|
||||
;; This only clears fragments, the file media objects still referenced because
|
||||
;; snapshots are preserved
|
||||
(let [res (th/run-task! :objects-gc {:min-age 0})]
|
||||
(t/is (= 3 (:processed res))))
|
||||
(t/is (= 2 (:processed res))))
|
||||
|
||||
;; Mark all snapshots to be a non-snapshot file change
|
||||
(th/db-exec! ["update file_change set data = null where file_id = ?" (:id file)])
|
||||
(th/db-exec! ["update file set has_media_trimmed = false where id = ?" (:id file)])
|
||||
|
||||
;; Rerun the file-gc and objects-gc
|
||||
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||
(let [res (th/run-task! :objects-gc {:min-age 0})]
|
||||
(t/is (= 2 (:processed res))))
|
||||
|
||||
;; Now that file-gc have deleted the file-media-object usage,
|
||||
;; lets execute the touched-gc task, we should see that two of
|
||||
@@ -417,20 +428,6 @@
|
||||
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out)))
|
||||
(:result out)))
|
||||
|
||||
(update-file! [& {:keys [profile-id file-id changes revn] :or {revn 0}}]
|
||||
(let [params {::th/type :update-file
|
||||
::rpc/profile-id profile-id
|
||||
:id file-id
|
||||
:session-id (uuid/random)
|
||||
:revn revn
|
||||
:vern 0
|
||||
:components-v2 true
|
||||
:changes changes}
|
||||
out (th/command! params)]
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out)))
|
||||
(:result out)))]
|
||||
|
||||
(let [storage (:app.storage/storage th/*system*)
|
||||
@@ -550,8 +547,20 @@
|
||||
;; as deleted.
|
||||
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||
|
||||
;; This only removes unused fragments, file media are still
|
||||
;; referenced on snapshots.
|
||||
(let [res (th/run-task! :objects-gc {:min-age 0})]
|
||||
(t/is (= 7 (:processed res))))
|
||||
(t/is (= 2 (:processed res))))
|
||||
|
||||
;; Mark all snapshots to be a non-snapshot file change
|
||||
(th/db-exec! ["update file set has_media_trimmed = false where id = ?" (:id file)])
|
||||
(th/db-exec! ["update file_change set data = null where file_id = ?" (:id file)])
|
||||
|
||||
;; Rerun file-gc and objects-gc task for the same file once all snapshots are
|
||||
;; "expired/deleted"
|
||||
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||
(let [res (th/run-task! :objects-gc {:min-age 0})]
|
||||
(t/is (= 6 (:processed res))))
|
||||
|
||||
(let [rows (th/db-query :file-data-fragment {:file-id (:id file)
|
||||
:deleted-at nil})]
|
||||
@@ -591,20 +600,7 @@
|
||||
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out)))
|
||||
(:result out)))
|
||||
|
||||
#_(update-file! [& {:keys [profile-id file-id changes revn] :or {revn 0}}]
|
||||
(let [params {::th/type :update-file
|
||||
::rpc/profile-id profile-id
|
||||
:id file-id
|
||||
:session-id (uuid/random)
|
||||
:revn revn
|
||||
:features cfeat/supported-features
|
||||
:changes changes}
|
||||
out (th/command! params)]
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out)))
|
||||
(:result out)))]
|
||||
(:result out)))]
|
||||
|
||||
(let [storage (:app.storage/storage th/*system*)
|
||||
profile (th/create-profile* 1)
|
||||
@@ -1336,3 +1332,329 @@
|
||||
(t/is (every? #(bytes? (:data %)) rows))
|
||||
(t/is (every? #(nil? (:data-ref-id %)) rows))
|
||||
(t/is (every? #(nil? (:data-backend %)) rows)))))
|
||||
|
||||
(t/deftest file-gc-with-components-1
|
||||
(let [storage (:app.storage/storage th/*system*)
|
||||
profile (th/create-profile* 1)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})
|
||||
|
||||
s-id-1 (uuid/random)
|
||||
s-id-2 (uuid/random)
|
||||
c-id (uuid/random)
|
||||
|
||||
page-id (first (get-in file [:data :pages]))]
|
||||
|
||||
(let [rows (th/db-query :file-data-fragment {:file-id (:id file)
|
||||
:deleted-at nil})]
|
||||
(t/is (= (count rows) 1)))
|
||||
|
||||
;; Update file inserting new component
|
||||
(update-file!
|
||||
:file-id (:id file)
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes
|
||||
[{:type :add-obj
|
||||
:page-id page-id
|
||||
:id s-id-1
|
||||
:parent-id uuid/zero
|
||||
:frame-id uuid/zero
|
||||
:components-v2 true
|
||||
:obj (cts/setup-shape
|
||||
{:id s-id-1
|
||||
:name "Board"
|
||||
:frame-id uuid/zero
|
||||
:parent-id uuid/zero
|
||||
:type :frame
|
||||
:main-instance true
|
||||
:component-root true
|
||||
:component-file (:id file)
|
||||
:component-id c-id})}
|
||||
|
||||
{:type :add-obj
|
||||
:page-id page-id
|
||||
:id s-id-2
|
||||
:parent-id uuid/zero
|
||||
:frame-id uuid/zero
|
||||
:components-v2 true
|
||||
:obj (cts/setup-shape
|
||||
{:id s-id-2
|
||||
:name "Board"
|
||||
:frame-id uuid/zero
|
||||
:parent-id uuid/zero
|
||||
:type :frame
|
||||
:main-instance false
|
||||
:component-root true
|
||||
:component-file (:id file)
|
||||
:component-id c-id})}
|
||||
|
||||
{:type :add-component
|
||||
:path ""
|
||||
:name "Board"
|
||||
:main-instance-id s-id-1
|
||||
:main-instance-page page-id
|
||||
:id c-id
|
||||
:anotation nil}])
|
||||
|
||||
;; Run the file-gc task immediately without forced min-age
|
||||
(t/is (false? (th/run-task! :file-gc {:file-id (:id file)})))
|
||||
|
||||
;; Run the task again
|
||||
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||
|
||||
;; Retrieve file and check trimmed attribute
|
||||
(let [row (th/db-get :file {:id (:id file)})]
|
||||
(t/is (true? (:has-media-trimmed row))))
|
||||
|
||||
;; Check that component exists
|
||||
(let [data {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file)}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (th/success? out))
|
||||
(let [result (:result out)
|
||||
component (get-in result [:data :components c-id])]
|
||||
|
||||
(t/is (some? component))
|
||||
(t/is (nil? (:objects component)))))
|
||||
|
||||
;; Now proceed to delete a component
|
||||
(update-file!
|
||||
:file-id (:id file)
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes
|
||||
[{:type :del-component
|
||||
:id c-id}
|
||||
{:type :del-obj
|
||||
:page-id page-id
|
||||
:id s-id-1
|
||||
:ignore-touched true}])
|
||||
|
||||
;; ;; Check that component is marked as deleted
|
||||
(let [data {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file)}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (th/success? out))
|
||||
(let [result (:result out)
|
||||
component (get-in result [:data :components c-id])]
|
||||
(t/is (true? (:deleted component)))
|
||||
(t/is (some? (not-empty (:objects component))))))
|
||||
|
||||
;; Re-run the file-gc task
|
||||
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||
(let [row (th/db-get :file {:id (:id file)})]
|
||||
(t/is (true? (:has-media-trimmed row))))
|
||||
|
||||
;; Check that component is still there after file-gc task
|
||||
(let [data {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file)}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (th/success? out))
|
||||
(let [result (:result out)
|
||||
component (get-in result [:data :components c-id])]
|
||||
(t/is (true? (:deleted component)))
|
||||
(t/is (some? (not-empty (:objects component))))))
|
||||
|
||||
;; Now delete the last instance using deleted component
|
||||
(update-file!
|
||||
:file-id (:id file)
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes
|
||||
[{:type :del-obj
|
||||
:page-id page-id
|
||||
:id s-id-2
|
||||
:ignore-touched true}])
|
||||
|
||||
;; Now, we have deleted the usage of component if we pass file-gc,
|
||||
;; that component should be deleted
|
||||
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||
|
||||
;; Check that component is properly removed
|
||||
(let [data {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file)}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (th/success? out))
|
||||
(let [result (:result out)
|
||||
components (get-in result [:data :components])]
|
||||
(t/is (not (contains? components c-id)))))))
|
||||
|
||||
(t/deftest file-gc-with-components-2
|
||||
(let [storage (:app.storage/storage th/*system*)
|
||||
profile (th/create-profile* 1)
|
||||
file-1 (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared true})
|
||||
|
||||
file-2 (th/create-file* 2 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})
|
||||
|
||||
rel (th/link-file-to-library*
|
||||
{:file-id (:id file-2)
|
||||
:library-id (:id file-1)})
|
||||
|
||||
s-id-1 (uuid/random)
|
||||
s-id-2 (uuid/random)
|
||||
c-id (uuid/random)
|
||||
|
||||
f1-page-id (first (get-in file-1 [:data :pages]))
|
||||
f2-page-id (first (get-in file-2 [:data :pages]))]
|
||||
|
||||
;; Update file library inserting new component
|
||||
(update-file!
|
||||
:file-id (:id file-1)
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes
|
||||
[{:type :add-obj
|
||||
:page-id f1-page-id
|
||||
:id s-id-1
|
||||
:parent-id uuid/zero
|
||||
:frame-id uuid/zero
|
||||
:components-v2 true
|
||||
:obj (cts/setup-shape
|
||||
{:id s-id-1
|
||||
:name "Board"
|
||||
:frame-id uuid/zero
|
||||
:parent-id uuid/zero
|
||||
:type :frame
|
||||
:main-instance true
|
||||
:component-root true
|
||||
:component-file (:id file-1)
|
||||
:component-id c-id})}
|
||||
{:type :add-component
|
||||
:path ""
|
||||
:name "Board"
|
||||
:main-instance-id s-id-1
|
||||
:main-instance-page f1-page-id
|
||||
:id c-id
|
||||
:anotation nil}])
|
||||
|
||||
;; Instanciate a component in a different file
|
||||
(update-file!
|
||||
:file-id (:id file-2)
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes
|
||||
[{:type :add-obj
|
||||
:page-id f2-page-id
|
||||
:id s-id-2
|
||||
:parent-id uuid/zero
|
||||
:frame-id uuid/zero
|
||||
:components-v2 true
|
||||
:obj (cts/setup-shape
|
||||
{:id s-id-2
|
||||
:name "Board"
|
||||
:frame-id uuid/zero
|
||||
:parent-id uuid/zero
|
||||
:type :frame
|
||||
:main-instance false
|
||||
:component-root true
|
||||
:component-file (:id file-1)
|
||||
:component-id c-id})}])
|
||||
|
||||
;; Run the file-gc on file and library
|
||||
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file-1)})))
|
||||
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file-2)})))
|
||||
|
||||
;; Check that component exists
|
||||
(let [data {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file-1)}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (th/success? out))
|
||||
(let [result (:result out)
|
||||
component (get-in result [:data :components c-id])]
|
||||
|
||||
(t/is (some? component))
|
||||
(t/is (nil? (:objects component)))))
|
||||
|
||||
;; Now proceed to delete a component
|
||||
(update-file!
|
||||
:file-id (:id file-1)
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes
|
||||
[{:type :del-component
|
||||
:id c-id}
|
||||
{:type :del-obj
|
||||
:page-id f1-page-id
|
||||
:id s-id-1
|
||||
:ignore-touched true}])
|
||||
|
||||
;; Check that component is marked as deleted
|
||||
(let [data {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file-1)}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (th/success? out))
|
||||
(let [result (:result out)
|
||||
component (get-in result [:data :components c-id])]
|
||||
(t/is (true? (:deleted component)))
|
||||
(t/is (some? (not-empty (:objects component))))))
|
||||
|
||||
;; Re-run the file-gc task
|
||||
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file-1)})))
|
||||
(t/is (false? (th/run-task! :file-gc {:min-age 0 :file-id (:id file-2)})))
|
||||
|
||||
;; Check that component is still there after file-gc task
|
||||
(let [data {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file-1)}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (th/success? out))
|
||||
(let [result (:result out)
|
||||
component (get-in result [:data :components c-id])]
|
||||
(t/is (true? (:deleted component)))
|
||||
(t/is (some? (not-empty (:objects component))))))
|
||||
|
||||
;; Now delete the last instance using deleted component
|
||||
(update-file!
|
||||
:file-id (:id file-2)
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes
|
||||
[{:type :del-obj
|
||||
:page-id f2-page-id
|
||||
:id s-id-2
|
||||
:ignore-touched true}])
|
||||
|
||||
;; Mark
|
||||
(th/db-exec! ["update file set has_media_trimmed = false where id = ?" (:id file-1)])
|
||||
|
||||
;; Now, we have deleted the usage of component if we pass file-gc,
|
||||
;; that component should be deleted
|
||||
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file-1)})))
|
||||
|
||||
;; Check that component is properly removed
|
||||
(let [data {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file-1)}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (th/success? out))
|
||||
(let [result (:result out)
|
||||
components (get-in result [:data :components])]
|
||||
(t/is (not (contains? components c-id)))))))
|
||||
|
||||
|
||||
@@ -6,4 +6,4 @@
|
||||
|
||||
(ns app.common.files.defaults)
|
||||
|
||||
(def version 57)
|
||||
(def version 59)
|
||||
|
||||
@@ -1130,6 +1130,21 @@
|
||||
(update :pages-index dissoc nil)
|
||||
(update :pages-index update-vals update-page))))
|
||||
|
||||
(defn migrate-up-59
|
||||
[data]
|
||||
(letfn [(fix-touched [elem]
|
||||
(cond-> elem (string? elem) keyword))
|
||||
|
||||
(update-shape [shape]
|
||||
(d/update-when shape :touched #(into #{} (map fix-touched) %)))
|
||||
|
||||
(update-container [container]
|
||||
(d/update-when container :objects update-vals update-shape))]
|
||||
|
||||
(-> data
|
||||
(update :pages-index update-vals update-container)
|
||||
(update :components update-vals update-container))))
|
||||
|
||||
(def migrations
|
||||
"A vector of all applicable migrations"
|
||||
[{:id 2 :migrate-up migrate-up-2}
|
||||
@@ -1178,5 +1193,6 @@
|
||||
{:id 54 :migrate-up migrate-up-54}
|
||||
{:id 55 :migrate-up migrate-up-55}
|
||||
{:id 56 :migrate-up migrate-up-56}
|
||||
{:id 57 :migrate-up migrate-up-57}])
|
||||
{:id 57 :migrate-up migrate-up-57}
|
||||
{:id 59 :migrate-up migrate-up-59}])
|
||||
|
||||
|
||||
@@ -320,6 +320,35 @@
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes shape-ids detach-shape))))))
|
||||
|
||||
|
||||
(defmethod repair-error :shape-ref-cycle
|
||||
[_ {:keys [shape args] :as error} file-data _]
|
||||
(let [repair-component
|
||||
(fn [component]
|
||||
(let [objects (:objects component) ;; we only have encounter this on deleted components,
|
||||
;; so the relevant objects are inside the component
|
||||
to-detach (->> (:cycles-ids args)
|
||||
(map #(get objects %))
|
||||
(map #(ctn/get-head-shape objects %))
|
||||
(map :id)
|
||||
distinct
|
||||
(mapcat #(ctn/get-children-in-instance objects %))
|
||||
(map :id)
|
||||
set)]
|
||||
|
||||
(update component :objects
|
||||
(fn [objects]
|
||||
(reduce-kv (fn [acc k v]
|
||||
(if (contains? to-detach k)
|
||||
(assoc acc k (ctk/detach-shape v))
|
||||
(assoc acc k v)))
|
||||
{}
|
||||
objects)))))]
|
||||
(log/dbg :hint "repairing component :shape-ref-cycle" :id (:id shape) :name (:name shape))
|
||||
(-> (pcb/empty-changes nil nil)
|
||||
(pcb/with-library-data file-data)
|
||||
(pcb/update-component (:id shape) repair-component))))
|
||||
|
||||
(defmethod repair-error :shape-ref-in-main
|
||||
[_ {:keys [shape page-id] :as error} file-data _]
|
||||
(let [repair-shape
|
||||
|
||||
@@ -55,7 +55,8 @@
|
||||
:component-nil-objects-not-allowed
|
||||
:instance-head-not-frame
|
||||
:misplaced-slot
|
||||
:missing-slot})
|
||||
:missing-slot
|
||||
:shape-ref-cycle})
|
||||
|
||||
(def ^:private schema:error
|
||||
[:map {:title "ValidationError"}
|
||||
@@ -482,6 +483,18 @@
|
||||
"This deleted component has children with the same swap slot"
|
||||
component file nil))))
|
||||
|
||||
(defn check-ref-cycles
|
||||
[component file]
|
||||
(let [cycles-ids (->> component
|
||||
:objects
|
||||
vals
|
||||
(filter #(= (:id %) (:shape-ref %)))
|
||||
(map :id))]
|
||||
|
||||
(when (seq cycles-ids)
|
||||
(report-error :shape-ref-cycle
|
||||
"This deleted component has shapes with shape-ref pointing to self"
|
||||
component file nil :cycles-ids cycles-ids))))
|
||||
|
||||
(defn- check-component
|
||||
"Validate semantic coherence of a component. Report all errors found."
|
||||
@@ -491,7 +504,8 @@
|
||||
"Objects list cannot be nil"
|
||||
component file nil))
|
||||
(when (:deleted component)
|
||||
(check-component-duplicate-swap-slot component file)))
|
||||
(check-component-duplicate-swap-slot component file)
|
||||
(check-ref-cycles component file)))
|
||||
|
||||
(defn- get-orphan-shapes
|
||||
[{:keys [objects] :as page}]
|
||||
|
||||
@@ -304,7 +304,12 @@
|
||||
(and (some? (ctk/get-swap-slot ref-shape))
|
||||
(nil? (ctk/get-swap-slot shape))
|
||||
(not= (:id shape) shape-id))
|
||||
(pcb/update-shapes [(:id shape)] #(ctk/set-swap-slot % (ctk/get-swap-slot ref-shape))))))]
|
||||
(pcb/update-shapes [(:id shape)] #(ctk/set-swap-slot % (ctk/get-swap-slot ref-shape)))
|
||||
|
||||
;; If we can't get the ref-shape (e.g. it's in an external library not linked),
|
||||
;: we can't do a suitable advance. So it's better to detach the shape
|
||||
(nil? ref-shape)
|
||||
(pcb/update-shapes [(:id shape)] ctk/detach-shape))))]
|
||||
|
||||
(reduce skip-near changes children)))
|
||||
|
||||
|
||||
@@ -335,24 +335,28 @@
|
||||
(true? (= (:id component) (:id ref-component)))))
|
||||
|
||||
(defn find-swap-slot
|
||||
[shape container file libraries]
|
||||
(if-let [swap-slot (ctk/get-swap-slot shape)]
|
||||
swap-slot
|
||||
(let [ref-shape (find-ref-shape file
|
||||
container
|
||||
libraries
|
||||
shape
|
||||
:include-deleted? true
|
||||
:with-context? true)
|
||||
shape-meta (meta ref-shape)
|
||||
ref-file (:file shape-meta)
|
||||
ref-container (:container shape-meta)]
|
||||
(when ref-shape
|
||||
(if-let [swap-slot (ctk/get-swap-slot ref-shape)]
|
||||
swap-slot
|
||||
(if (ctk/main-instance? ref-shape)
|
||||
(:id shape)
|
||||
(find-swap-slot ref-shape ref-container ref-file libraries)))))))
|
||||
([shape container file libraries]
|
||||
(find-swap-slot shape container file libraries #{}))
|
||||
([shape container file libraries viewed-ids]
|
||||
(if (contains? viewed-ids (:id shape)) ;; prevent cycles
|
||||
nil
|
||||
(if-let [swap-slot (ctk/get-swap-slot shape)]
|
||||
swap-slot
|
||||
(let [ref-shape (find-ref-shape file
|
||||
container
|
||||
libraries
|
||||
shape
|
||||
:include-deleted? true
|
||||
:with-context? true)
|
||||
shape-meta (meta ref-shape)
|
||||
ref-file (:file shape-meta)
|
||||
ref-container (:container shape-meta)]
|
||||
(when ref-shape
|
||||
(if-let [swap-slot (ctk/get-swap-slot ref-shape)]
|
||||
swap-slot
|
||||
(if (ctk/main-instance? ref-shape)
|
||||
(:id shape)
|
||||
(find-swap-slot ref-shape ref-container ref-file libraries (conj viewed-ids (:id shape)))))))))))
|
||||
|
||||
(defn match-swap-slot?
|
||||
[shape-main shape-inst container-inst container-main file libraries]
|
||||
@@ -738,16 +742,20 @@
|
||||
(:component-id shape) "@"
|
||||
:else "-")
|
||||
|
||||
(when (and (:component-file shape) component-file)
|
||||
(when (:component-file shape)
|
||||
(str/format "<%s> "
|
||||
(if (= (:id component-file) (:id file))
|
||||
"local"
|
||||
(:name component-file))))
|
||||
(if component-file
|
||||
(if (= (:id component-file) (:id file))
|
||||
"local"
|
||||
(:name component-file))
|
||||
(if show-ids
|
||||
(str/format "¿%s?" (:component-file shape))
|
||||
"?"))))
|
||||
|
||||
(or (:name component-shape)
|
||||
(str/format "?%s"
|
||||
(when show-ids
|
||||
(str " " (:shape-ref shape)))))
|
||||
(if show-ids
|
||||
(str/format "¿%s?" (:shape-ref shape))
|
||||
"?"))
|
||||
|
||||
(when (and show-ids component-shape)
|
||||
(str/format " %s" (:id component-shape)))
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
[:main-instance {:optional true} :boolean]
|
||||
[:remote-synced {:optional true} :boolean]
|
||||
[:shape-ref {:optional true} ::sm/uuid]
|
||||
[:touched {:optional true} [:maybe [:set :keyword]]]
|
||||
[:blocked {:optional true} :boolean]
|
||||
[:collapsed {:optional true} :boolean]
|
||||
[:locked {:optional true} :boolean]
|
||||
|
||||
@@ -103,6 +103,83 @@
|
||||
(t/is (= (:shape-ref copy-nested-ellipse) (thi/id :nested-ellipse)))
|
||||
(t/is (nil? (ctk/get-swap-slot copy-nested-ellipse)))))
|
||||
|
||||
(t/deftest test-advance-in-library
|
||||
(let [;; ==== Setup
|
||||
library (setup-file)
|
||||
file (-> (thf/sample-file :file2)
|
||||
(thc/instantiate-component :c-big-board
|
||||
:copy-big-board
|
||||
:library library
|
||||
:children-labels [:copy-h-board-with-ellipse
|
||||
:copy-nested-h-ellipse
|
||||
:copy-nested-ellipse]))
|
||||
page (thf/current-page file)
|
||||
|
||||
;; ==== Action
|
||||
changes (cll/generate-detach-instance (-> (pcb/empty-changes nil)
|
||||
(pcb/with-page page)
|
||||
(pcb/with-objects (:objects page)))
|
||||
page
|
||||
{(:id file) file
|
||||
(:id library) library}
|
||||
(thi/id :copy-big-board))
|
||||
file' (thf/apply-changes file changes)
|
||||
|
||||
;; ==== Get
|
||||
copy-h-board-with-ellipse (ths/get-shape file' :copy-h-board-with-ellipse)
|
||||
copy-nested-h-ellipse (ths/get-shape file' :copy-nested-h-ellipse)
|
||||
copy-nested-ellipse (ths/get-shape file' :copy-nested-ellipse)]
|
||||
|
||||
;; ==== Check
|
||||
|
||||
;; It should the same as above, but in an external library.
|
||||
(thf/dump-file file)
|
||||
(t/is (ctk/instance-root? copy-h-board-with-ellipse))
|
||||
(t/is (= (:shape-ref copy-h-board-with-ellipse) (thi/id :board-with-ellipse)))
|
||||
(t/is (nil? (ctk/get-swap-slot copy-h-board-with-ellipse)))
|
||||
|
||||
(t/is (ctk/instance-head? copy-nested-h-ellipse))
|
||||
(t/is (= (:shape-ref copy-nested-h-ellipse) (thi/id :nested-h-ellipse)))
|
||||
(t/is (nil? (ctk/get-swap-slot copy-nested-h-ellipse)))
|
||||
|
||||
(t/is (not (ctk/instance-head? copy-nested-ellipse)))
|
||||
(t/is (= (:shape-ref copy-nested-ellipse) (thi/id :nested-ellipse)))
|
||||
(t/is (nil? (ctk/get-swap-slot copy-nested-ellipse)))))
|
||||
|
||||
(t/deftest test-advance-in-broken-library
|
||||
(let [;; ==== Setup
|
||||
library (setup-file)
|
||||
file (-> (thf/sample-file :file2)
|
||||
(thc/instantiate-component :c-big-board
|
||||
:copy-big-board
|
||||
:library library
|
||||
:children-labels [:copy-h-board-with-ellipse
|
||||
:copy-nested-h-ellipse
|
||||
:copy-nested-ellipse]))
|
||||
page (thf/current-page file)
|
||||
|
||||
;; ==== Action
|
||||
changes (cll/generate-detach-instance (-> (pcb/empty-changes nil)
|
||||
(pcb/with-page page)
|
||||
(pcb/with-objects (:objects page)))
|
||||
page
|
||||
{(:id file) file}
|
||||
(thi/id :copy-big-board))
|
||||
file' (thf/apply-changes file changes)
|
||||
|
||||
;; ==== Get
|
||||
copy-h-board-with-ellipse (ths/get-shape file' :copy-h-board-with-ellipse)
|
||||
copy-nested-h-ellipse (ths/get-shape file' :copy-nested-h-ellipse)
|
||||
copy-nested-ellipse (ths/get-shape file' :copy-nested-ellipse)]
|
||||
|
||||
;; ==== Check
|
||||
|
||||
;; If the main component cannot be found, because it's in a library that is
|
||||
;; not available, the nested copies should be detached too.
|
||||
(t/is (not (ctk/in-component-copy? copy-h-board-with-ellipse)))
|
||||
(t/is (not (ctk/in-component-copy? copy-nested-h-ellipse)))
|
||||
(t/is (not (ctk/in-component-copy? copy-nested-ellipse)))))
|
||||
|
||||
(t/deftest test-dont-advance-when-swapped-copy
|
||||
(let [;; ==== Setup
|
||||
file (-> (setup-file)
|
||||
|
||||
@@ -43,7 +43,6 @@ services:
|
||||
|
||||
environment:
|
||||
- EXTERNAL_UID=${CURRENT_USER_ID}
|
||||
- PENPOT_SECRET_KEY=super-secret-devenv-key
|
||||
# SMTP setup
|
||||
- PENPOT_SMTP_ENABLED=true
|
||||
- PENPOT_SMTP_DEFAULT_FROM=no-reply@example.com
|
||||
|
||||
@@ -201,7 +201,7 @@ services:
|
||||
environment:
|
||||
# Don't touch it; this uses an internal docker network to
|
||||
# communicate with the frontend.
|
||||
PENPOT_PUBLIC_URI: http://penpot-frontend
|
||||
PENPOT_PUBLIC_URI: http://penpot-frontend:8080
|
||||
|
||||
## Redis is used for the websockets notifications.
|
||||
PENPOT_REDIS_URI: redis://penpot-redis/0
|
||||
|
||||
@@ -30,7 +30,7 @@ flags (that just enables or disables something). All flags are set in a single
|
||||
format: <code class="language-bash"><enable|disable>-\<flag-name></code>. For example:
|
||||
|
||||
```bash
|
||||
PENPOT_FLAGS: enable-smpt disable-registration disable-email-verification
|
||||
PENPOT_FLAGS: enable-smtp disable-registration disable-email-verification
|
||||
```
|
||||
|
||||
### Registration ###
|
||||
|
||||
@@ -124,10 +124,10 @@ title: 06· Styling
|
||||
<li><strong>Square</strong> - Adds a rectangular ending to the end of the path.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="radius">Corner radius</h2>
|
||||
<p>You can set values for corner radius to rectangles and images. There’s also the option to edit each corner individually.</p>
|
||||
<h2 id="radius">Border radius</h2>
|
||||
<p>You can customize the border radius of rectangles and images, with the option to customize each corner individually.</p>
|
||||
<figure>
|
||||
<video title="Corner radius" muted="" playsinline="" controls="" width="100%" poster="/img/styling/corners.webp" height="auto">
|
||||
<video title="Border radius" muted="" playsinline="" controls="" width="100%" poster="/img/styling/corners.webp" height="auto">
|
||||
<source src="/img/styling/corners.mp4" type="video/mp4">
|
||||
</video>
|
||||
</figure>
|
||||
|
||||
@@ -36,9 +36,10 @@ member is allowed to do depends on their permissions.</p>
|
||||
<h3>Team roles</h3>
|
||||
<p>These are the team roles currently available at Penpot:</p>
|
||||
<ul>
|
||||
<li><strong>Owner:</strong> There's only one owner per team, the role is automatically assigned to the team creator. Owners have permissions to change every other member role, including transfering ownership. Owners can update team settings, invite members and delete teams.</strong></li>
|
||||
<li><strong>Admin:</strong> Permissions to change every other member role except owners. Can invite members and update team settings.</strong></li>
|
||||
<li><strong>Editor:</strong> Without permissions to change member roles, invite members or update team settings.</strong></li>
|
||||
<li><strong>Viewer:</strong> Viewers can view, comment on and inspect files but will not be able to edit them, nor do they have permissions to manage team settings.</strong></li>
|
||||
<li><strong>Editor:</strong> Editors can create, import, edit and manage files and libraries, but do not have permissions to manage team settings.</strong></li>
|
||||
<li><strong>Admin:</strong> Admins have the same permissions as editors, with the added ability to change every other member's role except owners. They can invite members and update team settings.</strong></li>
|
||||
<li><strong>Owner:</strong> There's only one owner per team, the role is automatically assigned to the team creator. Owners have all the permissions of admins, with the additional ability to change any member's role, including transferring ownership. Owners can update team settings, invite members and delete teams.</strong></li>
|
||||
</ul>
|
||||
<figure><img src="/img/teams/teams-permissions.webp" alt="Team members" /></figure>
|
||||
<p class="advice">More team roles will be eventually available, as well as fine grained permissions management to control members access and actions.</p>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3.5 3.5h-2m2 0v-2m0 2h9m-9 0v9m9-9v-2m0 2h2m-2 0v9m0 0h2m-2 0v2m0-2h-9m0 0v2m0-2h-2"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 214 B |
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4.88 1.5H2.945c-.798 0-1.445.647-1.445 1.445v1.74M4.88 14.5H2.945c-.798 0-1.445-.842-1.445-1.64v-1.74m13-6.24V2.945c0-.798-.647-1.445-1.445-1.445H11.12m3.38 9.62v1.935c0 .798-.647 1.445-1.445 1.445H11.12"/>
|
||||
</svg>
|
||||
<path d="M3.5 3.5h-2m2 0v-2m0 2h9m-9 0v9m9-9v-2m0 2h2m-2 0v9m0 0h2m-2 0v2m0-2h-9m0 0v2m0-2h-2"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 335 B After Width: | Height: | Size: 214 B |
@@ -148,6 +148,11 @@
|
||||
(let [f (obj/get global "externalContextInfo")]
|
||||
(when (fn? f) (f))))
|
||||
|
||||
(defn initialize-external-context-info
|
||||
[]
|
||||
(let [f (obj/get global "initializeExternalConfigInfo")]
|
||||
(when (fn? f) (f))))
|
||||
|
||||
;; --- Helper Functions
|
||||
|
||||
(defn ^boolean check-browser? [candidate]
|
||||
|
||||
@@ -30,20 +30,21 @@
|
||||
[app.util.i18n :as i18n]
|
||||
[app.util.theme :as theme]
|
||||
[beicon.v2.core :as rx]
|
||||
[cuerdas.core :as str]
|
||||
[debug]
|
||||
[features]
|
||||
[potok.v2.core :as ptk]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(log/setup! {:app :info})
|
||||
(log/set-level! :debug)
|
||||
|
||||
(when (= :browser cf/target)
|
||||
(log/info :version (:full cf/version)
|
||||
:asserts *assert*
|
||||
:build-date cf/build-date
|
||||
:public-uri (dm/str cf/public-uri))
|
||||
(log/info :flags (str/join "," (map name cf/flags))))
|
||||
(log/inf :version (:full cf/version)
|
||||
:asserts *assert*
|
||||
:build-date cf/build-date
|
||||
:public-uri (dm/str cf/public-uri))
|
||||
(doseq [flag cf/flags]
|
||||
(log/dbg :hint "flag enabled" :flag (name flag))))
|
||||
|
||||
(declare reinit)
|
||||
|
||||
|
||||
@@ -207,6 +207,7 @@
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(when (is-authenticated? profile)
|
||||
(cf/initialize-external-context-info)
|
||||
(->> (rx/concat
|
||||
(rx/of (profile-fetched profile)
|
||||
(fetch-teams)
|
||||
|
||||
@@ -191,6 +191,25 @@
|
||||
(watch [it state _]
|
||||
(update-color* it state color file-id)))))
|
||||
|
||||
(defn update-color-data
|
||||
"Update color data without affecting the path location"
|
||||
[color file-id]
|
||||
(let [color (d/without-nils color)]
|
||||
|
||||
(dm/assert!
|
||||
"expected valid color data structure"
|
||||
(ctc/check-color! color))
|
||||
|
||||
(dm/assert!
|
||||
"expected file-id"
|
||||
(uuid? file-id))
|
||||
|
||||
(ptk/reify ::update-color-data
|
||||
ptk/WatchEvent
|
||||
(watch [it state _]
|
||||
(let [color (assoc color :name (dm/str (:path color) "/" (:name color)))]
|
||||
(update-color* it state color file-id))))))
|
||||
|
||||
(defn rename-color
|
||||
[file-id id new-name]
|
||||
(dm/assert!
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
[app.common.types.component :as ctk]
|
||||
[app.common.types.shape :as cts]
|
||||
[app.common.types.shape.layout :as ctl]
|
||||
[app.config :as cf]
|
||||
[app.main.ui.icons :as i]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
@@ -32,7 +31,7 @@
|
||||
i/flex-grid
|
||||
|
||||
:else
|
||||
(if (cf/external-feature-flag "boards-01" "test") i/board-2 i/board))
|
||||
i/board)
|
||||
;; TODO -> THUMBNAIL ICON
|
||||
:image i/img
|
||||
:line (if (cts/has-images? shape) i/img i/path)
|
||||
@@ -57,7 +56,7 @@
|
||||
(if main-instance?
|
||||
i/component
|
||||
(case type
|
||||
:frame (if (cf/external-feature-flag "boards-01" "test") i/board-2 i/board)
|
||||
:frame i/board
|
||||
:image i/img
|
||||
:shape i/path
|
||||
:text i/text
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
(ns app.main.ui.dashboard.placeholder
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.config :as cf]
|
||||
[app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]]
|
||||
[app.main.ui.ds.product.loader :refer [loader*]]
|
||||
[app.main.ui.icons :as i]
|
||||
@@ -44,7 +45,7 @@
|
||||
[:div {:class (stl/css :grid-empty-placeholder)}
|
||||
[:button {:class (stl/css :create-new)
|
||||
:on-click on-click}
|
||||
i/add]])))
|
||||
(if (cf/external-feature-flag "add-file-01" "test") (tr "dashboard.add-file") i/add)]])))
|
||||
|
||||
(mf/defc loading-placeholder
|
||||
[]
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
cursor: pointer;
|
||||
height: $s-160;
|
||||
margin: $s-8;
|
||||
text-transform: uppercase;
|
||||
border: $s-2 solid transparent;
|
||||
width: var(--th-width, #{g.$thumbnail-default-width});
|
||||
height: var(--th-height, #{g.$thumbnail-default-height});
|
||||
|
||||
@@ -63,7 +63,6 @@
|
||||
(def ^:icon arrow (icon-xref :arrow))
|
||||
(def ^:icon asc-sort (icon-xref :asc-sort))
|
||||
(def ^:icon board (icon-xref :board))
|
||||
(def ^:icon board-2 (icon-xref :board-2))
|
||||
(def ^:icon boards-thumbnail (icon-xref :boards-thumbnail))
|
||||
(def ^:icon boolean-difference (icon-xref :boolean-difference))
|
||||
(def ^:icon boolean-exclude (icon-xref :boolean-exclude))
|
||||
|
||||
@@ -476,7 +476,7 @@
|
||||
|
||||
request-access?
|
||||
(and
|
||||
(= (:type data) :not-found)
|
||||
(or (= (:type data) :not-found) (= (:type data) :authentication))
|
||||
(or workspace? dashboard? view?)
|
||||
(or (:file-id info)
|
||||
(:team-id info)))]
|
||||
|
||||
@@ -89,8 +89,11 @@
|
||||
reverse-sort? (= :desc ordering)
|
||||
num-libs (count (mf/deref refs/workspace-libraries))
|
||||
|
||||
show-templates-02-test?
|
||||
(and (cf/external-feature-flag "templates-02" "test") (zero? num-libs))
|
||||
show-templates-04-test1?
|
||||
(and (cf/external-feature-flag "templates-04" "test1") (zero? num-libs))
|
||||
|
||||
show-templates-04-test2?
|
||||
(and (cf/external-feature-flag "templates-04" "test2") (zero? num-libs))
|
||||
|
||||
toggle-ordering
|
||||
(mf/use-fn
|
||||
@@ -160,11 +163,18 @@
|
||||
[:article {:class (stl/css :assets-bar)}
|
||||
[:div {:class (stl/css :assets-header)}
|
||||
(when-not ^boolean read-only?
|
||||
(if show-templates-02-test?
|
||||
(cond
|
||||
show-templates-04-test1?
|
||||
[:button {:class (stl/css :libraries-button)
|
||||
:on-click show-libraries-dialog
|
||||
:data-testid "libraries"}
|
||||
(tr "workspace.assets.add-library")]
|
||||
show-templates-04-test2?
|
||||
[:button {:class (stl/css :add-library-button)
|
||||
:on-click show-libraries-dialog
|
||||
:data-testid "libraries"}
|
||||
(tr "workspace.assets.add-library")]
|
||||
:else
|
||||
[:button {:class (stl/css :libraries-button)
|
||||
:on-click show-libraries-dialog
|
||||
:data-testid "libraries"}
|
||||
@@ -172,32 +182,32 @@
|
||||
i/library]
|
||||
(tr "workspace.assets.libraries")]))
|
||||
|
||||
(when-not show-templates-02-test?
|
||||
[:div {:class (stl/css :search-wrapper)}
|
||||
[:& search-bar {:on-change on-search-term-change
|
||||
:value term
|
||||
:placeholder (tr "workspace.assets.search")}
|
||||
[:button
|
||||
{:on-click on-open-menu
|
||||
:title (tr "workspace.assets.filter")
|
||||
:class (stl/css-case :section-button true
|
||||
:opened menu-open?)}
|
||||
i/filter-icon]]
|
||||
[:> context-menu*
|
||||
{:on-close on-menu-close
|
||||
:selectable true
|
||||
:selected section
|
||||
:show menu-open?
|
||||
:fixed true
|
||||
:min-width true
|
||||
:width size
|
||||
:top 158
|
||||
:left 18
|
||||
:options options}]
|
||||
[:> icon-button* {:variant "ghost"
|
||||
:aria-label (tr "workspace.assets.sort")
|
||||
:on-click toggle-ordering
|
||||
:icon (if reverse-sort? "asc-sort" "desc-sort")}]])]
|
||||
|
||||
[:div {:class (stl/css :search-wrapper)}
|
||||
[:& search-bar {:on-change on-search-term-change
|
||||
:value term
|
||||
:placeholder (tr "workspace.assets.search")}
|
||||
[:button
|
||||
{:on-click on-open-menu
|
||||
:title (tr "workspace.assets.filter")
|
||||
:class (stl/css-case :section-button true
|
||||
:opened menu-open?)}
|
||||
i/filter-icon]]
|
||||
[:> context-menu*
|
||||
{:on-close on-menu-close
|
||||
:selectable true
|
||||
:selected section
|
||||
:show menu-open?
|
||||
:fixed true
|
||||
:min-width true
|
||||
:width size
|
||||
:top 158
|
||||
:left 18
|
||||
:options options}]
|
||||
[:> icon-button* {:variant "ghost"
|
||||
:aria-label (tr "workspace.assets.sort")
|
||||
:on-click toggle-ordering
|
||||
:icon (if reverse-sort? "asc-sort" "desc-sort")}]]]
|
||||
|
||||
[:& (mf/provider cmm/assets-filters) {:value filters}
|
||||
[:& (mf/provider cmm/assets-toggle-ordering) {:value toggle-ordering}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.config :as cf]
|
||||
[app.main.data.workspace.undo :as dwu]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
@@ -155,7 +154,7 @@
|
||||
:circle i/elipse
|
||||
:text i/text
|
||||
:path i/path
|
||||
:frame (if (cf/external-feature-flag "boards-01" "test") i/board-2 i/board)
|
||||
:frame i/board
|
||||
:group i/group
|
||||
:color i/drop-icon
|
||||
:typography i/text-palette
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.types.shape :as cts]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.main.data.workspace :as dw]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
@@ -336,7 +335,7 @@
|
||||
:on-click add-filter}
|
||||
[:div {:class (stl/css :filter-menu-item-name-wrapper)}
|
||||
[:span {:class (stl/css :filter-menu-item-icon)}
|
||||
(if (cf/external-feature-flag "boards-01" "test") i/board-2 i/board)]
|
||||
i/board]
|
||||
[:span {:class (stl/css :filter-menu-item-name)}
|
||||
(tr "workspace.sidebar.layers.frames")]]
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.media :as cm]
|
||||
[app.config :as cf]
|
||||
[app.main.data.events :as ev]
|
||||
[app.main.data.modal :as modal]
|
||||
[app.main.data.workspace :as dw]
|
||||
@@ -147,7 +146,7 @@
|
||||
:on-click select-drawtool
|
||||
:data-tool "frame"
|
||||
:data-testid "artboard-btn"}
|
||||
(if (cf/external-feature-flag "boards-01" "test") i/board-2 i/board)]]
|
||||
i/board]]
|
||||
[:li
|
||||
[:button
|
||||
{:title (tr "workspace.toolbar.rect" (sc/get-tooltip :draw-rect))
|
||||
|
||||
@@ -166,8 +166,8 @@
|
||||
|
||||
:replaceColor
|
||||
(fn [shapes old-color new-color]
|
||||
(let [old-color (parser/parse-color old-color)
|
||||
new-color (parser/parse-color new-color)]
|
||||
(let [old-color (parser/parse-color-data old-color)
|
||||
new-color (parser/parse-color-data new-color)]
|
||||
(cond
|
||||
(or (not (array? shapes)) (not (every? shape/shape-proxy? shapes)))
|
||||
(u/display-not-valid :replaceColor-shapes shapes)
|
||||
@@ -190,7 +190,9 @@
|
||||
shapes-by-color
|
||||
(->> (ctc/extract-all-colors shapes file-id shared-libs)
|
||||
(group-by :attrs))]
|
||||
(st/emit! (dwc/change-color-in-selected new-color (get shapes-by-color old-color) old-color))))))
|
||||
|
||||
(when-let [operations (get shapes-by-color old-color)]
|
||||
(st/emit! (dwc/change-color-in-selected operations new-color old-color)))))))
|
||||
|
||||
:getRoot
|
||||
(fn []
|
||||
|
||||
@@ -128,18 +128,19 @@
|
||||
;; image?: ImageData;
|
||||
;; }
|
||||
(defn format-color
|
||||
[{:keys [id name path color opacity ref-id ref-file gradient image] :as color-data}]
|
||||
[{:keys [id file-id name path color opacity ref-id ref-file gradient image] :as color-data}]
|
||||
(when (some? color-data)
|
||||
(obj/without-empty
|
||||
#js {:id (format-id id)
|
||||
:name name
|
||||
:path path
|
||||
:color color
|
||||
:opacity opacity
|
||||
:refId (format-id ref-id)
|
||||
:refFile (format-id ref-file)
|
||||
:gradient (format-gradient gradient)
|
||||
:image (format-image image)})))
|
||||
(let [id (or (format-id id) (format-id ref-id))
|
||||
file-id (or (format-id file-id) (format-id ref-file))]
|
||||
(obj/without-empty
|
||||
#js {:id (or (format-id id) (format-id ref-id))
|
||||
:fileId (or (format-id file-id) (format-id ref-file))
|
||||
:name name
|
||||
:path path
|
||||
:color color
|
||||
:opacity opacity
|
||||
:gradient (format-gradient gradient)
|
||||
:image (format-image image)}))))
|
||||
|
||||
;; Color & ColorShapeInfo
|
||||
(defn format-color-result
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
:$file {:enumerable false :get (constantly file-id)}
|
||||
|
||||
:id {:get (fn [] (dm/str id))}
|
||||
:fileId {:get #(dm/str file-id)}
|
||||
|
||||
:name
|
||||
{:this true
|
||||
@@ -98,7 +99,7 @@
|
||||
:else
|
||||
(let [color (-> (u/proxy->library-color self)
|
||||
(assoc :color value))]
|
||||
(st/emit! (dwl/update-color color file-id)))))}
|
||||
(st/emit! (dwl/update-color-data color file-id)))))}
|
||||
|
||||
:opacity
|
||||
{:this true
|
||||
@@ -115,7 +116,7 @@
|
||||
:else
|
||||
(let [color (-> (u/proxy->library-color self)
|
||||
(assoc :opacity value))]
|
||||
(st/emit! (dwl/update-color color file-id)))))}
|
||||
(st/emit! (dwl/update-color-data color file-id)))))}
|
||||
|
||||
:gradient
|
||||
{:this true
|
||||
@@ -133,7 +134,7 @@
|
||||
:else
|
||||
(let [color (-> (u/proxy->library-color self)
|
||||
(assoc :gradient value))]
|
||||
(st/emit! (dwl/update-color color file-id))))))}
|
||||
(st/emit! (dwl/update-color-data color file-id))))))}
|
||||
|
||||
:image
|
||||
{:this true
|
||||
@@ -151,7 +152,7 @@
|
||||
:else
|
||||
(let [color (-> (u/proxy->library-color self)
|
||||
(assoc :image value))]
|
||||
(st/emit! (dwl/update-color color file-id))))))}
|
||||
(st/emit! (dwl/update-color-data color file-id))))))}
|
||||
|
||||
:remove
|
||||
(fn []
|
||||
|
||||
@@ -111,28 +111,36 @@
|
||||
|
||||
;; export interface Color {
|
||||
;; id?: string;
|
||||
;; fileId?: string;
|
||||
;; refId?: string; // deprecated
|
||||
;; refFile?: string; // deprecated
|
||||
;; name?: string;
|
||||
;; path?: string;
|
||||
;; color?: string;
|
||||
;; opacity?: number;
|
||||
;; refId?: string;
|
||||
;; refFile?: string;
|
||||
;; gradient?: Gradient;
|
||||
;; image?: ImageData;
|
||||
;; }
|
||||
(defn parse-color-data
|
||||
[^js color]
|
||||
(when (some? color)
|
||||
(let [id (or (obj/get color "id") (obj/get color "refId"))
|
||||
file-id (or (obj/get color "fileId") (obj/get color "refFile"))]
|
||||
(d/without-nils
|
||||
{:id (parse-id id)
|
||||
:file-id (parse-id file-id)
|
||||
:color (-> (obj/get color "color") parse-hex)
|
||||
:opacity (obj/get color "opacity")
|
||||
:gradient (-> (obj/get color "gradient") parse-gradient)
|
||||
:image (-> (obj/get color "image") parse-image-data)}))))
|
||||
|
||||
(defn parse-color
|
||||
[^js color]
|
||||
(when (some? color)
|
||||
(d/without-nils
|
||||
{:id (-> (obj/get color "id") parse-id)
|
||||
:name (obj/get color "name")
|
||||
:path (obj/get color "path")
|
||||
:color (-> (obj/get color "color") parse-hex)
|
||||
:opacity (obj/get color "opacity")
|
||||
:ref-id (-> (obj/get color "refId") parse-id)
|
||||
:ref-file (-> (obj/get color "refFile") parse-id)
|
||||
:gradient (-> (obj/get color "gradient") parse-gradient)
|
||||
:image (-> (obj/get color "image") parse-image-data)})))
|
||||
(-> (parse-color-data color)
|
||||
(assoc :name (obj/get color "name")
|
||||
:path (obj/get color "path"))))))
|
||||
|
||||
;; export interface Shadow {
|
||||
;; id?: string;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* Copyright (c) KALEIDOS INC
|
||||
*/
|
||||
|
||||
import { createInline } from "./Inline.js";
|
||||
import { createInline, isLikeInline } from "./Inline.js";
|
||||
import {
|
||||
createEmptyParagraph,
|
||||
createParagraph,
|
||||
@@ -14,6 +14,31 @@ import {
|
||||
} from "./Paragraph.js";
|
||||
import { isDisplayBlock, normalizeStyles } from "./Style.js";
|
||||
|
||||
/**
|
||||
* Returns if the content fragment should be treated as
|
||||
* inline content and not a paragraphed one.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isContentFragmentFromDocumentInline(document) {
|
||||
const nodeIterator = document.createNodeIterator(
|
||||
document.documentElement,
|
||||
NodeFilter.SHOW_ELEMENT,
|
||||
);
|
||||
let currentNode = nodeIterator.nextNode();
|
||||
while (currentNode) {
|
||||
if (["HTML", "HEAD", "BODY"].includes(currentNode.nodeName)) {
|
||||
currentNode = nodeIterator.nextNode();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isLikeInline(currentNode)) return false;
|
||||
|
||||
currentNode = nodeIterator.nextNode();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps any HTML into a valid content DOM element.
|
||||
*
|
||||
@@ -58,6 +83,13 @@ export function mapContentFragmentFromDocument(document, root, styleDefaults) {
|
||||
}
|
||||
|
||||
fragment.appendChild(currentParagraph);
|
||||
if (fragment.children.length === 1) {
|
||||
const isContentInline = isContentFragmentFromDocumentInline(document);
|
||||
if (isContentInline) {
|
||||
currentParagraph.dataset.inline = "force";
|
||||
}
|
||||
}
|
||||
|
||||
return fragment;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
splitParagraph,
|
||||
mergeParagraphs,
|
||||
fixParagraph,
|
||||
createParagraph,
|
||||
} from "../content/dom/Paragraph.js";
|
||||
import {
|
||||
removeBackward,
|
||||
@@ -42,7 +43,7 @@ import { getTextNodeLength, getClosestTextNode, isTextNode } from "../content/do
|
||||
import TextNodeIterator from "../content/dom/TextNodeIterator.js";
|
||||
import TextEditor from "../TextEditor.js";
|
||||
import CommandMutations from "../commands/CommandMutations.js";
|
||||
import { setRootStyles } from "../content/dom/Root.js";
|
||||
import { isRoot, setRootStyles } from "../content/dom/Root.js";
|
||||
import { SelectionDirection } from "./SelectionDirection.js";
|
||||
import SafeGuard from "./SafeGuard.js";
|
||||
|
||||
@@ -946,6 +947,15 @@ export class SelectionController extends EventTarget {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is true if the current focus node is a root.
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isRootFocus() {
|
||||
return isRoot(this.focusNode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates that we have multiple nodes selected.
|
||||
*
|
||||
@@ -1024,7 +1034,23 @@ export class SelectionController extends EventTarget {
|
||||
* @param {DocumentFragment} fragment
|
||||
*/
|
||||
insertPaste(fragment) {
|
||||
const numParagraphs = fragment.children.length;
|
||||
if (fragment.children.length === 1
|
||||
&& fragment.firstElementChild?.dataset?.inline === "force"
|
||||
) {
|
||||
if (this.isInlineStart) {
|
||||
this.focusInline.before(...fragment.firstElementChild.children)
|
||||
} else if (this.isInlineEnd) {
|
||||
this.focusInline.after(...fragment.firstElementChild.children);
|
||||
} else {
|
||||
const newInline = splitInline(
|
||||
this.focusInline,
|
||||
this.focusOffset
|
||||
)
|
||||
this.focusInline.after(...fragment.firstElementChild.children, newInline)
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isParagraphStart) {
|
||||
this.focusParagraph.before(fragment);
|
||||
} else if (this.isParagraphEnd) {
|
||||
@@ -1045,7 +1071,6 @@ export class SelectionController extends EventTarget {
|
||||
* @param {DocumentFragment} fragment
|
||||
*/
|
||||
replaceWithPaste(fragment) {
|
||||
const numParagraphs = fragment.children.length;
|
||||
this.removeSelected();
|
||||
this.insertPaste(fragment);
|
||||
}
|
||||
@@ -1201,6 +1226,16 @@ export class SelectionController extends EventTarget {
|
||||
);
|
||||
} else if (this.isLineBreakFocus) {
|
||||
this.focusNode.replaceWith(new Text(newText));
|
||||
} else if (this.isRootFocus) {
|
||||
const newTextNode = new Text(newText);
|
||||
const newInline = createInline(newTextNode, this.#currentStyle);
|
||||
const newParagraph = createParagraph([
|
||||
newInline
|
||||
], this.#currentStyle)
|
||||
this.focusNode.replaceChildren(
|
||||
newParagraph
|
||||
);
|
||||
return this.collapse(newTextNode, newText.length + 1);
|
||||
} else {
|
||||
throw new Error('Unknown node type');
|
||||
}
|
||||
|
||||
@@ -246,6 +246,251 @@ describe("SelectionController", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("`insertPaste` should insert a paragraph from a pasted fragment (at start)", () => {
|
||||
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);
|
||||
const paragraph = createParagraph([createInline(new Text("Hello"))]);
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.append(paragraph);
|
||||
|
||||
selectionController.insertPaste(fragment);
|
||||
expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement);
|
||||
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(
|
||||
"inline",
|
||||
);
|
||||
expect(textEditorMock.root.textContent).toBe("Hello, World!");
|
||||
expect(textEditorMock.root.firstChild.firstChild.firstChild).toBeInstanceOf(
|
||||
Text,
|
||||
);
|
||||
expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe(
|
||||
"Hello",
|
||||
);
|
||||
expect(
|
||||
textEditorMock.root.lastChild.firstChild.firstChild.nodeValue,
|
||||
).toBe(", World!");
|
||||
});
|
||||
|
||||
test("`insertPaste` should insert a paragraph from a pasted fragment (at middle)", () => {
|
||||
const textEditorMock =
|
||||
TextEditorMock.createTextEditorMockWithText("Lorem dolor");
|
||||
const root = textEditorMock.root;
|
||||
const selection = document.getSelection();
|
||||
const selectionController = new SelectionController(
|
||||
textEditorMock,
|
||||
selection,
|
||||
);
|
||||
focus(selection, textEditorMock, root.firstChild.firstChild.firstChild, "Lorem ".length);
|
||||
const paragraph = createParagraph([createInline(new Text("ipsum "))]);
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.append(paragraph);
|
||||
|
||||
selectionController.insertPaste(fragment);
|
||||
expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement);
|
||||
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(
|
||||
"inline",
|
||||
);
|
||||
expect(textEditorMock.root.textContent).toBe("Lorem ipsum dolor");
|
||||
expect(textEditorMock.root.firstChild.firstChild.firstChild).toBeInstanceOf(
|
||||
Text,
|
||||
);
|
||||
expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe(
|
||||
"Lorem ",
|
||||
);
|
||||
expect(textEditorMock.root.children.item(1).firstChild.firstChild.nodeValue).toBe(
|
||||
"ipsum ",
|
||||
);
|
||||
expect(textEditorMock.root.lastChild.firstChild.firstChild.nodeValue).toBe(
|
||||
"dolor",
|
||||
);
|
||||
});
|
||||
|
||||
test("`insertPaste` should insert a paragraph from a pasted fragment (at end)", () => {
|
||||
const textEditorMock = TextEditorMock.createTextEditorMockWithText("Hello");
|
||||
const root = textEditorMock.root;
|
||||
const selection = document.getSelection();
|
||||
const selectionController = new SelectionController(
|
||||
textEditorMock,
|
||||
selection,
|
||||
);
|
||||
focus(
|
||||
selection,
|
||||
textEditorMock,
|
||||
root.firstChild.firstChild.firstChild,
|
||||
"Hello".length,
|
||||
);
|
||||
const paragraph = createParagraph([createInline(new Text(", World!"))]);
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.append(paragraph);
|
||||
|
||||
selectionController.insertPaste(fragment);
|
||||
expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement);
|
||||
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(
|
||||
"inline",
|
||||
);
|
||||
expect(textEditorMock.root.textContent).toBe("Hello, World!");
|
||||
expect(textEditorMock.root.firstChild.firstChild.firstChild).toBeInstanceOf(
|
||||
Text,
|
||||
);
|
||||
expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe(
|
||||
"Hello",
|
||||
);
|
||||
expect(
|
||||
textEditorMock.root.lastChild.firstChild.firstChild.nodeValue,
|
||||
).toBe(", World!");
|
||||
});
|
||||
|
||||
test("`insertPaste` should insert an inline from a pasted fragment (at start)", () => {
|
||||
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,
|
||||
);
|
||||
const paragraph = createParagraph([createInline(new Text("Hello"))]);
|
||||
paragraph.dataset.inline = "force";
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.append(paragraph);
|
||||
|
||||
selectionController.insertPaste(fragment);
|
||||
expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement);
|
||||
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(
|
||||
"inline",
|
||||
);
|
||||
expect(textEditorMock.root.textContent).toBe("Hello, World!");
|
||||
expect(textEditorMock.root.firstChild.firstChild.firstChild).toBeInstanceOf(
|
||||
Text,
|
||||
);
|
||||
expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe(
|
||||
"Hello",
|
||||
);
|
||||
expect(
|
||||
textEditorMock.root.firstChild.children.item(1).firstChild.nodeValue,
|
||||
).toBe(", World!");
|
||||
});
|
||||
|
||||
test("`insertPaste` should insert an inline from a pasted fragment (at middle)", () => {
|
||||
const textEditorMock =
|
||||
TextEditorMock.createTextEditorMockWithText("Lorem dolor");
|
||||
const root = textEditorMock.root;
|
||||
const selection = document.getSelection();
|
||||
const selectionController = new SelectionController(
|
||||
textEditorMock,
|
||||
selection,
|
||||
);
|
||||
focus(selection, textEditorMock, root.firstChild.firstChild.firstChild, "Lorem ".length);
|
||||
const paragraph = createParagraph([createInline(new Text("ipsum "))]);
|
||||
paragraph.dataset.inline = "force";
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.append(paragraph);
|
||||
|
||||
selectionController.insertPaste(fragment);
|
||||
expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement);
|
||||
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(
|
||||
"inline",
|
||||
);
|
||||
expect(textEditorMock.root.textContent).toBe("Lorem ipsum dolor");
|
||||
expect(textEditorMock.root.firstChild.firstChild.firstChild).toBeInstanceOf(
|
||||
Text,
|
||||
);
|
||||
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(2).firstChild.nodeValue,
|
||||
).toBe("dolor");
|
||||
});
|
||||
|
||||
test("`insertPaste` should insert an inline from a pasted fragment (at end)", () => {
|
||||
const textEditorMock = TextEditorMock.createTextEditorMockWithText("Hello");
|
||||
const root = textEditorMock.root;
|
||||
const selection = document.getSelection();
|
||||
const selectionController = new SelectionController(
|
||||
textEditorMock,
|
||||
selection,
|
||||
);
|
||||
focus(
|
||||
selection,
|
||||
textEditorMock,
|
||||
root.firstChild.firstChild.firstChild,
|
||||
"Hello".length,
|
||||
);
|
||||
const paragraph = createParagraph([
|
||||
createInline(new Text(", World!"))
|
||||
]);
|
||||
paragraph.dataset.inline = "force";
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.append(paragraph);
|
||||
|
||||
selectionController.insertPaste(fragment);
|
||||
expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement);
|
||||
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(
|
||||
"inline",
|
||||
);
|
||||
expect(textEditorMock.root.textContent).toBe("Hello, World!");
|
||||
expect(textEditorMock.root.firstChild.firstChild.firstChild).toBeInstanceOf(
|
||||
Text,
|
||||
);
|
||||
expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe(
|
||||
"Hello",
|
||||
);
|
||||
expect(textEditorMock.root.firstChild.children.item(1).firstChild.nodeValue).toBe(
|
||||
", World!",
|
||||
);
|
||||
});
|
||||
|
||||
test("`removeBackwardText` should remove text in backward direction (backspace)", () => {
|
||||
const textEditorMock =
|
||||
TextEditorMock.createTextEditorMockWithText("Hello, World!");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -394,6 +394,10 @@ msgstr "El token expirará el %s"
|
||||
msgid "dashboard.access-tokens.token-will-not-expire"
|
||||
msgstr "El token no tiene fecha de expiración"
|
||||
|
||||
#: src/app/main/ui/dashboard/placeholder.cljs:48
|
||||
msgid "dashboard.add-file"
|
||||
msgstr "Añadir archivo"
|
||||
|
||||
#: src/app/main/ui/dashboard/file_menu.cljs:311, src/app/main/ui/workspace/main_menu.cljs:585
|
||||
msgid "dashboard.add-shared"
|
||||
msgstr "Añadir como Biblioteca Compartida"
|
||||
|
||||
Reference in New Issue
Block a user