From 1dcae42878e5f3fe3b0678aa207c131db992a437 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Tue, 13 Oct 2026 23:25:24 +0200 Subject: [PATCH] :sparkles: Give a synced copy text variants of its own font families Sync and the variant switch can take a copy text's family from one side and its weight from the other. The variant id goes with the weight, so it can name a variant of the other family: the WASM renderer then picks the closest variant, but the SVG renderer falls back to the regular one. The frontend now gives each text content that a sync or a component swap commits variants of its own families: a run whose variant is not one of its family's takes the family's closest to its weight and style (cttx/update-font-variants, fonts/find-closest-variant). The pure runner has no fonts, so case BA leaves variant ids out of its comparison. The merge also leaves alone a run that already shows the placed style, so a main edit no longer rewrites the variant ids of a copy whose family is its own; with a new family, the run still takes the placed variant id. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 35.7 / 1.01 edit-through-a-chain-of-dependents 68.9 / 65.2 / 0.95 library-update-into-copies 83.1 / 75.1 / 0.90 main-edit-into-10-copies 1.8 / 1.5 / 0.86 main-edit-into-100-copies 15.4 / 15.1 / 0.98 main-edit-into-1000-copies 153.1 / 151.4 / 0.99 variant-switch-into-copies 32.1 / 30.4 / 0.95 AI-assisted-by: claude-opus-5-5 --- .../test_helpers/composable/comp/cases.cljc | 36 +++++---- common/src/app/common/types/text.cljc | 25 ++++++- .../common_tests/types/text_letters_test.cljc | 7 ++ .../app/main/data/workspace/libraries.cljs | 49 +++++++++--- .../data/workspace_font_variants_test.cljs | 74 +++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + 6 files changed, 167 insertions(+), 26 deletions(-) create mode 100644 frontend/test/frontend_tests/data/workspace_font_variants_test.cljs diff --git a/common/src/app/common/test_helpers/composable/comp/cases.cljc b/common/src/app/common/test_helpers/composable/comp/cases.cljc index 369d299810..c96f3c3210 100644 --- a/common/src/app/common/test_helpers/composable/comp/cases.cljc +++ b/common/src/app/common/test_helpers/composable/comp/cases.cljc @@ -86,7 +86,8 @@ [app.common.types.shape-tree :as ctst] [app.common.types.shape.layout :as ctl] [app.common.uuid :as uuid] - [clojure.test :as t])) + [clojure.test :as t] + [clojure.walk :as walk])) (def ^:private red "#ff0000") (def ^:private green "#00ff00") @@ -705,6 +706,15 @@ thtx/colors)) :font-family #(thtx/restyle % :family value)))) +(defn- without-variants + "`text` in the notation with no `:font-variant-id`." + [text] + (walk/postwalk #(cond-> % (map? %) (dissoc :font-variant-id)) text)) + +(defn- normalize + [text] + (without-variants (thtx/normalize text))) + (defn text-style-override-against-main-style-change "Case BA. A main text in two styles (\"Price: \" and a bold \"$10\") and its copy. The copy changes the fill or the font family of all of its text, @@ -726,34 +736,32 @@ :asserter (fn [s] (let [copy-node (tm/get-choice s copy-edit) main-node (tm/get-choice s main-edit) - same? (= (:edit copy-node) (:edit main-node)) main-text ((text-restyle main-node) setup/two-style-text) - merged (cond-> ((text-restyle copy-node) main-text) - ;; the weight, from the main, keeps the - ;; bold's variant id, for the frontend to - ;; pick the copy's family's - (and (= :font-family (:edit copy-node)) (not same?)) - (update-in [0 1 1] assoc :font-variant-id "bold")) + merged ((text-restyle copy-node) main-text) copy (setup/copy-instance s) main (setup/main-instance s) - text-of #(n/text-span-value % :content)] + ;; the variant id is left out: with the family from + ;; one side and the weight from the other, the + ;; frontend picks the family's closest variant, and + ;; the pure runner has no fonts to pick from + text-of #(without-variants (n/text-span-value % :content))] (cond (tm/applied? s reset) (do - (t/is (= (thtx/normalize main-text) (text-of copy))) - (t/is (= (thtx/normalize main-text) (text-of main))) + (t/is (= (normalize main-text) (text-of copy))) + (t/is (= (normalize main-text) (text-of main))) (t/is (empty? (:touched copy)))) (tm/applied? s push) (do - (t/is (= (thtx/normalize merged) (text-of main))) + (t/is (= (normalize merged) (text-of main))) (t/is (= (text-of main) (text-of copy))) (t/is (empty? (:touched copy)))) :else (do - (t/is (= (thtx/normalize main-text) (text-of main))) - (t/is (= (thtx/normalize merged) (text-of copy)))))))})) + (t/is (= (normalize main-text) (text-of main))) + (t/is (= (normalize merged) (text-of copy)))))))})) ;; --------------------------------------------------------------------------- ;; Swap outcomes diff --git a/common/src/app/common/types/text.cljc b/common/src/app/common/types/text.cljc index d1599f6e31..4aa1d5e29f 100644 --- a/common/src/app/common/types/text.cljc +++ b/common/src/app/common/types/text.cljc @@ -913,10 +913,17 @@ and on a paragraph when it has one or no run of it has." [dst placed drop-typo?] (let [run-units (filterv #(= :run (get-in style-units [% :level])) (keys placed)) + ;; a run that already shows the placed style keeps its own values; + ;; but its variant id names a font of its own family, so with a new + ;; family it takes the placed one + family? (contains? placed :text-content-family) write-run (fn [node styles] (cond-> (reduce (fn [node [unit style]] - (write-style node style (get-in style-units [unit :attrs]) - (= unit :text-content-line-height))) + (let [attrs (get-in style-units [unit :attrs])] + (if (and (= (style-key unit (select-keys node attrs)) (style-key unit style)) + (not (and family? (= unit :text-content-weight)))) + node + (write-style node style attrs (= unit :text-content-line-height))))) node (map vector run-units styles)) drop-typo? drop-typography)) @@ -1101,6 +1108,20 @@ (not same-letters?) (conj :text-content-text)))) +(defn update-font-variants + "`content` with the variant id of each node that names a font set to + `(variant-of node)`, when that gives one. Sync and the variant switch + can take a text's family from one side and its weight from the other, + leaving a variant id of another family; the frontend, which knows the + fonts, picks the family's closest variant through this." + [content variant-of] + (transform-nodes #(and (is-content-node? %) (some? (:font-id %))) + (fn [node] + (if-some [variant-id (variant-of node)] + (assoc node :font-variant-id variant-id) + node)) + content)) + (defn content-has-text? [content search] (let [search-lower (str/lower search)] diff --git a/common/test/common_tests/types/text_letters_test.cljc b/common/test/common_tests/types/text_letters_test.cljc index d562939240..cd4c067670 100644 --- a/common/test/common_tests/types/text_letters_test.cljc +++ b/common/test/common_tests/types/text_letters_test.cljc @@ -107,3 +107,10 @@ (t/is (not (cttx/same-style? {:font-size "14"} {:font-size "18"}))) (t/is (not (cttx/same-style? {:font-variant-id "700"} {:font-variant-id "700italic"}))) (t/is (not (cttx/same-style? {:fills [{:fill-color "#000000" :fill-opacity 1}]} {})))) + +(t/deftest font-variants-of-the-nodes-that-name-a-font + (let [content (thtx/content [[["Price: "] ["$10" {:weight 700}]]]) + fixed (cttx/update-font-variants content #(when (= "bold" (:font-variant-id %)) "700"))] + (t/is (= [[["Price: "] ["$10" {:weight 700 :font-variant-id "700"}]]] (thtx/view fixed))) + (t/testing "the paragraph, which holds its first run's styles, keeps them" + (t/is (= "regular" (get-in fixed [:children 0 :children 0 :font-variant-id])))))) diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index 6fee51ba09..edbb99eb3e 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -26,6 +26,7 @@ [app.common.types.container :as ctn] [app.common.types.file :as ctf] [app.common.types.library :as ctl] + [app.common.types.text :as cttx] [app.common.types.typography :as ctt] [app.common.uuid :as uuid] [app.config :as cf] @@ -53,6 +54,7 @@ [app.main.data.workspace.zoom :as dwz] [app.main.features :as features] [app.main.features.pointer-map :as fpmap] + [app.main.fonts :as fonts] [app.main.refs :as refs] [app.main.repo :as rp] [app.main.router :as rt] @@ -981,6 +983,32 @@ (rx/of (dwt.wasm/render-thumbnail file-id page-id root-id :persist? true))) (rx/of (update-component-thumbnail-sync state component-id file-id "component")))))) +(defn- closest-font-variant + "The id of the variant of the family of the text node `node` closest to its + weight and style, when its variant is not one of the family's." + [node] + (when-let [font (fonts/get-font-data (:font-id node))] + (when-not (d/seek #(= (:id %) (:font-variant-id node)) (:variants font)) + (:id (fonts/find-closest-variant font (:font-weight node) (:font-style node)))))) + +(defn fix-font-variants + "`changes` with each text content they set given variants of its own + families: a sync or a variant switch can take a copy text's family from + one side and its weight from the other (`mem:common/component-sync-contract`)." + [changes] + (let [fix-operation + (fn [{:keys [type attr val] :as operation}] + (if (and (= type :set) (= attr :content) (cttx/is-root-node? val)) + (update operation :val cttx/update-font-variants closest-font-variant) + operation))] + (update changes :redo-changes + (fn [redo-changes] + (mapv (fn [change] + (cond-> change + (= :mod-obj (:type change)) + (update :operations #(mapv fix-operation %)))) + redo-changes))))) + (defn component-swap "Swaps a component with another one" [shape file-id id-new-component keep-touched?] @@ -1017,7 +1045,7 @@ update-layout-ids (concat update-layout-ids new-children-ids)] (rx/of (dwu/start-undo-transaction undo-id) - (dch/commit-changes changes) + (dch/commit-changes (fix-font-variants changes)) (dwtp/propagate-workspace-tokens) ;; Make the new instance get the token values from the current file, not from the component's library (when (and (features/active-feature? state "render-wasm/v1") (seq new-text-ids)) @@ -1139,15 +1167,16 @@ libraries (dsh/lookup-libraries state) current-file-id (:current-file-id state) - changes (cll/generate-sync-file-changes - (pcb/empty-changes it) - undo-group - asset-type - file-id - asset-id - library-id - libraries - current-file-id) + changes (-> (cll/generate-sync-file-changes + (pcb/empty-changes it) + undo-group + asset-type + file-id + asset-id + library-id + libraries + current-file-id) + (fix-font-variants)) find-frames (fn [change] (->> (ch/frames-changed ldata change) diff --git a/frontend/test/frontend_tests/data/workspace_font_variants_test.cljs b/frontend/test/frontend_tests/data/workspace_font_variants_test.cljs new file mode 100644 index 0000000000..5120db0312 --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_font_variants_test.cljs @@ -0,0 +1,74 @@ +;; 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-font-variants-test + "A sync that takes a copy text's family from the copy and its weight from + the main gives each run a variant of its own family: the closest to its + weight and style." + (:require + [app.common.test-helpers.composable.core :as tm] + [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.texts :as cthtx] + [app.common.types.text :as cttx] + [app.main.data.workspace.libraries :as dwl] + [app.main.data.workspace.shapes :as dwsh] + [app.main.fonts :as fonts] + [cljs.test :as t :include-macros true] + [frontend-tests.composable-tests.interpreter :as ftm])) + +(def ^:private test-font + {:id "test-font" + :family "Test" + :name "Test" + :variants [{:id "400" :name "400" :weight "400" :style "normal"} + {:id "700" :name "700" :weight "700" :style "normal"}]}) + +(t/use-fixtures :each + {:before (fn [] + ((:before ftm/fixture)) + (fonts/register! :test-fonts [test-font])) + :after (fn [] + (fonts/register! :test-fonts []) + ((:after ftm/fixture)))}) + +(def ^:private in-test-font + {:font-family "Test" :font-id "test-font"}) + +(defn- run-variants + [label] + (->> (cttx/node-seq cttx/is-text-node? (:content (cths/get-shape (ftm/current-file) label))) + (mapv (juxt :text :font-variant-id)))) + +(t/deftest fix-font-variants-picks-the-family-closest + (let [content [[["Price: " (assoc in-test-font :font-variant-id "regular")] + ["$10" (assoc in-test-font :weight 700 :font-variant-id "bold")] + ["!" {:font-variant-id "bold" :weight 700}]]] + changes {:redo-changes [{:type :mod-obj + :id (cthi/new-id! :text) + :operations [{:type :set :attr :content :val (cthtx/content content)} + {:type :set :attr :name :val "Price"}]}]} + fixed (-> (dwl/fix-font-variants changes) :redo-changes first :operations)] + (t/is (= [["Price: " "400"] ["$10" "700"] ["!" "bold"]] + (mapv (juxt :text :font-variant-id) + (cttx/node-seq cttx/is-text-node? (:val (first fixed)))))) + (t/is (= {:type :set :attr :name :val "Price"} (second fixed))))) + +(t/deftest ^:async a-sync-gives-the-copy-family-variants + (ftm/install! (tm/make-situation + (-> (cthf/sample-file :file1) + (cthtx/add-text-component-with-copy [[["Price: "] ["$10" {:weight 700}]]]) + ;; the copy changes its family, as the editor does + (cthtx/set-text :copy-child [[["Price: " (assoc in-test-font :font-variant-id "400")] + ["$10" (assoc in-test-font :weight 700 :font-variant-id "700")]]])))) + (t/is (= [["Price: " "400"] ["$10" "700"]] (run-variants :copy-child))) + ;; the main makes all of it bold: the weight comes from the main, with a + ;; variant of the main's family ("bold") + (t/is (= :settled (await (ftm/await-step [(dwsh/update-shapes #{(cthi/id :main-child)} + #(assoc % :content (cthtx/content [[["Price: " {:weight 700}] + ["$10" {:weight 700}]]])))])))) + (t/is (= [["Price: $10" "700"]] (run-variants :copy-child)))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index a8e2192b48..4227f9a6b7 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -33,6 +33,7 @@ [frontend-tests.data.workspace-context-menu-test] [frontend-tests.data.workspace-edit-watchers-test] [frontend-tests.data.workspace-interactions-test] + [frontend-tests.data.workspace-font-variants-test] [frontend-tests.data.workspace-libraries-test] [frontend-tests.data.workspace-library-links-test] [frontend-tests.data.workspace-library-updates-test] @@ -201,6 +202,7 @@ 'frontend-tests.data.workspace-context-menu-test 'frontend-tests.data.workspace-edit-watchers-test 'frontend-tests.data.workspace-interactions-test + 'frontend-tests.data.workspace-font-variants-test 'frontend-tests.data.workspace-libraries-test 'frontend-tests.data.workspace-library-links-test 'frontend-tests.data.workspace-library-updates-test