diff --git a/backend/src/app/media.clj b/backend/src/app/media.clj index c00ac00b24..bdf6ed1c49 100644 --- a/backend/src/app/media.clj +++ b/backend/src/app/media.clj @@ -59,8 +59,8 @@ :hint "seems like the url points to resource with unknown size")) (-> {:size size :mtype mtype} - (validation/validate-media-type!) - (validation/validate-media-size!))))] + (validation/check-media-type) + (validation/check-media-size))))] (let [{:keys [body] :as response} (try diff --git a/backend/src/app/media/validation.clj b/backend/src/app/media/validation.clj index 5da86fb99f..df7e534f54 100644 --- a/backend/src/app/media/validation.clj +++ b/backend/src/app/media/validation.clj @@ -32,8 +32,26 @@ (def check-input (sm/check-fn schema:input)) -(defn validate-media-type! - ([upload] (validate-media-type! upload cm/image-types)) +(def allowed-upload-prefixes + ;; Upload temp files are created server-side by the multipart parser + ;; (yetti `default-temp-dir`, `/tmp/undertow/`) plus the tmp dir used + ;; by tests and storage tmp files (`/tmp/penpot/`). Anything else + ;; (e.g. `/etc/passwd` smuggled via Transit `~#path`) is rejected. + ["/tmp/undertow/" "/tmp/penpot/"]) + +(defn check-upload-path + [upload] + (let [real (try (str (fs/real (:path upload))) + (catch Exception _ nil))] + (when (or (nil? real) + (not (some #(str/starts-with? real %) allowed-upload-prefixes))) + (ex/raise :type :validation + :code :params-validation + :hint "invalid upload path")) + upload)) + +(defn check-media-type + ([upload] (check-media-type upload cm/image-types)) ([upload allowed] (when-not (contains? allowed (:mtype upload)) (ex/raise :type :validation @@ -42,7 +60,7 @@ upload)) -(defn validate-media-size! +(defn check-media-size [upload] (let [max-size (cf/get :media-max-file-size)] (when (> (:size upload) max-size) @@ -53,10 +71,10 @@ max-size))) upload)) -(defn validate-font-size! +(defn check-font-size "Validates that the font file `upload` does not exceed the configured `:font-max-file-size` limit. Accepts the same map shape as - `validate-media-size!` — requires a `:size` key in bytes." + `check-media-size` — requires a `:size` key in bytes." [upload] (let [max-size (cf/get :font-max-file-size)] (when (> (:size upload) max-size) diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index 35dbf7eb67..61fa58cbac 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -194,6 +194,8 @@ (assoc params :file file)) params) + _ (media.v/check-upload-path (:file params)) + version (or version (case (bfc/parse-file-format (-> params :file :path)) :binfile-v1 1 diff --git a/backend/src/app/rpc/commands/files_thumbnails.clj b/backend/src/app/rpc/commands/files_thumbnails.clj index 6c5fbc206a..879dcee805 100644 --- a/backend/src/app/rpc/commands/files_thumbnails.clj +++ b/backend/src/app/rpc/commands/files_thumbnails.clj @@ -302,8 +302,9 @@ ::rpc/multipart-only-params [:media]} [cfg {:keys [::rpc/profile-id file-id object-id media tag]}] - (media.v/validate-media-type! media) - (media.v/validate-media-size! media) + (media.v/check-media-type media) + (media.v/check-media-size media) + (media.v/check-upload-path media) (db/run! cfg files/check-edition-permissions! profile-id file-id) (when-let [file (files/get-minimal-file cfg file-id {::db/check-deleted false})] @@ -404,8 +405,9 @@ ::rpc/multipart-only-params [:media]} [cfg {:keys [::rpc/profile-id file-id] :as params}] - (media.v/validate-media-type! (:media params)) - (media.v/validate-media-size! (:media params)) + (media.v/check-media-type (:media params)) + (media.v/check-media-size (:media params)) + (media.v/check-upload-path (:media params)) (db/run! cfg files/check-edition-permissions! profile-id file-id) diff --git a/backend/src/app/rpc/commands/fonts.clj b/backend/src/app/rpc/commands/fonts.clj index a35f4e7ce7..46aa009e20 100644 --- a/backend/src/app/rpc/commands/fonts.clj +++ b/backend/src/app/rpc/commands/fonts.clj @@ -123,8 +123,8 @@ (fn [acc mtype session-id] (let [assembled (assemble-chunks cfg profile-id session-id)] (-> {:mtype mtype :size (:size assembled)} - (media.v/validate-media-type! cm/font-types) - (media.v/validate-font-size!)) + (media.v/check-media-type cm/font-types) + (media.v/check-font-size)) (assoc acc mtype (:path assembled)))) {} uploads)] diff --git a/backend/src/app/rpc/commands/media.clj b/backend/src/app/rpc/commands/media.clj index 3ba28923b0..8a876460fb 100644 --- a/backend/src/app/rpc/commands/media.clj +++ b/backend/src/app/rpc/commands/media.clj @@ -65,8 +65,9 @@ [:process-image/global]]} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id content] :as params}] (files/check-edition-permissions! pool profile-id file-id) - (media.v/validate-media-type! content) - (media.v/validate-media-size! content) + (media.v/check-media-type content) + (media.v/check-media-size content) + (media.v/check-upload-path content) (let [team-id (:team-id (db/exec-one! pool [sql:get-team-id-for-file file-id]))] ;; TEMPORARILY DISABLED (2026-10-19): media-storage-bytes-per-team @@ -376,6 +377,7 @@ ::sm/result schema:upload-chunk-result} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id session-id index content] :as _params}] + (media.v/check-upload-path content) (let [session (db/tx-run! cfg check-upload-chunk-slot session-id profile-id index content)] (l/trc :hint "upload-chunk" :session-id session-id @@ -576,8 +578,8 @@ content (-> content (assoc :filename (str "upload:" name)) (assoc :mtype mtype) - (media.v/validate-media-type!) - (media.v/validate-media-size!)) + (media.v/check-media-type) + (media.v/check-media-size)) mobj (create-file-media-object cfg (assoc params :from-chunks? true :content content))] diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 7cc977c6ca..8afdd2a48c 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -313,8 +313,9 @@ ::sm/result :nil} [cfg {:keys [::rpc/profile-id file] :as params}] ;; Validate incoming mime type - (media.v/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"}) - (media.v/validate-media-size! file) + (media.v/check-media-type file #{"image/jpeg" "image/png" "image/webp"}) + (media.v/check-media-size file) + (media.v/check-upload-path file) (update-profile-photo cfg (assoc params :profile-id profile-id))) (defn update-profile-photo diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index a03d89fe82..c0bd5b52ee 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -1102,8 +1102,9 @@ [cfg {:keys [::rpc/profile-id file] :as params}] ;; Validate incoming mime type - (media.v/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"}) - (media.v/validate-media-size! file) + (media.v/check-media-type file #{"image/jpeg" "image/png" "image/webp"}) + (media.v/check-media-size file) + (media.v/check-upload-path file) (update-team-photo cfg (assoc params :profile-id profile-id))) (defn update-team-photo diff --git a/backend/src/app/rpc/management/exporter.clj b/backend/src/app/rpc/management/exporter.clj index 1e37e5943f..0b41f3d5b3 100644 --- a/backend/src/app/rpc/management/exporter.clj +++ b/backend/src/app/rpc/management/exporter.clj @@ -39,7 +39,8 @@ ::sm/result schema:upload-tempfile-result ::rpc/allowed-shared-keys allowed-shared-key-ids} [cfg {:keys [::rpc/profile-id content]}] - (media.v/validate-media-type! content cm/tempfile-types) + (media.v/check-media-type content cm/tempfile-types) + (media.v/check-upload-path content) (let [storage (sto/resolve cfg) hash (sto/calculate-hash (:path content)) data (-> (sto/content (:path content)) diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index 13f8e01817..5199945d70 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -209,7 +209,8 @@ ::sm/result schema:upload-organization-logo-result ::nitrate/sso false} [{:keys [::sto/storage]} {:keys [content organization-id previous-id]}] - (media.v/validate-media-type! content cm/image-types) + (media.v/check-media-type content cm/image-types) + (media.v/check-upload-path content) (when previous-id (sto/touch-object! storage previous-id)) (let [hash (sto/calculate-hash (:path content)) diff --git a/backend/test/backend_tests/binfile_svg_test.clj b/backend/test/backend_tests/binfile_svg_test.clj index b954226726..1c6f503920 100644 --- a/backend/test/backend_tests/binfile_svg_test.clj +++ b/backend/test/backend_tests/binfile_svg_test.clj @@ -146,7 +146,7 @@ (defn- write-temp-svg "Write `text` to a tempfile and return `[path size]`." [text] - (let [path (fs/create-tempfile :prefix "penpot-svg-import-test-" :suffix ".svg") + (let [path (fs/create-tempfile :dir "/tmp/penpot" :prefix "penpot-svg-import-test-" :suffix ".svg") data (utf8bytes text)] (spit (str path) text :encoding "UTF-8") [path (alength data)])) diff --git a/backend/test/backend_tests/media_validation_test.clj b/backend/test/backend_tests/media_validation_test.clj new file mode 100644 index 0000000000..f4431e1b71 --- /dev/null +++ b/backend/test/backend_tests/media_validation_test.clj @@ -0,0 +1,68 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns backend-tests.media-validation-test + (:require + [app.media.validation :as media.v] + [backend-tests.helpers :as th] + [clojure.test :as t] + [datoteka.fs :as fs])) + +(t/deftest check-media-type + (t/testing "allowed mtype passes through" + (let [upload {:filename "a.png" :size 1 :path (fs/path "/tmp/penpot/a.png") :mtype "image/png"}] + (t/is (= upload (media.v/check-media-type upload))))) + (t/testing "disallowed mtype is rejected" + (let [upload {:filename "a.txt" :size 1 :path (fs/path "/tmp/penpot/a.txt") :mtype "text/plain"} + error (try (media.v/check-media-type upload) nil + (catch clojure.lang.ExceptionInfo e e))] + (t/is (some? error)) + (t/is (th/ex-of-type? error :validation)) + (t/is (th/ex-of-code? error :media-type-not-allowed))))) + +(t/deftest check-media-size + (t/testing "small file passes through" + (let [upload {:filename "a.png" :size 1 :path (fs/path "/tmp/penpot/a.png") :mtype "image/png"}] + (t/is (= upload (media.v/check-media-size upload))))) + (t/testing "oversized file is rejected" + (let [upload {:filename "a.png" :size (* 1024 1024 31) :path (fs/path "/tmp/penpot/a.png") :mtype "image/png"} + error (try (media.v/check-media-size upload) nil + (catch clojure.lang.ExceptionInfo e e))] + (t/is (some? error)) + (t/is (th/ex-of-type? error :restriction)) + (t/is (th/ex-of-code? error :media-max-file-size-reached))))) + +(t/deftest check-font-size + (t/testing "small font passes through" + (let [upload {:filename "a.ttf" :size 1 :path (fs/path "/tmp/penpot/a.ttf") :mtype "font/ttf"}] + (t/is (= upload (media.v/check-font-size upload))))) + (t/testing "oversized font is rejected" + (let [upload {:filename "a.ttf" :size (* 1024 1024 31) :path (fs/path "/tmp/penpot/a.ttf") :mtype "font/ttf"} + error (try (media.v/check-font-size upload) nil + (catch clojure.lang.ExceptionInfo e e))] + (t/is (some? error)) + (t/is (th/ex-of-type? error :restriction)) + (t/is (th/ex-of-code? error :font-max-file-size-reached))))) + +(t/deftest check-upload-path + (t/testing "tempfile under /tmp/penpot passes through" + (let [path (th/tempfile "backend_tests/test_files/sample.jpg") + upload {:filename "sample.jpg" :size 7923 :path path :mtype "image/jpeg"}] + (t/is (= upload (media.v/check-upload-path upload))))) + (t/testing "system file outside temp is rejected" + (let [upload {:filename "x.png" :size 1 :path (fs/path "/etc/passwd") :mtype "image/png"} + error (try (media.v/check-upload-path upload) nil + (catch clojure.lang.ExceptionInfo e e))] + (t/is (some? error)) + (t/is (th/ex-of-type? error :validation)) + (t/is (th/ex-of-code? error :params-validation)))) + (t/testing "missing file is rejected" + (let [upload {:filename "x.png" :size 1 :path (fs/path "/tmp/penpot/no-such-file-12345.png") :mtype "image/png"} + error (try (media.v/check-upload-path upload) nil + (catch clojure.lang.ExceptionInfo e e))] + (t/is (some? error)) + (t/is (th/ex-of-type? error :validation)) + (t/is (th/ex-of-code? error :params-validation))))) diff --git a/backend/test/backend_tests/rpc_file_thumbnails_test.clj b/backend/test/backend_tests/rpc_file_thumbnails_test.clj index 6025849773..2a61841061 100644 --- a/backend/test/backend_tests/rpc_file_thumbnails_test.clj +++ b/backend/test/backend_tests/rpc_file_thumbnails_test.clj @@ -921,39 +921,44 @@ (t/is (= 1 (count rows2))) (t/is (nil? (:deleted-at (first rows2))))))) - -(t/deftest create-file-thumbnail-rejects-forged-media - ;; A transit body like {"~:media": {"~:path": ["~#path", "/etc/passwd"]}} - ;; decodes to this exact map. Without a multipart request it must fail at - ;; params validation, before any file is read. NOTE: plain command! on - ;; purpose, a multipart header would make it legitimate. +(t/deftest rejects-forged-path-on-create-file-thumbnail (let [profile (th/create-profile* 1) file (th/create-file* 1 {:profile-id (:id profile) :project-id (:default-project-id profile) :is-shared false}) - out (th/command! {::th/type :create-file-thumbnail - ::rpc/profile-id (:id profile) - :file-id (:id file) - :revn 0 - :media {:filename "evil.jpg" - :size 123 - :path (fs/path "/etc/passwd") - :mtype "image/jpeg"}})] - (t/is (th/ex-of-code? (:error out) :params-validation)))) + data {::th/type :create-file-thumbnail + ::rpc/profile-id (:id profile) + :file-id (:id file) + :revn 1 + :media {:filename "x.png" + :size 1 + :path (fs/path "/etc/passwd") + :mtype "image/png"}} + out (th/command! data) + error (:error out)] + (t/is (nil? (:result out))) + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :validation)) + (t/is (th/ex-of-code? error :params-validation)) + (t/is (= 0 (count (th/db-query :file-thumbnail {:file-id (:id file)})))))) -(t/deftest create-file-object-thumbnail-rejects-forged-media - ;; Same transit-forgery vector through object thumbnails. NOTE: plain - ;; command! on purpose, a multipart header would make it legitimate. +(t/deftest rejects-forged-path-on-create-file-object-thumbnail (let [profile (th/create-profile* 1) file (th/create-file* 1 {:profile-id (:id profile) :project-id (:default-project-id profile) :is-shared false}) - out (th/command! {::th/type :create-file-object-thumbnail - ::rpc/profile-id (:id profile) - :file-id (:id file) - :object-id "obj" - :media {:filename "evil.jpg" - :size 123 - :path (fs/path "/etc/passwd") - :mtype "image/jpeg"}})] - (t/is (th/ex-of-code? (:error out) :params-validation)))) + data {::th/type :create-file-object-thumbnail + ::rpc/profile-id (:id profile) + :file-id (:id file) + :object-id "test-key-forged" + :media {:filename "x.png" + :size 1 + :path (fs/path "/etc/passwd") + :mtype "image/png"}} + out (th/command! data) + error (:error out)] + (t/is (nil? (:result out))) + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :validation)) + (t/is (th/ex-of-code? error :params-validation)) + (t/is (= 0 (count (th/db-query :file-tagged-object-thumbnail {:file-id (:id file)})))))) diff --git a/common/src/app/common/files/migrations.cljc b/common/src/app/common/files/migrations.cljc index 676cc6291a..aa1f95bca4 100644 --- a/common/src/app/common/files/migrations.cljc +++ b/common/src/app/common/files/migrations.cljc @@ -2298,6 +2298,45 @@ (update :pages-index d/update-vals update-container) (d/update-when :components d/update-vals update-container)))) +(defmethod migrate-data "0034-fix-path-content-offset" + [data _] + ;; A path stores its geometry twice: the box (:selrect and :points) and the + ;; drawing (:content), and the box is always derived from the drawing. Some + ;; files have component copies whose drawing and box were moved apart, so + ;; the drawing renders away from where the sidebar, the layouts and the + ;; syncs place the shape. We move the content back onto the box. A path whose drawing + ;; has another size than its box is not a displaced drawing, and is left as + ;; it is. + (let [tolerance 0.1] + (letfn [(fix-path [shape] + (when (and (cfh/path-shape? shape) + (some? (:selrect shape)) + (some? (path.segment/content->selrect (:content shape)))) + (let [selrect (:selrect shape) + derived (:selrect (path/update-geometry shape)) + offset (gpt/subtract (grc/rect->center selrect) + (grc/rect->center derived))] + (when (and (mth/close? (:width selrect) (:width derived) tolerance) + (mth/close? (:height selrect) (:height derived) tolerance) + (or (> (abs (:x offset)) tolerance) + (> (abs (:y offset)) tolerance))) + (update shape :content path/move-content offset))))) + + (fix-objects [objects] + (reduce-kv (fn [objects id shape] + (if-let [shape (fix-path shape)] + (assoc objects id shape) + objects)) + objects + objects)) + + (update-container [container] + (d/update-when container :objects fix-objects))] + + (-> data + (update :pages-index d/update-vals update-container) + (d/update-when :components d/update-vals update-container))))) + (def available-migrations (into (d/ordered-set) ["legacy-2" @@ -2388,4 +2427,5 @@ "0030-remove-stroke-per-side-attr" "0031-migrate-stroke-width-token-attr" "0032-fix-children-not-in-parent" - "0033-normalize-main-instance-names"])) + "0033-normalize-main-instance-names" + "0034-fix-path-content-offset"])) diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index 838d0e2463..a7296ff5d5 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -3204,7 +3204,8 @@ (cond-> (ctsl/grid-layout? obj) (ctsl/remap-grid-cells ids-map)) - (cond-> (ctk/is-variant-container? parent) + (cond-> (and (ctk/is-variant-container? parent) + (ctk/main-instance? obj)) (assoc :variant-id parent-id)) (cond-> (and (ctk/is-variant? obj) (not (ctk/is-variant-container? parent))) diff --git a/common/src/app/common/logic/shapes.cljc b/common/src/app/common/logic/shapes.cljc index 30d6067889..8cb30231d8 100644 --- a/common/src/app/common/logic/shapes.cljc +++ b/common/src/app/common/logic/shapes.cljc @@ -10,6 +10,7 @@ [app.common.data.macros :as dm] [app.common.files.changes-builder :as pcb] [app.common.files.helpers :as cfh] + [app.common.files.shapes-helpers :as cfsh] [app.common.geom.shapes :as gsh] [app.common.logic.variant-properties :as clvp] [app.common.types.component :as ctk] @@ -450,6 +451,18 @@ [all-parents changes]))) +(defn- find-empty-variant-containers + [objects excluded-parent-id ids parent-ids] + (let [ids (set ids)] + (reduce (fn [to-delete candidate-id] + (let [parent (get objects candidate-id)] + (if (and (ctk/is-variant-container? parent) + (empty? (remove ids (:shapes parent)))) + (conj to-delete (:id parent)) + to-delete))) + #{} + (remove #(= % excluded-parent-id) parent-ids)))) + (defn generate-relocate [changes parent-id to-index ids & {:keys [cell ignore-parents?]}] (let [objects (pcb/get-objects changes) @@ -553,128 +566,146 @@ ;; Parents that are a variant-container that becomes empty - empty-variant-cont (reduce - (fn [to-delete parent-id] - (let [parent (get objects parent-id)] - (if (and (ctk/is-variant-container? parent) - (empty? (remove (set ids) (:shapes parent)))) - (conj to-delete (:id parent)) - to-delete))) - #{} - (remove #(= % parent-id) all-parents))] + empty-variant-cont (find-empty-variant-containers objects parent-id ids all-parents)] - (-> changes - ;; Remove layout-item properties and tokens when moving a shape outside a layout - (cond-> (not (ctl/any-layout? parent)) - (-> (pcb/update-shapes ids ctl/remove-layout-item-data) - (pcb/update-shapes ids cto/unapply-layout-item-tokens))) + (if (and (ctk/is-variant-container? parent) + (not (every? ctk/main-instance? shapes))) + changes + (-> changes + ;; Remove layout-item properties and tokens when moving a shape outside a layout + (cond-> (not (ctl/any-layout? parent)) + (-> (pcb/update-shapes ids ctl/remove-layout-item-data) + (pcb/update-shapes ids cto/unapply-layout-item-tokens))) - ;; Remove the hide in viewer flag - (cond-> (and (not= uuid/zero parent-id) (cfh/frame-shape? parent)) - (pcb/update-shapes ids #(cond-> % (cfh/frame-shape? %) (assoc :hide-in-viewer true)))) + ;; Remove the hide in viewer flag + (cond-> (and (not= uuid/zero parent-id) (cfh/frame-shape? parent)) + (pcb/update-shapes ids #(cond-> % (cfh/frame-shape? %) (assoc :hide-in-viewer true)))) - ;; Remove the swap slots if it is moving to a different component - (pcb/update-shapes - child-heads-ids - (fn [shape] - (cond-> shape - (not= component-main-parent (ctn/find-component-main objects shape false)) - (ctk/remove-swap-slot)))) + ;; Remove the swap slots if it is moving to a different component + (pcb/update-shapes + child-heads-ids + (fn [shape] + (cond-> shape + (not= component-main-parent (ctn/find-component-main objects shape false)) + (ctk/remove-swap-slot)))) - ;; Remove component-root property when moving a shape inside a component - (cond-> (ctn/get-instance-root objects parent) - (pcb/update-shapes children-ids #(dissoc % :component-root))) + ;; Remove component-root property when moving a shape inside a component + (cond-> (ctn/get-instance-root objects parent) + (pcb/update-shapes children-ids #(dissoc % :component-root))) - ;; Add component-root property when moving a component outside a component - (cond-> (not (ctn/get-instance-root objects parent)) - (pcb/update-shapes child-heads-ids #(assoc % :component-root true))) + ;; Add component-root property when moving a component outside a component + (cond-> (not (ctn/get-instance-root objects parent)) + (pcb/update-shapes child-heads-ids #(assoc % :component-root true))) - ;; Remove variant info and rename when moving outside a variant-container - (cond-> (not (ctk/is-variant-container? parent)) - (clvp/generate-make-shapes-no-variant variant-shapes)) + ;; Remove variant info and rename when moving outside a variant-container + (cond-> (not (ctk/is-variant-container? parent)) + (clvp/generate-make-shapes-no-variant variant-shapes)) - ;; Add variant info and rename when moving into a different variant-container - (cond-> (ctk/is-variant-container? parent) - (clvp/generate-make-shapes-variant child-heads parent)) + ;; Add variant info and rename when moving into a different variant-container + (cond-> (ctk/is-variant-container? parent) + (clvp/generate-make-shapes-variant child-heads parent)) - ;; Move the shapes - (pcb/change-parent parent-id - shapes - to-index) + ;; Move the shapes + (pcb/change-parent parent-id + shapes + to-index) - ;; Remove empty groups - (pcb/remove-objects groups-to-delete) + ;; Remove empty groups + (pcb/remove-objects groups-to-delete) - ;; Unmask groups whose mask have moved outside - (pcb/update-shapes groups-to-unmask - (fn [shape] - (assoc shape :masked-group false))) + ;; Unmask groups whose mask have moved outside + (pcb/update-shapes groups-to-unmask + (fn [shape] + (assoc shape :masked-group false))) - ;; Detach shapes moved out of their component - (pcb/update-shapes shapes-to-detach ctk/detach-shape) + ;; Detach shapes moved out of their component + (pcb/update-shapes shapes-to-detach ctk/detach-shape) - ;; Make non root a component moved inside another one - (pcb/update-shapes shapes-to-deroot - (fn [shape] - (assoc shape :component-root nil))) + ;; Make non root a component moved inside another one + (pcb/update-shapes shapes-to-deroot + (fn [shape] + (assoc shape :component-root nil))) - ;; Make root a subcomponent moved outside its parent component - (pcb/update-shapes shapes-to-reroot - (fn [shape] - (assoc shape :component-root true))) + ;; Make root a subcomponent moved outside its parent component + (pcb/update-shapes shapes-to-reroot + (fn [shape] + (assoc shape :component-root true))) - ;; Reset constraints depending on the new parent - (pcb/update-shapes ids - (fn [shape] - (let [frame-id (if (= (:type parent) :frame) - (:id parent) - (:frame-id parent)) - moved-shape (assoc shape - :parent-id parent-id - :frame-id frame-id)] - (assoc shape - :constraints-h (gsh/default-constraints-h moved-shape) - :constraints-v (gsh/default-constraints-v moved-shape)))) - {:ignore-touched true}) + ;; Reset constraints depending on the new parent + (pcb/update-shapes ids + (fn [shape] + (let [frame-id (if (= (:type parent) :frame) + (:id parent) + (:frame-id parent)) + moved-shape (assoc shape + :parent-id parent-id + :frame-id frame-id)] + (assoc shape + :constraints-h (gsh/default-constraints-h moved-shape) + :constraints-v (gsh/default-constraints-v moved-shape)))) + {:ignore-touched true}) - ;; Fix the sizing when moving a shape - (pcb/update-shapes parents - (fn [parent] - (if (ctl/flex-layout? parent) - (cond-> parent - (ctl/change-h-sizing? (:id parent) objects (:shapes parent)) - (assoc :layout-item-h-sizing :fix) + ;; Fix the sizing when moving a shape + (pcb/update-shapes parents + (fn [parent] + (if (ctl/flex-layout? parent) + (cond-> parent + (ctl/change-h-sizing? (:id parent) objects (:shapes parent)) + (assoc :layout-item-h-sizing :fix) - (ctl/change-v-sizing? (:id parent) objects (:shapes parent)) - (assoc :layout-item-v-sizing :fix)) - parent))) + (ctl/change-v-sizing? (:id parent) objects (:shapes parent)) + (assoc :layout-item-v-sizing :fix)) + parent))) - ;; Change the grid cell in a grid layout - (cond-> (ctl/grid-layout? objects parent-id) - (-> (pcb/update-shapes - [parent-id] - (fn [frame objects] - (let [[row column] cell] - (-> frame - ;; Assign the cell when pushing into a specific grid cell - (cond-> (some? cell) - (-> (ctl/free-cell-shapes ids) - (ctl/push-into-cell ids row column) - (ctl/assign-cells objects))) - (ctl/assign-cell-positions objects)))) - {:with-objects? true}) - (pcb/reorder-grid-children [parent-id]))) + ;; Change the grid cell in a grid layout + (cond-> (ctl/grid-layout? objects parent-id) + (-> (pcb/update-shapes + [parent-id] + (fn [frame objects] + (let [[row column] cell] + (-> frame + ;; Assign the cell when pushing into a specific grid cell + (cond-> (some? cell) + (-> (ctl/free-cell-shapes ids) + (ctl/push-into-cell ids row column) + (ctl/assign-cells objects))) + (ctl/assign-cell-positions objects)))) + {:with-objects? true}) + (pcb/reorder-grid-children [parent-id]))) - ;; If parent locked, lock the added shapes - (cond-> (:blocked parent) - (pcb/update-shapes ids #(assoc % :blocked true))) + ;; If parent locked, lock the added shapes + (cond-> (:blocked parent) + (pcb/update-shapes ids #(assoc % :blocked true))) - ;; Resize parent containers that need to - (pcb/resize-parents parents) + ;; Resize parent containers that need to + (pcb/resize-parents parents) - ;; Remove parents when are a variant-container that becomes empty - (cond-> (seq empty-variant-cont) - (#(second (generate-delete-shapes % empty-variant-cont {}))))))) + ;; Remove parents when are a variant-container that becomes empty + (cond-> (seq empty-variant-cont) + (#(second (generate-delete-shapes % empty-variant-cont {})))))))) + +(defn generate-move-shapes-into-frame + "Reparent `shapes` into `frame-id`, stripping variant records when a + variant main leaves its container. The builder must already have + objects and library data." + [changes frame-id shapes objects remove-layout-data?] + (let [parent-id (dm/get-in objects [frame-id :parent-id]) + ids (remove #(= % parent-id) shapes) + to-move (->> ids + (map (d/getf objects)) + (filter some?)) + new-parent (get objects frame-id) + variant-shapes (filter ctk/is-variant? to-move) + all-parents (into #{frame-id} (map #(cfh/get-parent-id objects %)) ids) + empty-variant-cont (find-empty-variant-containers objects frame-id ids all-parents)] + (if (empty? to-move) + changes + (-> changes + (cond-> (not (ctk/is-variant-container? new-parent)) + (clvp/generate-make-shapes-no-variant variant-shapes)) + (cfsh/prepare-move-shapes-into-frame frame-id shapes objects remove-layout-data?) + (cond-> (seq empty-variant-cont) + (#(second (generate-delete-shapes % empty-variant-cont {})))))))) (defn change-show-in-viewer [shape hide?] diff --git a/common/src/app/common/logic/variant_properties.cljc b/common/src/app/common/logic/variant_properties.cljc index 81dc28399d..3e950a81b8 100644 --- a/common/src/app/common/logic/variant_properties.cljc +++ b/common/src/app/common/logic/variant_properties.cljc @@ -61,15 +61,18 @@ [changes component-id pos value] (if-let [value (ctv/prepare-property-value value)] (let [data (pcb/get-library-data changes) + objects (pcb/get-objects changes) component (ctcl/get-component data component-id true) - main-id (:main-instance-id component) - name (-> (:variant-properties component) - (update pos assoc :value value) - ctv/properties-to-name)] - (-> changes - (pcb/update-component component-id #(assoc-in % [:variant-properties pos :value] value) - {:apply-changes-local-library? true}) - (pcb/update-shapes [main-id] #(assoc % :variant-name name)))) + main-id (:main-instance-id component)] + (if (contains? objects main-id) + (let [name (-> (:variant-properties component) + (update pos assoc :value value) + ctv/properties-to-name)] + (-> changes + (pcb/update-component component-id #(assoc-in % [:variant-properties pos :value] value) + {:apply-changes-local-library? true}) + (pcb/update-shapes [main-id] #(assoc % :variant-name name)))) + changes)) changes)) diff --git a/common/test/common_tests/files_migrations_0034_test.cljc b/common/test/common_tests/files_migrations_0034_test.cljc new file mode 100644 index 0000000000..82d335e4ff --- /dev/null +++ b/common/test/common_tests/files_migrations_0034_test.cljc @@ -0,0 +1,116 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns common-tests.files-migrations-0034-test + (:require + [app.common.files.migrations :as cfm] + [app.common.geom.matrix :as gmt] + [app.common.geom.point :as gpt] + [app.common.geom.shapes :as gsh] + [app.common.math :as mth] + [app.common.types.modifiers :as ctm] + [app.common.types.path :as path] + [app.common.types.path.segment :as segment] + [app.common.types.shape :as cts] + [app.common.uuid :as uuid] + [clojure.test :as t])) + +;; 0034-fix-path-content-offset +;; A path whose drawing (:content) was moved apart from its box +;; (:selrect and :points) gets its content moved back onto the box. + +(def ^:private migration "0034-fix-path-content-offset") + +(defn- triangle + [x0 y0] + [{:command :move-to :params {:x x0 :y y0}} + {:command :line-to :params {:x (+ x0 14) :y y0}} + {:command :line-to :params {:x (+ x0 7) :y (+ y0 14)}} + {:command :close-path}]) + +(defn- make-path + [shape-id x0 y0] + (cts/setup-shape {:id shape-id :type :path :content (triangle x0 y0)})) + +(defn- rotate + [shape angle] + (gsh/transform-shape shape (ctm/rotation-modifiers shape (gsh/shape->center shape) angle))) + +(defn- displace-content + [shape delta] + (update shape :content path/move-content delta)) + +(defn- migrate-shape + [shape] + (let [page-id (uuid/next) + data {:pages-index {page-id {:objects {(:id shape) shape}}}} + data' (cfm/migrate-data data migration)] + (get-in data' [:pages-index page-id :objects (:id shape)]))) + +(defn- content-matches-box? + [shape] + (let [derived (path/update-geometry shape)] + (and (every? true? (map gpt/close? (:points shape) (:points derived))) + (every? #(mth/close? (get (:selrect shape) %) (get (:selrect derived) %)) + [:x :y :width :height])))) + +(t/deftest migration-0034-moves-displaced-content-onto-its-box + (let [shape (make-path (uuid/next) 1 1) + broken (displace-content shape (gpt/point 0 -182)) + shape' (migrate-shape broken)] + + (t/is (not (content-matches-box? broken)) "the fixture is broken") + (t/is (content-matches-box? shape') "content back onto the box") + (t/is (= (:selrect broken) (:selrect shape')) "box unchanged") + (t/is (= (:points broken) (:points shape')) "points unchanged") + (t/is (= (segment/content->selrect (:content shape)) + (segment/content->selrect (:content shape'))) + "content where it was before being displaced"))) + +(t/deftest migration-0034-moves-displaced-content-of-a-rotated-path + (let [shape (rotate (make-path (uuid/next) 100 100) 30) + broken (displace-content shape (gpt/point 25 -40)) + shape' (migrate-shape broken)] + + (t/is (not (gmt/close? (gmt/matrix) (:transform shape))) "the fixture is rotated") + (t/is (not (content-matches-box? broken)) "the fixture is broken") + (t/is (content-matches-box? shape') "content back onto the box") + (t/is (= (:selrect broken) (:selrect shape')) "box unchanged") + (t/is (= (:transform broken) (:transform shape')) "rotation unchanged"))) + +(t/deftest migration-0034-keeps-paths-whose-content-matches-the-box + (let [shape (make-path (uuid/next) 10 20) + small (displace-content shape (gpt/point 0.05 0))] + + (t/is (identical? shape (migrate-shape shape)) "consistent path untouched") + (t/is (identical? small (migrate-shape small)) "float noise untouched"))) + +(t/deftest migration-0034-keeps-paths-whose-content-has-another-size + (let [shape (make-path (uuid/next) 0 0) + bigger (assoc shape :content (path/content [{:command :move-to :params {:x 40 :y 0}} + {:command :line-to :params {:x 70 :y 0}} + {:command :line-to :params {:x 55 :y 30}} + {:command :close-path}]))] + + (t/is (identical? bigger (migrate-shape bigger)) "size mismatch untouched"))) + +(t/deftest migration-0034-ignores-shapes-other-than-paths + (let [shape-id (uuid/next) + rect (cts/setup-shape {:id shape-id :type :rect :x 0 :y 0 :width 10 :height 10})] + (t/is (identical? rect (migrate-shape rect))))) + +(t/deftest migration-0034-fixes-paths-inside-components + (let [shape (make-path (uuid/next) 1 1) + broken (displace-content shape (gpt/point 0 109)) + component-id (uuid/next) + data {:pages-index {} + :components {component-id {:id component-id + :deleted true + :objects {(:id broken) broken}}}} + data' (cfm/migrate-data data migration) + shape' (get-in data' [:components component-id :objects (:id broken)])] + + (t/is (content-matches-box? shape') "deleted component content fixed"))) diff --git a/common/test/common_tests/logic/variants_reparent_test.cljc b/common/test/common_tests/logic/variants_reparent_test.cljc new file mode 100644 index 0000000000..5ff04f9153 --- /dev/null +++ b/common/test/common_tests/logic/variants_reparent_test.cljc @@ -0,0 +1,316 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns common-tests.logic.variants-reparent-test + (:require + [app.common.files.changes-builder :as pcb] + [app.common.logic.shapes :as cls] + [app.common.test-helpers.components :as thc] + [app.common.test-helpers.compositions :as tho] + [app.common.test-helpers.files :as thf] + [app.common.test-helpers.ids-map :as thi] + [app.common.test-helpers.shapes :as ths] + [app.common.test-helpers.variants :as thv] + [app.common.types.component :as ctk] + [clojure.test :as t])) + +(t/use-fixtures :each thi/test-fixture) + +(defn- relocate + [file parent-id ids & {:keys [to-index] :or {to-index 0}}] + (let [page (thf/current-page file)] + (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-objects (:objects page)) + (pcb/with-library-data (:data file)) + (cls/generate-relocate parent-id to-index (set ids))))) + +(defn- move-into-frame + [file frame-id ids] + (let [page (thf/current-page file) + objects (:objects page)] + (-> (pcb/empty-changes nil (:id page)) + (pcb/with-objects objects) + (pcb/with-library-data (:data file)) + (cls/generate-move-shapes-into-frame frame-id ids objects true)))) + +(defn- property-names + [component] + (mapv :name (:variant-properties component))) + +(defn- strip-component-times + "Undo restamps :modified-at on components when mains are updated. + Compare file data without that clock." + [data] + (update data :components update-vals #(dissoc % :modified-at))) + +(t/deftest test-relocate-plain-shape-onto-container-is-rejected + (let [file (-> (thf/sample-file :file1) + (thv/add-variant :v01 :c01 :m01 :c02 :m02) + (tho/add-rect :rect1)) + container (ths/get-shape file :v01) + rect (ths/get-shape file :rect1) + children (:shapes container) + changes (relocate file (:id container) [(:id rect)]) + file' (thf/apply-changes file changes) + rect' (ths/get-shape file' :rect1) + container' (ths/get-shape file' :v01)] + + (t/is (empty? (:redo-changes changes))) + (t/is (= (:parent-id rect) (:parent-id rect'))) + (t/is (= (:constraints-h rect) (:constraints-h rect'))) + (t/is (= (:constraints-v rect) (:constraints-v rect'))) + (t/is (= children (:shapes container'))) + (thf/validate-file! file'))) + +(t/deftest test-relocate-component-copy-onto-container-is-rejected + (let [file (-> (thf/sample-file :file1) + (thv/add-variant :v01 :c01 :m01 :c02 :m02) + (tho/add-simple-component-with-copy :c03 :m03 :r03 :copy03)) + container (ths/get-shape file :v01) + copy (ths/get-shape file :copy03) + comp (thc/get-component file :c03) + children (:shapes container) + changes (relocate file (:id container) [(:id copy)]) + file' (thf/apply-changes file changes) + copy' (ths/get-shape file' :copy03) + comp' (thc/get-component file' :c03) + container' (ths/get-shape file' :v01)] + + (t/is (empty? (:redo-changes changes))) + (t/is (= (:parent-id copy) (:parent-id copy'))) + (t/is (nil? (:variant-id copy'))) + (t/is (= (:variant-id comp) (:variant-id comp'))) + (t/is (= (:variant-properties comp) (:variant-properties comp'))) + (t/is (= children (:shapes container'))) + (thf/validate-file! file'))) + +(t/deftest test-relocate-mixed-selection-onto-container-is-rejected + (let [file (-> (thf/sample-file :file1) + (thv/add-variant :v01 :c01 :m01 :c02 :m02) + (tho/add-rect :rect1) + (tho/add-simple-component :c03 :m03 :r03)) + container (ths/get-shape file :v01) + rect (ths/get-shape file :rect1) + main (ths/get-shape file :m03) + changes (relocate file (:id container) [(:id rect) (:id main)]) + file' (thf/apply-changes file changes) + rect' (ths/get-shape file' :rect1) + main' (ths/get-shape file' :m03)] + + (t/is (empty? (:redo-changes changes))) + (t/is (= (:parent-id rect) (:parent-id rect'))) + (t/is (= (:parent-id main) (:parent-id main'))) + (thf/validate-file! file'))) + +(t/deftest test-relocate-main-into-container-becomes-variant + (let [file (-> (thf/sample-file :file1) + (thv/add-variant :v01 :c01 :m01 :c02 :m02) + (tho/add-simple-component :c03 :m03 :r03)) + container (ths/get-shape file :v01) + main (ths/get-shape file :m03) + sibling (thc/get-component file :c01) + changes (relocate file (:id container) [(:id main)]) + file' (thf/apply-changes file changes) + file'' (thf/apply-undo-changes file' changes) + main' (ths/get-shape file' :m03) + comp' (thc/get-component file' :c03) + sibling' (thc/get-component file' :c01) + container' (ths/get-shape file' :v01)] + + (t/is (= (:id container') (:parent-id main'))) + (t/is (= (:id container') (:variant-id main'))) + (t/is (= (:id container') (:variant-id comp'))) + (t/is (some? (:variant-name main'))) + (t/is (= (property-names sibling') (property-names comp'))) + (t/is (= (property-names sibling) (property-names sibling'))) + (t/is (= (strip-component-times (:data file)) + (strip-component-times (:data file'')))) + (thf/validate-file! file'))) + +(t/deftest test-relocate-reorder-mains-does-not-rebuild-properties + (let [file (-> (thf/sample-file :file1) + (thv/add-variant :v01 :c01 :m01 :c02 :m02)) + container (ths/get-shape file :v01) + m01 (ths/get-shape file :m01) + m02 (ths/get-shape file :m02) + c01 (thc/get-component file :c01) + c02 (thc/get-component file :c02) + changes (relocate file (:id container) [(:id m01)] :to-index 0) + file' (thf/apply-changes file changes) + file'' (thf/apply-undo-changes file' changes) + m01' (ths/get-shape file' :m01) + m02' (ths/get-shape file' :m02) + c01' (thc/get-component file' :c01) + c02' (thc/get-component file' :c02)] + + (t/is (= (:variant-name m01) (:variant-name m01'))) + (t/is (= (:variant-name m02) (:variant-name m02'))) + (t/is (= (:variant-properties c01) (:variant-properties c01'))) + (t/is (= (:variant-properties c02) (:variant-properties c02'))) + (t/is (= (strip-component-times (:data file)) + (strip-component-times (:data file'')))) + (thf/validate-file! file'))) + +(t/deftest test-relocate-all-mains-within-container-keeps-container + (let [file (-> (thf/sample-file :file1) + (thv/add-variant :v01 :c01 :m01 :c02 :m02)) + container (ths/get-shape file :v01) + m01 (ths/get-shape file :m01) + m02 (ths/get-shape file :m02) + changes (relocate file (:id container) [(:id m01) (:id m02)]) + file' (thf/apply-changes file changes) + container' (ths/get-shape file' :v01)] + + (t/is (= (:id container) (:id container'))) + (t/is (= (set (:shapes container)) (set (:shapes container')))) + (thf/validate-file! file'))) + +(t/deftest test-relocate-main-out-of-container-clears-variant-data + (let [file (-> (thf/sample-file :file1) + (thv/add-variant :v01 :c01 :m01 :c02 :m02) + (tho/add-frame :board1)) + board (ths/get-shape file :board1) + m01 (ths/get-shape file :m01) + changes (relocate file (:id board) [(:id m01)]) + file' (thf/apply-changes file changes) + file'' (thf/apply-undo-changes file' changes) + m01' (ths/get-shape file' :m01) + c01' (thc/get-component file' :c01) + container' (ths/get-shape file' :v01)] + + (t/is (= (:id board) (:parent-id m01'))) + (t/is (nil? (:variant-id m01'))) + (t/is (nil? (:variant-name m01'))) + (t/is (nil? (:variant-id c01'))) + (t/is (nil? (:variant-properties c01'))) + (t/is (some? container')) + (t/is (= (strip-component-times (:data file)) + (strip-component-times (:data file'')))) + (thf/validate-file! file'))) + +(t/deftest test-relocate-last-mains-out-deletes-empty-container + (let [file (-> (thf/sample-file :file1) + (thv/add-variant :v01 :c01 :m01 :c02 :m02) + (tho/add-frame :board1)) + board (ths/get-shape file :board1) + m01 (ths/get-shape file :m01) + m02 (ths/get-shape file :m02) + changes (relocate file (:id board) [(:id m01) (:id m02)]) + file' (thf/apply-changes file changes) + file'' (thf/apply-undo-changes file' changes) + container' (ths/get-shape file' :v01) + m01' (ths/get-shape file' :m01) + m02' (ths/get-shape file' :m02) + c01' (thc/get-component file' :c01) + c02' (thc/get-component file' :c02)] + + (t/is (nil? container')) + (t/is (some? m01')) + (t/is (some? m02')) + (t/is (= (:id board) (:parent-id m01'))) + (t/is (= (:id board) (:parent-id m02'))) + (t/is (nil? (:variant-id m01'))) + (t/is (nil? (:variant-id m02'))) + (t/is (nil? (:variant-name m01'))) + (t/is (nil? (:variant-name m02'))) + (t/is (some? c01')) + (t/is (some? c02')) + (t/is (nil? (:variant-id c01'))) + (t/is (nil? (:variant-id c02'))) + (t/is (nil? (:variant-properties c01'))) + (t/is (nil? (:variant-properties c02'))) + (t/is (= (strip-component-times (:data file)) + (strip-component-times (:data file'')))) + (thf/validate-file! file'))) + +(t/deftest test-board-draw-strips-variant-data-when-main-leaves + (let [file (-> (thf/sample-file :file1) + (thv/add-variant :v01 :c01 :m01 :c02 :m02) + (tho/add-frame :board1)) + board (ths/get-shape file :board1) + m01 (ths/get-shape file :m01) + changes (move-into-frame file (:id board) [(:id m01)]) + file' (thf/apply-changes file changes) + file'' (thf/apply-undo-changes file' changes) + m01' (ths/get-shape file' :m01) + c01' (thc/get-component file' :c01) + container' (ths/get-shape file' :v01)] + + (t/is (= (:id board) (:parent-id m01'))) + (t/is (nil? (:variant-id m01'))) + (t/is (nil? (:variant-name m01'))) + (t/is (nil? (:variant-id c01'))) + (t/is (nil? (:variant-properties c01'))) + (t/is (some? container')) + (t/is (= (strip-component-times (:data file)) + (strip-component-times (:data file'')))) + (thf/validate-file! file'))) + +(t/deftest test-board-draw-last-main-deletes-empty-container + (let [file (-> (thf/sample-file :file1) + (thv/add-variant :v01 :c01 :m01 :c02 :m02) + (tho/add-frame :board1)) + board (ths/get-shape file :board1) + m01 (ths/get-shape file :m01) + m02 (ths/get-shape file :m02) + file* (thf/apply-changes file (move-into-frame file (:id board) [(:id m01)])) + changes (move-into-frame file* (:id board) [(:id m02)]) + file' (thf/apply-changes file* changes) + container' (ths/get-shape file' :v01) + m01' (ths/get-shape file' :m01) + m02' (ths/get-shape file' :m02) + c01' (thc/get-component file' :c01) + c02' (thc/get-component file' :c02)] + + (t/is (nil? container')) + (t/is (some? m01')) + (t/is (some? m02')) + (t/is (= (:id board) (:parent-id m01'))) + (t/is (= (:id board) (:parent-id m02'))) + (t/is (nil? (:variant-id m01'))) + (t/is (nil? (:variant-id m02'))) + (t/is (nil? (:variant-name m01'))) + (t/is (nil? (:variant-name m02'))) + (t/is (some? c01')) + (t/is (some? c02')) + (t/is (nil? (:variant-id c01'))) + (t/is (nil? (:variant-id c02'))) + (t/is (nil? (:variant-properties c01'))) + (t/is (nil? (:variant-properties c02'))) + (thf/validate-file! file'))) + +(t/deftest test-board-draw-undo-restores-emptied-container + (let [file (-> (thf/sample-file :file1) + (thv/add-variant :v01 :c01 :m01 :c02 :m02) + (tho/add-frame :board1)) + board (ths/get-shape file :board1) + m01 (ths/get-shape file :m01) + m02 (ths/get-shape file :m02) + changes* (move-into-frame file (:id board) [(:id m01)]) + file* (thf/apply-changes file changes*) + changes (move-into-frame file* (:id board) [(:id m02)]) + file' (thf/apply-changes file* changes) + file'' (thf/apply-undo-changes file' changes) + file''' (thf/apply-undo-changes file'' changes*)] + + (t/is (= (strip-component-times (:data file*)) + (strip-component-times (:data file'')))) + (t/is (= (strip-component-times (:data file)) + (strip-component-times (:data file''')))))) + +(t/deftest test-duplicate-variant-main-keeps-variant-id + (let [file (-> (thf/sample-file :file1) + (thv/add-variant :v01 :c01 :m01 :c02 :m02)) + container (ths/get-shape file :v01) + file' (tho/duplicate-shape file :m01) + objects' (:objects (thf/current-page file')) + variant-mains (->> (vals objects') + (filter ctk/main-instance?) + (filter #(= (:id container) (:variant-id %))))] + + (t/is (= 3 (count variant-mains))) + (thf/validate-file! file'))) diff --git a/common/test/common_tests/logic/variants_test.cljc b/common/test/common_tests/logic/variants_test.cljc index d4a4696336..d164c0e066 100644 --- a/common/test/common_tests/logic/variants_test.cljc +++ b/common/test/common_tests/logic/variants_test.cljc @@ -511,3 +511,55 @@ (thf/validate-file! file') (t/is (some? new-shape')) (t/is (not= (:parent-id new-shape') (:id container))))) + +(defn- two-property-file-with-other-page + [] + (-> (thf/sample-file :file1 :page-label :page1) + (thv/add-variant-two-properties :v01 :c01 :m01 :c02 :m02) + (thf/add-sample-page :page2))) + +(t/deftest test-update-property-value-skips-when-main-not-in-objects + (let [file (two-property-file-with-other-page) + other (thf/current-page file) + comp01 (thc/get-component file :c01) + main (ths/get-shape file :m01 :page-label :page1) + before (:variant-properties comp01) + label (:variant-name main) + + changes (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id other)) + (pcb/with-library-data (:data file)) + (pcb/with-objects (:objects other)) + (clvp/generate-update-property-value (:id comp01) 1 "crítica")) + + file' (thf/apply-changes file changes) + comp01' (thc/get-component file' :c01) + main' (ths/get-shape file' :m01 :page-label :page1)] + + (thf/validate-file! file') + (t/is (empty? (:redo-changes changes))) + (t/is (= before (:variant-properties comp01'))) + (t/is (= label (:variant-name main'))))) + +(t/deftest test-update-property-value-writes-main-page-and-chains + (let [file (two-property-file-with-other-page) + mains (thf/get-page file :page1) + comp01 (thc/get-component file :c01) + + changes (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id mains)) + (pcb/with-library-data (:data file)) + (pcb/with-objects (:objects mains)) + (clvp/generate-update-property-value (:id comp01) 0 "active") + (clvp/generate-update-property-value (:id comp01) 1 "crítica")) + + file' (thf/apply-changes file changes) + comp01' (thc/get-component file' :c01) + main' (ths/get-shape file' :m01 :page-label :page1) + props' (:variant-properties comp01')] + + (thf/validate-file! file') + (t/is (= "active" (-> props' first :value))) + (t/is (= "crítica" (-> props' second :value))) + (t/is (= (ctv/properties-to-name props') (:variant-name main'))) + (t/is (= "active, crítica" (:variant-name main'))))) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index 884c9a7356..9a774d271d 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -22,6 +22,7 @@ [common-tests.files-changes-test] [common-tests.files-migrations-0025-test] [common-tests.files-migrations-0026-test] + [common-tests.files-migrations-0034-test] [common-tests.files-migrations-test] [common-tests.files.comp-processors-test] [common-tests.files.helpers-test] @@ -77,6 +78,7 @@ [common-tests.logic.text-touched-test] [common-tests.logic.token-apply-test] [common-tests.logic.token-test] + [common-tests.logic.variants-reparent-test] [common-tests.logic.variants-switch-test] [common-tests.logic.variants-test] [common-tests.math-test] @@ -129,6 +131,7 @@ 'common-tests.files-changes-test 'common-tests.files-migrations-0025-test 'common-tests.files-migrations-0026-test + 'common-tests.files-migrations-0034-test 'common-tests.files-migrations-test 'common-tests.files.comp-processors-test 'common-tests.files.helpers-test @@ -184,6 +187,7 @@ 'common-tests.logic.text-touched-test 'common-tests.logic.token-apply-test 'common-tests.logic.token-test + 'common-tests.logic.variants-reparent-test 'common-tests.logic.variants-switch-test 'common-tests.logic.variants-test 'common-tests.math-test diff --git a/docs/img/components/components-show-asset.mp4 b/docs/img/components/components-show-asset.mp4 index d1c16a5999..7d6e46057d 100644 Binary files a/docs/img/components/components-show-asset.mp4 and b/docs/img/components/components-show-asset.mp4 differ diff --git a/docs/img/components/components-show-asset.webp b/docs/img/components/components-show-asset.webp index ecc7c34d62..919b76ebd8 100644 Binary files a/docs/img/components/components-show-asset.webp and b/docs/img/components/components-show-asset.webp differ diff --git a/docs/img/components/components-show-main.mp4 b/docs/img/components/components-show-main.mp4 index cba4d46ed4..7150638803 100644 Binary files a/docs/img/components/components-show-main.mp4 and b/docs/img/components/components-show-main.mp4 differ diff --git a/docs/img/components/components-show-main.webp b/docs/img/components/components-show-main.webp index bd36169c9a..ec285b9549 100644 Binary files a/docs/img/components/components-show-main.webp and b/docs/img/components/components-show-main.webp differ diff --git a/docs/img/customfonts-families.png b/docs/img/customfonts-families.png index 9f2bc0a152..b51c8b1b74 100644 Binary files a/docs/img/customfonts-families.png and b/docs/img/customfonts-families.png differ diff --git a/docs/img/customfonts-upload.png b/docs/img/customfonts-upload.png index 301456584c..4780a5d542 100644 Binary files a/docs/img/customfonts-upload.png and b/docs/img/customfonts-upload.png differ diff --git a/docs/img/customfonts-use.gif b/docs/img/customfonts-use.gif index 4416b268f4..0afeff7003 100644 Binary files a/docs/img/customfonts-use.gif and b/docs/img/customfonts-use.gif differ diff --git a/docs/img/customfonts.png b/docs/img/customfonts.png index fb7b868528..188be15c48 100644 Binary files a/docs/img/customfonts.png and b/docs/img/customfonts.png differ diff --git a/docs/img/design-tokens/01-tokens-cover.webp b/docs/img/design-tokens/01-tokens-cover.webp index 9d163897d7..baf8ff08bb 100644 Binary files a/docs/img/design-tokens/01-tokens-cover.webp and b/docs/img/design-tokens/01-tokens-cover.webp differ diff --git a/docs/img/design-tokens/05-tokens-edit.webp b/docs/img/design-tokens/05-tokens-edit.webp index b77b9cf50e..804081c434 100644 Binary files a/docs/img/design-tokens/05-tokens-edit.webp and b/docs/img/design-tokens/05-tokens-edit.webp differ diff --git a/docs/img/design-tokens/06-tokens-radius.webp b/docs/img/design-tokens/06-tokens-radius.webp index a8d479e769..757260148a 100644 Binary files a/docs/img/design-tokens/06-tokens-radius.webp and b/docs/img/design-tokens/06-tokens-radius.webp differ diff --git a/docs/img/design-tokens/08-tokens-color.webp b/docs/img/design-tokens/08-tokens-color.webp index 2556a9a765..3dfbeb0fc9 100644 Binary files a/docs/img/design-tokens/08-tokens-color.webp and b/docs/img/design-tokens/08-tokens-color.webp differ diff --git a/docs/img/design-tokens/09-tokens-dimensions-sizing.webp b/docs/img/design-tokens/09-tokens-dimensions-sizing.webp index 397508cf36..2077b9a769 100644 Binary files a/docs/img/design-tokens/09-tokens-dimensions-sizing.webp and b/docs/img/design-tokens/09-tokens-dimensions-sizing.webp differ diff --git a/docs/img/design-tokens/10-tokens-dimensions-spacing.webp b/docs/img/design-tokens/10-tokens-dimensions-spacing.webp index 38b7b0f279..dd51893e4b 100644 Binary files a/docs/img/design-tokens/10-tokens-dimensions-spacing.webp and b/docs/img/design-tokens/10-tokens-dimensions-spacing.webp differ diff --git a/docs/img/design-tokens/11-tokens-spacing.webp b/docs/img/design-tokens/11-tokens-spacing.webp index 4680497444..9922d5371a 100644 Binary files a/docs/img/design-tokens/11-tokens-spacing.webp and b/docs/img/design-tokens/11-tokens-spacing.webp differ diff --git a/docs/img/export/export-main-menu.webp b/docs/img/export/export-main-menu.webp index 9844752af6..1043a6041d 100644 Binary files a/docs/img/export/export-main-menu.webp and b/docs/img/export/export-main-menu.webp differ diff --git a/docs/img/export/export-pdf.webp b/docs/img/export/export-pdf.webp index d239f8ebb1..82a2268e57 100644 Binary files a/docs/img/export/export-pdf.webp and b/docs/img/export/export-pdf.webp differ diff --git a/docs/img/interface/dashboard-dark.webp b/docs/img/interface/dashboard-dark.webp index c0a3d53cbd..cb5f385861 100644 Binary files a/docs/img/interface/dashboard-dark.webp and b/docs/img/interface/dashboard-dark.webp differ diff --git a/docs/img/interface/dashboard-light.webp b/docs/img/interface/dashboard-light.webp index e04bbbd4b6..5904c1978b 100644 Binary files a/docs/img/interface/dashboard-light.webp and b/docs/img/interface/dashboard-light.webp differ diff --git a/docs/img/interface/workspace-dark.webp b/docs/img/interface/workspace-dark.webp index 5bfdab77ad..1d7de171fc 100644 Binary files a/docs/img/interface/workspace-dark.webp and b/docs/img/interface/workspace-dark.webp differ diff --git a/docs/img/interface/workspace-light.webp b/docs/img/interface/workspace-light.webp index 83606806de..009337d192 100644 Binary files a/docs/img/interface/workspace-light.webp and b/docs/img/interface/workspace-light.webp differ diff --git a/docs/img/interface/youraccount-notifications.webp b/docs/img/interface/youraccount-notifications.webp index 4abb846785..731afc331a 100644 Binary files a/docs/img/interface/youraccount-notifications.webp and b/docs/img/interface/youraccount-notifications.webp differ diff --git a/docs/img/interface/youraccount-password.webp b/docs/img/interface/youraccount-password.webp index 72979791b0..36d2ce783b 100644 Binary files a/docs/img/interface/youraccount-password.webp and b/docs/img/interface/youraccount-password.webp differ diff --git a/docs/img/interface/youraccount-profile.webp b/docs/img/interface/youraccount-profile.webp index 122242b84c..36be1b4d1c 100644 Binary files a/docs/img/interface/youraccount-profile.webp and b/docs/img/interface/youraccount-profile.webp differ diff --git a/docs/img/interface/youraccount-settings.webp b/docs/img/interface/youraccount-settings.webp index 3fb1bbe93b..fa27757504 100644 Binary files a/docs/img/interface/youraccount-settings.webp and b/docs/img/interface/youraccount-settings.webp differ diff --git a/docs/img/layers-select-menu.gif b/docs/img/layers-select-menu.gif index 365c1d9407..2875134e81 100644 Binary files a/docs/img/layers-select-menu.gif and b/docs/img/layers-select-menu.gif differ diff --git a/docs/img/layers/layers-collapse.mp4 b/docs/img/layers/layers-collapse.mp4 index f2bc9ce5ac..e81818c936 100644 Binary files a/docs/img/layers/layers-collapse.mp4 and b/docs/img/layers/layers-collapse.mp4 differ diff --git a/docs/img/layers/layers-collapse.webp b/docs/img/layers/layers-collapse.webp index 77c9ca90cb..87fb540388 100644 Binary files a/docs/img/layers/layers-collapse.webp and b/docs/img/layers/layers-collapse.webp differ diff --git a/docs/img/layers/layers-create.mp4 b/docs/img/layers/layers-create.mp4 index e5b30e933b..911b4e007a 100644 Binary files a/docs/img/layers/layers-create.mp4 and b/docs/img/layers/layers-create.mp4 differ diff --git a/docs/img/layers/layers-create.webp b/docs/img/layers/layers-create.webp index 87ea736358..576e54bec7 100644 Binary files a/docs/img/layers/layers-create.webp and b/docs/img/layers/layers-create.webp differ diff --git a/docs/img/layers/layers-focus.mp4 b/docs/img/layers/layers-focus.mp4 index 1f4db7bb3b..d7efd7aa20 100644 Binary files a/docs/img/layers/layers-focus.mp4 and b/docs/img/layers/layers-focus.mp4 differ diff --git a/docs/img/layers/layers-focus.webp b/docs/img/layers/layers-focus.webp index 084dd35aca..bd11a51754 100644 Binary files a/docs/img/layers/layers-focus.webp and b/docs/img/layers/layers-focus.webp differ diff --git a/docs/img/layers/pages-create.mp4 b/docs/img/layers/pages-create.mp4 index a5d1ffb2b6..0a59c266c8 100644 Binary files a/docs/img/layers/pages-create.mp4 and b/docs/img/layers/pages-create.mp4 differ diff --git a/docs/img/layers/pages-create.webp b/docs/img/layers/pages-create.webp index 1568ca45f9..2b616a527b 100644 Binary files a/docs/img/layers/pages-create.webp and b/docs/img/layers/pages-create.webp differ diff --git a/docs/img/libraries/add-color.mp4 b/docs/img/libraries/add-color.mp4 index f5b0636982..05307681ef 100644 Binary files a/docs/img/libraries/add-color.mp4 and b/docs/img/libraries/add-color.mp4 differ diff --git a/docs/img/libraries/add-color.webp b/docs/img/libraries/add-color.webp index 21f99547e9..c6ddc2daa8 100644 Binary files a/docs/img/libraries/add-color.webp and b/docs/img/libraries/add-color.webp differ diff --git a/docs/img/libraries/add-component.mp4 b/docs/img/libraries/add-component.mp4 index 932f1a0f9f..ff8e7a9553 100644 Binary files a/docs/img/libraries/add-component.mp4 and b/docs/img/libraries/add-component.mp4 differ diff --git a/docs/img/libraries/add-component.webp b/docs/img/libraries/add-component.webp index d36432d6fb..2b8d97db3e 100644 Binary files a/docs/img/libraries/add-component.webp and b/docs/img/libraries/add-component.webp differ diff --git a/docs/img/libraries/asset-options.mp4 b/docs/img/libraries/asset-options.mp4 index 14c92ee7f1..862abb68ab 100644 Binary files a/docs/img/libraries/asset-options.mp4 and b/docs/img/libraries/asset-options.mp4 differ diff --git a/docs/img/libraries/asset-options.webp b/docs/img/libraries/asset-options.webp index e55e1fc55a..8257ba22a5 100644 Binary files a/docs/img/libraries/asset-options.webp and b/docs/img/libraries/asset-options.webp differ diff --git a/docs/img/libraries/asset-use.mp4 b/docs/img/libraries/asset-use.mp4 index b61f4d02f7..7a6a212c67 100644 Binary files a/docs/img/libraries/asset-use.mp4 and b/docs/img/libraries/asset-use.mp4 differ diff --git a/docs/img/libraries/asset-use.webp b/docs/img/libraries/asset-use.webp index 5908ef7367..7867a19c54 100644 Binary files a/docs/img/libraries/asset-use.webp and b/docs/img/libraries/asset-use.webp differ diff --git a/docs/img/libraries/assets-tab.webp b/docs/img/libraries/assets-tab.webp index 5c412d733f..4cb4956984 100644 Binary files a/docs/img/libraries/assets-tab.webp and b/docs/img/libraries/assets-tab.webp differ diff --git a/docs/img/libraries/libraries-launch.mp4 b/docs/img/libraries/libraries-launch.mp4 index 10dbc5b3aa..4977bced2d 100644 Binary files a/docs/img/libraries/libraries-launch.mp4 and b/docs/img/libraries/libraries-launch.mp4 differ diff --git a/docs/img/libraries/libraries-launch.webp b/docs/img/libraries/libraries-launch.webp index 10c15fcdcb..d3ee665636 100644 Binary files a/docs/img/libraries/libraries-launch.webp and b/docs/img/libraries/libraries-launch.webp differ diff --git a/docs/img/libraries/libraries-sidebar.webp b/docs/img/libraries/libraries-sidebar.webp index e4458888e0..5b56efc0e6 100644 Binary files a/docs/img/libraries/libraries-sidebar.webp and b/docs/img/libraries/libraries-sidebar.webp differ diff --git a/docs/img/objects/board-create.webp b/docs/img/objects/board-create.webp index 606c1dcc54..19d94862ac 100644 Binary files a/docs/img/objects/board-create.webp and b/docs/img/objects/board-create.webp differ diff --git a/docs/img/objects/board-creation.mp4 b/docs/img/objects/board-creation.mp4 index 485b7fdb61..e2ee8b0c3f 100644 Binary files a/docs/img/objects/board-creation.mp4 and b/docs/img/objects/board-creation.mp4 differ diff --git a/docs/img/objects/board-tool.webp b/docs/img/objects/board-tool.webp index b86d537318..b4fd227fac 100644 Binary files a/docs/img/objects/board-tool.webp and b/docs/img/objects/board-tool.webp differ diff --git a/docs/img/objects/rectangles-ellipses.mp4 b/docs/img/objects/rectangles-ellipses.mp4 index 3ced3b26ec..19250eac8a 100644 Binary files a/docs/img/objects/rectangles-ellipses.mp4 and b/docs/img/objects/rectangles-ellipses.mp4 differ diff --git a/docs/img/objects/rectangles-ellipses.webp b/docs/img/objects/rectangles-ellipses.webp index 67d2d3496d..72aea55237 100644 Binary files a/docs/img/objects/rectangles-ellipses.webp and b/docs/img/objects/rectangles-ellipses.webp differ diff --git a/docs/img/objects/text-create.mp4 b/docs/img/objects/text-create.mp4 index c798e755c0..d04e1728a2 100644 Binary files a/docs/img/objects/text-create.mp4 and b/docs/img/objects/text-create.mp4 differ diff --git a/docs/img/objects/text-create.webp b/docs/img/objects/text-create.webp index 466f1c507a..b39518d6b2 100644 Binary files a/docs/img/objects/text-create.webp and b/docs/img/objects/text-create.webp differ diff --git a/docs/img/plugins/03_toolbar_plugins.png b/docs/img/plugins/03_toolbar_plugins.png index 3cd5a8d51b..71cf62e971 100644 Binary files a/docs/img/plugins/03_toolbar_plugins.png and b/docs/img/plugins/03_toolbar_plugins.png differ diff --git a/docs/img/plugins/04_menu_plugins.png b/docs/img/plugins/04_menu_plugins.png index d546948d20..880d730e59 100644 Binary files a/docs/img/plugins/04_menu_plugins.png and b/docs/img/plugins/04_menu_plugins.png differ diff --git a/docs/img/plugins/plugins-menu.mp4 b/docs/img/plugins/plugins-menu.mp4 index e0fb360676..52a93af645 100644 Binary files a/docs/img/plugins/plugins-menu.mp4 and b/docs/img/plugins/plugins-menu.mp4 differ diff --git a/docs/img/plugins/plugins-menu.png b/docs/img/plugins/plugins-menu.png index 1230554d3f..472d4fbbe7 100644 Binary files a/docs/img/plugins/plugins-menu.png and b/docs/img/plugins/plugins-menu.png differ diff --git a/docs/img/plugins/plugins-toolbar.mp4 b/docs/img/plugins/plugins-toolbar.mp4 index 0b99b9c081..9412bccaf8 100644 Binary files a/docs/img/plugins/plugins-toolbar.mp4 and b/docs/img/plugins/plugins-toolbar.mp4 differ diff --git a/docs/img/plugins/plugins-toolbar.png b/docs/img/plugins/plugins-toolbar.png index c8212d32df..a230f9c299 100644 Binary files a/docs/img/plugins/plugins-toolbar.png and b/docs/img/plugins/plugins-toolbar.png differ diff --git a/docs/img/styling/color-picker-gradient.webp b/docs/img/styling/color-picker-gradient.webp index eec479e702..50e9af2790 100644 Binary files a/docs/img/styling/color-picker-gradient.webp and b/docs/img/styling/color-picker-gradient.webp differ diff --git a/docs/img/styling/color-picker.webp b/docs/img/styling/color-picker.webp index 6c7e9584e1..a83e98254f 100644 Binary files a/docs/img/styling/color-picker.webp and b/docs/img/styling/color-picker.webp differ diff --git a/docs/img/tech-guide/access-tokens-create-1.webp b/docs/img/tech-guide/access-tokens-create-1.webp index 8aff3a665c..bb6f333c62 100644 Binary files a/docs/img/tech-guide/access-tokens-create-1.webp and b/docs/img/tech-guide/access-tokens-create-1.webp differ diff --git a/docs/img/tech-guide/access-tokens-create-2.webp b/docs/img/tech-guide/access-tokens-create-2.webp index c7176a3d4a..980448fde7 100644 Binary files a/docs/img/tech-guide/access-tokens-create-2.webp and b/docs/img/tech-guide/access-tokens-create-2.webp differ diff --git a/docs/img/tech-guide/access-tokens-create-3.webp b/docs/img/tech-guide/access-tokens-create-3.webp index 2e84e15368..24db9ead20 100644 Binary files a/docs/img/tech-guide/access-tokens-create-3.webp and b/docs/img/tech-guide/access-tokens-create-3.webp differ diff --git a/docs/img/tech-guide/access-tokens.webp b/docs/img/tech-guide/access-tokens.webp index 6a7d616d80..39947de1e7 100644 Binary files a/docs/img/tech-guide/access-tokens.webp and b/docs/img/tech-guide/access-tokens.webp differ diff --git a/docs/img/workspace-basics/guides-snap.mp4 b/docs/img/workspace-basics/guides-snap.mp4 index 447d385aee..3048542b85 100644 Binary files a/docs/img/workspace-basics/guides-snap.mp4 and b/docs/img/workspace-basics/guides-snap.mp4 differ diff --git a/docs/img/workspace-basics/guides-snap.webp b/docs/img/workspace-basics/guides-snap.webp index 821db4edba..13cd1fb908 100644 Binary files a/docs/img/workspace-basics/guides-snap.webp and b/docs/img/workspace-basics/guides-snap.webp differ diff --git a/docs/img/workspace-basics/main-menu.webp b/docs/img/workspace-basics/main-menu.webp index 8f91da0fa9..a308d0067c 100644 Binary files a/docs/img/workspace-basics/main-menu.webp and b/docs/img/workspace-basics/main-menu.webp differ diff --git a/docs/img/workspace-basics/nudge-menu.webp b/docs/img/workspace-basics/nudge-menu.webp index cbae6c7081..2e70a7aaf8 100644 Binary files a/docs/img/workspace-basics/nudge-menu.webp and b/docs/img/workspace-basics/nudge-menu.webp differ diff --git a/docs/img/workspace-basics/ruler-guides.mp4 b/docs/img/workspace-basics/ruler-guides.mp4 index 3e98354266..9eb9362751 100644 Binary files a/docs/img/workspace-basics/ruler-guides.mp4 and b/docs/img/workspace-basics/ruler-guides.mp4 differ diff --git a/docs/img/workspace-basics/ruler-guides.webp b/docs/img/workspace-basics/ruler-guides.webp index 819078c3fc..90e72a0f99 100644 Binary files a/docs/img/workspace-basics/ruler-guides.webp and b/docs/img/workspace-basics/ruler-guides.webp differ diff --git a/docs/img/workspace-basics/viewport.webp b/docs/img/workspace-basics/viewport.webp index 1a89970007..8659546af6 100644 Binary files a/docs/img/workspace-basics/viewport.webp and b/docs/img/workspace-basics/viewport.webp differ diff --git a/docs/img/workspace-basics/workspace-snap.webp b/docs/img/workspace-basics/workspace-snap.webp index 3ea9a225a4..f7b366a9b8 100644 Binary files a/docs/img/workspace-basics/workspace-snap.webp and b/docs/img/workspace-basics/workspace-snap.webp differ diff --git a/docs/img/workspace-basics/workspace-zoom-layer.mp4 b/docs/img/workspace-basics/workspace-zoom-layer.mp4 index c8eee212d9..b9eed42016 100644 Binary files a/docs/img/workspace-basics/workspace-zoom-layer.mp4 and b/docs/img/workspace-basics/workspace-zoom-layer.mp4 differ diff --git a/docs/img/workspace-basics/workspace-zoom-layer.webp b/docs/img/workspace-basics/workspace-zoom-layer.webp index fe5b5114b5..86c59660dd 100644 Binary files a/docs/img/workspace-basics/workspace-zoom-layer.webp and b/docs/img/workspace-basics/workspace-zoom-layer.webp differ diff --git a/docs/technical-guide/integration.md b/docs/technical-guide/integration.md index 2d8d1c5a0a..9bd3aad3ba 100644 --- a/docs/technical-guide/integration.md +++ b/docs/technical-guide/integration.md @@ -67,13 +67,13 @@ Personal access tokens function like an alternative to our login/password authen

Important: Treat your access tokens like passwords as they provide access to our account.

### Manage access tokens -In Penpot, access tokens are configured at user account level. To manage your access tokens, go to Your account > Access tokens. +In Penpot, access tokens are configured at user account level. To manage your access tokens, go to Your account > Integrations. You'll find them in the Personal access tokens section. ![Access tokens](/img/tech-guide/access-tokens.webp) ### Generate access tokens -1. Press the "Generate new token" button. +1. Press the "Create new access token" button. ![Creating token](/img/tech-guide/access-tokens-create-1.webp) @@ -83,7 +83,7 @@ In Penpot, access tokens are configured at user account level. To manage your ac ![Token expiration](/img/tech-guide/access-tokens-create-2.webp) -4. Once you're happy with the name and the expiration date, press "Create token". At this step you will be able to copy the token to your clipboard. +4. Once you're happy with the name and the expiration date, press "Create access token". At this step you will be able to copy the token to your clipboard. Keep it somewhere safe: once you close the dialog you will not be able to see it again. ![Token created](/img/tech-guide/access-tokens-create-3.webp) diff --git a/docs/user-guide/account-teams/your-account.njk b/docs/user-guide/account-teams/your-account.njk index 3267ac5886..00233dccc9 100644 --- a/docs/user-guide/account-teams/your-account.njk +++ b/docs/user-guide/account-teams/your-account.njk @@ -76,6 +76,6 @@ desc: Learn how to import and export files in Penpot, the free, open-source desi

Access tokens #

-

At the Asset tokens section you can manage your access tokens. Read more about access tokens here.

+

At the Integrations section, under Personal access tokens, you can manage your access tokens. Read more about access tokens here.

diff --git a/docs/user-guide/design-systems/libraries.njk b/docs/user-guide/design-systems/libraries.njk index d4b66e692e..d31debde9c 100644 --- a/docs/user-guide/design-systems/libraries.njk +++ b/docs/user-guide/design-systems/libraries.njk @@ -24,7 +24,7 @@ desc: Use Penpot's libraries for reusable design elements! Learn to create, mana

There are two ways to publish a library:

Publish library diff --git a/docs/user-guide/designing/workspace-basics.njk b/docs/user-guide/designing/workspace-basics.njk index bcd23eb546..1f00bb725b 100644 --- a/docs/user-guide/designing/workspace-basics.njk +++ b/docs/user-guide/designing/workspace-basics.njk @@ -25,7 +25,7 @@ desc: Master Penpot's workspace basics! Learn interface navigation, zoom tools,

The menu

-

There's a main menu at the workspace where you will find groups with all the actions that you can do at file level. File, View, Edit, Preferences and Help.

+

There's a main menu at the workspace where you will find groups with all the actions that you can do at file level. File, Edit, View, Preferences, Plugins and Help & info. If the MCP server is enabled, you'll also find an MCP server entry.

Main menu
diff --git a/docs/user-guide/first-steps/the-interface.njk b/docs/user-guide/first-steps/the-interface.njk index c670c0f6c6..81e469c92f 100644 --- a/docs/user-guide/first-steps/the-interface.njk +++ b/docs/user-guide/first-steps/the-interface.njk @@ -131,8 +131,8 @@ desc: Discover Penpot's free user guide! Learn the interface, workspace basics,
  • Search: If you are looking for a specific file just type its title at the search box.
  • Projects: A project allows you to group design files. It works pretty much like a folder in a file system. You can create as many projects as you need. If you are going to work with more people in a project, you should create it inside a team.
  • Drafts: The drafts section is where you can find the design files that are not inside any project.
  • -
  • Shared Libraries: In this section you will find all the design files that have been added as shared libraries. That way you will be able to better control the files that are sharing their assets.
  • -
  • Custom fonts: If you have purchased or own personal fonts that are not included in the catalog provided by Penpot, you can upload them from your computer and use them across the files of a team.
  • +
  • Libraries: In this section you will find all the design files that have been added as shared libraries. That way you will be able to better control the files that are sharing their assets.
  • +
  • Fonts: If you have purchased or own personal fonts that are not included in the catalog provided by Penpot, you can upload them from your computer and use them across the files of a team.
  • Pinned projects: If you want to keep some projects handy (for instance because you’re currently working on them) you can pin them to make them quickly available at the sidebar.
  • User area: This must be you! Access your profile settings, Penpot tutorials, the Penpot Community and more. You can also find here a way to leave us feedback. We’d love to read your thoughts :).
  • Comments notifications: Here you will be able to see if you have unread comments inside the files of the team. There's also a button to mark all notifications as read.
  • diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 5441abf974..73a58c40cf 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -1328,3 +1328,25 @@ test.describe("Gradient type select", () => { await expect(panel).toHaveScreenshot(shot); }); }); + +test.describe("Shortcuts panel", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Wraps long key lists between combinations", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + + await page.keyboard.press("Shift+?"); + const list = page.locator("[class*='shortcuts-list']"); + await expect(list).toBeVisible(); + await page.getByRole("textbox").first().fill("board"); + + const next = page.getByRole("listitem", { name: "Next board" }); + const prev = page.getByRole("listitem", { name: "Previous board" }); + await prev.scrollIntoViewIfNeeded(); + await page.mouse.move(640, 300); + await expect(next).toHaveScreenshot(shot); + await expect(prev).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/src/app/main/data/workspace/shapes.cljs b/frontend/src/app/main/data/workspace/shapes.cljs index 587956c084..f3ba4cd9e5 100644 --- a/frontend/src/app/main/data/workspace/shapes.cljs +++ b/frontend/src/app/main/data/workspace/shapes.cljs @@ -361,10 +361,11 @@ (remove #(dm/get-in objects [% :blocked])) (cfh/order-by-indexed-shapes objects)) + data (dsh/lookup-file-data state) changes (-> (pcb/empty-changes it page-id) - (pcb/with-objects objects)) - - changes (cfsh/prepare-move-shapes-into-frame changes frame-id shapes objects true)] + (pcb/with-objects objects) + (pcb/with-library-data data) + (cls/generate-move-shapes-into-frame frame-id shapes objects true))] (if (some? changes) (rx/of (dch/commit-changes changes)) diff --git a/frontend/src/app/main/data/workspace/variants.cljs b/frontend/src/app/main/data/workspace/variants.cljs index 09f6003a9d..149a5edeff 100644 --- a/frontend/src/app/main/data/workspace/variants.cljs +++ b/frontend/src/app/main/data/workspace/variants.cljs @@ -38,6 +38,34 @@ [beicon.v2.core :as rx] [potok.v2.core :as ptk])) +(defn- get-variant-mains-page + "Return the page that holds the variant mains. + + Pass `:component-id` or `:variant-id`. Finds nothing when that id has + no main page in the file." + [data {:keys [component-id variant-id]}] + (let [page-id (cond + (some? component-id) + (:main-instance-page (ctkl/get-component data component-id)) + + (some? variant-id) + (-> (cfv/find-variant-components data variant-id) + first + :main-instance-page)) + objects (when page-id + (dm/get-in data [:pages-index page-id :objects]))] + (when (and page-id objects) + {:page-id page-id + :objects objects}))) + +(defn- with-main-page + "Run `f` with the page id and objects that hold the component/variant mains. + Returns an empty stream when the file cannot resolve them." + [data opts f] + (if-let [{:keys [page-id objects]} (get-variant-mains-page data opts)] + (f page-id objects) + (rx/empty))) + (defn update-properties-names-and-values "Compares the previous properties with the updated ones and executes the correspondent action for each one depending on if it needs to be removed, updated or added" @@ -49,53 +77,51 @@ ptk/WatchEvent (watch [it state _] - (let [page-id (:current-page-id state) - data (dsh/lookup-file-data state) - objects (-> (dsh/get-page data page-id) - (get :objects)) + (let [data (dsh/lookup-file-data state)] + (with-main-page data {:component-id component-id} + (fn [page-id objects] + (let [updated-properties (ctv/update-number-in-repeated-prop-names updated-properties) - updated-properties (ctv/update-number-in-repeated-prop-names updated-properties) + properties-to-remove (ctv/find-properties-to-remove previous-properties updated-properties) + properties-to-add (ctv/find-properties-to-add previous-properties updated-properties) + properties-to-update (ctv/find-properties-to-update previous-properties updated-properties) - properties-to-remove (ctv/find-properties-to-remove previous-properties updated-properties) - properties-to-add (ctv/find-properties-to-add previous-properties updated-properties) - properties-to-update (ctv/find-properties-to-update previous-properties updated-properties) + changes (-> (pcb/empty-changes it page-id) + (pcb/with-objects objects) + (pcb/with-library-data data)) - changes (-> (pcb/empty-changes it page-id) - (pcb/with-objects objects) - (pcb/with-library-data data)) + changes (reduce + (fn [changes {:keys [name]}] + (-> changes + (clvp/generate-update-property-value component-id (ctv/find-index-for-property-name previous-properties name) ""))) + changes + properties-to-remove) - changes (reduce - (fn [changes {:keys [name]}] - (-> changes - (clvp/generate-update-property-value component-id (ctv/find-index-for-property-name previous-properties name) ""))) - changes - properties-to-remove) + changes (reduce + (fn [changes {:keys [name value]}] + (-> changes + (clvp/generate-update-property-value component-id (ctv/find-index-for-property-name previous-properties name) value))) + changes + properties-to-update) - changes (reduce - (fn [changes {:keys [name value]}] - (-> changes - (clvp/generate-update-property-value component-id (ctv/find-index-for-property-name previous-properties name) value))) - changes - properties-to-update) + changes (reduce + (fn [changes [idx {:keys [name value]}]] + (-> changes + (clvp/generate-add-new-property variant-id {:property-name name}) + (clvp/generate-update-property-value component-id (+ idx (count previous-properties)) value))) + changes + (map-indexed vector properties-to-add)) - changes (reduce - (fn [changes [idx {:keys [name value]}]] - (-> changes - (clvp/generate-add-new-property variant-id {:property-name name}) - (clvp/generate-update-property-value component-id (+ idx (count previous-properties)) value))) - changes - (map-indexed vector properties-to-add)) + undo-id (js/Symbol)] - undo-id (js/Symbol)] - - (rx/of - (when (or (seq properties-to-remove) (seq properties-to-update)) - (ev/event {::ev/name "variant-edit-property-value" ::ev/origin "workspace:rename-in-layers"})) - (when (seq properties-to-add) - (ev/event {::ev/name "variant-add-property" ::ev/origin "workspace:rename-in-layers"})) - (dwu/start-undo-transaction undo-id) - (dch/commit-changes changes) - (dwu/commit-undo-transaction undo-id)))))) + (rx/of + (when (or (seq properties-to-remove) (seq properties-to-update)) + (ev/event {::ev/name "variant-edit-property-value" ::ev/origin "workspace:rename-in-layers"})) + (when (seq properties-to-add) + (ev/event {::ev/name "variant-add-property" ::ev/origin "workspace:rename-in-layers"})) + (dwu/start-undo-transaction undo-id) + (dch/commit-changes changes) + (dwu/commit-undo-transaction undo-id))))))))) (defn update-property-name "Update the variant property name on the position pos @@ -105,44 +131,39 @@ ptk/UpdateEvent (update [_ state] (let [file-id (:current-file-id state) - data (dsh/lookup-file-data state) - objects (dsh/lookup-page-objects state) - - related-components (cfv/find-variant-components data objects variant-id)] - - (reduce - (fn [s related-component] - (update-in s - [:files file-id :data :components (:id related-component) :variant-properties] - (fn [props] (mapv #(with-meta % nil) props)))) - state - related-components))) + data (dsh/lookup-file-data state)] + (if-let [{:keys [objects]} (get-variant-mains-page data {:variant-id variant-id})] + (let [related-components (cfv/find-variant-components data objects variant-id)] + (reduce + (fn [s related-component] + (update-in s + [:files file-id :data :components (:id related-component) :variant-properties] + (fn [props] (mapv #(with-meta % nil) props)))) + state + related-components)) + state))) ptk/WatchEvent (watch [it state _] - (let [page-id (:current-page-id state) - data (dsh/lookup-file-data state) - objects (-> (dsh/get-page data page-id) - (get :objects)) - - related-components (cfv/find-variant-components data objects variant-id) - - props (-> related-components last :variant-properties) - valid-pos? (> (count props) pos) - prop-name (when valid-pos? (-> props (nth pos) :name)) - - changes (when valid-pos? - (-> (pcb/empty-changes it page-id) - (pcb/with-objects objects) - (pcb/with-library-data data) - (clvp/generate-update-property-name variant-id pos new-name))) - undo-id (js/Symbol)] - (when (and valid-pos? (not= prop-name new-name)) - (rx/of - (dwu/start-undo-transaction undo-id) - (dch/commit-changes changes) - (dwu/commit-undo-transaction undo-id) - (ev/event {::ev/name "variant-edit-property-name" ::ev/origin trigger}))))))) + (let [data (dsh/lookup-file-data state)] + (with-main-page data {:variant-id variant-id} + (fn [page-id objects] + (let [related-components (cfv/find-variant-components data objects variant-id) + props (-> related-components last :variant-properties) + valid-pos? (> (count props) pos) + prop-name (when valid-pos? (-> props (nth pos) :name)) + changes (when valid-pos? + (-> (pcb/empty-changes it page-id) + (pcb/with-objects objects) + (pcb/with-library-data data) + (clvp/generate-update-property-name variant-id pos new-name))) + undo-id (js/Symbol)] + (when (and valid-pos? (not= prop-name new-name)) + (rx/of + (dwu/start-undo-transaction undo-id) + (dch/commit-changes changes) + (dwu/commit-undo-transaction undo-id) + (ev/event {::ev/name "variant-edit-property-name" ::ev/origin trigger})))))))))) (defn update-property-value "Updates the variant property value on the position pos in a component" @@ -150,20 +171,18 @@ (ptk/reify ::update-property-value ptk/WatchEvent (watch [it state _] - (let [page-id (:current-page-id state) - data (dsh/lookup-file-data state) - objects (-> (dsh/get-page data page-id) - (get :objects)) - - changes (-> (pcb/empty-changes it page-id) - (pcb/with-library-data data) - (pcb/with-objects objects) - (clvp/generate-update-property-value component-id pos value)) - undo-id (js/Symbol)] - (rx/of - (dwu/start-undo-transaction undo-id) - (dch/commit-changes changes) - (dwu/commit-undo-transaction undo-id)))))) + (let [data (dsh/lookup-file-data state)] + (with-main-page data {:component-id component-id} + (fn [page-id objects] + (let [changes (-> (pcb/empty-changes it page-id) + (pcb/with-library-data data) + (pcb/with-objects objects) + (clvp/generate-update-property-value component-id pos value)) + undo-id (js/Symbol)] + (rx/of + (dwu/start-undo-transaction undo-id) + (dch/commit-changes changes) + (dwu/commit-undo-transaction undo-id))))))))) (defn update-error "Sets or unsets an error for a component" @@ -173,20 +192,18 @@ (ptk/reify ::update-error ptk/WatchEvent (watch [it state _] - (let [page-id (:current-page-id state) - data (dsh/lookup-file-data state) - objects (-> (dsh/get-page data page-id) - (get :objects)) - - changes (-> (pcb/empty-changes it page-id) - (pcb/with-library-data data) - (pcb/with-objects objects) - (clvp/generate-set-variant-error component-id value)) - undo-id (js/Symbol)] - (rx/of - (dwu/start-undo-transaction undo-id) - (dch/commit-changes changes) - (dwu/commit-undo-transaction undo-id))))))) + (let [data (dsh/lookup-file-data state)] + (with-main-page data {:component-id component-id} + (fn [page-id objects] + (let [changes (-> (pcb/empty-changes it page-id) + (pcb/with-library-data data) + (pcb/with-objects objects) + (clvp/generate-set-variant-error component-id value)) + undo-id (js/Symbol)] + (rx/of + (dwu/start-undo-transaction undo-id) + (dch/commit-changes changes) + (dwu/commit-undo-transaction undo-id)))))))))) (defn remove-property "Remove the variant property on the position pos @@ -195,21 +212,18 @@ (ptk/reify ::remove-property ptk/WatchEvent (watch [it state _] - (let [page-id (:current-page-id state) - data (dsh/lookup-file-data state) - objects (-> (dsh/get-page data page-id) - (get :objects)) - - changes (-> (pcb/empty-changes it page-id) - (pcb/with-library-data data) - (pcb/with-objects objects) - (clvp/generate-remove-property variant-id pos)) - - undo-id (js/Symbol)] - (rx/of - (dwu/start-undo-transaction undo-id) - (dch/commit-changes changes) - (dwu/commit-undo-transaction undo-id)))))) + (let [data (dsh/lookup-file-data state)] + (with-main-page data {:variant-id variant-id} + (fn [page-id objects] + (let [changes (-> (pcb/empty-changes it page-id) + (pcb/with-library-data data) + (pcb/with-objects objects) + (clvp/generate-remove-property variant-id pos)) + undo-id (js/Symbol)] + (rx/of + (dwu/start-undo-transaction undo-id) + (dch/commit-changes changes) + (dwu/commit-undo-transaction undo-id))))))))) (defn remove-empty-properties "Remove every empty property for all components when their respective values are empty @@ -218,45 +232,39 @@ (ptk/reify ::remove-empty-properties ptk/WatchEvent (watch [it state _] - (let [page-id (:current-page-id state) - data (dsh/lookup-file-data state) - objects (-> (dsh/get-page data page-id) - (get :objects)) + (let [data (dsh/lookup-file-data state)] + (with-main-page data {:variant-id variant-id} + (fn [page-id objects] + (let [variant-components (cfv/find-variant-components data objects variant-id) + properties-empty-pos (->> variant-components + (mapcat :variant-properties) + (group-by :name) + (map-indexed + (fn [i [_ v]] + [i (->> v + (map :value) + (remove empty?) + empty?)])) + (reverse)) + changes (-> (pcb/empty-changes it page-id) + (pcb/with-library-data data) + (pcb/with-objects objects)) + changes (reduce + (fn [changes [pos property-empty?]] + (if property-empty? + (-> changes + (clvp/generate-remove-property variant-id pos)) + changes)) + changes + properties-empty-pos) + undo-id (js/Symbol)] - variant-components (cfv/find-variant-components data objects variant-id) - - properties-empty-pos (->> variant-components - (mapcat :variant-properties) - (group-by :name) - (map-indexed - (fn [i [_ v]] - [i (->> v - (map :value) - (remove empty?) - empty?)])) - (reverse)) - - changes (-> (pcb/empty-changes it page-id) - (pcb/with-library-data data) - (pcb/with-objects objects)) - - changes (reduce - (fn [changes [pos property-empty?]] - (if property-empty? - (-> changes - (clvp/generate-remove-property variant-id pos)) - changes)) - changes - properties-empty-pos) - - undo-id (js/Symbol)] - - (when (seq (:redo-changes changes)) - (rx/of - (ev/event {::ev/name "variant-remove-property" ::ev/origin "workspace:rename-in-layers"}) - (dwu/start-undo-transaction undo-id) - (dch/commit-changes changes) - (dwu/commit-undo-transaction undo-id))))))) + (when (seq (:redo-changes changes)) + (rx/of + (ev/event {::ev/name "variant-remove-property" ::ev/origin "workspace:rename-in-layers"}) + (dwu/start-undo-transaction undo-id) + (dch/commit-changes changes) + (dwu/commit-undo-transaction undo-id)))))))))) (defn add-new-property "Add a new variant property to all the components with this variant-id" @@ -264,21 +272,18 @@ (ptk/reify ::add-new-property ptk/WatchEvent (watch [it state _] - (let [page-id (:current-page-id state) - data (dsh/lookup-file-data state) - objects (-> (dsh/get-page data page-id) - (get :objects)) - - changes (-> (pcb/empty-changes it page-id) - (pcb/with-library-data data) - (pcb/with-objects objects) - (clvp/generate-add-new-property variant-id options)) - - undo-id (js/Symbol)] - (rx/of - (dwu/start-undo-transaction undo-id) - (dch/commit-changes changes) - (dwu/commit-undo-transaction undo-id)))))) + (let [data (dsh/lookup-file-data state)] + (with-main-page data {:variant-id variant-id} + (fn [page-id objects] + (let [changes (-> (pcb/empty-changes it page-id) + (pcb/with-library-data data) + (pcb/with-objects objects) + (clvp/generate-add-new-property variant-id options)) + undo-id (js/Symbol)] + (rx/of + (dwu/start-undo-transaction undo-id) + (dch/commit-changes changes) + (dwu/commit-undo-transaction undo-id))))))))) (defn reorder-variant-poperties "Reorder properties by moving a property from some position to some space between positions" @@ -286,21 +291,18 @@ (ptk/reify ::reorder-variant-properties ptk/WatchEvent (watch [it state _] - (let [page-id (:current-page-id state) - data (dsh/lookup-file-data state) - objects (-> (dsh/get-page data page-id) - (get :objects)) - - changes (-> (pcb/empty-changes it page-id) - (pcb/with-library-data data) - (pcb/with-objects objects) - (clvp/generate-reorder-variant-poperties variant-id from-pos to-space-between-pos)) - - undo-id (js/Symbol)] - (rx/of - (dwu/start-undo-transaction undo-id) - (dch/commit-changes changes) - (dwu/commit-undo-transaction undo-id)))))) + (let [data (dsh/lookup-file-data state)] + (with-main-page data {:variant-id variant-id} + (fn [page-id objects] + (let [changes (-> (pcb/empty-changes it page-id) + (pcb/with-library-data data) + (pcb/with-objects objects) + (clvp/generate-reorder-variant-poperties variant-id from-pos to-space-between-pos)) + undo-id (js/Symbol)] + (rx/of + (dwu/start-undo-transaction undo-id) + (dch/commit-changes changes) + (dwu/commit-undo-transaction undo-id))))))))) (defn- set-variant-id "Sets the variant-id on a component" @@ -566,21 +568,19 @@ ptk/WatchEvent (watch [_ state _] - (let [page-id (:current-page-id state) - data (dsh/lookup-file-data state) - objects (-> (dsh/get-page data page-id) - (get :objects)) - variant-components (cfv/find-variant-components data objects variant-id) - clean-name (cpn/clean-path name) - undo-id (js/Symbol)] - - (rx/concat - (rx/of (dwu/start-undo-transaction undo-id) - (dwsh/update-shapes [variant-id] #(assoc % :name clean-name))) - (rx/from (map - #(dwl/rename-component-and-main-instance (:id %) clean-name) - variant-components)) - (rx/of (dwu/commit-undo-transaction undo-id))))))) + (let [data (dsh/lookup-file-data state)] + (with-main-page data {:variant-id variant-id} + (fn [page-id objects] + (let [variant-components (cfv/find-variant-components data objects variant-id) + clean-name (cpn/clean-path name) + undo-id (js/Symbol)] + (rx/concat + (rx/of (dwu/start-undo-transaction undo-id) + (dwsh/update-shapes [variant-id] #(assoc % :name clean-name) {:page-id page-id})) + (rx/from (map + #(dwl/rename-component-and-main-instance (:id %) clean-name) + variant-components)) + (rx/of (dwu/commit-undo-transaction undo-id)))))))))) (defn rename-comp-or-variant-and-main "If the component is in a variant, rename the variant. diff --git a/frontend/src/app/main/ui/shortcuts.cljs b/frontend/src/app/main/ui/shortcuts.cljs index 4166314a37..64df9a10bf 100644 --- a/frontend/src/app/main/ui/shortcuts.cljs +++ b/frontend/src/app/main/ui/shortcuts.cljs @@ -539,39 +539,31 @@ (mf/defc shortcuts-keys* [{:keys [content command is-customized has-conflict? light-shortcut]}] - (let [managed-list (if (coll? content) - content - (conj () content)) - chars-list (map ds/split-sc managed-list) - last-element (last chars-list) - short-char-list (if (= 1 (count chars-list)) - chars-list - (drop-last chars-list)) - penultimate (last short-char-list)] + (let [managed-list (if (coll? content) + content + (conj () content)) + chars-list (map ds/split-sc managed-list) + last-index (dec (count chars-list)) + key-class (cond + has-conflict? (stl/css :conflict-key) + is-customized (stl/css :customized-key) + light-shortcut (stl/css :light-key))] + ;; Each combination is a group that never breaks; the list wraps + ;; between groups. The separator stays inside a group so no line + ;; starts with a comma. [:span {:class (stl/css :keys)} - (for [chars short-char-list] - [:* {:key (str/join chars)} + (for [[index chars] (d/enumerate chars-list)] + [:span {:key (dm/str index "-" (str/join chars)) + :class (stl/css :key-group)} + (when (and (pos? index) (= index last-index)) + [:span {:class (stl/css :space)} (tr "shortcuts.or")]) (for [char chars] [:> converted-chars* {:key (dm/str char "-" (name command)) :char char :command command - :class (cond - has-conflict? (stl/css :conflict-key) - is-customized (stl/css :customized-key) - light-shortcut (stl/css :light-key))}]) - - (when (not= chars penultimate) [:span {:class (stl/css :space)} ","])]) - (when (not= last-element penultimate) - [:* - [:span {:class (stl/css :space)} (tr "shortcuts.or")] - (for [char last-element] - [:> converted-chars* {:key (dm/str char "-" (name command)) - :char char - :command command - :class (cond - has-conflict? (stl/css :conflict-key) - is-customized (stl/css :customized-key) - light-shortcut (stl/css :light-key))}])])])) + :class key-class}]) + (when (< index (dec last-index)) + [:span {:class (stl/css :space)} ","])])])) (mf/defc shortcut-row-editable* [{:keys [elements custom-shortcuts command-translate conflicts section-key]}] @@ -863,7 +855,7 @@ :data-conflict (str has-conflict?) :aria-label command-translate :key command-translate} - [:span + [:span {:class (stl/css :command-label)} [:span {:class (stl/css-case :command-name true :not-customizable-label (not customizable?)) :id (dm/str command-translate "-label")} diff --git a/frontend/src/app/main/ui/shortcuts.scss b/frontend/src/app/main/ui/shortcuts.scss index 11eef8a482..dea29e9aba 100644 --- a/frontend/src/app/main/ui/shortcuts.scss +++ b/frontend/src/app/main/ui/shortcuts.scss @@ -4,12 +4,10 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/_utils.scss" as *; -@use "ds/mixins.scss" as *; -@use "ds/typography.scss" as t; +@use "ds/typography.scss" as *; .section { margin: 0; @@ -21,7 +19,7 @@ .section-title, .subsection-title { - @include t.use-typography("headline-small"); + @include use-typography("headline-small"); display: flex; align-items: center; @@ -60,6 +58,7 @@ display: flex; align-items: center; justify-content: space-between; + column-gap: var(--sp-s); inline-size: 100%; min-block-size: $sz-32; padding: px2rem(6); @@ -69,7 +68,7 @@ } .command-name { - @include t.use-typography("body-small"); + @include use-typography("body-small"); margin-inline-start: var(--sp-xxs); color: var(--color-foreground-primary); @@ -78,6 +77,16 @@ text-align: start; } +.command-label { + flex: 0 1 auto; +} + +// The keys take the space the label leaves and wrap between groups. +.shortcuts-name .shortcut-actions { + flex: 1 1 0; + justify-content: flex-end; +} + .not-customizable-label { padding-inline-start: px2rem(6); } @@ -163,13 +172,20 @@ .keys { display: flex; - justify-content: center; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; + gap: var(--sp-xxs); +} + +.key-group { + display: flex; align-items: center; gap: var(--sp-xxs); } .key { - @include t.use-typography("body-small"); + @include use-typography("body-small"); display: flex; justify-content: center; @@ -203,7 +219,7 @@ } .shortcut-placeholder { - @include t.use-typography("body-small"); + @include use-typography("body-small"); color: var(--color-foreground-secondary); } diff --git a/frontend/src/app/plugins/flex.cljs b/frontend/src/app/plugins/flex.cljs index 57bf9f1953..0b5f9243b2 100644 --- a/frontend/src/app/plugins/flex.cljs +++ b/frontend/src/app/plugins/flex.cljs @@ -358,6 +358,9 @@ (not (r/check-permission plugin-id "content:write")) (u/not-valid plugin-id :appendChild "Plugin doesn't have 'content:write' permission") + (u/non-main-into-variant-container? shape child) + (u/not-valid plugin-id :appendChild "Only main instances can be added to a variant container") + :else (st/emit! (dwsh/relocate-shapes #{child-id} id index) diff --git a/frontend/src/app/plugins/grid.cljs b/frontend/src/app/plugins/grid.cljs index f15980bf2a..8860aa5d58 100644 --- a/frontend/src/app/plugins/grid.cljs +++ b/frontend/src/app/plugins/grid.cljs @@ -574,6 +574,9 @@ (u/changes-component-copy-structure? objects shape child-shape) (u/not-valid plugin-id :appendChild "Cannot change the structure of a component copy") + (u/non-main-into-variant-container? shape child) + (u/not-valid plugin-id :appendChild "Only main instances can be added to a variant container") + :else (st/emit! (dwt/move-shapes-to-frame #{child-id} id nil [row column]) (ptk/data-event :layout/update {:ids [id]}))))))) diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index 9a86ebd0d3..60a06e2c0f 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -1263,6 +1263,9 @@ (u/changes-component-copy-structure? objects shape child-shape) (u/not-valid plugin-id :appendChild "Cannot change the structure of a component copy") + (u/non-main-into-variant-container? shape child) + (u/not-valid plugin-id :appendChild "Only main instances can be added to a variant container") + :else (st/emit! (dwsh/relocate-shapes #{child-id} id index) @@ -1302,6 +1305,9 @@ (u/changes-component-copy-structure? objects shape child-shape) (u/not-valid plugin-id :insertChild "Cannot change the structure of a component copy") + (u/non-main-into-variant-container? shape child) + (u/not-valid plugin-id :insertChild "Only main instances can be added to a variant container") + :else (st/emit! (dwsh/relocate-shapes #{child-id} id index) diff --git a/frontend/src/app/plugins/utils.cljs b/frontend/src/app/plugins/utils.cljs index 22f57d45fd..581a41afe2 100644 --- a/frontend/src/app/plugins/utils.cljs +++ b/frontend/src/app/plugins/utils.cljs @@ -604,3 +604,10 @@ [p] (when-let [shape (proxy->shape p)] (ctk/main-instance? shape))) + +(defn non-main-into-variant-container? + "True when `parent` is a variant container and `child` (a shape proxy) + is not a main instance." + [parent child] + (and (ctk/is-variant-container? parent) + (not (is-main-component-proxy? child)))) diff --git a/frontend/test/frontend_tests/data/workspace_variants_test.cljs b/frontend/test/frontend_tests/data/workspace_variants_test.cljs new file mode 100644 index 0000000000..d96a7823b8 --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_variants_test.cljs @@ -0,0 +1,154 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.data.workspace-variants-test + (:require + [app.common.path-names :as cpn] + [app.common.test-helpers.components :as cthc] + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.ids-map :as cthi] + [app.common.test-helpers.shapes :as cths] + [app.common.test-helpers.variants :as thv] + [app.common.types.variant :as ctv] + [app.main.data.workspace.variants :as dwv] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.state :as ths])) + +(t/use-fixtures :each + {:before cthi/reset-idmap!}) + +(defn- two-property-file-on-other-page + [] + (-> (cthf/sample-file :file1 :page-label :page1) + (thv/add-variant-two-properties :v01 :c01 :m01 :c02 :m02) + (cthf/add-sample-page :page2))) + +(defn- run + [file events done assert-fn] + (ths/run-store + (ths/setup-store file) + done + events + (fn [state] + (cthf/validate-file! (ths/get-file-from-state state)) + (assert-fn state)))) + +(defn- variant-name + [file label] + (:variant-name (cths/get-shape file label :page-label :page1))) + +(t/deftest update-property-value-from-another-page + (t/async + done + (let [file (two-property-file-on-other-page) + component-id (:id (cthc/get-component file :c01)) + other-id (cthi/id :page2)] + (run file + [(dwv/update-property-value component-id 0 "active") + (dwv/update-property-value component-id 1 "crítica")] + done + (fn [state] + (let [file (ths/get-file-from-state state) + props (:variant-properties (cthc/get-component file :c01))] + (t/is (= other-id (:current-page-id state))) + (t/is (= "crítica" (-> props second :value))) + (t/is (= "active, crítica" (variant-name file :m01))) + (t/is (= (ctv/properties-to-name props) (variant-name file :m01))))))))) + +(t/deftest update-property-value-on-main-page + (t/async + done + (let [file (-> (two-property-file-on-other-page) + (cthf/switch-to-page :page1)) + component-id (:id (cthc/get-component file :c01))] + (run file + [(dwv/update-property-value component-id 1 "crítica")] + done + (fn [state] + (let [file (ths/get-file-from-state state) + props (:variant-properties (cthc/get-component file :c01))] + (t/is (= (cthi/id :page1) (:current-page-id state))) + (t/is (= "crítica" (-> props second :value))) + (t/is (= (ctv/properties-to-name props) (variant-name file :m01))))))))) + +(t/deftest add-remove-reorder-from-another-page + (t/async + done + (let [file (two-property-file-on-other-page) + variant-id (cthi/id :v01)] + (run file + [(dwv/add-new-property variant-id {:fill-values? true}) + (dwv/reorder-variant-poperties variant-id 0 3) + (dwv/remove-property variant-id 0)] + done + (fn [state] + (let [file (ths/get-file-from-state state) + comp1 (cthc/get-component file :c01) + comp2 (cthc/get-component file :c02) + names1 (mapv :name (:variant-properties comp1)) + names2 (mapv :name (:variant-properties comp2))] + (t/is (= names1 names2)) + (t/is (= (ctv/properties-to-name (:variant-properties comp1)) + (variant-name file :m01))) + (t/is (= (ctv/properties-to-name (:variant-properties comp2)) + (variant-name file :m02))))))))) + +(t/deftest formula-rename-from-another-page + (t/async + done + (let [file (two-property-file-on-other-page) + comp (cthc/get-component file :c01) + previous (:variant-properties comp) + updated [{:name "Property 1" :value "active"} + {:name "Property 2" :value "crítica"}]] + (run file + [(dwv/update-properties-names-and-values (:id comp) (:variant-id comp) previous updated) + (dwv/remove-empty-properties (:variant-id comp))] + done + (fn [state] + (let [file (ths/get-file-from-state state) + props (:variant-properties (cthc/get-component file :c01))] + (t/is (= "active" (-> props first :value))) + (t/is (= "crítica" (-> props second :value))) + (t/is (= (ctv/properties-to-name props) (variant-name file :m01))))))))) + +(t/deftest update-property-name-from-another-page + (t/async + done + (let [file (two-property-file-on-other-page) + variant-id (cthi/id :v01)] + (run file + [(dwv/update-property-name variant-id 0 "Status" {:trigger "test"})] + done + (fn [state] + (let [file (ths/get-file-from-state state) + names (mapv :name (:variant-properties (cthc/get-component file :c01)))] + (t/is (= names (mapv :name (:variant-properties (cthc/get-component file :c02))))) + (t/is (= "Status" (first names))))))))) + +(t/deftest rename-variant-from-another-page + (t/async + done + (let [file (two-property-file-on-other-page) + variant-id (cthi/id :v01) + other-id (cthi/id :page2) + clean-name (cpn/clean-path "Renamed set")] + (run file + [(dwv/rename-variant variant-id "Renamed set")] + done + (fn [state] + (let [file (ths/get-file-from-state state) + container (cths/get-shape file :v01 :page-label :page1) + main1 (cths/get-shape file :m01 :page-label :page1) + main2 (cths/get-shape file :m02 :page-label :page1) + comp1 (cthc/get-component file :c01) + comp2 (cthc/get-component file :c02)] + (t/is (= other-id (:current-page-id state))) + (t/is (= clean-name (:name container))) + (t/is (= clean-name (:name main1))) + (t/is (= clean-name (:name main2))) + (t/is (= clean-name (cpn/merge-path-item (:path comp1) (:name comp1)))) + (t/is (= clean-name (cpn/merge-path-item (:path comp2) (:name comp2)))))))))) diff --git a/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs b/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs index 7dca8b7af8..3d05d8abc1 100644 --- a/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs +++ b/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs @@ -18,6 +18,8 @@ [app.main.data.workspace.variants :as dwv] [app.main.store :as st] [app.plugins.api :as api] + [app.plugins.flex :as flex] + [app.plugins.grid :as grid] [app.plugins.public-utils :as public-utils] [app.plugins.register :as r] [app.plugins.shape :as shape] @@ -410,3 +412,125 @@ (t/is (= 1 (count @errors))) (t/is (= [plugin-id :combineAsVariants "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +;; --------------------------------------------------------------------------- +;; Variant container append / insert +;; --------------------------------------------------------------------------- + +(def ^:private variant-append-msg + "Only main instances can be added to a variant container") + +(defn- variant-append-objects + [child-attrs] + (let [container-id (uuid/next) + child-id (uuid/next)] + {:container-id container-id + :child-id child-id + :objects {container-id {:id container-id + :type :frame + :is-variant-container true + :shapes []} + child-id (merge {:id child-id + :type :rect + :parent-id uuid/zero} + child-attrs)}})) + +(defn- with-append-stubs + [objects f] + (let [errors (atom []) + emitted (atom [])] + (with-redefs [u/locate-shape (fn [_file _page id] (get objects id)) + ;; Multi-arity: CLJS callers hit IFn$_invoke$arity$2; `constantly` + ;; only installs arity-variadic and breaks those calls. + u/locate-objects (fn + ([] objects) + ([_file _page] objects)) + u/page-active? (constantly true) + u/natural-child-ordering? (constantly false) + r/check-permission (constantly true) + u/not-valid (mock/stub (fn [pid prop msg] + (swap! errors conj [pid prop msg]))) + st/emit! (fn [& args] + (swap! emitted conj args) + nil)] + (f errors emitted)))) + +(t/deftest append-child-rejects-plain-shape-into-variant-container + (let [{:keys [container-id child-id objects]} (variant-append-objects {})] + (with-append-stubs + objects + (fn [errors emitted] + (let [container (shape/shape-proxy plugin-id (uuid/next) (uuid/next) container-id) + child (shape/shape-proxy plugin-id (uuid/next) (uuid/next) child-id)] + (.appendChild container child) + (t/is (= 1 (count @errors))) + (t/is (= variant-append-msg (last (first @errors)))) + (t/is (empty? @emitted))))))) + +(t/deftest append-child-rejects-copy-into-variant-container + (let [{:keys [container-id child-id objects]} + (variant-append-objects {:type :frame + :component-id (uuid/next) + :component-root true})] + (with-append-stubs + objects + (fn [errors emitted] + (let [container (shape/shape-proxy plugin-id (uuid/next) (uuid/next) container-id) + child (shape/shape-proxy plugin-id (uuid/next) (uuid/next) child-id)] + (.appendChild container child) + (t/is (= 1 (count @errors))) + (t/is (= variant-append-msg (last (first @errors)))) + (t/is (empty? @emitted))))))) + +(t/deftest append-child-allows-main-into-variant-container + (let [{:keys [container-id child-id objects]} + (variant-append-objects {:type :frame + :main-instance true + :component-id (uuid/next) + :component-root true})] + (with-append-stubs + objects + (fn [errors emitted] + (let [container (shape/shape-proxy plugin-id (uuid/next) (uuid/next) container-id) + child (shape/shape-proxy plugin-id (uuid/next) (uuid/next) child-id)] + (.appendChild container child) + (t/is (empty? @errors)) + (t/is (pos? (count @emitted)))))))) + +(t/deftest insert-child-rejects-plain-shape-into-variant-container + (let [{:keys [container-id child-id objects]} (variant-append-objects {})] + (with-append-stubs + objects + (fn [errors emitted] + (let [container (shape/shape-proxy plugin-id (uuid/next) (uuid/next) container-id) + child (shape/shape-proxy plugin-id (uuid/next) (uuid/next) child-id)] + (.insertChild container 0 child) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :insertChild variant-append-msg] (first @errors))) + (t/is (empty? @emitted))))))) + +(t/deftest flex-append-child-rejects-plain-shape-into-variant-container + (let [{:keys [container-id child-id objects]} (variant-append-objects {})] + (with-append-stubs + objects + (fn [errors emitted] + (with-redefs [flex/shape-proxy? (constantly true)] + (let [layout (flex/flex-layout-proxy plugin-id (uuid/next) (uuid/next) container-id) + child (shape/shape-proxy plugin-id (uuid/next) (uuid/next) child-id)] + (.appendChild layout child) + (t/is (= 1 (count @errors))) + (t/is (= variant-append-msg (last (first @errors)))) + (t/is (empty? @emitted)))))))) + +(t/deftest grid-append-child-rejects-plain-shape-into-variant-container + (let [{:keys [container-id child-id objects]} (variant-append-objects {})] + (with-append-stubs + objects + (fn [errors emitted] + (with-redefs [grid/shape-proxy? (constantly true)] + (let [layout (grid/grid-layout-proxy plugin-id (uuid/next) (uuid/next) container-id) + child (shape/shape-proxy plugin-id (uuid/next) (uuid/next) child-id)] + (.appendChild layout child 0 0) + (t/is (= 1 (count @errors))) + (t/is (= variant-append-msg (last (first @errors)))) + (t/is (empty? @emitted)))))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index af463f9f19..5b21228c1d 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -41,6 +41,7 @@ [frontend-tests.data.workspace-texts-test] [frontend-tests.data.workspace-thumbnails-test] [frontend-tests.data.workspace-variant-rename-test] + [frontend-tests.data.workspace-variants-test] [frontend-tests.data.workspace-versions-test] [frontend-tests.debug-logging-test] [frontend-tests.errors-governor-test] @@ -215,6 +216,7 @@ 'frontend-tests.data.workspace-texts-test 'frontend-tests.data.workspace-thumbnails-test 'frontend-tests.data.workspace-variant-rename-test + 'frontend-tests.data.workspace-variants-test 'frontend-tests.data.workspace-versions-test 'frontend-tests.debug-logging-test 'frontend-tests.errors-governor-test