diff --git a/backend/src/app/rpc/commands/files_create.clj b/backend/src/app/rpc/commands/files_create.clj index 368f480ec7..df17eab0c4 100644 --- a/backend/src/app/rpc/commands/files_create.clj +++ b/backend/src/app/rpc/commands/files_create.clj @@ -18,6 +18,7 @@ [app.loggers.audit :as-alias audit] [app.loggers.webhooks :as-alias webhooks] [app.rpc :as-alias rpc] + [app.rpc.commands.profile :as profile] [app.rpc.commands.projects :as projects] [app.rpc.commands.teams :as teams] [app.rpc.doc :as-alias doc] @@ -38,7 +39,7 @@ [{:keys [::db/conn] :as cfg} {:keys [id name project-id is-shared revn modified-at deleted-at create-page page-id - ignore-sync-until features] + ignore-sync-until features text-language] :or {is-shared false revn 0 create-page true} :as params}] @@ -57,7 +58,10 @@ :created-at modified-at :deleted-at deleted-at} {:create-page create-page - :page-id page-id})] + :page-id page-id}) + file (cond-> file + (some? text-language) + (update :data ctf/set-default-text-language text-language))] (bfc/insert-file! cfg file) @@ -102,9 +106,14 @@ (set/difference cfeat/frontend-only-features) (set/union features)) + ;; New files start with the text language the profile picked. + language (-> (profile/get-profile conn profile-id) + (get-in [:props :default-text-language])) + params (-> params (assoc :profile-id profile-id) - (assoc :features features))] + (assoc :features features) + (assoc :text-language language))] (quotes/check! cfg {::quotes/id ::quotes/files-per-project ::quotes/team-id team-id diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 080f6ce8de..cd0f77e3b3 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -14,6 +14,7 @@ [app.common.time :as ct] [app.common.transit :as t] [app.common.types.plugins :as ctp] + [app.common.types.shape.text :as ctsx] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] @@ -62,9 +63,8 @@ [:map {:title "ProfileProps" :closed true} [:plugins {:optional true} ctp/schema:plugin-registry] [:renderer {:optional true} [::sm/one-of #{:svg :wasm}]] - [:japanese-layout-all-files {:optional true} ::sm/boolean] - [:chinese-layout-all-files {:optional true} ::sm/boolean] - [:korean-layout-all-files {:optional true} ::sm/boolean] + ;; Text language (BCP 47 tag) the files the profile creates start with. + [:default-text-language {:optional true} [:maybe ctsx/schema:text-language]] [:mcp-enabled {:optional true} ::sm/boolean] [:newsletter-updates {:optional true} ::sm/boolean] [:newsletter-news {:optional true} ::sm/boolean] diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index 593c769546..10a9bd54d4 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -145,6 +145,43 @@ (let [result (:result out)] (t/is (= 0 (count result)))))))) +(t/deftest new-files-take-the-profile-text-language + (let [prof (th/create-profile* 1 {:is-active true}) + proj-id (:default-project-id prof) + create (fn [] + (let [out (th/command! {::th/type :create-file + ::rpc/profile-id (:id prof) + :project-id proj-id + :name "foobar"})] + (t/is (nil? (:error out))) + (let [out (th/command! {::th/type :get-file + ::rpc/profile-id (:id prof) + :id (:id (:result out))})] + (t/is (nil? (:error out))) + (get-in out [:result :data :options :text-language])))) + set-language (fn [language] + (let [out (th/command! {::th/type :update-profile-props + ::rpc/profile-id (:id prof) + :props {:default-text-language language}})] + (t/is (nil? (:error out)))))] + + (t/testing "without a profile language new files have none" + (t/is (nil? (create)))) + + (t/testing "new files start with the profile language" + (set-language "zh-Hant-TW") + (t/is (= "zh-Hant-TW" (create)))) + + (t/testing "removing the profile language stops applying it" + (set-language nil) + (t/is (nil? (create)))) + + (t/testing "the profile language must be a language tag" + (let [out (th/command! {::th/type :update-profile-props + ::rpc/profile-id (:id prof) + :props {:default-text-language "not a tag!"}})] + (t/is (th/ex-of-type? (:error out) :validation)))))) + (t/deftest file-gc-with-fragments (let [profile (th/create-profile* 1) file (th/create-file* 1 {:profile-id (:id profile) diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj index 3e52e27d48..18be3e384b 100644 --- a/backend/test/backend_tests/rpc_profile_test.clj +++ b/backend/test/backend_tests/rpc_profile_test.clj @@ -164,44 +164,6 @@ (t/is (nil? (:error out))) (t/is (nil? (:photo-id (:result out)))))))) -(t/deftest update-profile-japanese-layout-setting - (let [profile (th/create-profile* 1) - params {::th/type :update-profile-props - ::rpc/profile-id (:id profile) - :props {:japanese-layout-all-files true}} - out (th/command! params)] - (t/is (nil? (:error out))) - (t/is (true? (get-in out [:result :japanese-layout-all-files]))) - - (let [params {::th/type :get-profile - ::rpc/profile-id (:id profile)} - out (th/command! params)] - (t/is (nil? (:error out))) - (t/is (true? (get-in out [:result :props :japanese-layout-all-files])))))) - -(t/deftest update-profile-language-layout-settings - (let [profile (th/create-profile* 1) - props {:japanese-layout-all-files true - :chinese-layout-all-files true - :korean-layout-all-files false} - params {::th/type :update-profile-props - ::rpc/profile-id (:id profile) - :props props} - out (th/command! params)] - (t/is (nil? (:error out))) - (t/is (= props (select-keys (:result out) (keys props)))) - (let [out (th/command! {::th/type :get-profile - ::rpc/profile-id (:id profile)})] - (t/is (= props (select-keys (get-in out [:result :props]) (keys props))))) - (let [out (th/command! (assoc params :props {:chinese-layout-all-files false}))] - (t/is (nil? (:error out)))) - (let [out (th/command! {::th/type :get-profile - ::rpc/profile-id (:id profile)})] - (t/is (= {:japanese-layout-all-files true - :chinese-layout-all-files false - :korean-layout-all-files false} - (select-keys (get-in out [:result :props]) (keys props))))))) - (t/deftest profile-deletion-1 (let [prof (th/create-profile* 1) file (th/create-file* 1 {:profile-id (:id prof) diff --git a/common/src/app/common/features.cljc b/common/src/app/common/features.cljc index 288c28a593..cb4fe42642 100644 --- a/common/src/app/common/features.cljc +++ b/common/src/app/common/features.cljc @@ -57,7 +57,7 @@ "text-editor/v2" "text-editor-wasm/v1" "text-editor-wasm/v1-html-paste" - "vertical-text-layout" + "cjk-support" "render-wasm/v1" "variants/v1"}) @@ -85,7 +85,7 @@ "text-editor-wasm/v1" "text-editor-wasm/v1-html-paste" "tokens/numeric-input" - "vertical-text-layout" + "cjk-support" "render-wasm/v1"}) ;; Features that are mainly backend only or there are a proper @@ -136,7 +136,7 @@ :feature-text-editor-v2-html-paste "text-editor/v2-html-paste" :feature-text-editor-wasm "text-editor-wasm/v1" :feature-text-editor-wasm-html-paste "text-editor-wasm/v1-html-paste" - :vertical-text-layout "vertical-text-layout" + :cjk-support "cjk-support" :feature-render-wasm "render-wasm/v1" :feature-variants "variants/v1" :feature-token-input "tokens/numeric-input" diff --git a/common/src/app/common/files/changes.cljc b/common/src/app/common/files/changes.cljc index 953d4ebd70..d5562e69a3 100644 --- a/common/src/app/common/files/changes.cljc +++ b/common/src/app/common/files/changes.cljc @@ -28,6 +28,7 @@ [app.common.types.path :as path] [app.common.types.shape :as cts] [app.common.types.shape-tree :as ctst] + [app.common.types.shape.text :as ctsx] [app.common.types.token :as cto] [app.common.types.tokens-lib :as ctob] [app.common.types.tokens-status :as ctos] @@ -435,6 +436,11 @@ [:before-path [:maybe [:vector :string]]] [:before-group [:maybe :boolean]]]] + [:set-default-text-language + [:map {:title "SetDefaultTextLanguage"} + [:type [:= :set-default-text-language]] + [:language [:maybe ctsx/schema:text-language]]]] + [:set-base-font-size [:map {:title "ModBaseFontSize"} [:type [:= :set-base-font-size]] @@ -444,13 +450,7 @@ [:map {:title "SetTokensSource"} [:type [:= :set-tokens-source]] [:file-id ::sm/uuid] - [:library-id [:maybe ::sm/uuid]]]] - - [:set-text-layout - [:map {:title "SetTextLayout"} - [:type [:= :set-text-layout]] - [:language [:enum :japanese :chinese :korean]] - [:enabled ::sm/boolean]]]]) + [:library-id [:maybe ::sm/uuid]]]]]) (def schema:changes [:sequential {:gen/max 5 :gen/min 1} schema:change]) @@ -1159,6 +1159,10 @@ ;; === Design Tokens configuration +(defmethod process-change :set-default-text-language + [data {:keys [language]}] + (ctf/set-default-text-language data language)) + (defmethod process-change :set-base-font-size [data {:keys [base-font-size]}] (ctf/set-base-font-size data base-font-size)) @@ -1167,10 +1171,6 @@ [data {:keys [library-id]}] (cfo/set-tokens-source data library-id)) -(defmethod process-change :set-text-layout - [data {:keys [language enabled]}] - (ctf/set-text-layout-enabled data language enabled)) - ;; === Operations (def decode-shape-attrs diff --git a/common/src/app/common/files/changes_builder.cljc b/common/src/app/common/files/changes_builder.cljc index 991b7527fc..b09d903217 100644 --- a/common/src/app/common/files/changes_builder.cljc +++ b/common/src/app/common/files/changes_builder.cljc @@ -1153,6 +1153,18 @@ :base-font-size previous-font-size}) (apply-changes-local)))) +(defn set-default-text-language + [changes language] + (assert-file-data! changes) + (let [file-data (::file-data (meta changes)) + previous (ctf/get-default-text-language file-data)] + (-> changes + (update :redo-changes conj {:type :set-default-text-language + :language language}) + (update :undo-changes conj {:type :set-default-text-language + :language previous}) + (apply-changes-local)))) + (defn set-tokens-source [changes library-id] (assert-library! changes) @@ -1168,20 +1180,6 @@ :library-id prev-val}) (apply-changes-local)))) -(defn set-text-layout - [changes language enabled?] - (assert-file-data! changes) - (let [file-data (::file-data (meta changes)) - previous-enabled (ctf/text-layout-enabled? file-data language)] - (-> changes - (update :redo-changes conj {:type :set-text-layout - :language language - :enabled enabled?}) - (update :undo-changes conj {:type :set-text-layout - :language language - :enabled previous-enabled}) - (apply-changes-local)))) - ;; Misc changes (defn reorder-children diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 7e64c26c2a..c6ece71554 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -156,7 +156,7 @@ ;; Show WASM renderer info label (hidden by default). :render-wasm-info :render-switch - :vertical-text-layout + :cjk-support :hide-release-modal :subscriptions :inspect-styles diff --git a/common/src/app/common/types/file.cljc b/common/src/app/common/types/file.cljc index 84a6bd9e52..65035c549a 100644 --- a/common/src/app/common/types/file.cljc +++ b/common/src/app/common/types/file.cljc @@ -28,6 +28,7 @@ [app.common.types.plugins :refer [schema:plugin-data]] [app.common.types.shape :as cts] [app.common.types.shape-tree :as ctst] + [app.common.types.shape.text :as ctsx] [app.common.types.text :as txt] [app.common.types.tokens-lib :as ctob] [app.common.types.tokens-status :as ctos] @@ -78,9 +79,9 @@ [:map {:title "FileOptions"} [:components-v2 {:optional true} ::sm/boolean] [:base-font-size {:optional true} :string] - [:japanese-layout {:optional true} ::sm/boolean] - [:chinese-layout {:optional true} ::sm/boolean] - [:korean-layout {:optional true} ::sm/boolean]]) + ;; Language of the texts that have none of their own (see + ;; `get-default-text-language`). + [:text-language {:optional true} ctsx/schema:text-language]]) (def schema:data [:map {:title "FileData"} @@ -1265,33 +1266,20 @@ [file-data base-font-size] (assoc-in file-data [:options :base-font-size] base-font-size)) +;; Default text language -;; Text layout controls +(defn get-default-text-language + "Language (a BCP 47 tag) of the texts of the file that have none of their + own, or nil." + [file-data] + (get-in file-data [:options :text-language])) -(defn text-layout-option - [language] - (case language - :japanese :japanese-layout - :chinese :chinese-layout - :korean :korean-layout)) - -(defn text-layout-preference - [language] - (case language - :japanese :japanese-layout-all-files - :chinese :chinese-layout-all-files - :korean :korean-layout-all-files)) - -(defn text-layout-enabled? +(defn set-default-text-language + "Set the file text language; nil removes it." [file-data language] - (true? (get-in file-data [:options (text-layout-option language)]))) - -(defn set-text-layout-enabled - [file-data language enabled?] - (let [option (text-layout-option language)] - (if enabled? - (assoc-in file-data [:options option] true) - (d/update-in-when file-data [:options] dissoc option)))) + (if (some? language) + (assoc-in file-data [:options :text-language] language) + (d/update-in-when file-data [:options] dissoc :text-language))) ;; Ref Chains diff --git a/common/src/app/common/types/text/cjk_layout.cljc b/common/src/app/common/types/text/cjk_layout.cljc index fac1eb5d93..c5920edf55 100644 --- a/common/src/app/common/types/text/cjk_layout.cljc +++ b/common/src/app/common/types/text/cjk_layout.cljc @@ -6,6 +6,7 @@ (ns app.common.types.text.cjk-layout (:require + [app.common.data :as d] [app.common.data.macros :as dm] [cuerdas.core :as str])) @@ -53,6 +54,11 @@ (def whole-shape-paragraph-attrs [:writing-mode :text-orientation]) +;; Paragraph attrs the text options write to every paragraph, whatever the +;; editor selection: a text has one writing mode and one language. +(def whole-text-paragraph-attrs + (conj whole-shape-paragraph-attrs :text-language)) + ;; Ruby attrs that change how a reading looks, not the reading itself. (def ruby-presentation-attrs [:ruby-hidden :ruby-zhuyin :ruby-size :ruby-align :ruby-overhang :ruby-side]) @@ -219,3 +225,80 @@ "True when the text content flows vertically (vertical-rl)." [content] (= "vertical-rl" (content-writing-mode content))) + +;; --- Active language + +(defn language-family + "CJK language of a BCP 47 `tag` (`:japanese`, `:chinese` or `:korean`), + or nil for any other value." + [tag] + (when (string? tag) + (case (-> tag (str/split "-") first str/lower) + "ja" :japanese + "zh" :chinese + "ko" :korean + nil))) + +;; Span attrs each language renders; the others stay in the content and come +;; back when the text takes that language again. +(def ^:private language-span-attrs + {:japanese #{:text-combine-upright :warichu :text-emphasis :annotation-clearance + :ruby :ruby-hidden :ruby-size :ruby-align :ruby-overhang :ruby-side + :font-features} + :chinese #{:text-emphasis :text-emphasis-position :text-underline-position + :annotation-clearance :ruby :ruby-zhuyin :ruby-hidden :ruby-size + :ruby-align :ruby-overhang :ruby-side} + :korean #{}}) + +(defn content-language + "Language of a text content: the first paragraph's `:text-language`, as a + text has one language. Nil when unset." + [content] + (whole-shape-attr content :text-language)) + +(defn- active-span + [span family] + (let [kept (get language-span-attrs family #{})] + (reduce (fn [span attr] + (if (contains? kept attr) span (dissoc span attr))) + span + span-attrs))) + +(defn- active-paragraph + [paragraph default-language] + (let [language (or (:text-language paragraph) default-language) + family (language-family language)] + (cond-> (d/update-when paragraph :children (partial mapv #(active-span % family))) + (some? language) + (assoc :text-language language) + + (nil? family) + (d/without-keys whole-shape-paragraph-attrs) + + (and (nil? family) (= "distribute" (:text-align paragraph))) + (assoc :text-align "left") + + (not= :korean family) + (dissoc :korean-wrap)))) + +(defn active-content + "Text `content` as the renderer lays it out. A paragraph without language + takes `default-language` (the file language), and the CJK attrs its + language does not use are left out: without a CJK language a text is + horizontal and not distributed. The stored content keeps every attr." + [content default-language] + (let [family (language-family (or (content-language content) default-language))] + (cond-> (d/update-when content :children + (partial mapv + (fn [paragraph-set] + (d/update-when paragraph-set :children + (partial mapv #(active-paragraph % default-language)))))) + (not= :japanese family) + (dissoc :line-adjustment)))) + +(defn active-writing-mode + "Writing mode the renderer lays `content` out with (see `active-content`): + nil when unset or when the text has no CJK language." + [content default-language] + (when (some? (language-family (or (content-language content) default-language))) + (content-writing-mode content))) diff --git a/common/test/common_tests/files_changes_test.cljc b/common/test/common_tests/files_changes_test.cljc index 751c9a8e9a..f690fab490 100644 --- a/common/test/common_tests/files_changes_test.cljc +++ b/common/test/common_tests/files_changes_test.cljc @@ -252,39 +252,22 @@ "The parent must use the updated bounds of both children") (assert-idempotent data changes)))))) -(t/deftest language-layout-changes-are-independent +(t/deftest default-text-language-round-trips-through-undo (let [data (assoc (make-file-data (uuid/custom 2 2) (uuid/custom 1 1)) - :options {:japanese-layout true :base-font-size "16"})] - (doseq [language [:japanese :chinese :korean]] - (let [change {:type :set-text-layout :language language :enabled true} - enabled (ch/process-changes data [change]) - disabled (ch/process-changes enabled [(assoc change :enabled false)]) - option (case language - :japanese :japanese-layout - :chinese :chinese-layout - :korean :korean-layout)] - (t/is (ch/valid-change? change)) - (t/is (true? (get-in enabled [:options option]))) - (t/is (not (contains? (:options disabled) option))) - (t/is (= (dissoc (:options data) option) (:options disabled))) - (t/is (= (dissoc data :options) (dissoc enabled :options)))))) - (t/is (not (ch/valid-change? {:type :set-text-layout :language :english - :enabled true})))) - -(t/deftest language-layout-switches-round-trip-through-undo - (let [data (assoc (make-file-data (uuid/custom 2 2) (uuid/custom 1 1)) - :options {:japanese-layout true :base-font-size "16"})] - (doseq [language [:japanese :chinese :korean] - enabled? [true false]] - (let [changes (-> (pcb/empty-changes) - (pcb/with-file-data data) - (pcb/set-text-layout language enabled?)) - edited (ch/process-changes data (:redo-changes changes)) + :options {:base-font-size "16"})] + (doseq [[before after] [[nil "ja"] ["ja" "zh-Hant-TW"] ["ko" nil]]] + (let [data (ctf/set-default-text-language data before) + changes (-> (pcb/empty-changes) + (pcb/with-file-data data) + (pcb/set-default-text-language after)) + edited (ch/process-changes data (:redo-changes changes)) restored (ch/process-changes edited (:undo-changes changes))] - (t/is (= enabled? (ctf/text-layout-enabled? edited language))) + (t/is (every? ch/valid-change? (:redo-changes changes))) + (t/is (= after (ctf/get-default-text-language edited))) (t/is (= data restored)) - (t/is (sm/validate ctf/schema:options (json-roundtrip (:options edited)))) - (t/is (= (:options edited) (json-roundtrip (:options edited)))))))) + (t/is (= "16" (get-in edited [:options :base-font-size]))) + (t/is (sm/validate ctf/schema:options (:options edited)))))) + (t/is (not (ch/valid-change? {:type :set-default-text-language :language "not a tag!"})))) (t/deftest add-obj (let [file-id (uuid/custom 2 2) diff --git a/common/test/common_tests/types/text_test.cljc b/common/test/common_tests/types/text_test.cljc index 4484983a40..7fd07f38f7 100644 --- a/common/test/common_tests/types/text_test.cljc +++ b/common/test/common_tests/types/text_test.cljc @@ -564,3 +564,79 @@ (t/testing "no spans give no spans" (t/is (= [] (cttx/merge-equal-spans []))))) + +(defn- cjk-content + "One paragraph with a span carrying the CJK attrs of every language." + [paragraph-attrs] + {:type "root" + :line-adjustment "push-out-first" + :children + [{:type "paragraph-set" + :children + [(merge {:type "paragraph" + :writing-mode "vertical-rl" + :text-orientation "upright" + :text-align "distribute" + :korean-wrap "keep-words" + :children [{:text "日本" + :ruby "にほん" + :ruby-zhuyin true + :text-combine-upright "digits" + :text-emphasis-position "over-left" + :warichu "warichu" + :font-features "palt"}]} + paragraph-attrs)]}]}) + +(defn- first-paragraph + [content] + (get-in content [:children 0 :children 0])) + +(t/deftest language-family-follows-the-primary-subtag + (t/is (= :japanese (cjk/language-family "ja-JP"))) + (t/is (= :chinese (cjk/language-family "zh-Hant-TW"))) + (t/is (= :korean (cjk/language-family "KO-kr"))) + (t/is (nil? (cjk/language-family "en-GB"))) + (t/is (nil? (cjk/language-family :multiple))) + (t/is (nil? (cjk/language-family nil)))) + +(t/deftest text-without-language-renders-horizontal-and-plain + (let [content (cjk-content {}) + active (cjk/active-content content nil) + paragraph (first-paragraph active)] + (t/is (not (contains? paragraph :writing-mode))) + (t/is (not (contains? paragraph :text-orientation))) + (t/is (= "left" (:text-align paragraph))) + (t/is (not (contains? paragraph :korean-wrap))) + (t/is (= {:text "日本"} (first (:children paragraph)))) + (t/is (not (contains? active :line-adjustment))) + (t/is (nil? (cjk/active-writing-mode content nil))) + (t/is (= content (cjk-content {})) "the stored content keeps every attr"))) + +(t/deftest each-language-renders-only-its-own-attrs + (let [span #(first (:children (first-paragraph %)))] + (let [active (cjk/active-content (cjk-content {:text-language "ja"}) nil)] + (t/is (= {:text "日本" :ruby "にほん" :text-combine-upright "digits" + :warichu "warichu" :font-features "palt"} + (span active))) + (t/is (= "push-out-first" (:line-adjustment active))) + (t/is (= "vertical-rl" (:writing-mode (first-paragraph active)))) + (t/is (= "distribute" (:text-align (first-paragraph active)))) + (t/is (not (contains? (first-paragraph active) :korean-wrap)))) + (let [active (cjk/active-content (cjk-content {:text-language "zh-Hant-TW"}) nil)] + (t/is (= {:text "日本" :ruby "にほん" :ruby-zhuyin true :text-emphasis-position "over-left"} + (span active))) + (t/is (not (contains? active :line-adjustment)))) + (let [active (cjk/active-content (cjk-content {:text-language "ko"}) nil)] + (t/is (= {:text "日本"} (span active))) + (t/is (= "keep-words" (:korean-wrap (first-paragraph active)))) + (t/is (= "vertical-rl" (:writing-mode (first-paragraph active))))))) + +(t/deftest text-without-language-takes-the-file-language + (let [content (cjk-content {}) + active (cjk/active-content content "ja")] + (t/is (= "ja" (:text-language (first-paragraph active)))) + (t/is (= "vertical-rl" (:writing-mode (first-paragraph active)))) + (t/is (= "vertical-rl" (cjk/active-writing-mode content "ja")))) + (let [active (cjk/active-content (cjk-content {:text-language "ko"}) "ja")] + (t/is (= "ko" (:text-language (first-paragraph active))) + "the text language wins over the file language"))) diff --git a/frontend/src/app/main/data/changes.cljs b/frontend/src/app/main/data/changes.cljs index ee8eaa33f3..4a7bc9a017 100644 --- a/frontend/src/app/main/data/changes.cljs +++ b/frontend/src/app/main/data/changes.cljs @@ -151,12 +151,19 @@ ptk/WatchEvent (watch [_ state _] - ;; `:add-obj` / `:mov-objects` are not tracked via `*shape-changes*`. - ;; Emit only for structural commits, after file data is in state. - (when (and (not ignore-wasm?) - (features/active-feature? state "render-wasm/v1") - (redo-changes-need-wasm-object-sync? redo-changes)) - (rx/of (sync-wasm-structural-changes {:redo-changes redo-changes})))))) + (let [wasm? (and (not ignore-wasm?) + (features/active-feature? state "render-wasm/v1"))] + (rx/merge + ;; `:add-obj` / `:mov-objects` are not tracked via `*shape-changes*`. + ;; Emit only for structural commits, after file data is in state. + (if (and wasm? (redo-changes-need-wasm-object-sync? redo-changes)) + (rx/of (sync-wasm-structural-changes {:redo-changes redo-changes})) + (rx/empty)) + ;; The texts without language take the file one, so they render + ;; differently when it changes (handled by the workspace). + (if (and wasm? (some #(= :set-default-text-language (:type %)) redo-changes)) + (rx/of (ptk/data-event ::default-text-language-changed {})) + (rx/empty))))))) (defn commit "Create a commit event instance" diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index f1c237396f..139f7b9088 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -411,7 +411,7 @@ (assoc :recent-fonts (:recent-fonts storage/user)) (assoc :current-file-id file-id) (assoc :workspace-presence {}) - (update :workspace-global dissoc :default-font) + (update :workspace-global dissoc :default-font :default-text-language) (update :comments-local dcmt/merge-persisted-filters))) ptk/WatchEvent @@ -523,6 +523,13 @@ (rx/map (fn [{:keys [ids]}] (dwwt/resize-wasm-text-all ids {:skip-component-sync? true})))) + ;; Lay out again the texts that take the file language when it + ;; changes, also on undo, redo and changes from other sessions. + (->> stream + (rx/filter (ptk/type? ::dch/default-text-language-changed)) + (rx/filter render-wasm-ready?) + (rx/map #(dwwt/refresh-file-language-texts))) + (let [local-commits-s (->> stream (rx/filter dch/commit?) @@ -616,7 +623,7 @@ :workspace-tokens :workspace-undo :workspace-versions) - (update :workspace-global dissoc :read-only? :preview-id :default-font) + (update :workspace-global dissoc :read-only? :preview-id :default-font :default-text-language) (assoc-in [:workspace-global :options-mode] :design) (update :files d/update-vals #(dissoc % :data)))) @@ -678,16 +685,20 @@ (dm/export layout/toggle-layout-flag) (dm/export layout/remove-layout-flag) -(defn set-text-layout - [language enabled?] - (ptk/reify ::set-text-layout +(defn set-default-text-language + "Set the language of the file texts that have none of their own; nil + removes it. The language remembered for new texts is dropped, so they take + the new file language. The workspace lays the texts out again once the + change applies (see `initialize-workspace`)." + [language] + (ptk/reify ::set-default-text-language ptk/WatchEvent (watch [it state _] - (let [file-data (dsh/lookup-file-data state) - changes (-> (pcb/empty-changes it) - (pcb/with-file-data file-data) - (pcb/set-text-layout language enabled?))] - (rx/of (dch/commit-changes changes)))))) + (let [changes (-> (pcb/empty-changes it) + (pcb/with-file-data (dsh/lookup-file-data state)) + (pcb/set-default-text-language language))] + (rx/of (dch/commit-changes changes) + (dwtxt/remember-text-language nil)))))) ;; --- Profile diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 83dd201960..76566011cd 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -982,6 +982,17 @@ (not multiple?) (update :workspace-global assoc :default-font font)))))) +(defn remember-text-language + "Remember `language` for the next new texts of the file; nil forgets it, + so they take the file language." + [language] + (ptk/reify ::remember-text-language + ptk/UpdateEvent + (update [_ state] + (if (some? language) + (assoc-in state [:workspace-global :default-text-language] language) + (update state :workspace-global dissoc :default-text-language))))) + (defn apply-text-modifier [shape text-modifier] diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs index c0824a65b5..32028b3d6a 100644 --- a/frontend/src/app/main/data/workspace/wasm_text.cljs +++ b/frontend/src/app/main/data/workspace/wasm_text.cljs @@ -16,6 +16,7 @@ [app.common.geom.point :as gpt] [app.common.render-wasm.api.select :as wselect] [app.common.types.modifiers :as ctm] + [app.common.types.text :as txt] [app.main.data.helpers :as dsh] [app.main.data.workspace :as-alias dw] [app.main.data.workspace.modifiers :as dwm] @@ -276,3 +277,26 @@ (rx/empty) resize-stream))))) resize-stream)))))) + +(defn- file-language-text? + "True when some paragraph of the text `shape` has no language of its own, + so it renders with the file language." + [shape] + (and (cfh/text-shape? shape) + (let [paragraphs (txt/node-seq txt/is-paragraph-node? (:content shape))] + (or (empty? paragraphs) + (some #(nil? (:text-language %)) paragraphs))))) + +(defn refresh-file-language-texts + "Lay out again the texts of the page that render with the file language, + after it changed, and resize the auto-sized ones." + [] + (ptk/reify ::refresh-file-language-texts + ptk/WatchEvent + (watch [_ state _] + (let [shapes (into [] (filter file-language-text?) (vals (dsh/lookup-page-objects state)))] + (rx/concat + (rx/defer (fn [] + (wasm.api/refresh-text-contents shapes) + (rx/empty))) + (rx/of (resize-wasm-text-all (mapv :id shapes)))))))) diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index b2b3a8e3b0..99c051509c 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -21,6 +21,7 @@ [app.main.data.workspace.tokens.selected-set :as dwts] [app.main.store :as st] [app.main.streams :as ms] + [app.util.text.layout :as tl] [beicon.v2.core :as rx] [clojure.string :as str] [okulary.core :as l])) @@ -247,6 +248,10 @@ (def default-font (l/derived :default-font workspace-global)) +(def default-text-language + "Language the last text option change picked, for the next new texts." + (l/derived :default-text-language workspace-global)) + (def inspect-expanded (l/derived :inspect-expanded workspace-local)) @@ -290,22 +295,6 @@ "Currently working file data on workspace" (l/derived dsh/lookup-file-data st/state)) -(def text-layout-config - "Control availability by language; it never changes paragraph profiles." - (l/derived - (fn [state] - (let [file-data (dsh/lookup-file-data state) - props (dm/get-in state [:profile :props]) - feature? (contains? (:features state) "vertical-text-layout") - config (fn [language] - {:feature-enabled feature? - :file? (ctf/text-layout-enabled? file-data language) - :all-files? (true? (get props (ctf/text-layout-preference language)))})] - {:japanese (config :japanese) - :chinese (config :chinese) - :korean (config :korean)})) - st/state =)) - (def workspace-file-colors (l/derived (fn [{:keys [id] :as data}] (some-> (:colors data) (update-vals #(assoc % :file-id id)))) @@ -369,6 +358,39 @@ [ids] (l/derived #(into [] (keep (d/getf %)) ids) workspace-page-objects =)) +(def file-text-language + "Language of the texts of the open file that have none of their own." + (l/derived ctf/get-default-text-language workspace-data)) + +(defn texts-language + "Language of the texts among `ids` and of the texts inside the groups + among them (see `tl/get-texts-language`): `:language` is the one they + render with, taking the file language when they have none, and `:own` the + one they store." + [ids] + (l/derived + (fn [state] + (let [objects (dsh/lookup-page-objects state) + contents (into [] + (mapcat + (fn [id] + (let [shape (get objects id)] + (cond + (cph/text-shape? shape) + [(:content shape)] + + (cph/group-shape? shape) + (into [] + (comp (map (d/getf objects)) + (filter cph/text-shape?) + (map :content)) + (cph/get-children-ids objects id)))))) + ids)] + {:language (tl/get-texts-language contents (ctf/get-default-text-language + (dsh/lookup-file-data state))) + :own (tl/get-texts-language contents)})) + st/state =)) + (defn parents-by-ids [ids] (l/derived diff --git a/frontend/src/app/main/ui/components/grouped_dropdown.cljs b/frontend/src/app/main/ui/components/grouped_dropdown.cljs new file mode 100644 index 0000000000..a136a74063 --- /dev/null +++ b/frontend/src/app/main/ui/components/grouped_dropdown.cljs @@ -0,0 +1,84 @@ +;; 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 app.main.ui.components.grouped-dropdown + "Dropdown list of options split in groups by non-selectable header rows, + with an optional search bar. The caller owns the trigger and the open + state, and positions the list from the nearest positioned ancestor." + (:require-macros [app.main.style :as stl]) + (:require + [app.main.ui.components.dropdown :refer [dropdown]] + [app.main.ui.components.search-bar :refer [search-bar*]] + [app.main.ui.icons :as deprecated-icon] + [app.util.dom :as dom] + [app.util.keyboard :as kbd] + [rumext.v2 :as mf])) + +(mf/defc option-row* + {::mf/private true} + [{:keys [option on-select]}] + (let [{:keys [id label detail selected]} option + + handle-click + (mf/use-fn + (mf/deps id on-select) + (fn [event] + (on-select id event))) + + handle-key-down + (mf/use-fn + (mf/deps id on-select) + (fn [event] + (when (kbd/enter? event) + (dom/prevent-default event) + (on-select id event))))] + [:li {:class (stl/css-case :option true + :selected selected) + :role "option" + :aria-selected (boolean selected) + :tab-index 0 + :data-testid (str "grouped-dropdown-option-" id) + :on-click handle-click + :on-key-down handle-key-down} + [:div {:class (stl/css :option-text)} + [:span {:class (stl/css :option-label)} label] + (when (some? detail) + [:span {:class (stl/css :option-detail)} detail])] + (when selected + [:span {:class (stl/css :check-icon)} deprecated-icon/tick])])) + +(mf/defc grouped-dropdown* + "`options` holds the rows in display order: a header row is + `{:id :label :header true}`, an option row `{:id :label}` with an optional + `:detail` text and `:selected` flag. `on-select` receives the option id and + the event. Passing `on-search-change` shows the search bar; `empty-label` + is the row shown when `options` is empty." + [{:keys [show on-close container options on-select class + search-term search-placeholder on-search-change empty-label]}] + [:& dropdown {:show show + :on-close on-close + :container container} + [:div {:class [class (stl/css :dropdown)] + :on-click dom/stop-propagation} + (when (some? on-search-change) + [:div {:class (stl/css :search)} + [:> search-bar* {:on-change on-search-change + :value search-term + :auto-focus true + :placeholder search-placeholder}]]) + [:ul {:class (stl/css :option-list) + :role "listbox"} + (if (empty? options) + [:li {:class (stl/css :empty)} empty-label] + (for [option options] + (if (:header option) + [:li {:key (:id option) + :class (stl/css :header) + :role "presentation"} + (:label option)] + [:> option-row* {:key (:id option) + :option option + :on-select on-select}])))]]]) diff --git a/frontend/src/app/main/ui/components/grouped_dropdown.scss b/frontend/src/app/main/ui/components/grouped_dropdown.scss new file mode 100644 index 0000000000..10e30dae95 --- /dev/null +++ b/frontend/src/app/main/ui/components/grouped_dropdown.scss @@ -0,0 +1,93 @@ +// 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 + +@use "refactor/common-refactor.scss" as deprecated; + +.dropdown { + @extend %dropdown-wrapper; + + margin-top: deprecated.$s-2; + max-height: 70vh; + width: deprecated.$s-252; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.search { + padding: deprecated.$s-4; + border-bottom: deprecated.$s-1 solid var(--menu-border-color-rest, transparent); +} + +.option-list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + margin: 0; + padding: 0; + list-style: none; +} + +.header, +.empty { + @extend %dropdown-element-base; + + pointer-events: none; + cursor: default; + color: var(--menu-foreground-color); +} + +.header { + text-transform: uppercase; +} + +.option { + @extend %dropdown-element-base; + + .option-text { + display: flex; + gap: deprecated.$s-8; + flex-grow: 1; + } + + .option-label, + .option-detail { + color: var(--menu-foreground-color-rest); + } + + .check-icon { + @include deprecated.flex-center; + + svg { + @extend %button-icon-small; + + stroke: var(--icon-foreground); + } + } + + &.selected { + .option-label { + color: var(--menu-foreground-color-hover); + } + + .check-icon svg { + stroke: var(--menu-foreground-color-hover); + } + } + + &:hover, + &:focus-visible { + background-color: var(--menu-background-color-hover); + + .option-label { + color: var(--menu-foreground-color-hover); + } + + .check-icon svg { + stroke: var(--menu-foreground-color-hover); + } + } +} diff --git a/frontend/src/app/main/ui/settings/options.cljs b/frontend/src/app/main/ui/settings/options.cljs index 2cf03ce92a..f510ac582d 100644 --- a/frontend/src/app/main/ui/settings/options.cljs +++ b/frontend/src/app/main/ui/settings/options.cljs @@ -7,7 +7,6 @@ (ns app.main.ui.settings.options (:require-macros [app.main.style :as stl]) (:require - [app.common.types.file :as ctf] [app.config :as cf] [app.main.data.event :as ev] [app.main.data.notifications :as ntf] @@ -17,6 +16,7 @@ [app.main.router :as rt] [app.main.store :as st] [app.main.ui.components.forms :as fm] + [app.main.ui.ds.controls.select :refer [select*]] [app.main.ui.ds.controls.switch :refer [switch*]] [app.main.ui.ds.foundations.assets.icon :refer [icon*]] [app.main.ui.ds.foundations.typography :as t] @@ -24,6 +24,7 @@ [app.main.ui.ds.foundations.typography.text :refer [text*]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] + [app.util.text.layout :as tl] [app.util.theme :as theme] [rumext.v2 :as mf])) @@ -123,46 +124,45 @@ :on-change handle-render-change}]] [:> text* {:typography t/body-medium :class (stl/css :feedback)} [:a {:href "#" :on-click go-settings-feedback :class (stl/css :link)} (tr "dashboard.webgl-switch.feedback") [:> icon* {:icon-id "arrow-up-right" :size "s"}]]]])) -(mf/defc language-layout-setting* - [{:keys [language label enabled]}] - (let [handle-change - (mf/use-fn - (mf/deps language) - (fn [enabled?] - (st/emit! (ev/event {::ev/name (str (if enabled? "enable-" "disable-") - (name language) "-layout-all-files") - ::ev/origin "settings"}) - (du/update-profile-props - {(ctf/text-layout-preference language) enabled?}) - (ntf/success (tr "notifications.profile-saved")))))] - [:> switch* {:label label - :default-checked enabled - :on-change handle-change}])) +(def ^:private no-text-language + "Id of the \"None\" choice: the DS select shows \"--\" for a blank id." + "none") -(mf/defc language-layout-settings* - [{:keys [config]}] - [:section {:class (stl/css :text-layout-container)} - [:> heading* {:class (stl/css :title) :level 2 :typography t/title-large} - (tr "dashboard.text-layout.title")] - [:> text* {:class (stl/css :description) :typography t/body-medium} - (tr "dashboard.text-layout.description")] - [:div {:class (stl/css :text-layout-form)} - [:> language-layout-setting* {:language :japanese - :label (tr "dashboard.japanese-layout.enable-all-files") - :enabled (get-in config [:japanese :all-files?])}] - [:> language-layout-setting* {:language :chinese - :label (tr "dashboard.chinese-layout.enable-all-files") - :enabled (get-in config [:chinese :all-files?])}] - [:> language-layout-setting* {:language :korean - :label (tr "dashboard.korean-layout.enable-all-files") - :enabled (get-in config [:korean :all-files?])}]]]) +(mf/defc text-language-settings* + "Text language the new files of the profile start with." + {::mf/private true} + [{:keys [language]}] + (let [options + (mf/with-memo [] + (into [{:id no-text-language :label (tr "dashboard.text-language.none")}] + (comp (remove :header) + (map (fn [{:keys [id]}] {:id id :label (tl/language-label id)}))) + (tl/language-options nil))) + + handle-change + (mf/use-fn + (fn [id] + (st/emit! (du/update-profile-props + {:default-text-language (when (not= no-text-language id) id)}) + (ntf/success (tr "notifications.profile-saved")))))] + + [:section {:class (stl/css :text-language-container)} + [:> heading* {:class (stl/css :title) :level 2 :typography t/title-large} + (tr "dashboard.text-language.title")] + [:> text* {:class (stl/css :description) :typography t/body-medium} + (tr "dashboard.text-language.description")] + [:div {:class (stl/css :text-language-form)} + [:> select* {:default-selected (or language no-text-language) + :aria-label (tr "dashboard.text-language.title") + :options options + :on-change handle-change + :data-testid "setting-text-language"}]]])) (mf/defc options-page* [] - (let [profile (mf/deref refs/profile) - renderer (or (-> profile :props :renderer) :svg) - layout-config (mf/deref refs/text-layout-config) - layout-feature? (features/use-feature "vertical-text-layout")] + (let [profile (mf/deref refs/profile) + renderer (or (-> profile :props :renderer) :svg) + cjk-feature? (features/use-feature "cjk-support")] (mf/use-effect #(dom/set-html-title (tr "title.settings.options"))) @@ -176,7 +176,7 @@ :id "options-section-title"} (tr "labels.settings")] [:> options-form*]] - (when layout-feature? - [:> language-layout-settings* {:config layout-config}]) + (when ^boolean cjk-feature? + [:> text-language-settings* {:language (-> profile :props :default-text-language)}]) (when (contains? cf/flags :render-switch) [:> webgl-settings* {:renderer renderer}])]])) diff --git a/frontend/src/app/main/ui/settings/options.scss b/frontend/src/app/main/ui/settings/options.scss index ff8da9c844..f2769e7d00 100644 --- a/frontend/src/app/main/ui/settings/options.scss +++ b/frontend/src/app/main/ui/settings/options.scss @@ -31,9 +31,10 @@ } } -/* Settings sections with standalone controls. */ +/* Copied from profile.scss .form-container, but without included nested + rules, since we want custom styles for it */ .webgl-container, -.text-layout-container { +.text-language-container { display: grid; grid-auto-rows: auto; gap: var(--sp-s); @@ -44,10 +45,8 @@ color: var(--color-foreground-primary); } -.text-layout-form { - display: flex; - flex-direction: column; - gap: var(--sp-s); +.text-language-form { + inline-size: $sz-252; padding-block-start: var(--sp-xs); } diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index cc5033f6c7..c104d666a4 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -46,6 +46,7 @@ [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] [app.util.text.layout :as tl] + [app.util.text.writing-mode :as wm] [beicon.v2.core :as rx] [potok.v2.core :as ptk] [rumext.v2 :as mf])) @@ -231,28 +232,11 @@ [:span {:class (stl/css :feedback :item-name)} (tr "labels.give-feedback")]])])) -(mf/defc language-layout-menu-item* - [{:keys [language config can-edit read-only label on-toggle]}] - (let [toggle (mf/use-fn - (mf/deps language on-toggle) - #(on-toggle language %))] - (when (tl/file-action-visible? config can-edit read-only) - [:> dropdown-menu-item* {:on-click toggle - :class (stl/css :base-menu-item :submenu-item) - :on-key-down (fn [event] - (when (kbd/enter? event) (toggle event))) - :data-testid (str (name language) "-layout") - :id (str "file-menu-" (name language) "-layout")} - [:span {:class (stl/css :item-name)} label]]))) - (mf/defc preferences-menu* {::mf/private true ::mf/wrap [mf/memo]} - [{:keys [layout-config layout profile toggle-flag - on-close toggle-theme toggle-render toggle-text-layout]}] - (let [renderer (or (-> profile :props :renderer) :svg) - read-only? (mf/use-ctx ctx/workspace-read-only?) - can-edit (:can-edit (mf/deref refs/permissions)) + [{:keys [layout profile toggle-flag on-close toggle-theme toggle-render]}] + (let [renderer (or (-> profile :props :renderer) :svg) show-nudge-options (mf/use-fn @@ -335,25 +319,6 @@ :id "file-menu-nudge"} [:span {:class (stl/css :item-name)} (tr "modals.nudge-title")]] - [:> language-layout-menu-item* - {:language :japanese :config (:japanese layout-config) - :can-edit can-edit :read-only read-only? :on-toggle toggle-text-layout - :label (if (get-in layout-config [:japanese :file?]) - (tr "workspace.header.menu.disable-japanese-layout") - (tr "workspace.header.menu.enable-japanese-layout"))}] - [:> language-layout-menu-item* - {:language :chinese :config (:chinese layout-config) - :can-edit can-edit :read-only read-only? :on-toggle toggle-text-layout - :label (if (get-in layout-config [:chinese :file?]) - (tr "workspace.header.menu.disable-chinese-layout") - (tr "workspace.header.menu.enable-chinese-layout"))}] - [:> language-layout-menu-item* - {:language :korean :config (:korean layout-config) - :can-edit can-edit :read-only read-only? :on-toggle toggle-text-layout - :label (if (get-in layout-config [:korean :file?]) - (tr "workspace.header.menu.disable-korean-layout") - (tr "workspace.header.menu.enable-korean-layout"))}] - [:> dropdown-menu-item* {:on-click toggle-theme :class (stl/css :base-menu-item :submenu-item) :on-key-down (fn [event] @@ -602,9 +567,78 @@ (tr "workspace.header.menu.redo")] [:> shortcuts* {:id :redo}]])])) +(mf/defc file-language-menu* + "File > Language: the language of the file texts without one of their own, + and of the new texts. The choices open beside the item." + {::mf/private true} + [{:keys [on-close-all]}] + (let [language (mf/deref refs/file-text-language) + open* (mf/use-state false) + open? (deref open*) + options (mf/with-memo [language] (tl/language-options language)) + + show (mf/use-fn #(reset! open* true)) + hide (mf/use-fn #(reset! open* false)) + + toggle + (mf/use-fn + (fn [event] + (dom/stop-propagation event) + (swap! open* not))) + + on-select + (mf/use-fn + (mf/deps on-close-all) + (fn [event] + (dom/stop-propagation event) + (let [tag (dom/get-data (dom/get-current-target event) "language")] + (st/emit! (-> (dw/set-default-text-language (when (not= "none" tag) tag)) + (vary-meta assoc ::ev/origin "workspace:menu"))) + (on-close-all))))] + + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item :language-menu-item) + :on-click toggle + :on-pointer-enter show + :on-pointer-leave hide + :aria-haspopup "menu" + :aria-expanded open? + :id "file-menu-language"} + [:span {:class (stl/css :item-name)} + (tr "workspace.header.menu.option.language")] + [:> icon* {:icon-id i/arrow-right + :class (stl/css :item-arrow)}] + + (when ^boolean open? + [:div {:class (stl/css :language-submenu)} + [:ul {:class (stl/css :base-menu :language-submenu-list) + :role "menu"} + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) + :data-language "none" + :on-click on-select + :id "file-menu-language-none"} + [:span {:class (stl/css :item-name)} + (tr "workspace.header.menu.language-none")] + (when (nil? language) + [:> icon* {:icon-id i/tick :class (stl/css :item-icon)}])] + + (for [{:keys [id label header selected]} options] + (if ^boolean header + [:li {:key id + :class (stl/css :language-header) + :role "presentation"} + label] + [:> dropdown-menu-item* {:key id + :class (stl/css :base-menu-item :submenu-item) + :data-language id + :on-click on-select + :id (str "file-menu-language-" id)} + [:span {:class (stl/css :item-name)} label] + (when ^boolean selected + [:> icon* {:icon-id i/tick :class (stl/css :item-icon)}])]))]])])) + (mf/defc file-menu* {::mf/private true} - [{:keys [on-close file]}] + [{:keys [on-close on-close-all file]}] (let [file-id (:id file) shared? (:is-shared file) @@ -619,6 +653,7 @@ perms (mf/use-ctx ctx/permissions) can-edit (:can-edit perms) + cjk-feature? (features/use-feature "cjk-support") on-remove-shared (mf/use-fn @@ -780,7 +815,12 @@ :on-key-down on-export-frames-key-down :id "file-menu-export-frames"} [:span {:class (stl/css :item-name)} - (tr "dashboard.export-frames")]])])) + (tr "dashboard.export-frames")]]) + + (when (and can-edit ^boolean cjk-feature? ^boolean (wm/vertical-layout-active?)) + [:* + [:div {:class (stl/css :separator)}] + [:> file-language-menu* {:on-close-all on-close-all}]])])) (mf/defc plugins-menu* {::mf/private true @@ -923,7 +963,6 @@ (mf/defc menu* [{:keys [layout file]}] (let [profile (mf/deref refs/profile) - layout-config (mf/deref refs/text-layout-config) show-menu* (mf/use-state false) show-menu? (deref show-menu*) @@ -985,15 +1024,6 @@ (dom/stop-propagation event) (st/emit! (du/toggle-theme)))) - toggle-text-layout - (mf/use-fn - (mf/deps layout-config) - (fn [language event] - (dom/stop-propagation event) - (st/emit! (dw/set-text-layout language (not (get-in layout-config [language :file?])))) - (reset! show-menu* false) - (reset! selected-sub-menu* nil))) - toggle-render (mf/use-fn (mf/deps profile) @@ -1179,7 +1209,8 @@ (case selected-sub-menu :file [:> file-menu* {:file file - :on-close close-sub-menu}] + :on-close close-sub-menu + :on-close-all close-all-menus}] :edit [:> edit-menu* {:on-close close-sub-menu}] @@ -1193,12 +1224,10 @@ :preferences [:> preferences-menu* {:layout layout :profile profile - :layout-config layout-config :toggle-flag toggle-flag :toggle-theme toggle-theme :toggle-render toggle-render :show-shortcuts show-shortcuts - :toggle-text-layout toggle-text-layout :on-close close-sub-menu}] :plugins diff --git a/frontend/src/app/main/ui/workspace/main_menu.scss b/frontend/src/app/main/ui/workspace/main_menu.scss index 591e96b9c7..ace7efae6f 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.scss +++ b/frontend/src/app/main/ui/workspace/main_menu.scss @@ -194,3 +194,39 @@ background-color: var(--color-background-info); color: var(--color-foreground-primary); } + +// File > Language: its choices open beside the item. The padding bridges the +// gap to the item, so moving the pointer there keeps the list open. +.language-menu-item { + position: relative; +} + +.language-submenu { + position: absolute; + inset-block-start: calc(-1 * (var(--sp-xs) + $b-2)); + inset-inline-start: 100%; + padding-inline-start: var(--sp-s); +} + +// The list sits inside the Language item, so it would inherit the colors +// that item sets on hover; it takes back the design-token defaults and its +// own items get their hover again. +.language-submenu-list { + --menu-background-color: var(--color-background-tertiary); + --menu-foreground-color: var(--color-foreground-primary); + --menu-icon-foreground-color: var(--color-foreground-secondary); + --menu-shortcut-foreground-color: var(--color-foreground-secondary); + + position: relative; + min-inline-size: $sz-192; + margin: 0; +} + +.language-header { + @include t.use-typography("body-small"); + + padding: var(--sp-xs) $sz-6; + color: var(--color-foreground-secondary); + text-transform: uppercase; + list-style: none; +} diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index 48846b1aab..55ec8fa6e0 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -9,7 +9,6 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data.macros :as dm] - [app.common.types.text.cjk-layout :as cjk] [app.config :as cf] [app.main.data.helpers :as dsh] [app.main.data.workspace :as dw] @@ -24,6 +23,7 @@ [app.util.dom :as dom] [app.util.keyboard :as kbd] [app.util.text.geometry :as text.geom] + [app.util.text.writing-mode :as wm] [app.util.timers :as ts] [beicon.v2.core :as rx] [cuerdas.core :as str] @@ -351,7 +351,7 @@ clip-id (dm/str "text-edition-clip" shape-id) - vertical? (cjk/vertical-text-content? (:content shape)) + vertical? (wm/vertical-text-content? (:content shape)) contenteditable-ref (mf/use-ref nil) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs index 3f56fbbc23..0241b0680c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs @@ -23,10 +23,9 @@ [app.main.features :as features] [app.main.refs :as refs] [app.main.store :as st] - [app.main.ui.components.dropdown :refer [dropdown]] + [app.main.ui.components.grouped-dropdown :refer [grouped-dropdown*]] [app.main.ui.components.numeric-input :as deprecated-input] [app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]] - [app.main.ui.components.search-bar :refer [search-bar*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.icons :as deprecated-icon] @@ -136,6 +135,20 @@ acc)))) acc))))) +(defn size-preset-options + "Rows of the size presets dropdown: a preset without `:width` heads the + presets that follow it. A preset is selected when it has the shape size." + [presets width height] + (mapv (fn [preset] + (let [preset-name (:name preset)] + (if (:width preset) + {:id preset-name + :label preset-name + :detail (str (:width preset) " x " (:height preset)) + :selected (and (= width (:width preset)) (= height (:height preset)))} + {:id preset-name :label preset-name :header true}))) + presets)) + (defn check-measures-menu-props [old-props new-props] (let [o-values (unchecked-get old-props "values") @@ -366,16 +379,18 @@ (mf/with-memo [preset-search-term] (filter-size-presets preset-search-term size-presets)) + preset-width (d/parse-integer (:width values) 0) + preset-height (d/parse-integer (:height values) 0) + + preset-options + (mf/with-memo [filtered-size-presets preset-width preset-height] + (size-preset-options filtered-size-presets preset-width preset-height)) + on-preset-selected (mf/use-fn (mf/deps ids) - (fn [event] - (let [width (-> (dom/get-current-target event) - (dom/get-data "width") - (d/read-string)) - height (-> (dom/get-current-target event) - (dom/get-data "height") - (d/read-string))] + (fn [preset-name _event] + (let [{:keys [width height]} (d/seek #(= preset-name (:name %)) size-presets)] (st/emit! (udw/update-dimensions ids :width width) (udw/update-dimensions ids :height height)) (reset! preset-state* false) @@ -498,42 +513,15 @@ [:span {:class (stl/css :select-name)} (tr "workspace.options.size-presets")] [:span {:class (stl/css :collapsed-icon)} deprecated-icon/arrow] - [:& dropdown {:show show-presets-dropdown? - :on-close close-presets - :container preset-container-ref} - [:div {:class (stl/css :custom-select-dropdown) - :on-click dom/stop-propagation} - [:div {:class (stl/css :preset-search)} - [:> search-bar* {:on-change on-preset-search-change - :value preset-search-term - :auto-focus true - :placeholder (tr "workspace.options.search-size-preset")}]] - [:ul {:class (stl/css :preset-list)} - (if (empty? filtered-size-presets) - [:li {:class (stl/css-case :dropdown-element true - :disabled true)} - [:span {:class (stl/css :preset-name)} - (tr "workspace.options.no-size-preset-results")]] - (for [size-preset filtered-size-presets] - (if-not (:width size-preset) - [:li {:key (:name size-preset) - :class (stl/css-case :dropdown-element true - :disabled true)} - [:span {:class (stl/css :preset-name)} (:name size-preset)]] - - (let [preset-match (and (= (:width size-preset) (d/parse-integer (:width values) 0)) - (= (:height size-preset) (d/parse-integer (:height values) 0)))] - [:li {:key (:name size-preset) - :class (stl/css-case :dropdown-element true - :match preset-match) - :data-width (str (:width size-preset)) - :data-height (str (:height size-preset)) - :on-click on-preset-selected} - [:div {:class (stl/css :name-wrapper)} - [:span {:class (stl/css :preset-name)} (:name size-preset)] - [:span {:class (stl/css :preset-size)} (:width size-preset) " x " (:height size-preset)]] - (when preset-match - [:span {:class (stl/css :check-icon)} deprecated-icon/tick])]))))]]]] + [:> grouped-dropdown* {:show show-presets-dropdown? + :on-close close-presets + :container preset-container-ref + :options preset-options + :on-select on-preset-selected + :search-term preset-search-term + :search-placeholder (tr "workspace.options.search-size-preset") + :on-search-change on-preset-search-change + :empty-label (tr "workspace.options.no-size-preset-results")}]] [:& radio-buttons {:selected (or (d/name orientation) "") :on-change on-orientation-change diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.scss index 8fa48d1bb0..d74103971f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.scss @@ -69,90 +69,6 @@ cursor: pointer; } -.custom-select-dropdown { - @extend %dropdown-wrapper; - - margin-top: deprecated.$s-2; - max-height: 70vh; - width: deprecated.$s-252; - display: flex; - flex-direction: column; - overflow: hidden; -} - -.preset-search { - padding: deprecated.$s-4; - border-bottom: deprecated.$s-1 solid var(--menu-border-color-rest, transparent); -} - -.preset-list { - flex: 1 1 auto; - min-height: 0; - overflow-y: auto; - margin: 0; - padding: 0; - list-style: none; - - .dropdown-element { - @extend %dropdown-element-base; - - .name-wrapper { - display: flex; - gap: deprecated.$s-8; - flex-grow: 1; - - .preset-name { - color: var(--menu-foreground-color-rest); - } - - .preset-size { - color: var(--menu-foreground-color-rest); - } - } - - .check-icon { - @include deprecated.flex-center; - - svg { - @extend %button-icon-small; - - stroke: var(--icon-foreground); - } - } - - &.disabled { - pointer-events: none; - cursor: default; - - .preset-name { - color: var(--menu-foreground-color); - } - } - - &.match { - .name-wrapper .preset-name { - color: var(--menu-foreground-color-hover); - } - - .check-icon svg { - stroke: var(--menu-foreground-color-hover); - } - } - - &:hover { - background-color: var(--menu-background-color-hover); - - .name-wrapper .preset-name { - color: var(--menu-foreground-color-hover); - } - - .check-icon svg { - stroke: var(--menu-foreground-color-hover); - } - } - } -} - .size, .position, .rotation-radius { diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs index 7e1398899a..a9b2a00a45 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs @@ -31,7 +31,6 @@ [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.hooks :as hooks] [app.main.ui.workspace.sidebar.options.common :refer [radio-selected]] - [app.main.ui.workspace.sidebar.options.menus.text-japanese-layout :as tjl] [app.main.ui.workspace.sidebar.options.menus.text-language-layout :as tll] [app.main.ui.workspace.sidebar.options.menus.token-typography-row :refer [token-typography-row*]] [app.main.ui.workspace.sidebar.options.menus.typography :refer [text-options* typography-entry*]] @@ -40,7 +39,6 @@ [app.util.i18n :as i18n :refer [tr]] [app.util.object :as obj] [app.util.text.content :as content] - [app.util.text.layout :as tl] [app.util.text.ui :as txu] [app.util.text.writing-mode :as wm] [app.util.timers :as ts] @@ -59,8 +57,7 @@ (mf/defc text-align-options* [{:keys [values on-change on-blur]}] - (let [vertical? (and (wm/vertical-layout-active?) - (tjl/vertical-japanese-layout? values)) + (let [vertical? (wm/vertical-values? values) options (mf/with-memo [vertical?] [{:value "left" @@ -101,8 +98,7 @@ (mf/defc text-direction-options* [{:keys [values on-change on-blur]}] - (let [vertical? (and (wm/vertical-layout-active?) - (tjl/vertical-japanese-layout? values)) + (let [vertical? (wm/vertical-values? values) direction (radio-selected (:text-direction values)) options (mf/with-memo [] @@ -131,8 +127,7 @@ (mf/defc vertical-align* [{:keys [values on-change on-blur]}] - (let [vertical? (and (wm/vertical-layout-active?) - (tjl/vertical-japanese-layout? values)) + (let [vertical? (wm/vertical-values? values) vertical-align (radio-selected (:vertical-align values) "top") options (mf/with-memo [vertical?] @@ -170,8 +165,7 @@ (mf/defc grow-options* [{:keys [ids values on-blur]}] - (let [vertical? (and (wm/vertical-layout-active?) - (tjl/vertical-japanese-layout? values)) + (let [vertical? (wm/vertical-values? values) grow-type (:grow-type values) selected (radio-selected grow-type) editor-instance (mf/deref refs/workspace-editor) @@ -371,26 +365,6 @@ ;; Main component ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(mf/defc language-layout-section* - [{:keys [language title config options-props]}] - (let [open* (mf/use-state true) - open? (deref open*) - toggle-open (mf/use-fn #(swap! open* not))] - (when (tl/controls-enabled? config) - [:section {:class (stl/css :element-set) - :data-keep-editing-on-blur true - :aria-label title} - [:div {:class (stl/css :element-title)} - [:> title-bar* {:collapsable true - :collapsed (not open?) - :on-collapsed toggle-open - :title title - :class (stl/css :title-spacing-text)}]] - (when open? - [:div {:class (stl/css :element-content)} - [:> tll/language-layout-options* - (mf/spread-props options-props {:language language})]])]))) - (mf/defc text-menu* {::mf/wrap [#(mf/memo' % check-props)]} [{:keys [ids type values ruby-values text-selection-active reading-selection-active zhuyin-selection-active @@ -402,7 +376,7 @@ menu-state (deref menu-state*) main-menu-open? (:main-menu menu-state) more-options-open? (:more-options menu-state) - layout-config (mf/deref refs/text-layout-config) + cjk-feature? (features/use-feature "cjk-support") font-id (or (:font-id values) (:font-id txt/default-typography)) @@ -713,15 +687,7 @@ [:> text-decoration-options* (mf/spread-props common-props {:token-applied current-token-name})] [:> text-direction-options* common-props]])])] - [:> language-layout-section* {:language :japanese - :title (tr "workspace.options.text-options.japanese-layout") - :config (:japanese layout-config) - :options-props language-layout-props}] - [:> language-layout-section* {:language :chinese - :title (tr "workspace.options.text-options.chinese-layout") - :config (:chinese layout-config) - :options-props language-layout-props}] - [:> language-layout-section* {:language :korean - :title (tr "workspace.options.text-options.korean-layout") - :config (:korean layout-config) - :options-props language-layout-props}]])) + (when (and ^boolean cjk-feature? ^boolean (wm/vertical-layout-active?)) + [:> tll/language-section* {:ids ids + :options-props language-layout-props + :on-change on-change}])])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs index 039a3ef32e..8f2287a2c9 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs @@ -181,10 +181,91 @@ [{:id "auto" :label (translate "workspace.options.text-options.ruby-overhang-auto")} {:id "none" :label (translate "workspace.options.text-options.ruby-overhang-none")}]) -(defn ruby-side-options - [translate] - [{:id "over" :label (translate "workspace.options.text-options.ruby-side-over")} - {:id "under" :label (translate "workspace.options.text-options.ruby-side-under")}]) +;; --- Positions +;; +;; Annotation positions store both axes (over or under the line, right or +;; left of the column), but only one applies to a writing mode: above/below +;; in horizontal text, right/left in vertical text. The controls show the +;; one that applies and keep the stored other. + +(defn- position-sides + "Block (`\"over\"`/`\"under\"`) and inline (`\"right\"`/`\"left\"`) side + of a position `value`, nil for the ones it leaves to the renderer." + [attr value] + (case attr + :ruby-side + [value nil] + + :text-emphasis-position + (when (and (string? value) (not= "auto" value)) + (let [[block inline] (.split value "-")] [block inline])) + + :text-underline-position + (case value + "under" ["under" nil] + "left" [nil "left"] + "right" [nil "right"] + "under-left" ["under" "left"] + "under-right" ["under" "right"] + nil))) + +(defn- position-of-sides + [attr [block inline]] + (case attr + :ruby-side block + :text-emphasis-position (if (some? block) (str block "-" inline) "auto") + :text-underline-position (cond + (and block inline) (str block "-" inline) + (or block inline) (or block inline) + :else "auto"))) + +(defn position-options + "Choices of the position `attr` for the writing mode." + [attr vertical] + (let [auto {:id "auto" :label (tr "workspace.options.text-options.position-auto")} + over {:id "over" :label (tr "workspace.options.text-options.position-over")} + under {:id "under" :label (tr "workspace.options.text-options.position-under")} + right {:id "right" :label (tr "workspace.options.text-options.position-right")} + left {:id "left" :label (tr "workspace.options.text-options.position-left")} + ;; Ruby takes the block side in vertical text too: over reads right. + ruby-v [(assoc right :id "over") (assoc left :id "under")]] + (case attr + :ruby-side (if ^boolean vertical ruby-v [over under]) + :text-emphasis-position (if ^boolean vertical [auto right left] [auto over under]) + :text-underline-position (if ^boolean vertical [auto right left] [auto under])))) + +(defn position-choice + "Choice of `position-options` a stored position `value` shows." + [attr vertical value] + (if (mixed-span-value? value) + value + (let [[block inline] (position-sides attr value)] + (cond + (= :ruby-side attr) (or block (cjk/enum-default :ruby-side)) + ^boolean vertical (or inline "auto") + :else (or block "auto"))))) + +(defn position-value + "Stored position for picking `choice` over the stored `value`: the side of + the other writing mode stays as it was." + [attr vertical value choice] + (cond + (= :ruby-side attr) + choice + + ;; Emphasis is auto as a whole, or sets both sides. + (= :text-emphasis-position attr) + (if (= "auto" choice) + "auto" + (let [[block inline] (when-not (mixed-span-value? value) (position-sides attr value))] + (position-of-sides attr (if ^boolean vertical + [(or block "over") choice] + [choice (or inline "right")])))) + + :else + (let [[block inline] (when-not (mixed-span-value? value) (position-sides attr value)) + choice (when (not= "auto" choice) choice)] + (position-of-sides attr (if ^boolean vertical [block choice] [choice inline]))))) (mf/defc text-combine-upright-options* ;; Digit TCY finds eligible runs itself, so it applies to a whole shape; @@ -307,22 +388,43 @@ :options options :on-change handle-change}]])) +(mf/defc position-select-option* + "Select of the position `attr` with the sides of the writing mode only." + [{:keys [values on-change on-blur attr vertical label]}] + (let [value (get values attr) + selected (span-select-value (position-choice attr vertical value) "auto") + options (mf/with-memo [attr vertical value] + (with-mixed-span-option (position-options attr vertical) value)) + handle-change + (mf/use-fn + (mf/deps attr vertical value on-change on-blur) + (fn [choice] + (on-change {attr (position-value attr vertical value choice)}) + (when (some? on-blur) (on-blur))))] + [:div {:class (stl/css :japanese-select-option)} + [:span {:class (stl/css :japanese-option-label)} label] + [:> select* {:default-selected selected + :aria-label label + :options options + :on-change handle-change}]])) + (defn- ruby-common-props - [values on-change on-blur labels] + [values on-change on-blur labels vertical] (mf/props {:values values :on-change on-change :on-blur on-blur - :labels labels})) + :labels labels + :vertical vertical})) (mf/defc ruby-hidden-option* - [{:keys [values on-change on-blur labels]}] + [{:keys [values on-change on-blur]}] (let [value (:ruby-hidden values) enabled? (cond (mixed-span-value? value) nil (true? value) true :else false) - label (or (:hidden labels) (tr "workspace.options.text-options.ruby-hidden")) + label (tr "workspace.options.text-options.ruby-hidden") handle-change (mf/use-fn (mf/deps on-change on-blur) @@ -336,42 +438,39 @@ :on-change handle-change}]])) (mf/defc ruby-customization-options* - [{:keys [values on-change on-blur labels]}] - (let [common-props (ruby-common-props values on-change on-blur labels) + [{:keys [values on-change on-blur labels vertical]}] + (let [common-props (ruby-common-props values on-change on-blur labels vertical) options (mf/with-memo [] {:ruby-size (ruby-size-options tr) :ruby-align (ruby-align-options tr) - :ruby-overhang (ruby-overhang-options tr) - :ruby-side (ruby-side-options tr)})] + :ruby-overhang (ruby-overhang-options tr)})] [:div {:class (stl/css :japanese-layout-controls)} [:> ruby-hidden-option* common-props] [:> span-select-option* (mf/spread-props common-props {:attr :ruby-size :default-value (cjk/enum-default :ruby-size) - :label (or (:size labels) (tr "workspace.options.text-options.ruby-size")) + :label (tr "workspace.options.text-options.ruby-size") :options (:ruby-size options)})] [:> span-select-option* (mf/spread-props common-props {:attr :ruby-align :default-value (cjk/enum-default :ruby-align) - :label (or (:align labels) (tr "workspace.options.text-options.ruby-align")) + :label (tr "workspace.options.text-options.ruby-align") :options (:ruby-align options)})] [:> span-select-option* (mf/spread-props common-props {:attr :ruby-overhang :default-value (cjk/enum-default :ruby-overhang) - :label (or (:overhang labels) (tr "workspace.options.text-options.ruby-overhang")) + :label (tr "workspace.options.text-options.ruby-overhang") :options (:ruby-overhang options)})] - [:> span-select-option* (mf/spread-props common-props - {:attr :ruby-side - :default-value (cjk/enum-default :ruby-side) - :label (or (:side labels) (tr "workspace.options.text-options.ruby-side")) - :options (:ruby-side options)})]])) + [:> position-select-option* (mf/spread-props common-props + {:attr :ruby-side + :label (tr "workspace.options.text-options.ruby-side")})]])) (mf/defc ruby-advanced-options* - [{:keys [values on-change on-blur labels]}] + [{:keys [values on-change on-blur labels vertical]}] (let [open* (mf/use-state false) open? (deref open*) toggle-open (mf/use-fn #(swap! open* not)) - common-props (ruby-common-props values on-change on-blur labels)] + common-props (ruby-common-props values on-change on-blur labels vertical)] [:div {:class (stl/css :ruby-advanced-options)} [:> ruby-options* (mf/spread-props common-props {:advanced-open open? @@ -381,11 +480,11 @@ [:> ruby-customization-options* common-props]]])) (mf/defc ruby-presentation-options* - [{:keys [values on-change on-blur labels]}] + [{:keys [values on-change on-blur labels vertical]}] (let [open* (mf/use-state false) open? (deref open*) toggle-open (mf/use-fn #(swap! open* not)) - common-props (ruby-common-props values on-change on-blur labels)] + common-props (ruby-common-props values on-change on-blur labels vertical)] [:div {:class (stl/css :ruby-advanced-options)} [:> title-bar* {:collapsable true :collapsed (not open?) @@ -475,17 +574,20 @@ :options options}])) (mf/defc japanese-layout-options* + "Japanese options. `children` go below the writing mode row." [{:keys [values ruby-values text-selection-active - on-change on-ruby-presentation-change on-blur]}] + on-change on-ruby-presentation-change on-blur children]}] (let [vertical? (vertical-japanese-layout? values) common-props (mf/props {:values values :on-change on-change - :on-blur on-blur}) + :on-blur on-blur + :vertical vertical?}) ruby-presentation-props (mf/props {:values ruby-values :on-change on-ruby-presentation-change - :on-blur on-blur}) + :on-blur on-blur + :vertical vertical?}) options (mf/with-memo [] {:writing-mode (writing-mode-options tr) :text-orientation (text-orientation-options tr) @@ -522,6 +624,7 @@ :name "warichu-options" :class (stl/css :warichu-options) :options (:warichu options)})])] + children (when ^boolean vertical? [:> text-combine-upright-count-options* common-props]) [:> font-features-options* common-props] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs index cdd46338fa..e465d7794c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs @@ -8,44 +8,21 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.types.text.cjk-layout :as cjk] + [app.main.data.workspace.texts :as dwt] + [app.main.refs :as refs] + [app.main.store :as st] + [app.main.ui.components.grouped-dropdown :refer [grouped-dropdown*]] + [app.main.ui.components.title-bar :refer [title-bar*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.controls.select :refer [select*]] [app.main.ui.ds.controls.switch :refer [switch*]] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.workspace.sidebar.options.common :refer [radio-selected]] [app.main.ui.workspace.sidebar.options.menus.text-japanese-layout :as tjl] [app.util.i18n :refer [tr]] [app.util.text.layout :as tl] [rumext.v2 :as mf])) -;; Id of the "No language profile" choice. The DS select shows "--" for a -;; blank id, so the choice cannot use "". -(def plain-profile "plain") - -(defn selected-profile - "Select id of a paragraph `profile`: the plain choice when unset." - [profile] - (if (= :multiple profile) "multiple" (or profile plain-profile))) - -(defn profile-options - [language value translate] - (let [options (into [{:id plain-profile :label (translate "workspace.options.text-options.profile-plain")}] - (case language - :japanese [{:id "ja" :label (translate "workspace.options.text-options.profile-japanese")}] - :chinese [{:id "zh-Hans" :label (translate "workspace.options.text-options.profile-chinese-simplified")} - {:id "zh-Hant" :label (translate "workspace.options.text-options.profile-chinese-traditional")} - {:id "zh-Hans-CN" :label (translate "workspace.options.text-options.profile-chinese-mainland-simplified")} - {:id "zh-Hant-CN" :label (translate "workspace.options.text-options.profile-chinese-mainland-traditional")} - {:id "zh-Hant-TW" :label (translate "workspace.options.text-options.profile-chinese-taiwan")} - {:id "zh-Hant-HK" :label (translate "workspace.options.text-options.profile-chinese-hong-kong")}] - :korean [{:id "ko" :label (translate "workspace.options.text-options.profile-korean")}])) - mixed? (= :multiple value) - id (selected-profile value)] - (cond-> options - (not (some #(= (:id %) id) options)) - (conj {:id id - :label (if mixed? (translate "settings.multiple") value) - :disabled true - :dimmed true})))) - (defn korean-wrap-options [value translate] (cond-> [{:id "syllable" :label (translate "workspace.options.text-options.korean-wrap-syllable")} @@ -53,43 +30,21 @@ (= value "spaces-only") (conj {:id "spaces-only" :label (translate "workspace.options.text-options.korean-wrap-spaces-only")}))) -(defn profile-change - [value] - {:text-language (when (and (seq value) (not= plain-profile value)) value)}) - -(defn emphasis-position-options - [translate] - [{:id "auto" :label (translate "workspace.options.text-options.position-auto")} - {:id "over-right" :label (translate "workspace.options.text-options.position-over-right")} - {:id "over-left" :label (translate "workspace.options.text-options.position-over-left")} - {:id "under-right" :label (translate "workspace.options.text-options.position-under-right")} - {:id "under-left" :label (translate "workspace.options.text-options.position-under-left")}]) - -(defn underline-position-options - [translate] - [{:id "auto" :label (translate "workspace.options.text-options.position-auto")} - {:id "under" :label (translate "workspace.options.text-options.position-under")} - {:id "left" :label (translate "workspace.options.text-options.position-left")} - {:id "right" :label (translate "workspace.options.text-options.position-right")} - {:id "under-left" :label (translate "workspace.options.text-options.position-under-left")} - {:id "under-right" :label (translate "workspace.options.text-options.position-under-right")}]) - (mf/defc chinese-decoration-options* [{:keys [values on-change on-blur]}] - (let [props (mf/props {:values values :on-change on-change :on-blur on-blur})] + (let [props (mf/props {:values values :on-change on-change :on-blur on-blur + :vertical (tjl/vertical-japanese-layout? values)})] [:div {:class (stl/css :decoration-options)} [:> tjl/span-select-option* (mf/spread-props props {:attr :text-emphasis :default-value "none" :label (tr "workspace.options.text-options.text-emphasis") :options (tjl/text-emphasis-options tr)})] - [:> tjl/span-select-option* - (mf/spread-props props {:attr :text-emphasis-position :default-value "auto" - :label (tr "workspace.options.text-options.emphasis-position") - :options (emphasis-position-options tr)})] - [:> tjl/span-select-option* - (mf/spread-props props {:attr :text-underline-position :default-value "auto" - :label (tr "workspace.options.text-options.underline-position") - :options (underline-position-options tr)})] + [:> tjl/position-select-option* + (mf/spread-props props {:attr :text-emphasis-position + :label (tr "workspace.options.text-options.emphasis-position")})] + [:> tjl/position-select-option* + (mf/spread-props props {:attr :text-underline-position + :label (tr "workspace.options.text-options.underline-position")})] [:> tjl/span-select-option* (mf/spread-props props {:attr :annotation-clearance :default-value "none" :label (tr "workspace.options.text-options.annotation-clearance") @@ -101,22 +56,18 @@ (let [zhuyin? (true? (:ruby-zhuyin values)) labels {:input (if zhuyin? (tr "workspace.options.text-options.zhuyin") (tr "workspace.options.text-options.pinyin")) :placeholder (if zhuyin? (tr "workspace.options.text-options.zhuyin-placeholder") (tr "workspace.options.text-options.pinyin-placeholder")) - :advanced (tr "workspace.options.text-options.pinyin-advanced-options") - :hidden (if zhuyin? (tr "workspace.options.text-options.zhuyin-hidden") (tr "workspace.options.text-options.pinyin-hidden")) - :size (tr "workspace.options.text-options.pinyin-size") - :align (tr "workspace.options.text-options.pinyin-align") - :overhang (tr "workspace.options.text-options.pinyin-overhang") - :side (tr "workspace.options.text-options.pinyin-side")} + :advanced (tr "workspace.options.text-options.pinyin-advanced-options")} handle-format-change (mf/use-fn (mf/deps on-change) (fn [value] (on-change {:ruby-zhuyin (= "zhuyin" value)}))) - props (mf/props {:values values :on-change on-change - :on-blur on-blur :labels (cond-> labels zhuyin? - (assoc :validate cjk/zhuyin-reading? - :invalid (tr "workspace.options.text-options.zhuyin-invalid")))})] + props (mf/props {:values values :on-change on-change :on-blur on-blur + :vertical (tjl/vertical-japanese-layout? values) + :labels (cond-> labels zhuyin? + (assoc :validate cjk/zhuyin-reading? + :invalid (tr "workspace.options.text-options.zhuyin-invalid")))})] [:div {:class (stl/css :pinyin-options)} (when reading-selection-active - [:div {:class (stl/css :profile-option)} + [:div {:class (stl/css :reading-format-option)} [:span {:class (stl/css :option-label)} (tr "workspace.options.text-options.chinese-reading-format")] [:> select* {:default-selected (if zhuyin? "zhuyin" "pinyin") :aria-label (tr "workspace.options.text-options.chinese-reading-format") @@ -160,45 +111,149 @@ :on-change handle-change}]])) (mf/defc language-layout-options* + "Options of the CJK `language` (`:japanese`, `:chinese` or `:korean`)." [{:keys [language values ruby-values text-selection-active reading-selection-active zhuyin-selection-active on-change on-ruby-presentation-change on-blur]}] - (let [profile (:text-language values) - selected (selected-profile profile) - options (mf/with-memo [language profile] (profile-options language profile tr)) - matches? (tl/profile-matches? language profile) - handle-profile-change (mf/use-fn - (mf/deps on-change) - #(on-change (profile-change %))) - props (mf/props {:values values :on-change on-change :on-blur on-blur})] + (let [props (mf/props {:values values :on-change on-change :on-blur on-blur})] [:div {:class (stl/css :language-layout-options)} - [:div {:class (stl/css :profile-option)} - [:span {:class (stl/css :option-label)} - (tr "workspace.options.text-options.language-profile")] - [:> select* {:default-selected selected - :aria-label (tr "workspace.options.text-options.language-profile") - :options options - :on-change handle-profile-change}]] - (when matches? - [:> distribute-option* props]) - (if (and (= :japanese language) matches?) + ;; Distribute goes below the writing mode row. + (if (= :japanese language) [:> tjl/japanese-layout-options* (mf/spread-props props {:ruby-values ruby-values :text-selection-active text-selection-active - :on-ruby-presentation-change on-ruby-presentation-change})] - [:> tjl/writing-mode-controls* props]) - (when (and (= :chinese language) matches?) + :on-ruby-presentation-change on-ruby-presentation-change}) + [:> distribute-option* props]] + [:* + [:> tjl/writing-mode-controls* props] + [:> distribute-option* props]]) + (when (= :chinese language) [:> chinese-decoration-options* props]) - (when (and (= :chinese language) matches?) + (when (= :chinese language) [:> chinese-reading-options* (mf/spread-props props {:ruby-values ruby-values :text-selection-active text-selection-active :reading-selection-active reading-selection-active :zhuyin-selection-active zhuyin-selection-active :on-ruby-presentation-change on-ruby-presentation-change})]) - (when (and (= :korean language) matches?) + (when (= :korean language) [:> tjl/span-select-option* (mf/spread-props props {:attr :korean-wrap :default-value "syllable" :label (tr "workspace.options.text-options.korean-wrap") :options (korean-wrap-options (:korean-wrap values) tr)})])])) + +(mf/defc language-row* + "The language of the texts, opening the language dropdown, and the button + removing it when the texts have a language of their own." + {::mf/private true} + [{:keys [language inherited open on-toggle on-remove children]}] + [:div {:class (stl/css :language-row)} + [:button {:type "button" + :class (stl/css-case :language-select true + :mixed (= :multiple language)) + :aria-haspopup "listbox" + :aria-expanded open + :aria-label (tr "workspace.options.text-options.language") + :data-testid "text-language-select" + :on-click on-toggle} + [:span {:class (stl/css :language-name)} (tl/language-label language)] + [:> icon* {:icon-id i/arrow-down + :class (stl/css :language-arrow) + :size "s" + :aria-hidden true}]] + (when-not ^boolean inherited + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.options.text-options.remove-language") + :data-testid "text-language-remove" + :on-click on-remove + :icon i/remove}]) + children]) + +(mf/defc language-section* + "Language panel of the text options. A text has one language: picking or + removing one changes every paragraph of the texts in `ids`, and new texts + take it. A text without language takes the file one. The options of the + language show once it is the same for all of them." + [{:keys [ids options-props on-change]}] + (let [language-ref (mf/with-memo [ids] (refs/texts-language ids)) + {:keys [language own]} (mf/deref language-ref) + family (cjk/language-family language) + inherited (and (some? language) (nil? own)) + + open* (mf/use-state true) + open? (deref open*) + dropdown* (mf/use-state false) + dropdown? (deref dropdown*) + container-ref (mf/use-ref nil) + + title (tr "workspace.options.text-options.language") + options (mf/with-memo [language] (tl/language-options language)) + + toggle-open + (mf/use-fn #(swap! open* not)) + + toggle-dropdown + (mf/use-fn #(swap! dropdown* not)) + + close-dropdown + (mf/use-fn #(reset! dropdown* false)) + + handle-select + (mf/use-fn + (mf/deps on-change) + (fn [tag _event] + (reset! dropdown* false) + (reset! open* true) + (on-change {:text-language tag}) + (st/emit! (dwt/remember-text-language tag)))) + + handle-remove + (mf/use-fn + (mf/deps on-change) + (fn [_event] + (reset! dropdown* false) + (on-change {:text-language nil}) + (st/emit! (dwt/remember-text-language nil)))) + + ;; Opens below the control that toggles it: the add button of the + ;; title, or the language row. + dropdown + (mf/html + [:> grouped-dropdown* {:show dropdown? + :on-close close-dropdown + :container container-ref + :options options + :on-select handle-select}])] + + [:section {:class (stl/css :language-section) + :data-keep-editing-on-blur true + :aria-label title + :ref container-ref} + [:div {:class (stl/css :language-title)} + [:> title-bar* {:collapsable (some? language) + :collapsed (not open?) + :on-collapsed toggle-open + :title title} + (when (nil? language) + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.options.text-options.add-language") + :aria-haspopup "listbox" + :aria-expanded dropdown? + :data-testid "text-language-add" + :on-click toggle-dropdown + :icon i/add}])] + (when (nil? language) + dropdown)] + + (when (and (some? language) open?) + [:div {:class (stl/css :language-content)} + [:> language-row* {:language language + :inherited inherited + :open dropdown? + :on-toggle toggle-dropdown + :on-remove handle-remove} + dropdown] + (when (some? family) + [:> language-layout-options* + (mf/spread-props options-props {:language family})])])])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.scss index 7532d913ed..27d504740f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.scss @@ -4,9 +4,85 @@ // // Copyright (c) KALEIDOS INC Sucursal en España SL +@use "../../../sidebar/common/sidebar.scss" as sidebar; @use "ds/typography.scss" as t; +@use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; +.language-section { + @include sidebar.option-grid-structure; +} + +.language-title { + position: relative; + grid-column: span 8; +} + +.language-content { + display: flex; + flex-direction: column; + gap: var(--sp-xs); + grid-column: span 8; + margin-block-start: var(--sp-xs); +} + +.language-row { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: var(--sp-xs); +} + +// Looks like the DS select it stands for; it opens the grouped dropdown. +.language-select { + --language-select-background: var(--color-background-tertiary); + --language-select-icon-color: var(--color-icon-default); + --language-select-outline-color: none; + + @include t.use-typography("body-small"); + + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: var(--sp-xs); + block-size: $sz-32; + padding: var(--sp-s); + border: none; + border-radius: $br-8; + outline: $b-1 solid var(--language-select-outline-color); + background: var(--language-select-background); + color: var(--color-foreground-primary); + text-align: start; + cursor: pointer; + appearance: none; + + &:is(:hover, [aria-expanded="true"]) { + --language-select-background: var(--color-background-quaternary); + --language-select-icon-color: var(--color-foreground-primary); + } + + &:focus-visible { + --language-select-background: var(--color-background-quaternary); + --language-select-outline-color: var(--color-accent-primary); + } +} + +.language-name { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + padding-inline-start: var(--sp-xxs); +} + +.mixed .language-name { + color: var(--color-foreground-secondary); +} + +.language-arrow { + color: var(--language-select-icon-color); +} + .language-layout-options { display: flex; flex-direction: column; @@ -14,7 +90,7 @@ padding-block: var(--sp-xs); } -.profile-option { +.reading-format-option { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr); align-items: center; diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 46bca2a7d5..9d48f43e0d 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -61,6 +61,7 @@ [app.util.modules :as mod] [app.util.text.content :as tc] [app.util.text.geometry :as text.geom] + [app.util.text.writing-mode :as wm] [app.util.timers :as timers] [beicon.v2.core :as rx] [cuerdas.core :as str] @@ -339,7 +340,7 @@ ;; forward declare helpers so render can call them (declare request-render) -(declare set-shape-vertical-align set-shape-line-adjustment fonts-from-text-content) +(declare set-shape-vertical-align set-shape-line-adjustment fonts-from-text-content ensure-text-content) (declare reload-renderer!) (declare request-render-preserving-target) (declare render-pending?) @@ -744,7 +745,9 @@ (t/write-shape-text spans paragraph text))))) (defn set-shape-text-content - "This function sets shape text content and returns a stream that loads the needed fonts asynchronously" + "This function sets shape text content and returns a stream that loads the needed fonts asynchronously. + The editor caches the stored content; WASM lays out the active one, with + the CJK attrs of the text language only (see `wm/active-content`)." [shape-id content] ;; Cache content for text editor sync @@ -757,17 +760,28 @@ (when (wasm/live?) (h/call wasm/internal-module "_clear_shape_text") - (set-shape-vertical-align (get content :vertical-align)) - (set-shape-line-adjustment (get content :line-adjustment)) + (let [content (wm/active-content content)] + (set-shape-vertical-align (get content :vertical-align)) + (set-shape-line-adjustment (get content :line-adjustment)) - (let [fonts (f/get-content-fonts content) - fallback-fonts (fonts-from-text-content content false) - all-fonts (concat fonts fallback-fonts) - result (f/store-fonts all-fonts)] - (write-text-content! content) - (f/load-fallback-fonts-for-editor! fallback-fonts) - (h/call wasm/internal-module "_update_shape_text_layout") - result))) + (let [fonts (f/get-content-fonts content) + fallback-fonts (fonts-from-text-content content false) + all-fonts (concat fonts fallback-fonts) + result (f/store-fonts all-fonts)] + (write-text-content! content) + (f/load-fallback-fonts-for-editor! fallback-fonts) + (h/call wasm/internal-module "_update_shape_text_layout") + result)))) + +(defn refresh-text-contents + "Lay the text `shapes` out again from their stored content, after a change + outside the shapes (the file language) changed how they render." + [shapes] + (when (wasm/live?) + (doseq [{:keys [id content]} shapes] + (wselect/use-shape id) + (set-shape-text-content id (ensure-text-content content))) + (request-render "refresh-text-contents"))) (defn finish-text-editor-composition [text] diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 8df9dbddd7..34fe6cc99c 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -1220,12 +1220,12 @@ (defn apply-paragraph-attrs-to-range "Apply paragraph attrs to the paragraphs `selection` touches (a caret - touches one). Whole-shape attrs (writing mode, orientation) go to every - paragraph; a nil value removes them." + touches one). Whole-text attrs (writing mode, orientation, language) go + to every paragraph; a nil value removes them." [content selection attrs] (let [{:keys [start-para end-para]} (normalize-selection selection) - whole-attrs (select-keys attrs cjk/whole-shape-paragraph-attrs) - range-attrs (apply dissoc attrs cjk/whole-shape-paragraph-attrs) + whole-attrs (select-keys attrs cjk/whole-text-paragraph-attrs) + range-attrs (apply dissoc attrs cjk/whole-text-paragraph-attrs) apply-whole (fn [para] (reduce-kv (fn [para attr value] (if (some? value) diff --git a/frontend/src/app/util/text/content.cljs b/frontend/src/app/util/text/content.cljs index 5e6212d3cf..7721b03ac5 100644 --- a/frontend/src/app/util/text/content.cljs +++ b/frontend/src/app/util/text/content.cljs @@ -24,11 +24,13 @@ (defn v2-default-text-content "Build the base text tree (root -> paragraph-set -> paragraph -> span) with the - current default typography. Used by the V2 editor/WASM path when a shape is - created with no content yet." + current default typography and the remembered text language. Used by the V2 + editor/WASM path when a shape is created with no content yet." [] (let [default-font (deref refs/default-font) - text-defaults (merge (txt/get-default-text-attrs) default-font) + language (deref refs/default-text-language) + text-defaults (cond-> (merge (txt/get-default-text-attrs) default-font) + (some? language) (assoc :text-language language)) default-span (merge {:text ""} (select-keys text-defaults txt/text-node-attrs)) default-paragraph (merge {:type "paragraph" diff --git a/frontend/src/app/util/text/geometry.cljs b/frontend/src/app/util/text/geometry.cljs index 2ff6ed4bac..c25829b7c0 100644 --- a/frontend/src/app/util/text/geometry.cljs +++ b/frontend/src/app/util/text/geometry.cljs @@ -7,7 +7,7 @@ (ns app.util.text.geometry (:require [app.common.geom.point :as gpt] - [app.common.types.text.cjk-layout :as cjk])) + [app.util.text.writing-mode :as wm])) (defn- live-axis "Measured axis, or the committed one when the measure is missing or not @@ -19,7 +19,7 @@ "Auto-height grows along the block axis: height horizontally, width vertically." [selrect grow-type content dimension] (when (or (= :fixed grow-type) (some? dimension)) - (let [vertical? (cjk/vertical-text-content? content)] + (let [vertical? (wm/vertical-text-content? content)] {:width (if (or (= :fixed grow-type) (and (= :auto-height grow-type) (not vertical?))) (:width selrect) @@ -31,7 +31,7 @@ (defn- block-anchor [content] - (if (cjk/vertical-text-content? content) + (if (wm/vertical-text-content? content) (case (:vertical-align content) "center" 0.5 "bottom" 0 1) (case (:vertical-align content) "center" 0.5 "bottom" 1 0))) @@ -39,14 +39,14 @@ "Keep the text's block alignment anchored when its measured size changes." [{:keys [points content]}] (gpt/lerp (nth points 0) - (nth points (if (cjk/vertical-text-content? content) 1 3)) + (nth points (if (wm/vertical-text-content? content) 1 3)) (block-anchor content))) (defn live-rect "Bounds of live editor content, before its geometry is committed." [selrect grow-type content dimension] (if-let [{:keys [width height]} (resolve-size selrect grow-type content dimension)] - (let [vertical? (cjk/vertical-text-content? content) + (let [vertical? (wm/vertical-text-content? content) anchor (block-anchor content)] (assoc selrect :x (+ (:x selrect) (if vertical? (* anchor (- (:width selrect) width)) 0)) diff --git a/frontend/src/app/util/text/layout.cljs b/frontend/src/app/util/text/layout.cljs index c6d1d8282e..be5cda802b 100644 --- a/frontend/src/app/util/text/layout.cljs +++ b/frontend/src/app/util/text/layout.cljs @@ -5,26 +5,68 @@ ;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.text.layout + "Language of text shapes, as the CJK text options read it. A text has one + language: every paragraph carries the same `:text-language` tag. A text + without one takes the file language." (:require - [app.util.text.writing-mode :as wm] - [clojure.string :as str])) + [app.common.types.text :as txt] + [app.util.i18n :refer [tr]])) -(defn controls-enabled? - [{:keys [feature-enabled file? all-files?]}] - (and (true? feature-enabled) - ^boolean (wm/vertical-layout-active?) - (boolean (or file? all-files?)))) +(defn- get-paragraph-languages + "Language of each paragraph of `content`, `default-language` for the ones + without; a content without paragraphs reads as one such paragraph." + [content default-language] + (let [paragraphs (txt/node-seq txt/is-paragraph-node? content)] + (if (seq paragraphs) + (map #(or (:text-language %) default-language) paragraphs) + [default-language]))) -(defn file-action-visible? - [{:keys [feature-enabled all-files?]} can-edit? read-only?] - (and (true? feature-enabled) - ^boolean (wm/vertical-layout-active?) - (true? can-edit?) - (not read-only?) - (not all-files?))) +(defn get-texts-language + "Language of the text `contents`: the tag all their paragraphs share, nil + when none has a language, `:multiple` when they differ. A paragraph + without language takes `default-language`, so with none it differs from + one with a language." + ([contents] + (get-texts-language contents nil)) + ([contents default-language] + (let [languages (into #{} (mapcat #(get-paragraph-languages % default-language)) contents)] + (if (> (count languages) 1) + :multiple + (first languages))))) -(defn profile-matches? - [language text-language] - (and (string? text-language) - (= (case language :japanese "ja" :chinese "zh" :korean "ko") - (first (str/split (str/lower-case text-language) #"-"))))) +(defn language-label + "Name of the language `tag` in the Language panel row: \"Mixed\" for + `:multiple`, the tag itself when it is not one of the listed languages." + [tag] + (case tag + :multiple (tr "settings.multiple") + "ja" (tr "workspace.options.text-options.profile-japanese") + "ko" (tr "workspace.options.text-options.profile-korean") + "zh-Hans-CN" (tr "workspace.options.text-options.language-chinese-variant" + (tr "workspace.options.text-options.profile-chinese-mainland-simplified")) + "zh-Hant-CN" (tr "workspace.options.text-options.language-chinese-variant" + (tr "workspace.options.text-options.profile-chinese-mainland-traditional")) + "zh-Hant-TW" (tr "workspace.options.text-options.language-chinese-variant" + (tr "workspace.options.text-options.profile-chinese-taiwan")) + "zh-Hant-HK" (tr "workspace.options.text-options.language-chinese-variant" + (tr "workspace.options.text-options.profile-chinese-hong-kong")) + tag)) + +(defn language-options + "Rows of the language dropdown: one group per language, the `selected` + tag checked." + [selected] + (let [option (fn [tag label] + {:id tag :label label :selected (= selected tag)})] + [{:id "japanese" :header true + :label (tr "workspace.options.text-options.language-group-japanese")} + (option "ja" (tr "workspace.options.text-options.profile-japanese")) + {:id "korean" :header true + :label (tr "workspace.options.text-options.language-group-korean")} + (option "ko" (tr "workspace.options.text-options.profile-korean")) + {:id "chinese" :header true + :label (tr "workspace.options.text-options.language-group-chinese")} + (option "zh-Hans-CN" (tr "workspace.options.text-options.profile-chinese-mainland-simplified")) + (option "zh-Hant-CN" (tr "workspace.options.text-options.profile-chinese-mainland-traditional")) + (option "zh-Hant-TW" (tr "workspace.options.text-options.profile-chinese-taiwan")) + (option "zh-Hant-HK" (tr "workspace.options.text-options.profile-chinese-hong-kong"))])) diff --git a/frontend/src/app/util/text/writing_mode.cljs b/frontend/src/app/util/text/writing_mode.cljs index 8c74b182c5..dbd2a3a76c 100644 --- a/frontend/src/app/util/text/writing_mode.cljs +++ b/frontend/src/app/util/text/writing_mode.cljs @@ -5,30 +5,56 @@ ;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.text.writing-mode - "Writing mode of text content as the active renderer lays it out. Only the - WASM renderer supports vertical writing; the SVG renderer lays every text - out horizontally and leaves the stored attrs as they are." + "Text content as the active renderer lays it out. The CJK attrs only apply + under the language that uses them: the text language, or the file language + for a text without one (see `cjk/active-content`). Only the WASM renderer + supports vertical writing; the SVG renderer lays every text out + horizontally and leaves the stored attrs as they are." (:require + [app.common.types.file :as ctf] [app.common.types.shape :as cts] [app.common.types.text :as txt] - [app.common.types.text.cjk-layout :as cjk])) + [app.common.types.text.cjk-layout :as cjk] + [app.main.data.helpers :as dsh] + [app.main.store :as st])) (defn vertical-layout-active? "True when the active renderer lays out vertical writing (WASM)." [] cts/wasm-enabled?) +(defn get-file-language + "Language of the open file for its texts without one, or nil." + [] + (ctf/get-default-text-language (dsh/lookup-file-data @st/state))) + +(defn active-content + "Text content with the CJK attrs its language uses (see + `cjk/active-content`)." + [content] + (cjk/active-content content (get-file-language))) + (defn content-writing-mode - "Effective writing mode of a text content (see `cjk/content-writing-mode`)." + "Writing mode the active renderer lays the text content out with." [content] (when ^boolean (vertical-layout-active?) - (cjk/content-writing-mode content))) + (cjk/active-writing-mode content (get-file-language)))) (defn vertical-text-content? "True when the text content flows vertically under the active renderer." [content] - (and ^boolean (vertical-layout-active?) - (cjk/vertical-text-content? content))) + (= "vertical-rl" (content-writing-mode content))) + +(defn vertical-values? + "True when the text option `values` describe vertical text: vertical + writing under a CJK language, the text one or else the file one. Mixed + languages count as CJK." + [values] + (let [language (:text-language values)] + (and ^boolean (vertical-layout-active?) + (= "vertical-rl" (:writing-mode values)) + (or (= :multiple language) + (some? (cjk/language-family (or language (get-file-language)))))))) (defn stale-vertical-layout? "True when the content is stored as vertical but the active renderer lays @@ -47,9 +73,10 @@ (apply dissoc paragraph cjk/whole-shape-paragraph-attrs))) (defn renderable-content - "Text content as the active renderer lays it out (see + "Text content as the active renderer lays it out (see `active-content` and `renderable-paragraph`)." [content] - (if ^boolean (vertical-layout-active?) - content - (txt/transform-nodes txt/is-paragraph-node? renderable-paragraph content))) + (let [content (active-content content)] + (if ^boolean (vertical-layout-active?) + content + (txt/transform-nodes txt/is-paragraph-node? renderable-paragraph content)))) diff --git a/frontend/test/frontend_tests/code_gen_style_test.cljs b/frontend/test/frontend_tests/code_gen_style_test.cljs index 19345f68c8..4fb51a260c 100644 --- a/frontend/test/frontend_tests/code_gen_style_test.cljs +++ b/frontend/test/frontend_tests/code_gen_style_test.cljs @@ -186,7 +186,7 @@ (def ^:private ruby-text-content {:type "root" :children [{:type "paragraph-set" - :children [{:type "paragraph" + :children [{:type "paragraph" :text-language "ja" :writing-mode "vertical-rl" :text-orientation "upright" :children [{:text "漢字" @@ -224,7 +224,7 @@ (def ^:private warichu-text-content {:type "root" :children [{:type "paragraph-set" - :children [{:type "paragraph" + :children [{:type "paragraph" :text-language "ja" :writing-mode "vertical-rl" :text-orientation "upright" :children [{:text "割注入り" @@ -236,7 +236,7 @@ (def ^:private palt-text-content {:type "root" :children [{:type "paragraph-set" - :children [{:type "paragraph" + :children [{:type "paragraph" :text-language "ja" :children [{:text "かな" :font-features "palt" :font-size "20" @@ -267,7 +267,7 @@ (def ^:private tcy-digits2-text-content {:type "root" :children [{:type "paragraph-set" - :children [{:type "paragraph" + :children [{:type "paragraph" :text-language "ja" :writing-mode "vertical-rl" :children [{:text "平成31年" :text-combine-upright "digits2" diff --git a/frontend/test/frontend_tests/data/workspace_texts_test.cljs b/frontend/test/frontend_tests/data/workspace_texts_test.cljs index 23ec29aba8..44de1977f2 100644 --- a/frontend/test/frontend_tests/data/workspace_texts_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_texts_test.cljs @@ -30,15 +30,25 @@ [potok.v2.core :as ptk])) (defn- text-content + "Japanese text content with `writing-mode`: vertical writing needs a CJK + language." [writing-mode] - {:children [{:children [{:writing-mode writing-mode}]}]}) + {:children [{:children [{:writing-mode writing-mode :text-language "ja"}]}]}) (t/deftest vertical-auto-height-grows-width - (let [selrect {:width 100 :height 200} - dimension {:width 240 :height 360} - content (text-content "vertical-rl")] - (t/is (= {:width 240 :height 200} - (dwwt/resolve-text-size selrect :auto-height content dimension))))) + (with-redefs [cts/wasm-enabled? true] + (let [selrect {:width 100 :height 200} + dimension {:width 240 :height 360} + content (text-content "vertical-rl")] + (t/is (= {:width 240 :height 200} + (dwwt/resolve-text-size selrect :auto-height content dimension)))))) + +(t/deftest vertical-writing-without-language-grows-as-horizontal + (with-redefs [cts/wasm-enabled? true] + (let [content {:children [{:children [{:writing-mode "vertical-rl"}]}]}] + (t/is (= {:width 100 :height 360} + (dwwt/resolve-text-size {:width 100 :height 200} :auto-height + content {:width 240 :height 360})))))) (t/deftest horizontal-auto-height-grows-height (let [selrect {:width 100 :height 200} @@ -48,28 +58,30 @@ (dwwt/resolve-text-size selrect :auto-height content dimension))))) (t/deftest vertical-auto-grow-keeps-the-block-anchor - (doseq [[align expected-x] [["top" -90] ["center" -40] ["bottom" 10]]] - (let [shape (cts/setup-shape - {:type :text :x 10 :y 20 :width 100 :height 200 - :grow-type :auto-height - :content (assoc (text-content "vertical-rl") :vertical-align align)}) - resized (with-redefs [dwwt/get-wasm-text-new-size - (fn ([_] {:width 200 :height 200}) - ([_ _] {:width 200 :height 200}))] - (let [modifiers (dwwt/resize-wasm-text-modifiers shape)] - (gsh/transform-shape shape (get-in modifiers [(:id shape) :modifiers]))))] - (t/is (= expected-x (:x resized)) align) - (t/is (= 200 (:height resized)) align)))) + (with-redefs [cts/wasm-enabled? true] + (doseq [[align expected-x] [["top" -90] ["center" -40] ["bottom" 10]]] + (let [shape (cts/setup-shape + {:type :text :x 10 :y 20 :width 100 :height 200 + :grow-type :auto-height + :content (assoc (text-content "vertical-rl") :vertical-align align)}) + resized (with-redefs [dwwt/get-wasm-text-new-size + (fn ([_] {:width 200 :height 200}) + ([_ _] {:width 200 :height 200}))] + (let [modifiers (dwwt/resize-wasm-text-modifiers shape)] + (gsh/transform-shape shape (get-in modifiers [(:id shape) :modifiers]))))] + (t/is (= expected-x (:x resized)) align) + (t/is (= 200 (:height resized)) align))))) (t/deftest live-vertical-bounds-grow-left-and-keep-the-wrap-height - (let [selrect {:x 10 :y 20 :width 100 :height 200} - content (text-content "vertical-rl")] - (t/is (= {:x -130 :y 20 :width 240 :height 200} - (text.geom/live-rect selrect :auto-height content {:width 240 :height 360}))) - (t/is (= {:x -130 :y 20 :width 240 :height 360} - (text.geom/live-rect selrect :auto-width content {:width 240 :height 360}))) - (t/is (= selrect (text.geom/live-rect selrect :fixed content {:width 240 :height 360}))) - (t/is (= selrect (text.geom/live-rect selrect :auto-width content nil))))) + (with-redefs [cts/wasm-enabled? true] + (let [selrect {:x 10 :y 20 :width 100 :height 200} + content (text-content "vertical-rl")] + (t/is (= {:x -130 :y 20 :width 240 :height 200} + (text.geom/live-rect selrect :auto-height content {:width 240 :height 360}))) + (t/is (= {:x -130 :y 20 :width 240 :height 360} + (text.geom/live-rect selrect :auto-width content {:width 240 :height 360}))) + (t/is (= selrect (text.geom/live-rect selrect :fixed content {:width 240 :height 360}))) + (t/is (= selrect (text.geom/live-rect selrect :auto-width content nil)))))) (t/deftest live-horizontal-bounds-grow-down-and-keep-the-wrap-width (let [selrect {:x 10 :y 20 :width 100 :height 200} @@ -88,16 +100,17 @@ (text.geom/live-rect selrect :auto-width content {:width 0 :height 360}))))) (t/deftest vertical-auto-grow-keeps-the-anchor-after-rotation - (let [base (cts/setup-shape - {:type :text :x 10 :y 20 :width 100 :height 200 - :grow-type :auto-height :content (text-content "vertical-rl")}) - shape (gsh/transform-shape base (ctm/rotation-modifiers base (gpt/point 60 120) 45)) - resized (with-redefs [dwwt/get-wasm-text-new-size - (fn ([_] {:width 200 :height 200}) - ([_ _] {:width 200 :height 200}))] - (let [modifiers (dwwt/resize-wasm-text-modifiers shape)] - (gsh/transform-shape shape (get-in modifiers [(:id shape) :modifiers]))))] - (t/is (gpt/almost-zero? (gpt/subtract (nth (:points shape) 1) (nth (:points resized) 1)))))) + (with-redefs [cts/wasm-enabled? true] + (let [base (cts/setup-shape + {:type :text :x 10 :y 20 :width 100 :height 200 + :grow-type :auto-height :content (text-content "vertical-rl")}) + shape (gsh/transform-shape base (ctm/rotation-modifiers base (gpt/point 60 120) 45)) + resized (with-redefs [dwwt/get-wasm-text-new-size + (fn ([_] {:width 200 :height 200}) + ([_ _] {:width 200 :height 200}))] + (let [modifiers (dwwt/resize-wasm-text-modifiers shape)] + (gsh/transform-shape shape (get-in modifiers [(:id shape) :modifiers]))))] + (t/is (gpt/almost-zero? (gpt/subtract (nth (:points shape) 1) (nth (:points resized) 1))))))) (t/deftest clicking-vertical-text-anchors-its-right-edge-at-the-click (with-redefs [cts/wasm-enabled? true] diff --git a/frontend/test/frontend_tests/render_wasm/texts_test.cljs b/frontend/test/frontend_tests/render_wasm/texts_test.cljs index e2f5530b3a..29b4590651 100644 --- a/frontend/test/frontend_tests/render_wasm/texts_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/texts_test.cljs @@ -559,13 +559,13 @@ (t/is (not-any? #(contains? % :text-orientation) paragraphs)) (t/is (= [nil "center" nil] (mapv :text-align paragraphs))))) -(t/deftest language-and-wrapping-only-change-selected-paragraphs +(t/deftest language-applies-to-the-whole-text-and-wrapping-to-selected-paragraphs (let [content (editor-content [{:text "日本"}] [{:text "한국어"}] [{:text "中文"}]) result (text-editor/apply-paragraph-attrs-to-range content (selection 1 0 1 0) {:text-language "ko" :korean-wrap "spaces-only"}) paragraphs (-> result :children first :children)] - (t/is (= [nil "ko" nil] (mapv :text-language paragraphs))) + (t/is (= ["ko" "ko" "ko"] (mapv :text-language paragraphs))) (t/is (= [nil "spaces-only" nil] (mapv :korean-wrap paragraphs))) (t/is (= result (text-editor/merge-exported-texts-into-content diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index e08f1a1db1..150b034f43 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -137,6 +137,7 @@ [frontend-tests.ui.ds-controls-numeric-input-test] [frontend-tests.ui.error-boundary-test] [frontend-tests.ui.gradient-handlers-test] + [frontend-tests.ui.grouped-dropdown-test] [frontend-tests.ui.history-test] [frontend-tests.ui.inspect-geometry-test] [frontend-tests.ui.inspect-stroke-tokens-test] @@ -149,6 +150,7 @@ [frontend-tests.ui.routes-test] [frontend-tests.ui.settings-password-schema-test] [frontend-tests.ui.settings-shortcuts-test] + [frontend-tests.ui.settings-text-language-test] [frontend-tests.ui.shape-filters-test] [frontend-tests.ui.shortcuts-labels-test] [frontend-tests.ui.sidebar-scroll-test] @@ -323,6 +325,7 @@ 'frontend-tests.ui.ds-controls-numeric-input-test 'frontend-tests.ui.error-boundary-test 'frontend-tests.ui.gradient-handlers-test + 'frontend-tests.ui.grouped-dropdown-test 'frontend-tests.ui.history-test 'frontend-tests.ui.inspect-geometry-test 'frontend-tests.ui.inspect-stroke-tokens-test @@ -337,6 +340,7 @@ 'frontend-tests.text-editor-paste-guard-test 'frontend-tests.ui.settings-password-schema-test 'frontend-tests.ui.settings-shortcuts-test + 'frontend-tests.ui.settings-text-language-test 'frontend-tests.ui.shape-filters-test 'frontend-tests.ui.shortcuts-labels-test 'frontend-tests.ui.sidebar-scroll-test diff --git a/frontend/test/frontend_tests/ui/grouped_dropdown_test.cljs b/frontend/test/frontend_tests/ui/grouped_dropdown_test.cljs new file mode 100644 index 0000000000..d300995470 --- /dev/null +++ b/frontend/test/frontend_tests/ui/grouped_dropdown_test.cljs @@ -0,0 +1,66 @@ +;; 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.ui.grouped-dropdown-test + (:require + ["react-dom/server" :as rds] + [app.main.ui.components.grouped-dropdown :refer [grouped-dropdown*]] + [app.main.ui.workspace.sidebar.options.menus.measures :as measures] + [cljs.test :as t :include-macros true] + [cuerdas.core :as str] + [rumext.v2 :as mf])) + +(def ^:private sample-options + [{:id "fruits" :label "FRUITS" :header true} + {:id "apple" :label "Apple" :detail "red"} + {:id "pear" :label "Pear" :selected true}]) + +(defn- render + [{:keys [show options search-term on-search-change empty-label] + :or {show true}}] + (rds/renderToStaticMarkup + (mf/element grouped-dropdown* + (mf/props {:show show + :on-close identity + :on-select identity + :options options + :search-term search-term + :on-search-change on-search-change + :empty-label empty-label})))) + +(t/deftest headers-are-not-options + (let [markup (render {:options sample-options})] + (t/is (= 2 (count (re-seq #"role=\"option\"" markup)))) + (t/is (re-find #"role=\"presentation\"[^>]*>FRUITS" markup)) + (t/is (str/includes? markup "red")) + (t/is (= ["false" "true"] + (mapv second (re-seq #"aria-selected=\"([^\"]+)\"" markup)))))) + +(t/deftest search-bar-shows-only-with-a-search-handler + (t/is (not (str/includes? (render {:options sample-options}) " {:type "paragraph" :children [{:text "文"}]} + (some? language) (assoc :text-language language))) + languages)}]}) -(t/deftest language-controls-and-menu-actions-respect-independent-preferences - (with-redefs [wm/vertical-layout-active? (constantly true)] - (doseq [file? [true false] all-files? [true false]] - (let [config {:feature-enabled true :file? file? :all-files? all-files?}] - (t/is (= (or file? all-files?) (tl/controls-enabled? config))) - (t/is (= (not all-files?) (tl/file-action-visible? config true false))) - (t/is (false? (tl/file-action-visible? config false false))) - (t/is (false? (tl/file-action-visible? config true true))) - (t/is (false? (tl/controls-enabled? (assoc config :feature-enabled false))))))) - (with-redefs [wm/vertical-layout-active? (constantly false)] - (t/is (false? (tl/controls-enabled? {:feature-enabled true :file? true}))) - (t/is (false? (tl/file-action-visible? {:feature-enabled true :file? true} true false))))) +(defn- render-language-section + "Static markup of the Language panel for texts that render with `language` + and store `own` (the same by default)." + ([language] + (render-language-section language language)) + ([language own] + (with-redefs [refs/texts-language (fn [_] (l/atom {:language language :own own})) + hooks/use-portal-container (fn ([] nil) ([_] nil))] + (rds/renderToStaticMarkup + (mf/element tll/language-section* + (mf/props {:ids [(random-uuid)] + :options-props (mf/props {:values {:text-language own} + :on-change identity}) + :on-change identity})))))) -(t/deftest choosing-a-profile-only-changes-the-paragraph-language - (t/is (= {:text-language "ko"} (tll/profile-change "ko"))) - (t/is (= {:text-language nil} (tll/profile-change tll/plain-profile))) - (t/is (tl/profile-matches? :japanese "ja-JP")) - (t/is (tl/profile-matches? :chinese "zh-Hant-TW")) - (t/is (tl/profile-matches? :korean "KO-kr")) - (t/is (not (tl/profile-matches? :chinese "ja"))) - (t/is (not (tl/profile-matches? :korean :multiple))) - (t/is (not (tl/profile-matches? :japanese nil)))) +(t/deftest texts-without-language-take-the-default-language + (t/is (= "ja" (tl/get-texts-language [(text-content nil) (text-content "ja")] "ja"))) + (t/is (= "ko" (tl/get-texts-language [(text-content nil) nil] "ko"))) + (t/is (= :multiple (tl/get-texts-language [(text-content nil) (text-content "zh-Hant-TW")] "ja")))) -(t/deftest profile-choices-keep-mixed-and-other-language-values-visible - (let [mixed (last (tll/profile-options :korean :multiple identity)) - other (last (tll/profile-options :chinese "ja" identity))] - (t/is (= {:id "multiple" :label "settings.multiple" :disabled true :dimmed true} mixed)) - (t/is (= {:id "ja" :label "ja" :disabled true :dimmed true} other)) - (t/is (= ["plain" "zh-Hans" "zh-Hant" "zh-Hans-CN" "zh-Hant-CN" "zh-Hant-TW" "zh-Hant-HK"] - (mapv :id (tll/profile-options :chinese nil identity)))) - ;; The DS select shows "--" for a blank id, so the plain choice needs one. - (t/is (not-any? (comp str/blank? :id) (tll/profile-options :korean nil identity))) - (t/is (= "plain" (tll/selected-profile nil))) - (t/is (= "multiple" (tll/selected-profile :multiple))) - (t/is (= "ko" (tll/selected-profile "ko"))))) +(t/deftest texts-language-is-mixed-when-any-paragraph-differs + (t/is (= "ja" (tl/get-texts-language [(text-content "ja" "ja") (text-content "ja")]))) + (t/is (nil? (tl/get-texts-language [(text-content nil nil)]))) + (t/is (nil? (tl/get-texts-language []))) + (t/is (= :multiple (tl/get-texts-language [(text-content "ja" nil)])) + "a paragraph without language differs from one with it") + (t/is (= :multiple (tl/get-texts-language [(text-content "ko") (text-content "zh-Hant-TW")]))) + (t/is (= :multiple (tl/get-texts-language [(text-content "ko") (text-content nil)]))) + (t/is (= :multiple (tl/get-texts-language [(text-content "ko") nil])) + "a text that has no content yet has no language")) + +(t/deftest language-dropdown-groups-the-regional-languages + (let [options (tl/language-options "zh-Hant-TW")] + (t/is (= ["japanese" "ja" "korean" "ko" "chinese" + "zh-Hans-CN" "zh-Hant-CN" "zh-Hant-TW" "zh-Hant-HK"] + (mapv :id options))) + (t/is (= ["japanese" "korean" "chinese"] (mapv :id (filter :header options)))) + (t/is (= ["zh-Hant-TW"] (mapv :id (filter :selected options)))) + (t/is (not-any? :detail options) "the list shows no language tags") + (t/is (not-any? :selected (tl/language-options :multiple))))) + +(t/deftest language-row-names-mixed-and-unlisted-languages + (t/is (= "settings.multiple" (tl/language-label :multiple))) + (t/is (= "workspace.options.text-options.profile-japanese" (tl/language-label "ja"))) + (t/is (= "zh-Hans" (tl/language-label "zh-Hans")) + "a tag outside the list shows as it is")) + +(t/deftest language-panel-without-language-only-offers-to-add-one + (let [markup (render-language-section nil)] + (t/is (str/includes? markup "data-testid=\"text-language-add\"")) + (t/is (not (str/includes? markup "data-testid=\"text-language-select\""))) + (t/is (not (str/includes? markup "horizontal-tb-writing-mode")) + "writing mode needs a language"))) + +(t/deftest language-panel-shows-the-options-of-the-chosen-language + (doseq [[language label] [["ja" "workspace.options.text-options.line-adjustment"] + ["ko" "workspace.options.text-options.korean-wrap"] + ["zh-Hant-HK" "workspace.options.text-options.text-emphasis"]]] + (let [markup (render-language-section language)] + (t/is (not (str/includes? markup "data-testid=\"text-language-add\""))) + (t/is (str/includes? markup "data-testid=\"text-language-select\"")) + (t/is (str/includes? markup "data-testid=\"text-language-remove\"")) + (t/is (str/includes? markup "horizontal-tb-writing-mode")) + (t/is (str/includes? markup label))))) + +(t/deftest file-language-shows-its-options-without-a-remove-button + (let [markup (render-language-section "ja" nil)] + (t/is (str/includes? markup "workspace.options.text-options.profile-japanese")) + (t/is (str/includes? markup "workspace.options.text-options.line-adjustment")) + (t/is (not (str/includes? markup "data-testid=\"text-language-remove\""))) + (t/is (not (str/includes? markup "data-testid=\"text-language-add\""))))) + +(t/deftest mixed-languages-hide-the-language-options + (let [markup (render-language-section :multiple)] + (t/is (str/includes? markup "settings.multiple")) + (t/is (str/includes? markup "data-testid=\"text-language-remove\"")) + (t/is (not (str/includes? markup "data-testid=\"text-language-add\""))) + (t/is (not (str/includes? markup "horizontal-tb-writing-mode"))))) (t/deftest korean-word-wrapping-is-distinct-from-the-saved-experiment (t/is (= ["syllable" "keep-words"] (mapv :id (tll/korean-wrap-options nil identity)))) (t/is (= ["syllable" "keep-words" "spaces-only"] - (mapv :id (tll/korean-wrap-options "spaces-only" identity)))) - (t/is (= "workspace.options.text-options.profile-chinese-taiwan" - (:label (select/get-option (tll/profile-options :chinese "zh-Hant-TW" identity) "zh-Hant-TW")))) - (t/is (= {:text-language "zh-Hant-CN"} (tll/profile-change "zh-Hant-CN")))) + (mapv :id (tll/korean-wrap-options "spaces-only" identity))))) -(t/deftest language-specific-controls-require-an-explicit-matching-profile +(t/deftest language-controls-follow-the-language (with-redefs [hooks/use-portal-container (fn ([] nil) ([_] nil))] - (doseq [[language attr label] [[:japanese "ja" "workspace.options.text-options.line-adjustment"] - [:korean "ko" "workspace.options.text-options.korean-wrap"]] - profile [nil :multiple "zh-Hans" attr]] + (doseq [[language label] [[:japanese "workspace.options.text-options.line-adjustment"] + [:korean "workspace.options.text-options.korean-wrap"]] + shown [:japanese :chinese :korean]] (let [changes (atom []) markup (rds/renderToStaticMarkup (mf/element tll/language-layout-options* - #js {:language language - :values {:text-language profile} + #js {:language shown + :values {} :on-change #(swap! changes conj %)}))] - (t/is (= (= profile attr) (str/includes? markup label))) + (t/is (= (= shown language) (str/includes? markup label))) (t/is (str/includes? markup "horizontal-tb-writing-mode")) - (t/is (empty? @changes) "Showing controls must not assign a profile"))))) + (t/is (empty? @changes) "Showing controls must not edit text"))))) - -(t/deftest chinese-decoration-controls-require-a-matching-profile +(t/deftest chinese-decoration-controls-only-show-for-chinese (with-redefs [hooks/use-portal-container (fn ([] nil) ([_] nil))] - (doseq [profile [nil :multiple "ja" "ko" "zh-Hans-CN" "zh-Hant-TW"]] - (let [changes (atom []) - markup (rds/renderToStaticMarkup + (doseq [language [:japanese :chinese :korean]] + (let [markup (rds/renderToStaticMarkup (mf/element tll/language-layout-options* - (mf/props {:language :chinese - :values {:text-language profile} - :on-change #(swap! changes conj %)})))] - (doseq [label ["workspace.options.text-options.text-emphasis" - "workspace.options.text-options.emphasis-position" + (mf/props {:language language + :values {} + :on-change identity})))] + (doseq [label ["workspace.options.text-options.emphasis-position" "workspace.options.text-options.underline-position"]] - (t/is (= (tl/profile-matches? :chinese profile) (str/includes? markup label)))) - (t/is (empty? @changes)))))) + (t/is (= (= :chinese language) (str/includes? markup label)))))))) -(t/deftest pinyin-reading-editor-requires-a-chinese-profile-and-text-selection +(t/deftest pinyin-reading-editor-requires-a-text-selection (with-redefs [hooks/use-portal-container (fn ([] nil) ([_] nil))] - (doseq [profile [nil :multiple "ja" "ko" "zh-Hans" "zh-Hant-TW"] - selected [false true]] + (doseq [selected [false true]] (let [changes (atom []) markup (rds/renderToStaticMarkup (mf/element tll/language-layout-options* (mf/props {:language :chinese - :values {:text-language profile :ruby "nǐ hǎo"} + :values {:text-language "zh-Hant-TW" :ruby "nǐ hǎo"} :ruby-values {:ruby-hidden true} :text-selection-active selected :reading-selection-active selected :on-change #(swap! changes conj %) - :on-ruby-presentation-change #(swap! changes conj %)}))) - chinese (tl/profile-matches? :chinese profile)] - (t/is (= (and chinese selected) + :on-ruby-presentation-change #(swap! changes conj %)})))] + (t/is (= selected (str/includes? markup "workspace.options.text-options.pinyin-placeholder"))) - (t/is (= chinese - (str/includes? markup "workspace.options.text-options.pinyin-advanced-options"))) + (t/is (str/includes? markup "workspace.options.text-options.pinyin-advanced-options")) (t/is (not (str/includes? markup "workspace.options.text-options.ruby-placeholder"))) (t/is (empty? @changes) "Showing Pinyin controls must not edit text"))))) @@ -368,7 +407,8 @@ (t/is (= (:ruby-size cjk/enum-values) (ids (tjl/ruby-size-options identity)))) (t/is (= (:ruby-align cjk/enum-values) (ids (tjl/ruby-align-options identity)))) (t/is (= (:ruby-overhang cjk/enum-values) (ids (tjl/ruby-overhang-options identity)))) - (t/is (= (:ruby-side cjk/enum-values) (ids (tjl/ruby-side-options identity)))) + (t/is (= (:ruby-side cjk/enum-values) (ids (tjl/position-options :ruby-side false)))) + (t/is (= (:ruby-side cjk/enum-values) (ids (tjl/position-options :ruby-side true)))) (t/is (= (:line-adjustment cjk/enum-values) (ids (tjl/line-adjustment-options identity)))) (t/is (every? #(cjk/valid-enum-value? :text-combine-upright %) (values (tjl/text-combine-upright-options true identity)))) @@ -381,6 +421,7 @@ [{:type "paragraph-set" :children [{:type "paragraph" + :text-language "ja" :writing-mode "vertical-rl" :text-orientation "upright" :children [{:text "縦"}]}]}]}} @@ -390,6 +431,37 @@ (with-redefs [wm/vertical-layout-active? (constantly true)] (t/is (str/includes? (markup) "writing-mode:vertical-rl"))))) +(t/deftest vertical-writing-only-renders-under-a-cjk-language + (let [shape {:id (random-uuid) :width 100 :height 100 + :content {:type "root" + :children + [{:type "paragraph-set" + :children + [{:type "paragraph" + :writing-mode "vertical-rl" + :children [{:text "縦"}]}]}]}} + markup #(rds/renderToStaticMarkup (mf/element html-text/text-shape* #js {:shape shape}))] + (with-redefs [wm/vertical-layout-active? (constantly true) + wm/get-file-language (constantly nil)] + (t/is (not (str/includes? (markup) "vertical-rl")) + "a text without language renders horizontal")) + (with-redefs [wm/vertical-layout-active? (constantly true) + wm/get-file-language (constantly "zh-Hant-TW")] + (t/is (str/includes? (markup) "writing-mode:vertical-rl") + "a text without language takes the file language")))) + +(t/deftest vertical-controls-need-a-cjk-language + (with-redefs [wm/vertical-layout-active? (constantly true) + wm/get-file-language (constantly nil)] + (t/is (true? (wm/vertical-values? {:writing-mode "vertical-rl" :text-language "ko"}))) + (t/is (true? (wm/vertical-values? {:writing-mode "vertical-rl" :text-language :multiple}))) + (t/is (false? (wm/vertical-values? {:writing-mode "vertical-rl"}))) + (t/is (false? (wm/vertical-values? {:writing-mode "vertical-rl" :text-language "en"}))) + (t/is (false? (wm/vertical-values? {:writing-mode "horizontal-tb" :text-language "ja"})))) + (with-redefs [wm/vertical-layout-active? (constantly true) + wm/get-file-language (constantly "ja")] + (t/is (true? (wm/vertical-values? {:writing-mode "vertical-rl"}))))) + (defn- alignment-markup ([component writing-mode wasm-enabled] (alignment-markup component writing-mode wasm-enabled {})) @@ -398,7 +470,7 @@ (with-redefs [wm/vertical-layout-active? (constantly wasm-enabled) hooks/use-portal-container (fn ([] nil) ([_] nil))] (rds/renderToStaticMarkup - (mf/element component #js {:values (assoc values :writing-mode writing-mode) + (mf/element component #js {:values (merge {:text-language "ja"} values {:writing-mode writing-mode}) :on-change identity}))))) (defn- alignment-labels @@ -529,3 +601,45 @@ (t/is (str/includes? html "ㄧ")) (t/is (str/includes? html "ˇ")) (t/is (not (str/includes? html " Language." + +#: src/app/main/ui/settings/options.cljs +msgid "dashboard.text-language.none" +msgstr "None" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs +msgid "workspace.options.text-options.position-over" +msgstr "Above" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index ab15524501..6b6ebe5769 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -8411,9 +8411,6 @@ msgstr "Subrayado (%s)" msgid "workspace.options.text-options.zhuyin" msgstr "Zhuyin" -msgid "workspace.options.text-options.zhuyin-hidden" -msgstr "Ocultar zhuyin" - msgid "workspace.options.text-options.zhuyin-invalid" msgstr "" "Escribe entre 1 y 3 símbolos zhuyin con un solo tono como máximo. Coloca el " @@ -9968,66 +9965,10 @@ msgstr "Este cliente puede leer y modificar diseños mediante conexiones MCP est msgid "mcp.oauth.cancelled" msgstr "Conexión cancelada" -#: src/app/main/ui/settings/options.cljs -msgid "dashboard.text-layout.title" -msgstr "Disposición del texto por idioma" - -#: src/app/main/ui/settings/options.cljs -msgid "dashboard.text-layout.description" -msgstr "Elige qué controles de disposición del texto están disponibles en todos los archivos." - -#: src/app/main/ui/settings/options.cljs -msgid "dashboard.chinese-layout.enable-all-files" -msgstr "Activar la disposición del texto chino en todos los archivos" - -#: src/app/main/ui/settings/options.cljs -msgid "dashboard.korean-layout.enable-all-files" -msgstr "Activar la disposición del texto coreano en todos los archivos" - -#: src/app/main/ui/workspace/main_menu.cljs -msgid "workspace.header.menu.enable-chinese-layout" -msgstr "Activar la disposición del texto chino" - -#: src/app/main/ui/workspace/main_menu.cljs -msgid "workspace.header.menu.disable-chinese-layout" -msgstr "Desactivar la disposición del texto chino" - -#: src/app/main/ui/workspace/main_menu.cljs -msgid "workspace.header.menu.enable-korean-layout" -msgstr "Activar la disposición del texto coreano" - -#: src/app/main/ui/workspace/main_menu.cljs -msgid "workspace.header.menu.disable-korean-layout" -msgstr "Desactivar la disposición del texto coreano" - -#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs -msgid "workspace.options.text-options.chinese-layout" -msgstr "Disposición del texto chino" - -#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs -msgid "workspace.options.text-options.korean-layout" -msgstr "Disposición del texto coreano" - -#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs -msgid "workspace.options.text-options.language-profile" -msgstr "Perfil de idioma" - -#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs -msgid "workspace.options.text-options.profile-plain" -msgstr "Sin perfil de idioma" - #: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs msgid "workspace.options.text-options.profile-japanese" msgstr "Japonés" -#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs -msgid "workspace.options.text-options.profile-chinese-simplified" -msgstr "Chino simplificado" - -#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs -msgid "workspace.options.text-options.profile-chinese-traditional" -msgstr "Chino tradicional" - #: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs msgid "workspace.options.text-options.profile-korean" msgstr "Coreano" @@ -10046,11 +9987,11 @@ msgstr "En los espacios (experimental)" #: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs msgid "workspace.options.text-options.profile-chinese-mainland-simplified" -msgstr "China continental · Simplificado" +msgstr "China continental (simplificado)" #: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs msgid "workspace.options.text-options.profile-chinese-mainland-traditional" -msgstr "China continental · Tradicional" +msgstr "China continental (tradicional)" #: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs msgid "workspace.options.text-options.profile-chinese-taiwan" @@ -10076,26 +10017,6 @@ msgstr "nǐ hǎo" msgid "workspace.options.text-options.pinyin-advanced-options" msgstr "Opciones de pinyin" -#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs -msgid "workspace.options.text-options.pinyin-hidden" -msgstr "Ocultar pinyin" - -#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs -msgid "workspace.options.text-options.pinyin-size" -msgstr "Tamaño del pinyin" - -#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs -msgid "workspace.options.text-options.pinyin-align" -msgstr "Alineación del pinyin" - -#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs -msgid "workspace.options.text-options.pinyin-overhang" -msgstr "Saliente del pinyin" - -#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs -msgid "workspace.options.text-options.pinyin-side" -msgstr "Lado del pinyin" - #: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs msgid "workspace.options.text-options.pinyin-selection-hint" msgstr "Selecciona caracteres con un mismo estilo para añadir una lectura. Selecciona toda la base para editar su lectura." @@ -10112,30 +10033,94 @@ msgstr "Posición del subrayado" msgid "workspace.options.text-options.position-auto" msgstr "Automática" -#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs -msgid "workspace.options.text-options.position-over-right" -msgstr "Arriba / derecha" - -#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs -msgid "workspace.options.text-options.position-over-left" -msgstr "Arriba / izquierda" - -#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs -msgid "workspace.options.text-options.position-under-right" -msgstr "Abajo / derecha" - -#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs -msgid "workspace.options.text-options.position-under-left" -msgstr "Abajo / izquierda" - #: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs msgid "workspace.options.text-options.position-under" -msgstr "Debajo del texto" +msgstr "Debajo" #: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs msgid "workspace.options.text-options.position-left" -msgstr "Izquierda en vertical" +msgstr "Izquierda" #: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs msgid "workspace.options.text-options.position-right" -msgstr "Derecha en vertical" +msgstr "Derecha" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.language" +msgstr "Idioma" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.add-language" +msgstr "Añadir idioma" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.remove-language" +msgstr "Quitar idioma" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.language-group-japanese" +msgstr "Japonés" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.language-group-korean" +msgstr "Coreano" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.language-group-chinese" +msgstr "Chino" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.language-chinese-variant" +msgstr "Chino · %s" + +#: src/app/main/ui/workspace/main_menu.cljs +msgid "workspace.header.menu.option.language" +msgstr "Idioma" + +#: src/app/main/ui/workspace/main_menu.cljs +msgid "workspace.header.menu.language-none" +msgstr "Ninguno" + +#: src/app/main/ui/settings/options.cljs +msgid "dashboard.text-language.title" +msgstr "Idioma de los textos" + +#: src/app/main/ui/settings/options.cljs +msgid "dashboard.text-language.description" +msgstr "Los archivos nuevos empiezan con este idioma para sus textos. Cada archivo puede cambiarlo en Archivo > Idioma." + +#: src/app/main/ui/settings/options.cljs +msgid "dashboard.text-language.none" +msgstr "Ninguno" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs +msgid "workspace.options.text-options.ruby" +msgstr "Furigana" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs +msgid "workspace.options.text-options.ruby-advanced-options" +msgstr "Opciones de furigana" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs +msgid "workspace.options.text-options.ruby-hidden" +msgstr "Ocultar" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs +msgid "workspace.options.text-options.ruby-size" +msgstr "Tamaño" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs +msgid "workspace.options.text-options.ruby-align" +msgstr "Alineación" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs +msgid "workspace.options.text-options.ruby-overhang" +msgstr "Saliente" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs +msgid "workspace.options.text-options.ruby-side" +msgstr "Posición" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs +msgid "workspace.options.text-options.position-over" +msgstr "Encima" diff --git a/frontend/translations/jpn_JP.po b/frontend/translations/jpn_JP.po index be284bdf44..ee3ab43961 100644 --- a/frontend/translations/jpn_JP.po +++ b/frontend/translations/jpn_JP.po @@ -777,18 +777,6 @@ msgstr "メンバーを招待" msgid "dashboard.invite-profile-disabled" msgstr "このチームにユーザーを招待する権限がありません" -#: src/app/main/ui/settings/options.cljs -msgid "dashboard.japanese-layout.description" -msgstr "すべてのファイルで日本語組版コントロールを利用できるようにします。" - -#: src/app/main/ui/settings/options.cljs -msgid "dashboard.japanese-layout.enable-all-files" -msgstr "すべてのファイルで日本語組版を有効にする" - -#: src/app/main/ui/settings/options.cljs -msgid "dashboard.japanese-layout.title" -msgstr "日本語組版" - #: src/app/main/ui/dashboard/organization_team_switch_menus.cljs:235, src/app/main/ui/dashboard/organization_team_switch_menus.cljs:266 msgid "dashboard.leave-organization" msgstr "組織から退出" @@ -6172,10 +6160,6 @@ msgstr "放射状グラデーション" msgid "workspace.header.menu.disable-dynamic-alignment" msgstr "動的整列を無効化" -#: src/app/main/ui/workspace/main_menu.cljs -msgid "workspace.header.menu.disable-japanese-layout" -msgstr "日本語組版を無効にする" - #: src/app/main/ui/workspace/main_menu.cljs:254 msgid "workspace.header.menu.disable-scale-content" msgstr "比率を保った拡大縮小を無効化" @@ -6200,10 +6184,6 @@ msgstr "WebGL レンダリングを無効化(ベータ)" msgid "workspace.header.menu.enable-dynamic-alignment" msgstr "動的整列を有効化" -#: src/app/main/ui/workspace/main_menu.cljs -msgid "workspace.header.menu.enable-japanese-layout" -msgstr "日本語組版を有効にする" - #: src/app/main/ui/workspace/main_menu.cljs:255 msgid "workspace.header.menu.enable-scale-content" msgstr "比率を保った拡大縮小を有効化" @@ -7764,10 +7744,6 @@ msgstr "幅の自動調整" msgid "workspace.options.text-options.grow-fixed" msgstr "固定" -#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs -msgid "workspace.options.text-options.japanese-layout" -msgstr "日本語組版" - #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:688 msgid "workspace.options.text-options.letter-spacing" msgstr "文字間隔" @@ -7778,7 +7754,7 @@ msgstr "行の高さ" #: src/app/main/ui/workspace/sidebar/options/menus/text.cljs msgid "workspace.options.text-options.ruby" -msgstr "ルビ" +msgstr "ふりがな" #: src/app/main/ui/workspace/sidebar/options/menus/text.cljs msgid "workspace.options.text-options.ruby-advanced-options" @@ -7786,7 +7762,7 @@ msgstr "ふりがなの詳細設定" #: src/app/main/ui/workspace/sidebar/options/menus/text.cljs msgid "workspace.options.text-options.ruby-align" -msgstr "ルビの配置" +msgstr "配置" #: src/app/main/ui/workspace/sidebar/options/menus/text.cljs msgid "workspace.options.text-options.ruby-align-center" @@ -7806,11 +7782,11 @@ msgstr "先頭" #: src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs msgid "workspace.options.text-options.ruby-hidden" -msgstr "ふりがなを非表示" +msgstr "非表示" #: src/app/main/ui/workspace/sidebar/options/menus/text.cljs msgid "workspace.options.text-options.ruby-overhang" -msgstr "ルビのはみ出し" +msgstr "はみ出し" #: src/app/main/ui/workspace/sidebar/options/menus/text.cljs msgid "workspace.options.text-options.ruby-overhang-auto" @@ -7826,19 +7802,11 @@ msgstr "ふりがな" #: src/app/main/ui/workspace/sidebar/options/menus/text.cljs msgid "workspace.options.text-options.ruby-side" -msgstr "ルビの側" - -#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs -msgid "workspace.options.text-options.ruby-side-over" -msgstr "上/右" - -#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs -msgid "workspace.options.text-options.ruby-side-under" -msgstr "下/左" +msgstr "位置" #: src/app/main/ui/workspace/sidebar/options/menus/text.cljs msgid "workspace.options.text-options.ruby-size" -msgstr "ルビの大きさ" +msgstr "大きさ" #: src/app/main/ui/workspace/sidebar/options/menus/text.cljs msgid "workspace.options.text-options.ruby-size-half" diff --git a/render-wasm/src/shapes/text_horizontal/tests.rs b/render-wasm/src/shapes/text_horizontal/tests.rs index 4f7e8d232c..7f9f5463b7 100644 --- a/render-wasm/src/shapes/text_horizontal/tests.rs +++ b/render-wasm/src/shapes/text_horizontal/tests.rs @@ -2424,3 +2424,47 @@ fn spread_lines_start_and_end_at_the_box_edges() { first.rect.left ); } + +/// Japanese text distributes like the other languages (均等割り付け). +#[test] +fn japanese_distribution_reaches_the_box_edges() { + use skia::textlayout::{RectHeightStyle, RectWidthStyle}; + init_state(); + let mut resources = + crate::render::RenderResources::try_new_headless().expect("headless resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + for text in ["日本", "にほんご", "東京都"] { + let mut paragraph = Paragraph::new( + TextAlign::Distribute, + TextDirection::LTR, + None, + None, + 1.0, + 0.0, + vec![TextSpan { + fills: vec![crate::shapes::Fill::Solid(crate::shapes::SolidColor( + skia::Color::BLACK, + ))], + ..make_span(text, 0.0) + }], + ); + paragraph.set_language("ja".to_string()); + let bounds = crate::math::Rect::from_xywh(0.0, 0.0, 200.0, 200.0); + let mut content = TextContent::new(bounds, crate::shapes::GrowType::Fixed); + content.add_paragraph(paragraph); + content.update_layout(bounds); + let layout = &content.layout.paragraphs[0][0]; + let line = &layout.get_line_metrics()[0]; + let last = layout.get_rects_for_range( + line.end_excluding_whitespaces - 1..line.end_excluding_whitespaces, + RectHeightStyle::Tight, + RectWidthStyle::Tight, + )[0] + .rect; + assert!( + (last.right - 200.0).abs() < 0.1, + "{text}: ends at {}", + last.right + ); + } +}