diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index 2cf444ad04..c6fed68e39 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -487,7 +487,13 @@ :fn (mg/resource "app/migrations/sql/0149-mod-file-library-rel-synced-at.sql")} {:name "0150-mod-http-session-v2" - :fn (mg/resource "app/migrations/sql/0150-mod-http-session-v2.sql")}]) + :fn (mg/resource "app/migrations/sql/0150-mod-http-session-v2.sql")} + + {:name "0150-mod-storage-object-table" + :fn (mg/resource "app/migrations/sql/0150-mod-storage-object-table.sql")} + + {:name "0151-mod-file-tagged-object-thumbnail-table" + :fn (mg/resource "app/migrations/sql/0151-mod-file-tagged-object-thumbnail-table.sql")}]) (defn apply-migrations! [pool name migrations] diff --git a/backend/src/app/migrations/sql/0150-mod-storage-object-table.sql b/backend/src/app/migrations/sql/0150-mod-storage-object-table.sql new file mode 100644 index 0000000000..487fd66433 --- /dev/null +++ b/backend/src/app/migrations/sql/0150-mod-storage-object-table.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS storage_object__metadata_upload_id__idx ON storage_object ((metadata->>'~:upload-id')) WHERE deleted_at IS NULL; diff --git a/backend/src/app/migrations/sql/0151-mod-file-tagged-object-thumbnail-table.sql b/backend/src/app/migrations/sql/0151-mod-file-tagged-object-thumbnail-table.sql new file mode 100644 index 0000000000..394c9ba31f --- /dev/null +++ b/backend/src/app/migrations/sql/0151-mod-file-tagged-object-thumbnail-table.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS file_tagged_object_thumbnail__object_id__idx ON file_tagged_object_thumbnail (object_id); diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index c7d709483d..7cbfdcc4f5 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -9,7 +9,7 @@ data resources." (:refer-clojure :exclude [read-string hash-map merge name update-vals parse-double group-by iteration concat mapcat - parse-uuid max min regexp? array?]) + parse-uuid max min regexp? array? empty?]) #?(:cljs (:require-macros [app.common.data])) @@ -175,11 +175,17 @@ (.isArray (class o)) false))) +(defn empty? + [val] + (if (or (coll? val) (string? val)) + (clojure.core/empty? val) + (nil? val))) + (defn not-empty? - [coll] - (if (coll? coll) - (boolean (seq coll)) - (not (nil? coll)))) + [val] + (if (or (coll? val) (string? val)) + (boolean (seq val)) + (some? val))) (defn editable-collection? [m] diff --git a/common/src/app/common/test_helpers/tokens.cljc b/common/src/app/common/test_helpers/tokens.cljc index 21a8dd4f04..02becdc27a 100644 --- a/common/src/app/common/test_helpers/tokens.cljc +++ b/common/src/app/common/test_helpers/tokens.cljc @@ -6,12 +6,14 @@ (ns app.common.test-helpers.tokens (:require + [app.common.data :as d] [app.common.test-helpers.files :as thf] [app.common.test-helpers.shapes :as ths] [app.common.types.container :as ctn] [app.common.types.file :as ctf] [app.common.types.pages-list :as ctpl] [app.common.types.shape-tree :as ctst] + [app.common.types.text :as ctt] [app.common.types.token :as cto] [app.common.types.tokens-lib :as ctob])) @@ -81,11 +83,16 @@ :token {:name token-name} :attributes token-attrs}) (reduce (fn [shape attr] - (case attr - :stroke-width (set-stroke-width shape resolved-value) - :stroke-color (set-stroke-color shape resolved-value) - :fill (set-fill-color shape resolved-value) - (ctn/set-shape-attr shape attr resolved-value {:ignore-touched true}))) + (if (ctt/text-node-attr? attr) + (let [value (if (sequential? resolved-value) (first resolved-value) resolved-value)] + (ctt/update-text-content shape + ctt/is-content-node? + d/txt-merge {attr value})) + (case attr + :stroke-width (set-stroke-width shape resolved-value) + :stroke-color (set-stroke-color shape resolved-value) + :fill (set-fill-color shape resolved-value) + (ctn/set-shape-attr shape attr resolved-value {:ignore-touched true})))) $ shape-attrs)))] diff --git a/common/src/app/common/types/text.cljc b/common/src/app/common/types/text.cljc index f928321f77..e0330b3264 100644 --- a/common/src/app/common/types/text.cljc +++ b/common/src/app/common/types/text.cljc @@ -78,6 +78,10 @@ text-transform-attrs text-fills)) +(defn text-node-attr? + [attr] + (d/index-of text-node-attrs attr)) + (def text-all-attrs (d/concat-set shape-attrs root-attrs paragraph-attrs text-node-attrs)) (def text-style-attrs @@ -201,6 +205,27 @@ [text] (subs text 0 (min 280 (count text)))) +(defn- compare-text-attr + "Compare two attribute values and return true if they are different. + Take into account the following: + - Only process keys that belong to text node attrs (ignore deprecated + attributes or other things that may be attached). + - Consider nil values, empty strings or empty lists all equal. + - Normalize numeric values (legacy) into strings. + - No value is equal than the default value." + [key value1 value2] + (when (text-node-attr? key) + (let [default-value (get default-text-attrs key) + normalize-value (fn [value] + (as-> value $ + (if (number? $) (str $) $) + (if (or (d/empty? $) (= $ default-value)) + nil + $))) + value1' (normalize-value value1) + value2' (normalize-value value2)] + (not= value1' value2')))) + (defn- compare-text-content "Given two content text structures, conformed by maps and vectors, compare them, and returns a set with the differences info. @@ -245,8 +270,7 @@ ;; If the key is not :text, and they are different, it is an attribute difference. ;; Take into account that some processes remove empty attributes, so in some ;; cases we will compare [] with nil, and this is not a difference. - (if (and (not= v1 v2) - (or (d/not-empty? v1) (d/not-empty? v2))) + (if (compare-text-attr k v1 v2) (attribute-cb acc k) acc)))) #{} diff --git a/common/test/common_tests/logic/token_apply_test.cljc b/common/test/common_tests/logic/token_apply_test.cljc index 69e07e0510..9715b7f235 100644 --- a/common/test/common_tests/logic/token_apply_test.cljc +++ b/common/test/common_tests/logic/token_apply_test.cljc @@ -111,8 +111,8 @@ (tht/apply-token-to-shape :frame1 "token-color" [:stroke-color] [:stroke-color] "#00ff00") (tht/apply-token-to-shape :frame1 "token-color" [:fill] [:fill] "#00ff00") (tht/apply-token-to-shape :frame1 "token-dimensions" [:width :height] [:width :height] 100) - (tht/apply-token-to-shape :text1 "token-font-size" [:font-size] [:font-size] 24) - (tht/apply-token-to-shape :text1 "token-letter-spacing" [:letter-spacing] [:letter-spacing] 2) + (tht/apply-token-to-shape :text1 "token-font-size" [:font-size] [:font-size] "24") + (tht/apply-token-to-shape :text1 "token-letter-spacing" [:letter-spacing] [:letter-spacing] "2") (tht/apply-token-to-shape :text1 "token-font-family" [:font-family] [:font-family] ["Helvetica" "Arial" "sans-serif"]) (tht/apply-token-to-shape :circle1 "token-sizing" @@ -331,7 +331,7 @@ d/txt-merge {:fills (ths/sample-fills-color :fill-color "#fabada") :font-size "1" - :letter-spacing "0" + :letter-spacing "3" :font-family "Arial"})) (:objects page) {}) @@ -360,3 +360,40 @@ (t/is (= (count applied-tokens-frame') 0)) (t/is (= (count applied-tokens-text') 0)) (t/is (= (count applied-tokens-circle') 0)))) + +(t/deftest dont-unapply-automatic-when-null-change + (let [;; ==== Setup + file (-> (setup-file) + (tht/apply-token-to-shape :text1 "token-font-size" [:font-size] [:font-size] "14") + (tht/apply-token-to-shape :text1 "token-letter-spacing" [:letter-spacing] [:letter-spacing] "2") + (tht/apply-token-to-shape :text1 "token-font-family" [:font-family] [:font-family] ["Helvetica" "Arial" "sans-serif"])) + page (thf/current-page file) + text1 (ths/get-shape file :text1) + + ;; ==== Action + changes (-> (-> (pcb/empty-changes nil) + (pcb/with-page page) + (pcb/with-objects (:objects page))) + ;; Some changes in text content are not semantic changes + ;; and thus they don't unapply tokens (e.g. adding an attribute + ;; with value nil or with the default value). + (cls/generate-update-shapes [(:id text1)] + (fn [shape] + (txt/update-text-content + shape + txt/is-content-node? + d/txt-merge + {:font-size nil + :line-height "3" + :nonexistent-attr "sample value"})) + (:objects page) + {})) + + file' (thf/apply-changes file changes) + + ;; ==== Get + text1' (ths/get-shape file' :text1) + applied-tokens-text' (:applied-tokens text1')] + + ;; ==== Check + (t/is (= (count applied-tokens-text') 3)))) \ No newline at end of file diff --git a/common/test/common_tests/types/text_test.cljc b/common/test/common_tests/types/text_test.cljc index be4424ef27..f6dea90560 100644 --- a/common/test/common_tests/types/text_test.cljc +++ b/common/test/common_tests/types/text_test.cljc @@ -16,6 +16,8 @@ (get :content) (cttx/change-text "hello world"))) +;; Normal happy-path changes + (def content-changed-text (assoc-in content-base [:children 0 :children 0 :children 0 :text] "changed")) @@ -34,22 +36,127 @@ (update-in content-base [:children 0 :children 0 :children] #(conj % (assoc line :font-weight "700")))) - (def content-changed-structure-same-attrs (update-in content-base [:children 0 :children 0 :children] #(conj % line))) +;; Special cases + +;; Numeric value (legacy): number 14 should normalize to string "14", +;; which equals the default value in content-base +(def content-numeric-font-size-default + (assoc-in content-base [:children 0 :children 0 :children 0 :font-size] 14)) + +;; Numeric value (legacy): number 32 should normalize to string "32", +;; matching content-changed-attr (which uses string "32") +(def content-numeric-font-size-32 + (assoc-in content-base [:children 0 :children 0 :children 0 :font-size] 32)) + +;; Attribute set to nil (removed) +(def content-nil-font-size + (assoc-in content-base [:children 0 :children 0 :children 0 :font-size] nil)) + +;; Attribute set to empty string +(def content-empty-string-font-size + (assoc-in content-base [:children 0 :children 0 :children 0 :font-size] "")) + +;; Attribute set to empty vector +(def content-empty-list-font-size + (assoc-in content-base [:children 0 :children 0 :children 0 :font-size] [])) + +;; Attribute set to its default value (font-size default is "14") +(def content-default-font-size + (assoc-in content-base [:children 0 :children 0 :children 0 :font-size] "14")) + +;; Non-text-node-attr key change (grow-type is not in text-node-attrs) +(def content-non-text-node-attr + (assoc-in content-base [:children 0 :children 0 :children 0 :grow-type] "auto-height")) + +;; Other text-node-attr categories +(def content-changed-font-family + (assoc-in content-base [:children 0 :children 0 :children 0 :font-family] "Arial")) + +(def content-changed-line-height + (assoc-in content-base [:children 0 :children 0 :children 0 :line-height] "1.5")) + +(def content-changed-letter-spacing + (assoc-in content-base [:children 0 :children 0 :children 0 :letter-spacing] "2")) + +(def content-changed-text-decoration + (assoc-in content-base [:children 0 :children 0 :children 0 :text-decoration] "underline")) + +(def content-changed-text-transform + (assoc-in content-base [:children 0 :children 0 :children 0 :text-transform] "uppercase")) + +(def content-changed-typography-ref-id + (assoc-in content-base [:children 0 :children 0 :children 0 :typography-ref-id] "new-typography-id")) + +(def content-changed-fills + (assoc-in content-base [:children 0 :children 0 :children 0 :fills] + [{:fill-color "#ff0000" :fill-opacity 1}])) + + (t/deftest test-get-diff-type (let [diff-text (cttx/get-diff-type content-base content-changed-text) diff-attr (cttx/get-diff-type content-base content-changed-attr) diff-both (cttx/get-diff-type content-base content-changed-both) diff-structure (cttx/get-diff-type content-base content-changed-structure) - diff-structure-same-attrs (cttx/get-diff-type content-base content-changed-structure-same-attrs)] + diff-structure-same-attrs (cttx/get-diff-type content-base content-changed-structure-same-attrs) + ;; Numeric normalization: number 14 → string "14" → default → nil (same as base) + diff-numeric-default (cttx/get-diff-type content-base content-numeric-font-size-default) + ;; Numeric normalization: number 32 → string "32" (different from base's "14") + diff-numeric-different (cttx/get-diff-type content-base content-numeric-font-size-32) + ;; Numeric 32 vs string "32": should be equal after normalization + diff-numeric-vs-string (cttx/get-diff-type content-changed-attr content-numeric-font-size-32) + + ;; nil / empty-string / empty-list / default → all normalize to nil + diff-nil-attr (cttx/get-diff-type content-base content-nil-font-size) + diff-empty-string (cttx/get-diff-type content-base content-empty-string-font-size) + diff-empty-list (cttx/get-diff-type content-base content-empty-list-font-size) + diff-default-value (cttx/get-diff-type content-base content-default-font-size) + + ;; Non-text-node-attr key: should be ignored + diff-non-text-node (cttx/get-diff-type content-base content-non-text-node-attr) + + ;; Other text-node-attr categories + diff-font-family (cttx/get-diff-type content-base content-changed-font-family) + diff-line-height (cttx/get-diff-type content-base content-changed-line-height) + diff-letter-spacing (cttx/get-diff-type content-base content-changed-letter-spacing) + diff-text-decoration (cttx/get-diff-type content-base content-changed-text-decoration) + diff-text-transform (cttx/get-diff-type content-base content-changed-text-transform) + diff-typography-ref (cttx/get-diff-type content-base content-changed-typography-ref-id) + diff-fills (cttx/get-diff-type content-base content-changed-fills)] + + ;; Basic cases (t/is (= #{:text-content-text} diff-text)) (t/is (= #{:text-content-attribute} diff-attr)) (t/is (= #{:text-content-text :text-content-attribute} diff-both)) (t/is (= #{:text-content-structure} diff-structure)) - (t/is (= #{:text-content-structure} diff-structure-same-attrs)))) + (t/is (= #{:text-content-structure} diff-structure-same-attrs)) + + ;; Numeric normalization + (t/is (= #{} diff-numeric-default)) + (t/is (= #{:text-content-attribute} diff-numeric-different)) + (t/is (= #{} diff-numeric-vs-string)) + + ;; nil / empty / default normalization (content-base has default font-size "14", + ;; which normalizes to nil; nil also normalizes to nil → equal) + (t/is (= #{} diff-nil-attr)) + (t/is (= #{} diff-empty-string)) + (t/is (= #{} diff-empty-list)) + (t/is (= #{} diff-default-value)) + + ;; Non-text-node-attr key is ignored + (t/is (= #{} diff-non-text-node)) + + ;; Each text-node-attr category triggers attribute diff + (t/is (= #{:text-content-attribute} diff-font-family)) + (t/is (= #{:text-content-attribute} diff-line-height)) + (t/is (= #{:text-content-attribute} diff-letter-spacing)) + (t/is (= #{:text-content-attribute} diff-text-decoration)) + (t/is (= #{:text-content-attribute} diff-text-transform)) + (t/is (= #{:text-content-attribute} diff-typography-ref)) + (t/is (= #{:text-content-attribute} diff-fills)))) (t/deftest test-get-diff-attrs @@ -57,13 +164,62 @@ attrs-attr (cttx/get-diff-attrs content-base content-changed-attr) attrs-both (cttx/get-diff-attrs content-base content-changed-both) attrs-structure (cttx/get-diff-attrs content-base content-changed-structure) - attrs-structure-same-attrs (cttx/get-diff-attrs content-base content-changed-structure-same-attrs)] + attrs-structure-same-attrs (cttx/get-diff-attrs content-base content-changed-structure-same-attrs) + ;; Numeric normalization: number 14 → string "14" → default → nil (same as base) + attrs-numeric-default (cttx/get-diff-attrs content-base content-numeric-font-size-default) + ;; Numeric normalization: number 32 → string "32" (different from base's "14") + attrs-numeric-different (cttx/get-diff-attrs content-base content-numeric-font-size-32) + ;; Numeric 32 vs string "32": should be equal after normalization + attrs-numeric-vs-string (cttx/get-diff-attrs content-changed-attr content-numeric-font-size-32) + + ;; nil / empty-string / empty-list / default → all normalize to nil + attrs-nil-attr (cttx/get-diff-attrs content-base content-nil-font-size) + attrs-empty-string (cttx/get-diff-attrs content-base content-empty-string-font-size) + attrs-empty-list (cttx/get-diff-attrs content-base content-empty-list-font-size) + attrs-default-value (cttx/get-diff-attrs content-base content-default-font-size) + + ;; Non-text-node-attr key: should be ignored + attrs-non-text-node (cttx/get-diff-attrs content-base content-non-text-node-attr) + + ;; Other text-node-attr categories + attrs-font-family (cttx/get-diff-attrs content-base content-changed-font-family) + attrs-line-height (cttx/get-diff-attrs content-base content-changed-line-height) + attrs-letter-spacing (cttx/get-diff-attrs content-base content-changed-letter-spacing) + attrs-text-decoration (cttx/get-diff-attrs content-base content-changed-text-decoration) + attrs-text-transform (cttx/get-diff-attrs content-base content-changed-text-transform) + attrs-typography-ref (cttx/get-diff-attrs content-base content-changed-typography-ref-id) + attrs-fills (cttx/get-diff-attrs content-base content-changed-fills)] + + ;; Basic cases (t/is (= #{} attrs-text)) (t/is (= #{:font-size} attrs-attr)) (t/is (= #{:font-size} attrs-both)) (t/is (= #{} attrs-structure)) - (t/is (= #{} attrs-structure-same-attrs)))) + (t/is (= #{} attrs-structure-same-attrs)) + + ;; Numeric normalization + (t/is (= #{} attrs-numeric-default)) + (t/is (= #{:font-size} attrs-numeric-different)) + (t/is (= #{} attrs-numeric-vs-string)) + + ;; nil / empty / default normalization + (t/is (= #{} attrs-nil-attr)) + (t/is (= #{} attrs-empty-string)) + (t/is (= #{} attrs-empty-list)) + (t/is (= #{} attrs-default-value)) + + ;; Non-text-node-attr key is ignored + (t/is (= #{} attrs-non-text-node)) + + ;; Each text-node-attr category reports correct attr key + (t/is (= #{:font-family} attrs-font-family)) + (t/is (= #{:line-height} attrs-line-height)) + (t/is (= #{:letter-spacing} attrs-letter-spacing)) + (t/is (= #{:text-decoration} attrs-text-decoration)) + (t/is (= #{:text-transform} attrs-text-transform)) + (t/is (= #{:typography-ref-id} attrs-typography-ref)) + (t/is (= #{:fills} attrs-fills)))) (t/deftest test-equal-structure diff --git a/frontend/src/app/main/data/event.cljs b/frontend/src/app/main/data/event.cljs index ef14cc18cf..676937fd47 100644 --- a/frontend/src/app/main/data/event.cljs +++ b/frontend/src/app/main/data/event.cljs @@ -399,6 +399,7 @@ (fn [_] ;; Start the event collection pipeline (->> (rx/merge + (rx/filter (ptk/type? ::force-persist) stream) (->> (rx/from-atom buffer) (rx/filter #(pos? (count %))) (rx/debounce 2000)) @@ -416,6 +417,7 @@ (rx/map (constantly chunk)))))) (rx/take-until stopper) (rx/subs! (fn [chunk] + (st/emit! (ptk/data-event ::chunk-persisted {:chunk chunk})) (swap! buffer remove-from-buffer (count chunk))) (fn [cause] (l/error :hint "unexpected error on audit persistence" :cause cause)) diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index 84ac8883e1..51ca68d105 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -45,6 +45,7 @@ [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] [beicon.v2.core :as rx] + [potok.v2.core :as ptk] [rumext.v2 :as mf])) (mf/defc shortcuts* @@ -958,17 +959,24 @@ ev-name (if (= next-renderer :wasm) "enable-webgl-rendering" "disable-webgl-rendering")] + (if (cf/external-feature-flag "renderer-hard-reload" "test") ;; Bare RPC + hard reload: skips `du/update-profile-props`, so ;; `features/recompute-features` is not run here; bootstrap ;; after reload resolves render-wasm/v1 from the saved profile. (do - (st/emit! (ev/event {::ev/name ev-name - ::ev/origin "workspace:menu"})) - (->> (rp/cmd! :update-profile-props {:props {:renderer next-renderer}}) - (rx/subs! (fn [_] (dom/reload-current-window true)) + (->> (rx/zip + (rp/cmd! :update-profile-props {:props {:renderer next-renderer}}) + (rx/filter (ptk/type? ::ev/chunk-persisted) st/stream)) + (rx/timeout 2000 (rx/of :timeout)) + (rx/subs! (fn [_] + (dom/reload-current-window true)) (fn [_] - (st/emit! (ntf/error (tr "errors.generic"))))))) + (st/emit! (ntf/error (tr "errors.generic")))))) + (st/emit! (ev/event {::ev/name ev-name + ::ev/origin "workspace:menu"}) + (ptk/data-event ::ev/force-persist {}))) + ;; `update-profile-props` WatchEvent calls ;; `features/recompute-features`. (st/emit! (ev/event {::ev/name ev-name