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 "