From 5d647b2cd527b9b41970dfcb2ef6a269376ebfc0 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Thu, 5 Nov 2026 13:19:01 +0100 Subject: [PATCH] :sparkles: Replace the CJK layout toggles with a text Language panel The CJK text options were behind per-language "Enable layout" toggles in the file menu and the settings, each with its own sidebar section. A text now has one language, picked in a single Language panel, and the options of that language show below it. - Rename the `vertical-text-layout` flag and feature to `cjk-support`. - Remove the per-file and per-profile layout toggles, the file options that stored them and the `:set-text-layout` change. - Add the Language panel: a grouped dropdown of Japanese, Korean and the regional Chinese variants, a row to change the language and a button to remove it, "Mixed" when the selected texts differ. Picking a language writes it to every paragraph of the texts. - Render the CJK attrs only under the language that uses them, so a text without language is horizontal and plain. The stored content keeps every attr. - Remember the last picked language for the next new texts. - Add File > Language: the language of the texts without one of their own, applied at render time and laid out again when it changes. - Add the profile text language in Settings > Options: new files start with it as their file language. - Extract the grouped dropdown of the board size presets into a shared component used by the presets and the Language panel. - Name the Japanese readings furigana, drop the redundant prefixes of the furigana and pinyin options, move Distribute below the writing mode row and show only the position sides of the writing mode. AI-assisted-by: claude-opus-5-5 --- backend/src/app/rpc/commands/files_create.clj | 15 +- backend/src/app/rpc/commands/profile.clj | 6 +- backend/test/backend_tests/rpc_file_test.clj | 37 +++ .../test/backend_tests/rpc_profile_test.clj | 38 --- common/src/app/common/features.cljc | 6 +- common/src/app/common/files/changes.cljc | 22 +- .../src/app/common/files/changes_builder.cljc | 26 +- common/src/app/common/flags.cljc | 2 +- common/src/app/common/types/file.cljc | 42 +-- .../src/app/common/types/text/cjk_layout.cljc | 83 ++++++ .../test/common_tests/files_changes_test.cljc | 43 +-- common/test/common_tests/types/text_test.cljc | 76 +++++ frontend/src/app/main/data/changes.cljs | 19 +- frontend/src/app/main/data/workspace.cljs | 31 +- .../src/app/main/data/workspace/texts.cljs | 11 + .../app/main/data/workspace/wasm_text.cljs | 24 ++ frontend/src/app/main/refs.cljs | 54 ++-- .../main/ui/components/grouped_dropdown.cljs | 84 ++++++ .../main/ui/components/grouped_dropdown.scss | 93 ++++++ .../src/app/main/ui/settings/options.cljs | 78 ++--- .../src/app/main/ui/settings/options.scss | 11 +- .../src/app/main/ui/workspace/main_menu.cljs | 135 +++++---- .../src/app/main/ui/workspace/main_menu.scss | 36 +++ .../ui/workspace/shapes/text/v3_editor.cljs | 4 +- .../sidebar/options/menus/measures.cljs | 78 +++-- .../sidebar/options/menus/measures.scss | 84 ------ .../workspace/sidebar/options/menus/text.cljs | 52 +--- .../options/menus/text_japanese_layout.cljs | 157 +++++++++-- .../options/menus/text_language_layout.cljs | 243 +++++++++------- .../options/menus/text_language_layout.scss | 78 ++++- frontend/src/app/render_wasm/api.cljs | 38 ++- frontend/src/app/render_wasm/text_editor.cljs | 8 +- frontend/src/app/util/text/content.cljs | 8 +- frontend/src/app/util/text/geometry.cljs | 10 +- frontend/src/app/util/text/layout.cljs | 80 ++++-- frontend/src/app/util/text/writing_mode.cljs | 51 +++- .../frontend_tests/code_gen_style_test.cljs | 8 +- .../data/workspace_texts_test.cljs | 85 +++--- .../render_wasm/texts_test.cljs | 4 +- frontend/test/frontend_tests/runner.cljs | 4 + .../ui/grouped_dropdown_test.cljs | 66 +++++ .../ui/settings_text_language_test.cljs | 25 ++ .../frontend_tests/ui/text_options_test.cljs | 266 +++++++++++++----- frontend/translations/en.po | 201 +++++-------- frontend/translations/es.po | 185 ++++++------ frontend/translations/jpn_JP.po | 44 +-- .../src/shapes/text_horizontal/tests.rs | 44 +++ 47 files changed, 1790 insertions(+), 1005 deletions(-) create mode 100644 frontend/src/app/main/ui/components/grouped_dropdown.cljs create mode 100644 frontend/src/app/main/ui/components/grouped_dropdown.scss create mode 100644 frontend/test/frontend_tests/ui/grouped_dropdown_test.cljs create mode 100644 frontend/test/frontend_tests/ui/settings_text_language_test.cljs 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 + ); + } +}