Compare commits

..

52 Commits
2.4.0 ... 2.4.2

Author SHA1 Message Date
Andrey Antukh
7ca98ddf21 📎 Add missing entry on changelog 2025-01-22 16:07:07 +01:00
Pablo Alba
15157c54b1 🐛 Fix shape-ref cycles 2025-01-22 16:05:50 +01:00
Pablo Alba
232b29cd89 Merge pull request #5627 from penpot/niwinz-file-gc-improvements
 Minor improvements to file-gc code merged in other commit
2025-01-21 09:58:29 +01:00
Andrey Antukh
da0704081f 📎 Normalize default opts for db/plan function 2025-01-20 23:23:31 +01:00
Andrey Antukh
066b1235a6 🐛 Pass correct default options on db/plan fn 2025-01-20 23:23:31 +01:00
Pablo Alba
141694dc8d Merge pull request #5598 from penpot/niwinz-file-gc-improvements
 Add efficiency improvements to file-gc task
2025-01-20 21:14:32 +01:00
Andrey Antukh
151aedcf91 ♻️ Make the components cleaning mechanism on file-gc task more efficient 2025-01-20 16:35:14 +01:00
Andrey Antukh
5513daf17d ♻️ Make the media cleaning mechanism on file-gc task more efficient
Replaces the use of db/cursor with db/plan, that teorethically allows
processing large results without consuming all result set in memory
2025-01-20 16:34:05 +01:00
Andrey Antukh
fde0f3c182 🐛 Pass correct default options on db/plan fn 2025-01-20 16:34:05 +01:00
Aitor Moreno
7b408e4db1 Merge pull request #5622 from penpot/niwinz-api-doc-fix
🐛 Fix api docs page issue
2025-01-20 15:21:45 +01:00
Pablo Alba
b8fd829f9d Merge pull request #5621 from penpot/marina/testab-hover-state-add-file
 A/B test switching '+' to 'Add file' in an empty project
2025-01-20 12:49:19 +01:00
Andrey Antukh
089a66881c Make frontend app setup logging message more easy to be read
Mainly printing flag per line, making it more easily for human eye look
if some feature is active or not
2025-01-20 12:40:28 +01:00
Andrey Antukh
667b5fb6ee 🐛 Fix missing methods reference from api docs page 2025-01-20 12:30:20 +01:00
Andrey Antukh
f0f89151c5 Merge pull request #5620 from penpot/palba-consolidate-board-icon-change
Consolidate board icon change
2025-01-20 12:22:07 +01:00
Andrey Antukh
1221d60357 Merge pull request #5617 from penpot/alotor-hotfix-plugins
🐛 Fix problem in plugins with `replaceColor` method
2025-01-20 12:21:16 +01:00
alonso.torres
f553fa10d8 🐛 Fix problem in plugins with replaceColor method 2025-01-20 12:02:54 +01:00
Marina López
96947b0219 A/B test switching '+' to 'Add file' in an empty project 2025-01-20 11:56:15 +01:00
Pablo Alba
e2900d9012 🎉 Change of boards icon 2025-01-20 11:07:13 +01:00
Pablo Alba
1f0e470419 Revert "🎉 Add A/B test of use of boards if we just change the icon for “standard” one"
This reverts commit 0c586551c4.
2025-01-20 11:06:25 +01:00
Andrey Antukh
079a945c2f Merge branch 'main' into staging 2025-01-20 11:02:08 +01:00
Madalena Melo
542d709541 📚 Add viewer role documentation
Add viewer role to the team roles; also made some tweaks to the descriptions of the other roles
2025-01-20 11:01:38 +01:00
Madalena Melo
4f1d5a19e4 Change the order to add clarity to admin and owner roles
Switched the order of the roles to make it more logical and add more clarity about admins and owners ability to edit
2025-01-20 10:26:52 +01:00
Madalena Melo
91b0c47244 Add detail to role descriptions
Added more context to each role's description; I tried to keep it brief while including more information about what each role can do both within the team as well as in terms of team management
2025-01-20 10:26:46 +01:00
Madalena Melo
a7a49e4b39 Viewer Role - Update index.njk
Add viewer role to the team roles; also made some tweaks to the descriptions of the other roles
2025-01-20 10:26:39 +01:00
Andrey Antukh
ba81b2b14d Merge pull request #5613 from penpot/superalex-fix-staging-reply-to-comment
🐛 Fix reply to comment
2025-01-17 15:23:28 +01:00
Alejandro Alonso
423c237d42 🐛 Fix reply to comment 2025-01-17 11:57:44 +01:00
Andrey Antukh
5a55884b9f Merge pull request #5602 from penpot/hiru-fix-detach-2
🐛 Fix detach when top copy is dangling and nested copy is not
2025-01-16 12:21:33 +01:00
Andrey Antukh
38fd343c53 Merge remote-tracking branch 'origin/main' into staging 2025-01-16 12:20:27 +01:00
adi-lb-phoenix
94976aa2b1 📚 Fix incorrect flag on configuration.md 2025-01-16 12:19:03 +01:00
Andrés Moya
5247d217ab 🐛 Fix detach when top copy is dangling and nested copy is not 2025-01-16 10:45:54 +01:00
Andrey Antukh
40693e6857 🐛 Make the PENPOT_SECRET_KEY optional
Fix a regression introduced with 2.4
2025-01-16 09:59:19 +01:00
Andrey Antukh
5c428b5aa5 🐛 Fix repeated password update on login
because the default options were not being passed in the verification
2025-01-16 09:59:19 +01:00
Andrey Antukh
e92ddee33a 🐳 Move devenv secret key env asignation to scripts
from the docker compose
2025-01-16 09:59:19 +01:00
Eva Marco
c121f459ba Merge pull request #5571 from penpot/andy-docs-radius
📚 Change term for border radius
2025-01-15 10:18:05 +01:00
Andrey Antukh
698a258290 Merge remote-tracking branch 'origin/main' into staging 2025-01-14 17:47:32 +01:00
Yamila Moreno
aa023d847d 🐳 Set correct internal url frontend url for exporter 2025-01-14 17:37:14 +01:00
Andrey Antukh
53f57dad0b Merge pull request #5575 from penpot/azazeln28-fix-text-editor-issue-9285
🐛 Fix text editor copy/paste issue
2025-01-14 16:50:34 +01:00
AzazelN28
d7d7535ab4 🐛 Fix text editor copy/paste issue 2025-01-14 11:20:38 +01:00
Pablo Alba
accc662e1c 🐛 Fix login is not shown on 404 2025-01-14 09:51:12 +01:00
andy
1efc1516e2 📚 Change term for border radius 2025-01-13 15:53:06 +01:00
Alejandro
b5d731ca72 Merge pull request #5559 from penpot/palba-fix-flags-not-setting-login
🐛 Fix feature flags not setting on login
2025-01-13 10:57:37 +01:00
Pablo Alba
e380289e34 🐛 Fix feature flags not setting on login 2025-01-10 21:25:55 +01:00
Andrey Antukh
b22323a484 Merge pull request #5547 from penpot/alotor-bugs-1
🐛 Fix problem when changing color libraries
2025-01-10 15:16:01 +01:00
alonso.torres
58dd23f9c7 🐛 Fix problem when changing color libraries 2025-01-10 12:33:16 +01:00
Andrey Antukh
54e7551d56 🐛 Backport comments decoding from develop
Mainly for backward compatibility with database
layout on comments tables from develop / v2.5
2025-01-10 12:20:53 +01:00
Andrey Antukh
404297f837 Merge pull request #5537 from penpot/azazeln28-fix-text-editor-issue-9716
🐛 Fix text editor issue 9716
2025-01-10 10:24:23 +01:00
Andrey Antukh
33f853ff2e Merge pull request #5511 from penpot/alotor-fix-touched-import
🐛 Fix error when importing files with touched components
2025-01-09 17:13:22 +01:00
alonso.torres
d16513be9d 🐛 Fix error when importing files with touched components 2025-01-09 16:58:40 +01:00
AzazelN28
ad077696b0 🐛 Fix Unknown node type when replacing whole root node 2025-01-09 16:28:32 +01:00
Andrey Antukh
1cbeafe85c Merge pull request #5525 from penpot/palba-testabc-add-library
A/B/C test for rename and change look and feel of "libraries" button
2025-01-09 08:34:05 +01:00
Pablo Alba
76c8523f44 Add test ABC renaming "Libraries" to "Add library" 2025-01-08 13:20:52 +01:00
Pablo Alba
f277d8b125 Revert " Add test AB renaming "Libraries" to "Add library""
This reverts commit 664cacbe9d.
2025-01-08 11:05:50 +01:00
50 changed files with 1613 additions and 739 deletions

View File

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

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

@@ -6,4 +6,4 @@
(ns app.common.files.defaults)
(def version 57)
(def version 59)

View File

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

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]

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

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

View File

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

View File

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

View File

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

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

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

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

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

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!");

View File

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"