0
Fork 0
mirror of https://github.com/penpot/penpot.git synced 2025-03-27 23:21:47 -05:00

Merge tag '2.4.2'

This commit is contained in:
Andrey Antukh 2025-01-22 16:34:37 +01:00
commit 6be1023c0a
43 changed files with 1521 additions and 701 deletions

View file

@ -1,10 +1,21 @@
# 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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]
@ -191,8 +192,7 @@
[:grow-type {:optional true}
[::sm/one-of grow-types]]
[:applied-tokens {:optional true} ::cto/applied-tokens]
[:plugin-data {:optional true} ::ctpg/plugin-data]
[:touched {:optional true} [:maybe [:set :keyword]]]])
[:plugin-data {:optional true} ::ctpg/plugin-data]])
(def schema:group-attrs
[:map {:title "GroupAttrs"}

View file

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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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});

View file

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

View file

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

View file

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

View file

@ -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")]]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1034,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) {
@ -1055,7 +1071,6 @@ export class SelectionController extends EventTarget {
* @param {DocumentFragment} fragment
*/
replaceWithPaste(fragment) {
const numParagraphs = fragment.children.length;
this.removeSelected();
this.insertPaste(fragment);
}

View file

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

View file

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