From a4ada6dc8a4ac99407ed23162cca87554b62598f Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 25 Sep 2025 08:47:04 +0200 Subject: [PATCH 1/8] :bug: Add default flags for tokens (#7367) --- common/src/app/common/flags.cljc | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index ff272afffc..7cc41c19f6 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -156,7 +156,9 @@ :enable-dashboard-templates-section :enable-google-fonts-provider :enable-component-thumbnails - :enable-render-wasm-dpr]) + :enable-render-wasm-dpr + :enable-token-units + :enable-token-typography-types]) (defn parse [& flags] From e184a9a8b9b7b8344aec06032bd2aa72d1579043 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 25 Sep 2025 17:28:46 +0200 Subject: [PATCH 2/8] :bug: Fix context menu on spacing tokens (#7382) --- CHANGES.md | 1 + common/src/app/common/types/token.cljc | 27 ++++++++++++------- .../data/workspace/tokens/application.cljs | 2 +- .../tokens/management/context_menu.cljs | 5 ++-- .../tokens/management/token_pill.cljs | 2 +- .../tokens/logic/token_actions_test.cljs | 8 +++--- 6 files changed, 28 insertions(+), 17 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 0135f34f27..844714bfa3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -63,6 +63,7 @@ - Fix conflicting shortcuts (remove dec/inc line height and letter spacing) [Taiga #12102](https://tree.taiga.io/project/penpot/issue/12102) - Fix conflicting shortcuts (remove text-align shortcuts) [Taiga #12047](https://tree.taiga.io/project/penpot/issue/12047) - Fix export file with empty tokens library [Taiga #12137](https://tree.taiga.io/project/penpot/issue/12137) +- Fix context menu on spacing tokens [Taiga #12141](https://tree.taiga.io/project/penpot/issue/12141) ## 2.9.0 diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc index 6ae0620efe..02904bdbee 100644 --- a/common/src/app/common/types/token.cljc +++ b/common/src/app/common/types/token.cljc @@ -139,6 +139,13 @@ (def spacing-keys (schema-keys schema:spacing)) +(def ^:private schema:spacing-gap-padding + (-> (reduce mu/union [schema:spacing-gap + schema:spacing-padding]) + (mu/update-properties assoc :title "SpacingGapPaddingTokenAttrs"))) + +(def spacing-gap-padding-keys (schema-keys schema:spacing-gap-padding)) + (def ^:private schema:dimensions (-> (reduce mu/union [schema:sizing schema:spacing @@ -320,9 +327,9 @@ (set/union generic-attributes border-radius-keys)) -(def frame-attributes +(def frame-with-layout-attributes (set/union rect-attributes - spacing-keys)) + spacing-gap-padding-keys)) (def text-attributes (set/union generic-attributes @@ -330,12 +337,14 @@ number-keys)) (defn shape-type->attributes - [type] + [type is-layout] (case type :bool generic-attributes :circle generic-attributes :rect rect-attributes - :frame frame-attributes + :frame (if is-layout + frame-with-layout-attributes + rect-attributes) :image rect-attributes :path generic-attributes :svg-raw generic-attributes @@ -343,14 +352,14 @@ nil)) (defn appliable-attrs - "Returns intersection of shape `attributes` for `token-type`." - [attributes token-type] - (set/intersection attributes (shape-type->attributes token-type))) + "Returns intersection of shape `attributes` for `shape-type`." + [attributes shape-type is-layout] + (set/intersection attributes (shape-type->attributes shape-type is-layout))) (defn any-appliable-attr? "Checks if `token-type` supports given shape `attributes`." - [attributes token-type] - (seq (appliable-attrs attributes token-type))) + [attributes token-type is-layout] + (seq (appliable-attrs attributes token-type is-layout))) ;; Token attrs that are set inside content blocks of text shapes, instead ;; at the shape level. diff --git a/frontend/src/app/main/data/workspace/tokens/application.cljs b/frontend/src/app/main/data/workspace/tokens/application.cljs index d650962b1f..1e2a1bc8f1 100644 --- a/frontend/src/app/main/data/workspace/tokens/application.cljs +++ b/frontend/src/app/main/data/workspace/tokens/application.cljs @@ -487,7 +487,7 @@ (or (and (ctsl/any-layout-immediate-child? objects shape) (some ctt/spacing-margin-keys attributes)) - (ctt/any-appliable-attr? attributes (:type shape)))))) + (ctt/any-appliable-attr? attributes (:type shape) (:layout shape)))))) shape-ids (d/nilv (keys shapes) []) any-variant? (->> shapes vals (some ctk/is-variant?) boolean) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs index c053f0a695..08c1c77ff0 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs @@ -341,7 +341,7 @@ (:id token)))}])) (defn- allowed-shape-attributes [shapes] - (reduce into #{} (map #(ctt/shape-type->attributes (:type %)) shapes))) + (reduce into #{} (map #(ctt/shape-type->attributes (:type %) (:layout %)) shapes))) (defn menu-actions [{:keys [type token selected-shapes] :as context-data}] (let [context-data (assoc context-data :allowed-shape-attributes (allowed-shape-attributes selected-shapes)) @@ -445,7 +445,8 @@ (if (some? type) (submenu-actions-selection-actions context-data) (selection-actions context-data)) - (default-actions context-data))] + (default-actions context-data)) + entries (clean-separators entries)] (for [[index {:keys [title action selected? hint submenu no-selectable] :as entry}] (d/enumerate entries)] [:* {:key (dm/str title " " index)} (cond diff --git a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs index b24793a1bf..561b4b17a8 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs @@ -191,7 +191,7 @@ ;; Edge-case for allowing margin attribute on shapes inside layout parent (and selected-inside-layout? (set/subset? ctt/spacing-margin-keys attrs)) (some (fn [shape] - (ctt/any-appliable-attr? attrs (:type shape))) + (ctt/any-appliable-attr? attrs (:type shape) (:layout shape))) selected-shapes))) (def token-types-with-status-icon diff --git a/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs index 1329c9ea8b..9a694dd33c 100644 --- a/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs +++ b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs @@ -296,10 +296,10 @@ frame-1' (cths/get-shape file' :frame-1) frame-2' (cths/get-shape file' :frame-2)] (t/testing "shape `:applied-tokens` got updated" - (t/is (= (:p1 (:applied-tokens frame-1')) (:name token-target'))) - (t/is (= (:p2 (:applied-tokens frame-1')) (:name token-target'))) - (t/is (= (:p3 (:applied-tokens frame-1')) (:name token-target'))) - (t/is (= (:p4 (:applied-tokens frame-1')) (:name token-target'))) + (t/is (= (:p1 (:applied-tokens frame-1')) nil)) + (t/is (= (:p2 (:applied-tokens frame-1')) nil)) + (t/is (= (:p3 (:applied-tokens frame-1')) nil)) + (t/is (= (:p4 (:applied-tokens frame-1')) nil)) (t/is (= (:p1 (:applied-tokens frame-2')) (:name token-target'))) (t/is (= (:p2 (:applied-tokens frame-2')) (:name token-target'))) From d4b7f231c7a8c0ea4abdd271f32531e8b5e792e0 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 29 Sep 2025 12:02:52 +0200 Subject: [PATCH 3/8] :wrench: Add missing config for `:rewind:` on commit checker --- .github/workflows/commit-checker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/commit-checker.yml b/.github/workflows/commit-checker.yml index 60988a4daa..a5f51b7c38 100644 --- a/.github/workflows/commit-checker.yml +++ b/.github/workflows/commit-checker.yml @@ -26,7 +26,7 @@ jobs: - name: Check Commit Type uses: gsactions/commit-message-checker@v2 with: - pattern: '^(Merge|Revert|:(lipstick|globe_with_meridians|wrench|books|arrow_up|arrow_down|zap|ambulance|construction|boom|fire|whale|bug|sparkles|paperclip|tada|recycle):)\s[A-Z].*[^.]$' + pattern: '^(Merge|Revert|:(lipstick|globe_with_meridians|wrench|books|arrow_up|arrow_down|zap|ambulance|construction|boom|fire|whale|bug|sparkles|paperclip|tada|recycle|rewind):)\s[A-Z].*[^.]$' flags: 'gm' error: 'Commit should match CONTRIBUTING.md guideline' checkAllCommitMessages: 'true' # optional: this checks all commits associated with a pull request From d921e7eaa3ae1bf44d6001b5fce772e432b5c213 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 24 Sep 2025 09:59:18 +0200 Subject: [PATCH 4/8] :paperclip: Add not-empty generator to schema generator ns --- common/src/app/common/schema/generators.cljc | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/common/src/app/common/schema/generators.cljc b/common/src/app/common/schema/generators.cljc index 72b03f6730..16eab9b687 100644 --- a/common/src/app/common/schema/generators.cljc +++ b/common/src/app/common/schema/generators.cljc @@ -5,7 +5,7 @@ ;; Copyright (c) KALEIDOS INC (ns app.common.schema.generators - (:refer-clojure :exclude [set subseq uuid filter map let boolean vector keyword int double]) + (:refer-clojure :exclude [set subseq uuid filter map let boolean vector keyword int double not-empty]) #?(:cljs (:require-macros [app.common.schema.generators])) (:require [app.common.math :as mth] @@ -146,3 +146,5 @@ (def any (tg/one-of [text boolean double int keyword])) + +(def not-empty tg/not-empty) From 960b76f76036c91f8c8980c4958bee699a69c148 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 25 Sep 2025 09:51:57 +0200 Subject: [PATCH 5/8] :sparkles: Add minor improvement to cljs impl logging Mainly reduce the emmited code, that will contribute to reduce the bundle size and also adds timestamp to the default output. --- common/src/app/common/logging.cljc | 58 +++++++++++++++++------------- 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/common/src/app/common/logging.cljc b/common/src/app/common/logging.cljc index 7a4df8ebef..e678152b12 100644 --- a/common/src/app/common/logging.cljc +++ b/common/src/app/common/logging.cljc @@ -49,6 +49,7 @@ [app.common.exceptions :as ex] [app.common.pprint :as pp] [app.common.schema :as sm] + [app.common.time :as ct] [app.common.uuid :as uuid] [cuerdas.core :as str] [promesa.exec :as px] @@ -221,36 +222,42 @@ #?(:clj (inst-ms (java.time.Instant/now)) :cljs (js/Date.now))) +(defn emit-log + [props cause context logger level sync?] + (let [props (cond-> props sync? deref) + ts (current-timestamp) + gcontext *context* + logfn (fn [] + (let [props (if sync? props (deref props)) + props (into (d/ordered-map) props) + context (if (and (empty? gcontext) + (empty? context)) + {} + (d/without-nils (merge gcontext context))) + + lrecord {::id (uuid/next) + ::timestamp ts + ::message (delay (build-message props)) + ::props props + ::context context + ::level level + ::logger logger} + lrecord (cond-> lrecord + (some? cause) + (assoc ::cause cause + ::trace (delay (build-stack-trace cause))))] + (swap! log-record (constantly lrecord))))] + (if sync? + (logfn) + (px/exec! *default-executor* logfn)))) + (defmacro log! "Emit a new log record to the global log-record state (asynchronously). " [& props] (let [{:keys [::level ::logger ::context ::sync? cause] :or {sync? false}} props props (into [] msg-props-xf props)] `(when (enabled? ~logger ~level) - (let [props# (cond-> (delay ~props) ~sync? deref) - ts# (current-timestamp) - context# *context* - logfn# (fn [] - (let [props# (if ~sync? props# (deref props#)) - props# (into (d/ordered-map) props#) - cause# ~cause - context# (d/without-nils - (merge context# ~context)) - lrecord# {::id (uuid/next) - ::timestamp ts# - ::message (delay (build-message props#)) - ::props props# - ::context context# - ::level ~level - ::logger ~logger} - lrecord# (cond-> lrecord# - (some? cause#) - (assoc ::cause cause# - ::trace (delay (build-stack-trace cause#))))] - (swap! log-record (constantly lrecord#))))] - (if ~sync? - (logfn#) - (px/exec! *default-executor* logfn#)))))) + (emit-log (delay ~props) ~cause ~context ~logger ~level ~sync?)))) #?(:clj (defn slf4j-log-handler @@ -276,7 +283,8 @@ (when (enabled? logger level) (let [hstyles (str/ffmt "font-weight: 600; color: %" (level->color level)) mstyles (str/ffmt "font-weight: 300; color: %" (level->color level)) - header (str/concat "%c" (level->name level) " [" logger "] ") + ts (ct/format-inst (ct/now) "kk:mm:ss.SSSS") + header (str/concat "%c" (level->name level) " " ts " [" logger "] ") message (str/concat header "%c" @message)] (js/console.group message hstyles mstyles) From aaae35fb51dbe31df084b273b17e01d72af62356 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 24 Sep 2025 10:00:54 +0200 Subject: [PATCH 6/8] :tada: Add multiplatform impl of ObjectsMap The new type get influentiated by the ObjectsMap impl on backend code but with simplier implementation that no longer restricts keys to UUID type but preserves the same performance characteristics. This type encodes and decodes correctly both in fressian (backend) and transit (backend and frontend). This is an initial implementation and several memory usage optimizations are still missing. --- common/src/app/common/types/objects_map.cljc | 521 ++++++++++++++++++ common/test/common_tests/runner.cljc | 6 +- .../common_tests/types/objects_map_test.cljc | 133 +++++ 3 files changed, 658 insertions(+), 2 deletions(-) create mode 100644 common/src/app/common/types/objects_map.cljc create mode 100644 common/test/common_tests/types/objects_map_test.cljc diff --git a/common/src/app/common/types/objects_map.cljc b/common/src/app/common/types/objects_map.cljc new file mode 100644 index 0000000000..d08330765c --- /dev/null +++ b/common/src/app/common/types/objects_map.cljc @@ -0,0 +1,521 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.common.types.objects-map + "Implements a specialized map-like data structure for store an UUID => + OBJECT mappings. The main purpose of this data structure is be able + to serialize it on fressian as byte-array and have the ability to + decode each field separatelly without the need to decode the whole + map from the byte-array. + + It works transparently, so no aditional dynamic vars are needed. It + only works by reference equality and the hash-code is calculated + properly from each value." + + (:require + #?(:clj [app.common.fressian :as fres]) + #?(:clj [clojure.data.json :as json]) + [app.common.transit :as t] + [clojure.core :as c] + [clojure.core.protocols :as cp]) + #?(:clj + (:import + clojure.lang.Murmur3 + clojure.lang.RT + java.util.Iterator))) + +#?(:clj (set! *warn-on-reflection* true)) + +(declare create) +(declare ^:private do-compact) + +(defprotocol IObjectsMap + (^:no-doc compact [this]) + (^:no-doc get-data [this] "retrieve internal data") + (^:no-doc -hash-for-key [this key] "retrieve a hash for a key")) + +#?(:cljs + (deftype ObjectsMapEntry [key omap] + c/IMapEntry + (-key [_] key) + (-val [_] (get omap key)) + + c/IHash + (-hash [_] + (-hash-for-key omap key)) + + c/IEquiv + (-equiv [this other] + (and (c/map-entry? other) + (= (key this) + (key other)) + (= (val this) + (val other)))) + + c/ISequential + c/ISeqable + (-seq [this] + (cons key (lazy-seq (cons (c/-val this) nil)))) + + c/ICounted + (-count [_] 2) + + c/IIndexed + (-nth [node n] + (cond (== n 0) key + (== n 1) (c/-val node) + :else (throw (js/Error. "Index out of bounds")))) + + (-nth [node n not-found] + (cond (== n 0) key + (== n 1) (c/-val node) + :else not-found)) + + c/ILookup + (-lookup [node k] + (c/-nth node k nil)) + (-lookup [node k not-found] + (c/-nth node k not-found)) + + c/IFn + (-invoke [node k] + (c/-nth node k)) + + (-invoke [node k not-found] + (c/-nth node k not-found)) + + c/IPrintWithWriter + (-pr-writer [this writer opts] + (c/pr-sequential-writer + writer + (fn [item w _] + (c/-write w (pr-str item))) + "[" ", " "]" + opts + this))) + + :clj + (deftype ObjectsMapEntry [key omap] + clojure.lang.IMapEntry + (key [_] key) + (getKey [_] key) + + (val [_] + (get omap key)) + (getValue [_] + (get omap key)) + + clojure.lang.Indexed + (nth [node n] + (cond + (== n 0) key + (== n 1) (val node) + :else (throw (IllegalArgumentException. "Index out of bounds")))) + + (nth [node n not-found] + (cond + (== n 0) key + (== n 1) (val node) + :else not-found)) + + clojure.lang.IPersistentCollection + (empty [_] []) + (count [_] 2) + (seq [this] + (cons key (lazy-seq (cons (val this) nil)))) + (cons [this item] + (.cons ^clojure.lang.IPersistentCollection (vec this) item)) + + clojure.lang.IHashEq + (hasheq [_] + (-hash-for-key omap key)))) + +#?(:cljs + (deftype ObjectMapIterator [iterator omap] + Object + (hasNext [_] + (.hasNext ^js iterator)) + + (next [_] + (let [entry (.next iterator)] + (ObjectsMapEntry. (key entry) omap))) + + (remove [_] + (js/Error. "Unsupported operation"))) + + :clj + (deftype ObjectsMapIterator [^Iterator iterator omap] + Iterator + (hasNext [_] + (.hasNext iterator)) + + (next [_] + (let [entry (.next iterator)] + (ObjectsMapEntry. (key entry) omap))))) + +#?(:cljs + (deftype ObjectsMap [metadata cache + ^:mutable data + ^:mutable modified + ^:mutable hash] + Object + (toString [this] + (pr-str* this)) + (equiv [this other] + (c/-equiv this other)) + (keys [this] + (c/es6-iterator (keys this))) + (entries [this] + (c/es6-entries-iterator (seq this))) + (values [this] + (es6-iterator (vals this))) + (has [this k] + (c/contains? this k)) + (get [this k not-found] + (c/-lookup this k not-found)) + (forEach [this f] + (run! (fn [[k v]] (f v k)) this)) + + cp/Datafiable + (datafy [_] + {:data data + :cache cache + :modified modified + :hash hash}) + + IObjectsMap + (compact [this] + (when modified + (do-compact data cache + (fn [data'] + (set! (.-modified this) false) + (set! (.-data this) data')))) + this) + + (get-data [this] + (compact this) + data) + + (-hash-for-key [this key] + (if (c/-contains-key? cache key) + (c/-hash (c/-lookup cache key)) + (c/-hash (c/-lookup this key)))) + + c/IWithMeta + (-with-meta [this new-meta] + (if (identical? new-meta meta) + this + (ObjectsMap. new-meta + cache + data + modified + hash))) + + c/IMeta + (-meta [_] metadata) + + c/ICloneable + (-clone [this] + (compact this) + (ObjectsMap. metadata {} data false nil)) + + c/IIterable + (-iterator [this] + (c/seq-iter this)) + + c/ICollection + (-conj [this entry] + (cond + (map-entry? entry) + (c/-assoc this (c/-key entry) (c/-val entry)) + + (vector? entry) + (c/-assoc this (c/-nth entry 0) (c/-nth entry 1)) + + :else + (loop [ret this es (seq entry)] + (if (nil? es) + ret + (let [e (first es)] + (if (vector? e) + (recur (c/-assoc ret (c/-nth e 0) (c/-nth e 1)) + (next es)) + (throw (js/Error. "conj on a map takes map entries or seqables of map entries")))))))) + + c/IEmptyableCollection + (-empty [_] + (create)) + + c/IEquiv + (-equiv [this other] + (equiv-map this other)) + + c/IHash + (-hash [this] + (when-not hash + (set! hash (hash-unordered-coll this))) + hash) + + c/ISeqable + (-seq [this] + (->> (keys data) + (map (fn [id] (new ObjectsMapEntry id this))) + (seq))) + + c/ICounted + (-count [_] + (c/-count data)) + + c/ILookup + (-lookup [this k] + (or (c/-lookup cache k) + (if (c/-contains-key? data k) + (let [v (c/-lookup data k) + v (t/decode-str v)] + (set! (.-cache this) (c/-assoc cache k v)) + v) + (do + (set! (.-cache this) (assoc cache key nil)) + nil)))) + + (-lookup [this k not-found] + (if (c/-contains-key? data k) + (c/-lookup this k) + not-found)) + + c/IAssociative + (-assoc [_ k v] + (ObjectsMap. metadata + (c/-assoc cache k v) + (c/-assoc data k nil) + true + nil)) + + (-contains-key? [_ k] + (c/-contains-key? data k)) + + c/IFind + (-find [this k] + (when (c/-contains-key? data k) + (new ObjectsMapEntry k this))) + + c/IMap + (-dissoc [_ k] + (ObjectsMap. metadata + (c/-dissoc cache k) + (c/-dissoc data k) + true + nil)) + + c/IKVReduce + (-kv-reduce [this f init] + (c/-kv-reduce data + (fn [init k _] + (f init k (c/-lookup this k))) + init)) + + c/IFn + (-invoke [this k] + (c/-lookup this k)) + (-invoke [this k not-found] + (c/-lookup this k not-found)) + + c/IPrintWithWriter + (-pr-writer [this writer opts] + (c/pr-sequential-writer + writer + (fn [item w _] + (c/-write w (pr-str (c/-key item))) + (c/-write w \space) + (c/-write w (pr-str (c/-val item)))) + "#penpot/objects-map {" ", " "}" + opts + (seq this)))) + + :clj + (deftype ObjectsMap [metadata cache + ^:unsynchronized-mutable data + ^:unsynchronized-mutable modified + ^:unsynchronized-mutable hash] + + Object + (hashCode [this] + (.hasheq ^clojure.lang.IHashEq this)) + + cp/Datafiable + (datafy [_] + {:data data + :cache cache + :modified modified + :hash hash}) + + IObjectsMap + (compact [this] + (locking this + (when modified + (do-compact data cache + (fn [data'] + (set! (.-modified this) false) + (set! (.-data this) data'))))) + this) + + (get-data [this] + (compact this) + data) + + (-hash-for-key [this key] + (if (contains? cache key) + (c/hash (get cache key)) + (c/hash (get this key)))) + + json/JSONWriter + (-write [this writter options] + (json/-write (into {} this) writter options)) + + clojure.lang.IHashEq + (hasheq [this] + (when-not hash + (set! hash (Murmur3/hashUnordered this))) + hash) + + clojure.lang.Seqable + (seq [this] + (RT/chunkIteratorSeq (.iterator ^Iterable this))) + + java.lang.Iterable + (iterator [this] + (ObjectsMapIterator. (.iterator ^Iterable data) this)) + + clojure.lang.IPersistentCollection + (equiv [this other] + (and (instance? ObjectsMap other) + (= (count this) (count other)) + (reduce-kv (fn [_ id _] + (let [this-val (get this id) + other-val (get other id) + result (= this-val other-val)] + (or result + (reduced false)))) + true + data))) + + clojure.lang.IPersistentMap + (cons [this o] + (if (map-entry? o) + (assoc this (key o) (val o)) + (if (vector? o) + (assoc this (nth o 0) (nth o 1)) + (throw (UnsupportedOperationException. "invalid arguments to cons"))))) + + (empty [_] + (create)) + + (containsKey [_ key] + (.containsKey ^clojure.lang.IPersistentMap data key)) + + (entryAt [this key] + (ObjectsMapEntry. this key)) + + (valAt [this key] + (or (get cache key) + (locking this + (if (contains? data key) + (let [value (get data key) + value (t/decode-str value)] + (set! (.-cache this) (assoc cache key value)) + value) + (do + (set! (.-cache this) (assoc cache key nil)) + nil))))) + + (valAt [this key not-found] + (if (.containsKey ^clojure.lang.IPersistentMap data key) + (.valAt this key) + not-found)) + + (assoc [_ key val] + (ObjectsMap. metadata + (assoc cache key val) + (assoc data key nil) + true + nil)) + + + (assocEx [_ _ _] + (throw (UnsupportedOperationException. "method not implemented"))) + + (without [_ key] + (ObjectsMap. metadata + (dissoc cache key) + (dissoc data key) + true + nil)) + + clojure.lang.Counted + (count [_] + (count data)))) + +#?(:cljs (es6-iterable ObjectsMap)) + + +(defn- do-compact + [data cache update-fn] + (let [new-data + (persistent! + (reduce-kv (fn [data id obj] + (if (nil? obj) + (assoc! data id (t/encode-str (get cache id))) + data)) + (transient data) + data))] + (update-fn new-data) + nil)) + +(defn from-data + [data] + (ObjectsMap. {} {} + data + false + nil)) + +(defn objects-map? + [o] + (instance? ObjectsMap o)) + +(defn create + ([] (from-data {})) + ([other] + (cond + (objects-map? other) + (-> other get-data from-data) + + :else + (throw #?(:clj (UnsupportedOperationException. "invalid arguments") + :cljs (js/Error. "invalid arguments")))))) + +(defn wrap + [objects] + (if (instance? ObjectsMap objects) + objects + (->> objects + (into (create)) + (compact)))) + +#?(:clj + (fres/add-handlers! + {:name "penpot/objects-map/v2" + :class ObjectsMap + :wfn (fn [n w o] + (fres/write-tag! w n) + (fres/write-object! w (get-data o))) + :rfn (fn [r] + (-> r fres/read-object! from-data))})) + +(t/add-handlers! + {:id "penpot/objects-map/v2" + :class ObjectsMap + :wfn get-data + :rfn from-data}) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index c09ae65416..a657e096f0 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -41,6 +41,7 @@ [common-tests.types.components-test] [common-tests.types.fill-test] [common-tests.types.modifiers-test] + [common-tests.types.objects-map-test] [common-tests.types.path-data-test] [common-tests.types.shape-decode-encode-test] [common-tests.types.shape-interactions-test] @@ -90,9 +91,10 @@ 'common-tests.time-test 'common-tests.types.absorb-assets-test 'common-tests.types.components-test - 'common-tests.types.modifiers-test - 'common-tests.types.path-data-test 'common-tests.types.fill-test + 'common-tests.types.modifiers-test + 'common-tests.types.objects-map-test + 'common-tests.types.path-data-test 'common-tests.types.shape-decode-encode-test 'common-tests.types.shape-interactions-test 'common-tests.types.tokens-lib-test diff --git a/common/test/common_tests/types/objects_map_test.cljc b/common/test/common_tests/types/objects_map_test.cljc new file mode 100644 index 0000000000..a326d89b19 --- /dev/null +++ b/common/test/common_tests/types/objects_map_test.cljc @@ -0,0 +1,133 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns common-tests.types.objects-map-test + (:require + #?(:clj [app.common.fressian :as fres]) + [app.common.json :as json] + [app.common.pprint :as pp] + [app.common.schema :as sm] + [app.common.schema.generators :as sg] + [app.common.schema.test :as smt] + [app.common.transit :as transit] + [app.common.types.objects-map :as omap] + [app.common.types.path :as path] + [app.common.types.plugins :refer [schema:plugin-data]] + [app.common.types.shape :as cts] + [app.common.uuid :as uuid] + [clojure.datafy :refer [datafy]] + [clojure.test :as t])) + +(t/deftest basic-operations + (t/testing "assoc" + (let [id (uuid/custom 0 1) + id' (uuid/custom 0 2) + obj (-> (omap/create) (assoc id {:foo 1}))] + (t/is (not= id id')) + (t/is (not (contains? obj id'))) + (t/is (contains? obj id)))) + + (t/testing "assoc-with-non-uuid-keys" + (let [obj (-> (omap/create) + (assoc :a {:foo 1}) + (assoc :b {:bar 1}))] + (t/is (not (contains? obj :c))) + (t/is (contains? obj :a)) + (t/is (contains? obj :b)))) + + (t/testing "dissoc" + (let [id (uuid/custom 0 1) + obj (-> (omap/create) (assoc id {:foo 1}))] + (t/is (contains? obj id)) + (let [obj (dissoc obj id)] + (t/is (not (contains? obj id)))))) + + (t/testing "seq" + (let [id (uuid/custom 0 1) + obj (-> (omap/create) (assoc id 1))] + (t/is (contains? obj id)) + (let [[entry] (seq obj)] + (t/is (map-entry? entry)) + (t/is (= (key entry) id)) + (t/is (= (val entry) 1))))) + + (t/testing "cons & count" + (let [obj (into (omap/create) [[uuid/zero 1]])] + (t/is (contains? obj uuid/zero)) + (t/is (= 1 (count obj))) + (t/is (omap/objects-map? obj)))) + + (t/testing "wrap" + (let [obj1 (omap/wrap {}) + tmp (omap/create) + obj2 (omap/wrap tmp)] + (t/is (omap/objects-map? obj1)) + (t/is (omap/objects-map? obj2)) + (t/is (identical? tmp obj2)) + (t/is (= 0 (count obj1))) + (t/is (= 0 (count obj2)))))) + +(t/deftest internal-state + (t/testing "modified & compact" + (let [obj (-> (omap/create) + (assoc :a 1) + (assoc :b 2))] + (t/is (= 2 (count obj))) + (t/is (-> obj datafy :modified)) + (let [obj (omap/compact obj)] + (t/is (not (-> obj datafy :modified)))))) + + (t/testing "create from other" + (let [obj1 (-> (omap/create) + (assoc :a {:foo 1}) + (assoc :b {:bar 2})) + obj2 (omap/create obj1)] + + (t/is (not (identical? obj1 obj2))) + (t/is (= obj1 obj2)) + (t/is (= (hash obj1) (hash obj2))) + (t/is (= (get obj1 :a) (get obj2 :a))) + (t/is (= (get obj1 :b) (get obj2 :b)))))) + +(t/deftest creation-and-duplication + (smt/check! + (smt/for [data (->> (sg/map-of (sg/uuid) (sg/generator cts/schema:shape)) + (sg/not-empty))] + (let [obj1 (omap/wrap data) + obj2 (omap/create obj1)] + (and (= (hash obj1) (hash obj2)) + (= obj1 obj2)))) + {:num 100})) + +#?(:clj + (t/deftest fressian-encode-decode + (smt/check! + (smt/for [data (->> (sg/map-of (sg/uuid) (sg/generator cts/schema:shape)) + (sg/not-empty) + (sg/fmap omap/wrap) + (sg/fmap (fn [o] {:objects o})))] + + (let [res (-> data fres/encode fres/decode)] + (and (contains? res :objects) + (omap/objects-map? (:objects res)) + (= res data)))) + {:num 100}))) + +(t/deftest transit-encode-decode + (smt/check! + (smt/for [data (->> (sg/map-of (sg/uuid) (sg/generator cts/schema:shape)) + (sg/not-empty) + (sg/fmap omap/wrap) + (sg/fmap (fn [o] {:objects o})))] + (let [res (-> data transit/encode-str transit/decode-str)] + ;; (app.common.pprint/pprint data) + ;; (app.common.pprint/pprint res) + (and (every? (fn [[k v]] + (= v (get-in data [:objects k]))) + (:objects res)) + (omap/objects-map? (:objects data)) + (omap/objects-map? (:objects res))))) + {:num 100})) From c892a9f2543d683cc0860c86524641d90240ea06 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 25 Sep 2025 09:42:57 +0200 Subject: [PATCH 7/8] :sparkles: Integrate objects-map usage on backend and frontend --- backend/src/app/features/fdata.clj | 11 +++++------ backend/src/app/rpc/commands/files.clj | 26 ++++++++++++++++++-------- frontend/src/app/main.cljs | 1 + frontend/src/app/worker.cljs | 1 + 4 files changed, 25 insertions(+), 14 deletions(-) diff --git a/backend/src/app/features/fdata.clj b/backend/src/app/features/fdata.clj index c59cdd0ca4..43cf612068 100644 --- a/backend/src/app/features/fdata.clj +++ b/backend/src/app/features/fdata.clj @@ -12,12 +12,13 @@ [app.common.files.helpers :as cfh] [app.common.files.migrations :as fmg] [app.common.logging :as l] + [app.common.types.objects-map :as omap] [app.common.types.path :as path] [app.db :as db] [app.db.sql :as-alias sql] [app.storage :as sto] [app.util.blob :as blob] - [app.util.objects-map :as omap] + [app.util.objects-map :as omap.legacy] [app.util.pointer-map :as pmap] [app.worker :as wrk] [promesa.exec :as px])) @@ -38,10 +39,7 @@ [file & _opts] (let [update-page (fn [page] - (if (and (pmap/pointer-map? page) - (not (pmap/loaded? page))) - page - (update page :objects omap/wrap))) + (update page :objects omap/wrap)) update-data (fn [fdata] @@ -60,7 +58,8 @@ (fn [page] (update page :objects (fn [objects] - (if (omap/objects-map? objects) + (if (or (omap/objects-map? objects) + (omap.legacy/objects-map? objects)) (update-fn objects) objects))))) fdata)) diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 4b4149603f..91a4947608 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -342,14 +342,24 @@ (cfeat/check-client-features! (:features params)) (cfeat/check-file-features! (:features file))) - ;; This operation is needed for backward comapatibility with frontends that - ;; does not support pointer-map resolution mechanism; this just resolves the - ;; pointers on backend and return a complete file. - (if (and (contains? (:features file) "fdata/pointer-map") - (not (contains? (:features params) "fdata/pointer-map"))) - (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)] - (update file :data feat.fdata/process-pointers deref)) - file)))) + (as-> file file + ;; This operation is needed for backward comapatibility with + ;; frontends that does not support pointer-map resolution + ;; mechanism; this just resolves the pointers on backend and + ;; return a complete file + (if (and (contains? (:features file) "fdata/pointer-map") + (not (contains? (:features params) "fdata/pointer-map"))) + (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)] + (update file :data feat.fdata/process-pointers deref)) + file) + + ;; This operation is needed for backward comapatibility with + ;; frontends that does not support objects-map mechanism; this + ;; just converts all objects map instaces to plain maps + (if (and (contains? (:features file) "fdata/objects-map") + (not (contains? (:features params) "fdata/objects-map"))) + (update file :data feat.fdata/process-objects (partial into {})) + file))))) ;; --- COMMAND QUERY: get-file-fragment (by id) diff --git a/frontend/src/app/main.cljs b/frontend/src/app/main.cljs index ee08002475..06fde42430 100644 --- a/frontend/src/app/main.cljs +++ b/frontend/src/app/main.cljs @@ -8,6 +8,7 @@ (:require [app.common.data.macros :as dm] [app.common.logging :as log] + [app.common.types.objects-map] [app.common.uuid :as uuid] [app.config :as cf] [app.main.data.auth :as da] diff --git a/frontend/src/app/worker.cljs b/frontend/src/app/worker.cljs index dce2f952d1..d5d5f18e44 100644 --- a/frontend/src/app/worker.cljs +++ b/frontend/src/app/worker.cljs @@ -9,6 +9,7 @@ [app.common.data.macros :as dm] [app.common.logging :as log] [app.common.schema :as sm] + [app.common.types.objects-map] [app.util.object :as obj] [app.worker.export] [app.worker.impl :as impl] From 2d364dde5c477a5037971beeb64afe8c8e04249b Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 25 Sep 2025 10:49:30 +0200 Subject: [PATCH 8/8] :sparkles: Add several minor enhacements to features subsystem Mainly fixes the team non-inheritable features handling and removes unnecesary/duplicate checks. --- backend/src/app/rpc/commands/files_create.clj | 9 +++++---- backend/src/app/rpc/commands/files_update.clj | 9 ++++----- backend/src/app/rpc/commands/teams.clj | 2 +- common/src/app/common/features.cljc | 17 ++++++++--------- 4 files changed, 18 insertions(+), 19 deletions(-) diff --git a/backend/src/app/rpc/commands/files_create.clj b/backend/src/app/rpc/commands/files_create.clj index bcae300d30..8f4b9b428e 100644 --- a/backend/src/app/rpc/commands/files_create.clj +++ b/backend/src/app/rpc/commands/files_create.clj @@ -112,14 +112,15 @@ ;; FIXME: IMPORTANT: this code can have race conditions, because ;; we have no locks for updating team so, creating two files ;; concurrently can lead to lost team features updating - (when-let [features (-> features (set/difference (:features team)) (set/difference cfeat/no-team-inheritable-features) (not-empty))] - (let [features (->> features - (set/union (:features team)) - (db/create-array conn "text"))] + (let [features (-> features + (set/union (:features team)) + (set/difference cfeat/no-team-inheritable-features) + (into-array))] + (db/update! conn :team {:features features} {:id (:id team)} diff --git a/backend/src/app/rpc/commands/files_update.clj b/backend/src/app/rpc/commands/files_update.clj index e499ea2642..95f90e678f 100644 --- a/backend/src/app/rpc/commands/files_update.clj +++ b/backend/src/app/rpc/commands/files_update.clj @@ -160,7 +160,6 @@ tpoint (ct/tpoint)] - (when (not= (:vern params) (:vern file)) (ex/raise :type :validation @@ -183,15 +182,15 @@ (set/difference (:features team)) (set/difference cfeat/no-team-inheritable-features) (not-empty))] - (let [features (->> features - (set/union (:features team)) - (db/create-array conn "text"))] + (let [features (-> features + (set/union (:features team)) + (set/difference cfeat/no-team-inheritable-features) + (into-array))] (db/update! conn :team {:features features} {:id (:id team)} {::db/return-keys false}))) - (mtx/run! metrics {:id :update-file-changes :inc (count changes)}) (binding [l/*context* (some-> (meta params) diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 1c65e8e516..18f1f1b5f5 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -503,7 +503,7 @@ (let [features (-> (cfeat/get-enabled-features cf/flags) (set/difference cfeat/frontend-only-features) - (cfeat/check-client-features! (:features params))) + (set/difference cfeat/no-team-inheritable-features)) params (-> params (assoc :profile-id profile-id) (assoc :features features)) diff --git a/common/src/app/common/features.cljc b/common/src/app/common/features.cljc index 29f99d24db..ab99a95d69 100644 --- a/common/src/app/common/features.cljc +++ b/common/src/app/common/features.cljc @@ -67,11 +67,6 @@ "design-tokens/v1" "variants/v1"}) -;; A set of features that should not be propagated to team on creating -;; or modifying a file -(def no-team-inheritable-features - #{"fdata/path-data"}) - ;; A set of features which only affects on frontend and can be enabled ;; and disabled freely by the user any time. This features does not ;; persist on file features field but can be permanently enabled on @@ -85,8 +80,14 @@ ;; Features that are mainly backend only or there are a proper ;; fallback when frontend reports no support for it (def backend-only-features - #{"fdata/objects-map" - "fdata/pointer-map"}) + #{"fdata/pointer-map" + "fdata/objects-map"}) + +;; A set of features that should not be propagated to team on creating +;; or modifying a file or creating or modifying a team +(def no-team-inheritable-features + #{"fdata/path-data" + "fdata/shape-data-type"}) ;; This is a set of features that does not require an explicit ;; migration like components/v2 or the migration is not mandatory to @@ -222,8 +223,6 @@ :hint (str/ffmt "enabled feature '%' not present in file (missing migration)" not-supported))) - (check-supported-features! file-features) - ;; Components v1 is deprecated (when-not (contains? file-features "components/v2") (ex/raise :type :restriction