From f39eb85990d5e0c109fa38e1e69f97e3a271660e Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 7 Oct 2026 17:12:14 +0200 Subject: [PATCH] :sparkles: Add Japanese layout controls and opt-in settings Enable Japanese layout per file through undoable changes, or for all files through a saved profile preference. Show the controls when the WASM renderer and either setting are active. Add writing mode, orientation, ruby, emphasis, warichu and line fitting controls with icons and English and Japanese translations. Cover the file change, profile preference and control behavior. --- backend/src/app/rpc/commands/profile.clj | 1 + .../test/backend_tests/rpc_profile_test.clj | 15 + common/src/app/common/files/changes.cljc | 11 +- .../src/app/common/files/changes_builder.cljc | 12 + common/src/app/common/types/file.cljc | 16 +- .../test/common_tests/files_changes_test.cljc | 20 + .../images/icons/text-combine-upright-all.svg | 3 + .../icons/text-combine-upright-digits.svg | 3 + .../icons/text-combine-upright-none.svg | 3 + .../images/icons/text-orientation-mixed.svg | 3 + .../images/icons/text-orientation-upright.svg | 3 + .../resources/images/icons/warichu-none.svg | 3 + frontend/resources/images/icons/warichu.svg | 3 + .../images/icons/writing-mode-horizontal.svg | 3 + .../images/icons/writing-mode-vertical.svg | 3 + frontend/src/app/main/data/workspace.cljs | 11 + frontend/src/app/main/refs.cljs | 9 + .../main/ui/ds/foundations/assets/icon.cljs | 9 + frontend/src/app/main/ui/icons.cljs | 9 + .../src/app/main/ui/settings/options.cljs | 28 +- .../src/app/main/ui/settings/options.scss | 10 +- .../src/app/main/ui/workspace/main_menu.cljs | 36 +- .../ui/workspace/sidebar/options/common.cljs | 15 + .../workspace/sidebar/options/menus/text.cljs | 283 ++++++---- .../options/menus/text_japanese_layout.cljs | 523 ++++++++++++++++++ .../options/menus/text_japanese_layout.scss | 78 +++ .../sidebar/options/shapes/text.cljs | 48 +- frontend/test/frontend_tests/runner.cljs | 2 + .../frontend_tests/ui/text_options_test.cljs | 248 +++++++++ frontend/translations/en.po | 228 ++++++++ frontend/translations/jpn_JP.po | 228 ++++++++ 31 files changed, 1754 insertions(+), 113 deletions(-) create mode 100644 frontend/resources/images/icons/text-combine-upright-all.svg create mode 100644 frontend/resources/images/icons/text-combine-upright-digits.svg create mode 100644 frontend/resources/images/icons/text-combine-upright-none.svg create mode 100644 frontend/resources/images/icons/text-orientation-mixed.svg create mode 100644 frontend/resources/images/icons/text-orientation-upright.svg create mode 100644 frontend/resources/images/icons/warichu-none.svg create mode 100644 frontend/resources/images/icons/warichu.svg create mode 100644 frontend/resources/images/icons/writing-mode-horizontal.svg create mode 100644 frontend/resources/images/icons/writing-mode-vertical.svg create mode 100644 frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs create mode 100644 frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.scss create mode 100644 frontend/test/frontend_tests/ui/text_options_test.cljs diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index cefd382b72..0ab7d9ae75 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -62,6 +62,7 @@ [: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] [: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_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj index b0cda30dd8..7475e389e2 100644 --- a/backend/test/backend_tests/rpc_profile_test.clj +++ b/backend/test/backend_tests/rpc_profile_test.clj @@ -164,6 +164,21 @@ (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 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/files/changes.cljc b/common/src/app/common/files/changes.cljc index ce45edd485..baffa8d91d 100644 --- a/common/src/app/common/files/changes.cljc +++ b/common/src/app/common/files/changes.cljc @@ -438,7 +438,12 @@ [:map {:title "SetTokensSource"} [:type [:= :set-tokens-source]] [:file-id ::sm/uuid] - [:library-id [:maybe ::sm/uuid]]]]]) + [:library-id [:maybe ::sm/uuid]]]] + + [:set-japanese-layout + [:map {:title "SetJapaneseLayout"} + [:type [:= :set-japanese-layout]] + [:enabled ::sm/boolean]]]]) (def schema:changes [:sequential {:gen/max 5 :gen/min 1} schema:change]) @@ -1092,6 +1097,10 @@ [data {:keys [library-id]}] (cfo/set-tokens-source data library-id)) +(defmethod process-change :set-japanese-layout + [data {:keys [enabled]}] + (ctf/set-japanese-layout-enabled data 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 3317d91653..d948196886 100644 --- a/common/src/app/common/files/changes_builder.cljc +++ b/common/src/app/common/files/changes_builder.cljc @@ -1150,6 +1150,18 @@ :library-id prev-val}) (apply-changes-local)))) +(defn set-japanese-layout + [changes enabled?] + (assert-file-data! changes) + (let [file-data (::file-data (meta changes)) + previous-enabled (ctf/japanese-layout-enabled? file-data)] + (-> changes + (update :redo-changes conj {:type :set-japanese-layout + :enabled enabled?}) + (update :undo-changes conj {:type :set-japanese-layout + :enabled previous-enabled}) + (apply-changes-local)))) + ;; Misc changes (defn reorder-children diff --git a/common/src/app/common/types/file.cljc b/common/src/app/common/types/file.cljc index df4733c25a..6d4914ee06 100644 --- a/common/src/app/common/types/file.cljc +++ b/common/src/app/common/types/file.cljc @@ -77,7 +77,8 @@ (def schema:options [:map {:title "FileOptions"} [:components-v2 {:optional true} ::sm/boolean] - [:base-font-size {:optional true} :string]]) + [:base-font-size {:optional true} :string] + [:japanese-layout {:optional true} ::sm/boolean]]) (def schema:data [:map {:title "FileData"} @@ -1255,6 +1256,19 @@ (assoc-in file-data [:options :base-font-size] base-font-size)) +;; Japanese text layout + +(defn japanese-layout-enabled? + [file-data] + (true? (get-in file-data [:options :japanese-layout]))) + +(defn set-japanese-layout-enabled + [file-data enabled?] + (if enabled? + (assoc-in file-data [:options :japanese-layout] true) + (d/update-in-when file-data [:options] dissoc :japanese-layout))) + + ;; Ref Chains (defn get-ref-chain-until-target-ref "Returns a vector with the shape ref chain until target-ref, including itself" diff --git a/common/test/common_tests/files_changes_test.cljc b/common/test/common_tests/files_changes_test.cljc index 935ff092fb..0d6dbc86f6 100644 --- a/common/test/common_tests/files_changes_test.cljc +++ b/common/test/common_tests/files_changes_test.cljc @@ -251,6 +251,26 @@ "The parent must use the updated bounds of both children") (assert-idempotent data changes)))))) +(t/deftest set-japanese-layout-file-option + (let [file-id (uuid/custom 2 2) + page-id (uuid/custom 1 1) + data (make-file-data file-id page-id) + enabled (ch/process-changes data [{:type :set-japanese-layout + :enabled true}]) + disabled (ch/process-changes enabled [{:type :set-japanese-layout + :enabled false}]) + changes (-> (pcb/empty-changes) + (pcb/with-file-data data) + (pcb/set-japanese-layout true))] + (t/is (false? (ctf/japanese-layout-enabled? data))) + (t/is (true? (ctf/japanese-layout-enabled? enabled))) + (t/is (false? (ctf/japanese-layout-enabled? disabled))) + (t/is (not (contains? (:options disabled) :japanese-layout))) + (t/is (= [{:type :set-japanese-layout :enabled true}] + (:redo-changes changes))) + (t/is (= '({:type :set-japanese-layout :enabled false}) + (:undo-changes changes))))) + (t/deftest add-obj (let [file-id (uuid/custom 2 2) page-id (uuid/custom 1 1) diff --git a/frontend/resources/images/icons/text-combine-upright-all.svg b/frontend/resources/images/icons/text-combine-upright-all.svg new file mode 100644 index 0000000000..594b7f49fd --- /dev/null +++ b/frontend/resources/images/icons/text-combine-upright-all.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/text-combine-upright-digits.svg b/frontend/resources/images/icons/text-combine-upright-digits.svg new file mode 100644 index 0000000000..3e367a3fd3 --- /dev/null +++ b/frontend/resources/images/icons/text-combine-upright-digits.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/text-combine-upright-none.svg b/frontend/resources/images/icons/text-combine-upright-none.svg new file mode 100644 index 0000000000..f54cf6f1e3 --- /dev/null +++ b/frontend/resources/images/icons/text-combine-upright-none.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/text-orientation-mixed.svg b/frontend/resources/images/icons/text-orientation-mixed.svg new file mode 100644 index 0000000000..72cd592478 --- /dev/null +++ b/frontend/resources/images/icons/text-orientation-mixed.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/text-orientation-upright.svg b/frontend/resources/images/icons/text-orientation-upright.svg new file mode 100644 index 0000000000..67d241b2bb --- /dev/null +++ b/frontend/resources/images/icons/text-orientation-upright.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/warichu-none.svg b/frontend/resources/images/icons/warichu-none.svg new file mode 100644 index 0000000000..7ede0d3d0a --- /dev/null +++ b/frontend/resources/images/icons/warichu-none.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/warichu.svg b/frontend/resources/images/icons/warichu.svg new file mode 100644 index 0000000000..3348550253 --- /dev/null +++ b/frontend/resources/images/icons/warichu.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/writing-mode-horizontal.svg b/frontend/resources/images/icons/writing-mode-horizontal.svg new file mode 100644 index 0000000000..204e9190e7 --- /dev/null +++ b/frontend/resources/images/icons/writing-mode-horizontal.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/writing-mode-vertical.svg b/frontend/resources/images/icons/writing-mode-vertical.svg new file mode 100644 index 0000000000..e89a00a562 --- /dev/null +++ b/frontend/resources/images/icons/writing-mode-vertical.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index c8e538560f..ccd7c1ecae 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -679,6 +679,17 @@ (dm/export layout/toggle-layout-flag) (dm/export layout/remove-layout-flag) +(defn set-japanese-layout + [enabled?] + (ptk/reify ::set-japanese-layout + 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-japanese-layout enabled?))] + (rx/of (dch/commit-changes changes)))))) + ;; --- Profile (defn update-nudge diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index 84f6bd72f2..ddf143cae0 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -11,6 +11,7 @@ [app.common.data.macros :as dm] [app.common.files.helpers :as cph] [app.common.files.tokens :as cfo] + [app.common.types.file :as ctf] [app.common.types.shape-tree :as ctt] [app.common.types.shape.layout :as ctl] [app.common.types.tokens-lib :as ctob] @@ -289,6 +290,14 @@ "Currently working file data on workspace" (l/derived dsh/lookup-file-data st/state)) +(def japanese-layout-config + "Japanese layout switches: `:file?` for the workspace file and + `:all-files?` from the profile." + (l/derived (fn [state] + {:file? (ctf/japanese-layout-enabled? (dsh/lookup-file-data state)) + :all-files? (true? (dm/get-in state [:profile :props :japanese-layout-all-files]))}) + st/state =)) + (def workspace-file-colors (l/derived (fn [{:keys [id] :as data}] (some-> (:colors data) (update-vals #(assoc % :file-id id)))) diff --git a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs index 991532c963..1278e80fd3 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs +++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs @@ -276,6 +276,9 @@ (def ^:icon-id text-auto-height "text-auto-height") (def ^:icon-id text-auto-width "text-auto-width") (def ^:icon-id text-bottom "text-bottom") +(def ^:icon-id text-combine-upright-all "text-combine-upright-all") +(def ^:icon-id text-combine-upright-digits "text-combine-upright-digits") +(def ^:icon-id text-combine-upright-none "text-combine-upright-none") (def ^:icon-id text-fixed "text-fixed") (def ^:icon-id text-font-family "text-font-family") (def ^:icon-id text-font-size "text-font-size") @@ -287,6 +290,8 @@ (def ^:icon-id text-ltr "text-ltr") (def ^:icon-id text-middle "text-middle") (def ^:icon-id text-mixed "text-mixed") +(def ^:icon-id text-orientation-mixed "text-orientation-mixed") +(def ^:icon-id text-orientation-upright "text-orientation-upright") (def ^:icon-id text-palette "text-palette") (def ^:icon-id text-paragraph "text-paragraph") (def ^:icon-id text-rtl "text-rtl") @@ -309,7 +314,11 @@ (def ^:icon-id vertical-align-items-start "vertical-align-items-start") (def ^:icon-id view-as-icons "view-as-icons") (def ^:icon-id view-as-list "view-as-list") +(def ^:icon-id warichu "warichu") +(def ^:icon-id warichu-none "warichu-none") (def ^:icon-id wrap "wrap") +(def ^:icon-id writing-mode-horizontal "writing-mode-horizontal") +(def ^:icon-id writing-mode-vertical "writing-mode-vertical") (def icon-list "A collection of all icons" diff --git a/frontend/src/app/main/ui/icons.cljs b/frontend/src/app/main/ui/icons.cljs index 7dec276e5d..9d285841ad 100644 --- a/frontend/src/app/main/ui/icons.cljs +++ b/frontend/src/app/main/ui/icons.cljs @@ -247,6 +247,9 @@ (def ^:icon text-auto-height (icon-xref :text-auto-height)) (def ^:icon text-auto-width (icon-xref :text-auto-width)) (def ^:icon text-bottom (icon-xref :text-bottom)) +(def ^:icon text-combine-upright-all (icon-xref :text-combine-upright-all)) +(def ^:icon text-combine-upright-digits (icon-xref :text-combine-upright-digits)) +(def ^:icon text-combine-upright-none (icon-xref :text-combine-upright-none)) (def ^:icon text-fixed (icon-xref :text-fixed)) (def ^:icon text-font-family (icon-xref :text-font-family)) (def ^:icon text-font-size (icon-xref :text-font-size)) @@ -258,6 +261,8 @@ (def ^:icon text-ltr (icon-xref :text-ltr)) (def ^:icon text-middle (icon-xref :text-middle)) (def ^:icon text-mixed (icon-xref :text-mixed)) +(def ^:icon text-orientation-mixed (icon-xref :text-orientation-mixed)) +(def ^:icon text-orientation-upright (icon-xref :text-orientation-upright)) (def ^:icon text-palette (icon-xref :text-palette)) (def ^:icon text-paragraph (icon-xref :text-paragraph)) (def ^:icon text-rtl (icon-xref :text-rtl)) @@ -284,7 +289,11 @@ (def ^:icon vertical-align-items-start (icon-xref :vertical-align-items-start)) (def ^:icon view-as-icons (icon-xref :view-as-icons)) (def ^:icon view-as-list (icon-xref :view-as-list)) +(def ^:icon warichu (icon-xref :warichu)) +(def ^:icon warichu-none (icon-xref :warichu-none)) (def ^:icon wrap (icon-xref :wrap)) +(def ^:icon writing-mode-horizontal (icon-xref :writing-mode-horizontal)) +(def ^:icon writing-mode-vertical (icon-xref :writing-mode-vertical)) (def default "A collection of all icons" diff --git a/frontend/src/app/main/ui/settings/options.cljs b/frontend/src/app/main/ui/settings/options.cljs index c4d711ba79..dee7ba1d0a 100644 --- a/frontend/src/app/main/ui/settings/options.cljs +++ b/frontend/src/app/main/ui/settings/options.cljs @@ -115,10 +115,35 @@ :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 japanese-layout-settings* + [{:keys [enabled]}] + (let [handle-change + (mf/use-fn + (fn [enabled?] + (st/emit! (ev/event {::ev/name (if enabled? + "enable-japanese-layout-all-files" + "disable-japanese-layout-all-files") + ::ev/origin "settings"}) + (du/update-profile-props + {:japanese-layout-all-files enabled?}) + (ntf/success (tr "notifications.profile-saved")))))] + [:section {:class (stl/css :japanese-layout-container)} + [:> heading* {:class (stl/css :title) + :level 2 + :typography t/title-large} + (tr "dashboard.japanese-layout.title")] + [:> text* {:class (stl/css :description) :typography t/body-medium} + (tr "dashboard.japanese-layout.description")] + [:form {:class (stl/css :japanese-layout-form)} + [:> switch* {:label (tr "dashboard.japanese-layout.enable-all-files") + :default-checked enabled + :on-change handle-change}]]])) + (mf/defc options-page* [] (let [profile (mf/deref refs/profile) - renderer (or (-> profile :props :renderer) :svg)] + renderer (or (-> profile :props :renderer) :svg) + japanese-layout-enabled (:all-files? (mf/deref refs/japanese-layout-config))] (mf/use-effect #(dom/set-html-title (tr "title.settings.options"))) @@ -132,5 +157,6 @@ :id "options-section-title"} (tr "labels.settings")] [:> options-form*]] + [:> japanese-layout-settings* {:enabled japanese-layout-enabled}] (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 cdae1e185b..af13b89b95 100644 --- a/frontend/src/app/main/ui/settings/options.scss +++ b/frontend/src/app/main/ui/settings/options.scss @@ -21,9 +21,9 @@ } } -/* Copied from profile.scss .form-container, but without included nested - rules, since we want custom styles for it */ -.webgl-container { +/* Settings sections with standalone controls. */ +.webgl-container, +.japanese-layout-container { display: grid; grid-auto-rows: auto; gap: var(--sp-s); @@ -34,6 +34,10 @@ color: var(--color-foreground-primary); } +.japanese-layout-form { + padding-block-start: var(--sp-xs); +} + .webgl-header { display: flex; flex-direction: row; diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index 79d2b80a20..93c5ffd753 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -45,6 +45,7 @@ [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] + [app.util.text.writing-mode :as wm] [beicon.v2.core :as rx] [potok.v2.core :as ptk] [rumext.v2 :as mf])) @@ -233,8 +234,10 @@ (mf/defc preferences-menu* {::mf/private true ::mf/wrap [mf/memo]} - [{:keys [layout profile toggle-flag on-close toggle-theme toggle-render]}] - (let [renderer (or (-> profile :props :renderer) :svg) + [{:keys [japanese-layout-enabled japanese-layout-all-files layout profile toggle-flag + on-close toggle-theme toggle-render toggle-japanese-layout]}] + (let [renderer (or (-> profile :props :renderer) :svg) + read-only? (mf/use-ctx ctx/workspace-read-only?) show-nudge-options (mf/use-fn @@ -317,6 +320,21 @@ :id "file-menu-nudge"} [:span {:class (stl/css :item-name)} (tr "modals.nudge-title")]] + (when (and (not read-only?) + (not japanese-layout-all-files) + (wm/vertical-layout-active?)) + [:> dropdown-menu-item* {:on-click toggle-japanese-layout + :class (stl/css :base-menu-item :submenu-item) + :on-key-down (fn [event] + (when (kbd/enter? event) + (toggle-japanese-layout event))) + :data-testid "japanese-layout" + :id "file-menu-japanese-layout"} + [:span {:class (stl/css :item-name)} + (if japanese-layout-enabled + (tr "workspace.header.menu.disable-japanese-layout") + (tr "workspace.header.menu.enable-japanese-layout"))]]) + [:> dropdown-menu-item* {:on-click toggle-theme :class (stl/css :base-menu-item :submenu-item) :on-key-down (fn [event] @@ -886,6 +904,8 @@ (mf/defc menu* [{:keys [layout file]}] (let [profile (mf/deref refs/profile) + japanese-layout (mf/deref refs/japanese-layout-config) + japanese-layout-enabled (:file? japanese-layout) show-menu* (mf/use-state false) show-menu? (deref show-menu*) @@ -947,6 +967,15 @@ (dom/stop-propagation event) (st/emit! (du/toggle-theme)))) + toggle-japanese-layout + (mf/use-fn + (mf/deps japanese-layout-enabled) + (fn [event] + (dom/stop-propagation event) + (st/emit! (dw/set-japanese-layout (not japanese-layout-enabled))) + (reset! show-menu* false) + (reset! selected-sub-menu* nil))) + toggle-render (mf/use-fn (mf/deps profile) @@ -1146,10 +1175,13 @@ :preferences [:> preferences-menu* {:layout layout :profile profile + :japanese-layout-enabled japanese-layout-enabled + :japanese-layout-all-files (:all-files? japanese-layout) :toggle-flag toggle-flag :toggle-theme toggle-theme :toggle-render toggle-render :show-shortcuts show-shortcuts + :toggle-japanese-layout toggle-japanese-layout :on-close close-sub-menu}] :plugins diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/common.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/common.cljs index ad8bcc2a9c..10b430c7a3 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/common.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/common.cljs @@ -7,6 +7,7 @@ (ns app.main.ui.workspace.sidebar.options.common (:require-macros [app.main.style :as stl]) (:require + [app.common.data :as d] [app.main.data.workspace.tokens.application :as dwta] [app.main.store :as st] [app.util.dom :as dom] @@ -26,6 +27,20 @@ :ref ref} children]))) +(defn radio-selected + "Value to select in a radio group for an attribute value: its name, or + `default` when unset. Mixed (`:multiple`) values select nothing." + ([value] + (radio-selected value "")) + ([value default] + (cond + (= value :multiple) "" + (or (nil? value) + (and (string? value) (empty? value))) default + (keyword? value) (d/name value) + (string? value) value + :else (str value)))) + (defn emit-value-or-token [value emit-value-fn ids attrs] (if (or (string? value) (number? value) 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 61ee90bf07..b571baed15 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 @@ -30,6 +30,8 @@ [app.main.ui.ds.controls.shared.searchable-options-dropdown :refer [searchable-options-dropdown*]] [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.token-typography-row :refer [token-typography-row*]] [app.main.ui.workspace.sidebar.options.menus.typography :refer [text-options* typography-entry*]] [app.main.ui.workspace.tokens.management.forms.controls.utils :as csu] @@ -52,10 +54,6 @@ Evaluated once at module load time; cf/flags is immutable after startup." (contains? cf/flags :token-typography-row)) -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Sub-components -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - (mf/defc text-align-options* [{:keys [values on-change on-blur]}] (let [options @@ -85,14 +83,14 @@ (when (some? on-blur) (on-blur))))] [:div {:class (stl/css :align-options)} - [:> radio-buttons* {:selected (:text-align values) + [:> radio-buttons* {:selected (radio-selected (:text-align values)) :on-change handle-change :name "align-text-options" :options options}]])) (mf/defc text-direction-options* [{:keys [values on-change on-blur]}] - (let [direction (:text-direction values) + (let [direction (radio-selected (:text-direction values)) options (mf/with-memo [] [{:value "ltr" @@ -119,7 +117,7 @@ (mf/defc vertical-align* [{:keys [values on-change on-blur]}] - (let [vertical-align (or (:vertical-align values) "top") + (let [vertical-align (radio-selected (:vertical-align values) "top") options (mf/with-memo [] [{:value "top" @@ -151,6 +149,7 @@ (mf/defc grow-options* [{:keys [ids values on-blur]}] (let [grow-type (:grow-type values) + selected (radio-selected grow-type) editor-instance (mf/deref refs/workspace-editor) options (mf/with-memo [] @@ -191,14 +190,14 @@ (when (some? on-blur) (on-blur))))] [:div {:class (stl/css :grow-options)} - [:> radio-buttons* {:selected (d/name grow-type) + [:> radio-buttons* {:selected selected :on-change handle-change :name "grow-text-options" :options options}]])) (mf/defc text-decoration-options* [{:keys [values on-change on-blur token-applied]}] - (let [text-decoration (some-> (:text-decoration values) d/name) + (let [text-decoration (radio-selected (:text-decoration values)) options (mf/with-memo [token-applied] [{:value "underline" @@ -222,8 +221,8 @@ [:div {:class (stl/css :text-decoration-options)} [:> radio-buttons* {:selected (if (= text-decoration "none") - nil - text-decoration) + "" + (radio-selected text-decoration)) :on-change handle-change :name "text-decoration-options" :disabled (and token-typography-row-enabled? (some? token-applied)) @@ -246,8 +245,10 @@ (d/seek #(= (:id %) (uuid/uuid id))))) (defn- check-props [n-props o-props] - (let [o-values (unchecked-get o-props "values") - n-values (unchecked-get n-props "values")] + (let [o-values (unchecked-get o-props "values") + n-values (unchecked-get n-props "values") + o-ruby-values (unchecked-get o-props "rubyValues") + n-ruby-values (unchecked-get n-props "rubyValues")] (and (identical? (unchecked-get n-props "ids") (unchecked-get o-props "ids")) (identical? (unchecked-get n-props "type") @@ -258,6 +259,9 @@ (unchecked-get o-props "fileId")) (identical? (unchecked-get n-props "typographies") (unchecked-get o-props "typographies")) + (identical? (unchecked-get n-props "textSelectionActive") + (unchecked-get o-props "textSelectionActive")) + (= n-ruby-values o-ruby-values) (identical? (get o-values :fills) (get n-values :fills)) (identical? (get o-values :font-family) @@ -284,6 +288,32 @@ (get n-values :text-decoration)) (identical? (get o-values :text-direction) (get n-values :text-direction)) + (identical? (get o-values :writing-mode) + (get n-values :writing-mode)) + (identical? (get o-values :text-orientation) + (get n-values :text-orientation)) + (identical? (get o-values :text-combine-upright) + (get n-values :text-combine-upright)) + (identical? (get o-values :warichu) + (get n-values :warichu)) + (identical? (get o-values :font-features) + (get n-values :font-features)) + (identical? (get o-values :annotation-clearance) + (get n-values :annotation-clearance)) + (identical? (get o-values :text-emphasis) + (get n-values :text-emphasis)) + (identical? (get o-values :ruby) + (get n-values :ruby)) + (identical? (get o-values :ruby-hidden) + (get n-values :ruby-hidden)) + (identical? (get o-values :ruby-size) + (get n-values :ruby-size)) + (identical? (get o-values :ruby-align) + (get n-values :ruby-align)) + (identical? (get o-values :ruby-overhang) + (get n-values :ruby-overhang)) + (identical? (get o-values :ruby-side) + (get n-values :ruby-side)) (identical? (get o-values :text-transform) (get n-values :text-transform)) (identical? (get o-values :typography-ref-file) @@ -291,7 +321,9 @@ (identical? (get o-values :typography-ref-id) (get n-values :typography-ref-id)) (identical? (get o-values :vertical-align) - (get n-values :vertical-align))))) + (get n-values :vertical-align)) + (identical? (get o-values :line-adjustment) + (get n-values :line-adjustment))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Main component @@ -299,14 +331,20 @@ (mf/defc text-menu* {::mf/wrap [#(mf/memo' % check-props)]} - [{:keys [ids type values applied-tokens libraries file-id typographies]}] + [{:keys [ids type values ruby-values text-selection-active + applied-tokens libraries file-id typographies]}] (let [;; --- UI state menu-state* (mf/use-state {:main-menu true - :more-options false}) + :more-options false + :japanese-layout true}) menu-state (deref menu-state*) main-menu-open? (:main-menu menu-state) more-options-open? (:more-options menu-state) + japanese-layout-open? (:japanese-layout menu-state) + + japanese-layout-config-enabled? + (tjl/japanese-layout-config-enabled? (mf/deref refs/japanese-layout-config)) font-id (or (:font-id values) (:font-id txt/default-typography)) @@ -397,6 +435,10 @@ (mf/use-fn #(swap! menu-state* update :more-options not)) + toggle-japanese-layout + (mf/use-fn + #(swap! menu-state* update :japanese-layout not)) + toggle-token-dropdown (mf/use-fn #(swap! token-dropdown-open* not)) @@ -427,6 +469,14 @@ (fn [attrs] (emit-update! ids attrs))) + on-ruby-presentation-change + (mf/use-fn + (mf/deps ids on-change text-selection-active) + (fn [attrs] + (if text-selection-active + (on-change attrs) + (st/emit! (dwt/update-all-ruby-presentation ids attrs))))) + on-convert-to-typography (mf/use-fn (mf/deps values ids file-id emit-update!) @@ -485,7 +535,17 @@ :values values :on-change on-change :show-recent true - :on-blur on-text-blur})] + :on-blur on-text-blur}) + + japanese-layout-props + (mf/props + {:ids ids + :values values + :ruby-values ruby-values + :text-selection-active text-selection-active + :on-change on-change + :on-ruby-presentation-change on-ruby-presentation-change + :on-blur on-text-blur})] (hooks/use-stream expand-stream @@ -503,97 +563,114 @@ (when token-dropdown-open? (ts/schedule 0 #(some-> (mf/ref-val dropdown-ref) dom/focus!)))) - [:section {:class (stl/css :element-set) - ;; Focusing these controls must not exit the v3 text editor (see `keep-editing-on-blur?`). - :data-keep-editing-on-blur true - :aria-label (tr "workspace.options.text-options.text-section")} - [:div {:class (stl/css :element-title)} - [:> title-bar* {:collapsable true - :collapsed (not main-menu-open?) - :on-collapsed toggle-main-menu - :title label - :class (stl/css :title-spacing-text)} - [:* - (when (and token-typography-row-enabled? (some? (resolve-delay typography-tokens)) (not typography)) - [:> icon-button* {:variant "ghost" - :aria-label (tr "ds.inputs.numeric-input.open-token-list-dropdown") - :on-click toggle-token-dropdown - :tooltip-placement "top-left" - :icon i/tokens}]) - (when (and (not typography) (not multiple?) (not applied-token-name)) - [:> icon-button* {:variant "ghost" - :aria-label (if (not font) - (tr "workspace.options.font-not-available" (:font-family values)) - (tr "workspace.options.convert-to-typography")) - :on-click on-convert-to-typography - :tooltip-placement "top-left" - :disabled (not font) - :icon i/add}])]] - (when (and token-typography-row-enabled? token-dropdown-open?) - [:> searchable-options-dropdown* {:on-click on-option-click - :id listbox-id - :options (resolve-delay dropdown-options) - :selected selected-token-id - :align "right" - :placeholder (tr "workspace.tokens.search-by-token") - :ref set-option-ref}])] - - (when main-menu-open? - [:div {:class (stl/css :element-content)} - (cond - (and token-typography-row-enabled? (= :multiple current-token-name) (= typography-id :multiple)) - [:div {:class (stl/css :multiple-typography)} - [:span {:class (stl/css :multiple-text)} - (tr "workspace.libraries.text.mixed-tokens-and-assets")]] - - (and token-typography-row-enabled? (= :multiple current-token-name)) - [:div {:class (stl/css :multiple-typography)} - [:span {:class (stl/css :multiple-text)} - (tr "workspace.libraries.text.mixed-tokens")] - [:> icon-button* {:variant "ghost" - :aria-label (tr "workspace.libraries.text.multiple-token-tooltip") + [:* + [:section {:class (stl/css :element-set) + ;; Focusing these controls must not exit the v3 text editor (see `keep-editing-on-blur?`). + :data-keep-editing-on-blur true + :aria-label (tr "workspace.options.text-options.text-section")} + [:div {:class (stl/css :element-title)} + [:> title-bar* {:collapsable true + :collapsed (not main-menu-open?) + :on-collapsed toggle-main-menu + :title label + :class (stl/css :title-spacing-text)} + [:* + (when (and token-typography-row-enabled? (some? (resolve-delay typography-tokens)) (not typography)) + [:> icon-button* {:variant "ghost" + :aria-label (tr "ds.inputs.numeric-input.open-token-list-dropdown") + :on-click toggle-token-dropdown :tooltip-placement "top-left" - :on-click handle-detach-all-tokens - :icon i/detach}]] - - (and token-typography-row-enabled? current-token-name) - [:> token-typography-row* {:token-name current-token-name - :detach-token detach-token - :active-tokens (resolve-delay typography-tokens)}] - - (= typography-id :multiple) - [:div {:class (stl/css :multiple-typography)} - [:span {:class (stl/css :multiple-text)} - (tr "workspace.libraries.text.mixed-typography")] - [:> icon-button* {:variant "ghost" - :aria-label (tr "workspace.libraries.text.multiple-assets-tooltip") - :on-click handle-detach-typography + :icon i/tokens}]) + (when (and (not typography) (not multiple?) (not applied-token-name)) + [:> icon-button* {:variant "ghost" + :aria-label (if (not font) + (tr "workspace.options.font-not-available" (:font-family values)) + (tr "workspace.options.convert-to-typography")) + :on-click on-convert-to-typography :tooltip-placement "top-left" - :icon i/detach}]] + :disabled (not font) + :icon i/add}])]] + (when (and token-typography-row-enabled? token-dropdown-open?) + [:> searchable-options-dropdown* {:on-click on-option-click + :id listbox-id + :options (resolve-delay dropdown-options) + :selected selected-token-id + :align "right" + :placeholder (tr "workspace.tokens.search-by-token") + :ref set-option-ref}])] - typography - [:> typography-entry* {:file-id typography-file-id - :typography typography - :is-local (= typography-file-id file-id) - :on-detach handle-detach-typography - :on-change handle-change-typography}] + (when main-menu-open? + [:div {:class (stl/css :element-content)} + (cond + (and token-typography-row-enabled? (= :multiple current-token-name) (= typography-id :multiple)) + [:div {:class (stl/css :multiple-typography)} + [:span {:class (stl/css :multiple-text)} + (tr "workspace.libraries.text.mixed-tokens-and-assets")]] + (and token-typography-row-enabled? (= :multiple current-token-name)) + [:div {:class (stl/css :multiple-typography)} + [:span {:class (stl/css :multiple-text)} + (tr "workspace.libraries.text.mixed-tokens")] + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.libraries.text.multiple-token-tooltip") + :tooltip-placement "top-left" + :on-click handle-detach-all-tokens + :icon i/detach}]] + (and token-typography-row-enabled? current-token-name) + [:> token-typography-row* {:token-name current-token-name + :detach-token detach-token + :active-tokens (resolve-delay typography-tokens)}] - :else - [:> text-options* common-props]) + (= typography-id :multiple) + [:div {:class (stl/css :multiple-typography)} + [:span {:class (stl/css :multiple-text)} + (tr "workspace.libraries.text.mixed-typography")] + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.libraries.text.multiple-assets-tooltip") + :on-click handle-detach-typography + :tooltip-placement "top-left" + :icon i/detach}]] - [:div {:class (stl/css :text-align-options)} - [:> text-align-options* common-props] - [:> grow-options* common-props] - [:> icon-button* {:variant "ghost" - :aria-label (tr "labels.options") - :data-testid "text-align-options-button" - :on-click toggle-more-options - :icon i/menu}]] + typography + [:> typography-entry* {:file-id typography-file-id + :typography typography + :is-local (= typography-file-id file-id) + :on-detach handle-detach-typography + :on-change handle-change-typography}] - (when more-options-open? - [:div {:class (stl/css :text-decoration-options)} - [:> vertical-align* common-props] - [:> text-decoration-options* (mf/spread-props common-props {:token-applied current-token-name})] - [:> text-direction-options* common-props]])])])) + :else + [:> text-options* common-props]) + + [:div {:class (stl/css :text-align-options)} + [:> text-align-options* common-props] + [:> grow-options* common-props] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.options") + :data-testid "text-align-options-button" + :on-click toggle-more-options + :icon i/menu}]] + + (when japanese-layout-config-enabled? + [:> tjl/line-adjustment-option* common-props]) + + (when more-options-open? + [:div {:class (stl/css :text-decoration-options)} + [:> vertical-align* common-props] + [:> text-decoration-options* (mf/spread-props common-props {:token-applied current-token-name})] + [:> text-direction-options* common-props]])])] + + (when japanese-layout-config-enabled? + [:section {:class (stl/css :element-set) + ;; Focusing these controls must not exit the v3 text editor (see `keep-editing-on-blur?`). + :data-keep-editing-on-blur true + :aria-label (tr "workspace.options.text-options.japanese-layout")} + [:div {:class (stl/css :element-title)} + [:> title-bar* {:collapsable true + :collapsed (not japanese-layout-open?) + :on-collapsed toggle-japanese-layout + :title (tr "workspace.options.text-options.japanese-layout") + :class (stl/css :title-spacing-text)}]] + (when japanese-layout-open? + [:div {:class (stl/css :element-content)} + [:> tjl/japanese-layout-options* japanese-layout-props]])])])) 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 new file mode 100644 index 0000000000..8714f09158 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs @@ -0,0 +1,523 @@ +;; 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 INC Sucursal en España SL + +(ns app.main.ui.workspace.sidebar.options.menus.text-japanese-layout + (:require-macros [app.main.style :as stl]) + (:require + [app.common.types.text.cjk-layout :as cjk] + [app.main.ui.components.title-bar :refer [title-bar*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.controls.input :refer [input*]] + [app.main.ui.ds.controls.radio-buttons :refer [radio-buttons*]] + [app.main.ui.ds.controls.select :refer [select*]] + [app.main.ui.ds.controls.switch :refer [switch*]] + [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.workspace.sidebar.options.common :refer [advanced-options* radio-selected]] + [app.util.dom :as dom] + [app.util.i18n :refer [tr]] + [app.util.text.writing-mode :as wm] + [rumext.v2 :as mf])) + +(def ^:private mixed-span-values + #{:mixed :multiple "mixed" "multiple"}) + +(defn- mixed-span-value? + [value] + (contains? mixed-span-values value)) + +(defn- span-input-value + [value] + (if (mixed-span-value? value) + "mixed" + (radio-selected value))) + +(defn span-select-value + [value default] + (if (mixed-span-value? value) + "mixed" + (radio-selected value default))) + +(defn with-mixed-span-option + [options value] + (cond-> options + (mixed-span-value? value) + (conj {:id "mixed" + :label (tr "labels.mixed-values") + :disabled true + :dimmed true}))) + +(defn japanese-layout-config-enabled? + "True under the WASM renderer when Japanese layout is enabled for the file + or in the user's profile. `config` is a `refs/japanese-layout-config` + value." + [{:keys [file? all-files?]}] + (and ^boolean (wm/vertical-layout-active?) + (boolean (or file? all-files?)))) + +(defn vertical-japanese-layout? + "True when the Japanese layout controls are editing vertical text." + [values] + (= "vertical-rl" (:writing-mode values))) + +(defn proportional-metrics-feature + "Proportional metrics feature for the writing mode: `vpal` or `palt`." + [writing-mode] + (if (= writing-mode "vertical-rl") "vpal" "palt")) + +(defn text-combine-upright-options + [text-selection-active translate] + (cond-> [{:value "none" + :id "none-text-combine-upright" + :label (translate "workspace.options.text-options.text-combine-upright-none") + :icon i/text-combine-upright-none}] + text-selection-active + (conj {:value "all" + :id "all-text-combine-upright" + :label (translate "workspace.options.text-options.text-combine-upright-all") + :icon i/text-combine-upright-all}) + + true + (conj {:value "digits" + :id "digits-text-combine-upright" + :label (translate "workspace.options.text-options.text-combine-upright-digits") + :icon i/text-combine-upright-digits}))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Sub-components +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(mf/defc attr-radio-options* + "Radio group bound to one text attribute." + [{:keys [values on-change on-blur attr default-value name class options]}] + (let [selected (radio-selected (get values attr) default-value) + handle-change + (mf/use-fn + (mf/deps attr on-change on-blur) + (fn [value] + (on-change {attr value}) + (when (some? on-blur) (on-blur))))] + [:div {:class class} + [:> radio-buttons* {:selected selected + :on-change handle-change + :name name + :options options}]])) + +(defn writing-mode-options + [translate] + [{:value "horizontal-tb" + :id "horizontal-tb-writing-mode" + :label (translate "workspace.options.text-options.writing-mode-horizontal") + :icon i/writing-mode-horizontal} + {:value "vertical-rl" + :id "vertical-rl-writing-mode" + :label (translate "workspace.options.text-options.writing-mode-vertical") + :icon i/writing-mode-vertical}]) + +(defn text-orientation-options + [translate] + [{:value "mixed" + :id "mixed-text-orientation" + :label (translate "workspace.options.text-options.text-orientation-mixed") + :icon i/text-orientation-mixed} + {:value "upright" + :id "upright-text-orientation" + :label (translate "workspace.options.text-options.text-orientation-upright") + :icon i/text-orientation-upright}]) + +;; Warichu (割注): renders the selection as two half-size lines in one inline +;; position (top/bottom horizontally, right/left vertically). +(defn warichu-options + [translate] + [{:value "none" + :id "none-warichu" + :label (translate "workspace.options.text-options.warichu-none") + :icon i/warichu-none} + {:value "warichu" + :id "warichu-warichu" + :label (translate "workspace.options.text-options.warichu") + :icon i/warichu}]) + +;; select* matches options by :id, so each id is the stored value. +(defn text-combine-upright-count-options + [translate] + [{:id "digits2" + :label (translate "workspace.options.text-options.text-combine-upright-digits-2")} + {:id "digits3" + :label (translate "workspace.options.text-options.text-combine-upright-digits-3")} + {:id "digits" + :label (translate "workspace.options.text-options.text-combine-upright-digits-4")}]) + +(defn ruby-size-options + [translate] + [{:id "half" :label (translate "workspace.options.text-options.ruby-size-half")} + {:id "third" :label (translate "workspace.options.text-options.ruby-size-third")} + {:id "quarter" :label (translate "workspace.options.text-options.ruby-size-quarter")}]) + +(defn ruby-align-options + [translate] + [{:id "space-around" :label (translate "workspace.options.text-options.ruby-align-space-around")} + {:id "center" :label (translate "workspace.options.text-options.ruby-align-center")} + {:id "start" :label (translate "workspace.options.text-options.ruby-align-start")} + {:id "space-between" :label (translate "workspace.options.text-options.ruby-align-space-between")}]) + +(defn ruby-overhang-options + [translate] + [{: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")}]) + +(mf/defc text-combine-upright-options* + ;; Digit TCY finds eligible runs itself, so it applies to a whole shape; + ;; `all` is offered only for a text selection. + [{:keys [values on-change on-blur text-selection-active]}] + (let [text-combine-upright (radio-selected (:text-combine-upright values) + (cjk/enum-default :text-combine-upright)) + selected (if (cjk/digit-combine? text-combine-upright) "digits" text-combine-upright) + options + (mf/with-memo [text-selection-active] + (text-combine-upright-options text-selection-active tr)) + + handle-change + (mf/use-fn + (mf/deps on-change on-blur) + (fn [value] + (on-change {:text-combine-upright value}) + (when (some? on-blur) (on-blur))))] + + [:div {:class (stl/css :text-combine-upright-options)} + [:> radio-buttons* {:selected selected + :on-change handle-change + :name "text-combine-upright-options" + :options options}]])) + +(mf/defc text-combine-upright-count-options* + [{:keys [values on-change on-blur]}] + (let [text-combine-upright (radio-selected (:text-combine-upright values) + (cjk/enum-default :text-combine-upright)) + digits? (cjk/digit-combine? text-combine-upright) + options (mf/with-memo [] (text-combine-upright-count-options tr)) + + handle-change + (mf/use-fn + (mf/deps on-change on-blur) + (fn [value] + (on-change {:text-combine-upright value}) + (when (some? on-blur) (on-blur))))] + + (when digits? + [:div {:class (stl/css :japanese-select-option)} + [:span {:class (stl/css :japanese-option-label)} + (tr "workspace.options.text-options.text-combine-upright-digits-count")] + [:> select* {:default-selected text-combine-upright + :aria-label (tr "workspace.options.text-options.text-combine-upright-digits-count") + :options options + :on-change handle-change}]]))) + +(mf/defc ruby-options* + [{:keys [values on-change on-blur advanced-open on-toggle-advanced]}] + (let [ruby (span-input-value (:ruby values)) + ruby* (mf/use-state ruby) + dirty* (mf/use-state false) + value (deref ruby*) + dirty? (deref dirty*) + + commit! + (mf/use-fn + (mf/deps dirty? value on-change on-blur) + (fn [] + (when dirty? + (on-change {:ruby value}) + (reset! dirty* false)) + (when (some? on-blur) (on-blur)))) + + handle-change + (mf/use-fn + (fn [event] + (reset! dirty* true) + (reset! ruby* (dom/get-target-val event)))) + + handle-key-down + (mf/use-fn + (mf/deps commit!) + (fn [event] + (when (= "Enter" (.-key event)) + (dom/blur! (dom/get-target event)))))] + + (mf/with-effect [ruby] + (reset! ruby* ruby) + (reset! dirty* false)) + + [:div {:class (stl/css :ruby-options)} + [:> input* {:class (stl/css :ruby-input) + :label (tr "workspace.options.text-options.ruby") + :placeholder (tr "workspace.options.text-options.ruby-placeholder") + :value value + :on-change handle-change + :on-blur commit! + :on-key-down handle-key-down}] + [:> icon-button* {:variant "ghost" + :selected advanced-open + :aria-label (tr "workspace.options.text-options.ruby-advanced-options") + :aria-expanded advanced-open + :data-testid "ruby-advanced-options-toggle" + :on-click on-toggle-advanced + :icon i/menu}]])) + +(mf/defc span-select-option* + "Select bound to one span attribute, with a disabled entry for mixed values." + [{:keys [values on-change on-blur attr default-value label options]}] + (let [value (get values attr) + selected (span-select-value value default-value) + options (with-mixed-span-option options value) + handle-change + (mf/use-fn + (mf/deps attr on-change on-blur) + (fn [value] + (on-change {attr value}) + (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] + (mf/props + {:values values + :on-change on-change + :on-blur on-blur})) + +(mf/defc ruby-hidden-option* + [{:keys [values on-change on-blur]}] + (let [value (:ruby-hidden values) + enabled? (cond + (mixed-span-value? value) nil + (true? value) true + :else false) + label (tr "workspace.options.text-options.ruby-hidden") + handle-change + (mf/use-fn + (mf/deps on-change on-blur) + (fn [checked?] + (on-change {:ruby-hidden checked?}) + (when (some? on-blur) (on-blur))))] + [:div {:class (stl/css :ruby-hidden-option :japanese-select-option)} + [:span {:class (stl/css :japanese-option-label)} label] + [:> switch* {:default-checked enabled? + :aria-label label + :on-change handle-change}]])) + +(mf/defc ruby-customization-options* + [{:keys [values on-change on-blur]}] + (let [common-props (ruby-common-props values on-change on-blur) + 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)})] + [: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 (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 (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 (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 (tr "workspace.options.text-options.ruby-side") + :options (:ruby-side options)})]])) + +(mf/defc ruby-advanced-options* + [{:keys [values on-change on-blur]}] + (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)] + [:div {:class (stl/css :ruby-advanced-options)} + [:> ruby-options* (mf/spread-props common-props + {:advanced-open open? + :on-toggle-advanced toggle-open})] + [:> advanced-options* {:class (stl/css :ruby-advanced-content) + :is-visible open?} + [:> ruby-customization-options* common-props]]])) + +(mf/defc ruby-presentation-options* + [{:keys [values on-change on-blur]}] + (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)] + [:div {:class (stl/css :ruby-advanced-options)} + [:> title-bar* {:collapsable true + :collapsed (not open?) + :title (tr "workspace.options.text-options.ruby-advanced-options") + :on-collapsed toggle-open + :aria-expanded open? + :data-testid "ruby-presentation-options-toggle"}] + [:> advanced-options* {:class (stl/css :ruby-advanced-content) + :is-visible open?} + [:> ruby-customization-options* common-props]]])) + +(mf/defc font-features-options* + ;; Japanese proportional metric alternates, per span: `palt` for + ;; horizontal text, `vpal` for vertical. + [{:keys [values on-change on-blur]}] + (let [writing-mode (:writing-mode values) + feature (proportional-metrics-feature writing-mode) + value (:font-features values) + enabled? (cond + (mixed-span-value? value) nil + (= feature value) true + :else false) + + handle-change + (mf/use-fn + (mf/deps feature on-change on-blur) + (fn [checked?] + (on-change {:font-features (if checked? feature "none")}) + (when (some? on-blur) (on-blur))))] + + [:div {:class (stl/css :japanese-select-option)} + [:span {:class (stl/css :japanese-option-label)} + (tr "workspace.options.text-options.font-features")] + [:> switch* {:default-checked enabled? + :aria-label (tr "workspace.options.text-options.font-features") + :on-change handle-change}]])) + +(defn text-emphasis-options + ([] + (text-emphasis-options tr)) + ([translate] + [{:id "none" + :label (translate "workspace.options.text-options.text-emphasis-none")} + {:id "filled-dot" + :label (translate "workspace.options.text-options.text-emphasis-filled-dot")} + {:id "open-dot" + :label (translate "workspace.options.text-options.text-emphasis-open-dot")} + {:id "filled-circle" + :label (translate "workspace.options.text-options.text-emphasis-filled-circle")} + {:id "open-circle" + :label (translate "workspace.options.text-options.text-emphasis-open-circle")} + {:id "filled-sesame" + :label (translate "workspace.options.text-options.text-emphasis-filled-sesame")} + {:id "open-sesame" + :label (translate "workspace.options.text-options.text-emphasis-open-sesame")}])) + +(defn annotation-clearance-options + ([] + (annotation-clearance-options tr)) + ([translate] + [{:id "none" + :label (translate "workspace.options.text-options.annotation-clearance-none")} + {:id "auto" + :label (translate "workspace.options.text-options.annotation-clearance-auto")}])) + +(defn line-adjustment-options + ([] + (line-adjustment-options tr)) + ([translate] + [{:id "push-in-first" + :label (translate "workspace.options.text-options.line-adjustment-push-in-first")} + {:id "push-out-first" + :label (translate "workspace.options.text-options.line-adjustment-push-out-first")} + {:id "push-out-only" + :label (translate "workspace.options.text-options.line-adjustment-push-out-only")}])) + +(mf/defc line-adjustment-option* + "Whole-shape setting: how lines slightly too long are fitted." + [{:keys [values on-change on-blur]}] + (let [options (mf/with-memo [] (line-adjustment-options))] + [:> span-select-option* {:values values + :on-change on-change + :on-blur on-blur + :attr :line-adjustment + :default-value (cjk/enum-default :line-adjustment) + :label (tr "workspace.options.text-options.line-adjustment") + :options options}])) + +(mf/defc japanese-layout-options* + [{:keys [values ruby-values text-selection-active + on-change on-ruby-presentation-change on-blur]}] + (let [vertical? (vertical-japanese-layout? values) + common-props (mf/props + {:values values + :on-change on-change + :on-blur on-blur}) + ruby-presentation-props (mf/props + {:values ruby-values + :on-change on-ruby-presentation-change + :on-blur on-blur}) + options (mf/with-memo [] + {:writing-mode (writing-mode-options tr) + :text-orientation (text-orientation-options tr) + :warichu (warichu-options tr) + :text-emphasis (text-emphasis-options) + :annotation-clearance (annotation-clearance-options)})] + + [:div {:class (stl/css :japanese-layout-options)} + [:div {:class (stl/css :japanese-layout-controls)} + [:div {:class (stl/css :japanese-icon-options)} + [:> attr-radio-options* (mf/spread-props common-props + {:attr :writing-mode + :default-value (cjk/enum-default :writing-mode) + :name "writing-mode-options" + :class (stl/css :writing-mode-options) + :options (:writing-mode options)})] + (when ^boolean vertical? + [:* + ;; An unset orientation reads as nil, or "" in the v2 editor; both + ;; select "mixed". + [:> attr-radio-options* (mf/spread-props common-props + {:attr :text-orientation + :default-value (cjk/enum-default :text-orientation) + :name "text-orientation-options" + :class (stl/css :text-orientation-options) + :options (:text-orientation options)})] + [:> text-combine-upright-options* + (mf/spread-props common-props + {:text-selection-active text-selection-active})]]) + (when ^boolean text-selection-active + [:> attr-radio-options* (mf/spread-props common-props + {:attr :warichu + :default-value (cjk/enum-default :warichu) + :name "warichu-options" + :class (stl/css :warichu-options) + :options (:warichu options)})])] + (when ^boolean vertical? + [:> text-combine-upright-count-options* common-props]) + [:> font-features-options* common-props] + ;; Emphasis marks (圏点 / bouten) apply to the selected characters. + (when ^boolean text-selection-active + [:> span-select-option* (mf/spread-props common-props + {:attr :text-emphasis + :default-value (cjk/enum-default :text-emphasis) + :label (tr "workspace.options.text-options.text-emphasis") + :options (:text-emphasis options)})]) + [:> span-select-option* (mf/spread-props common-props + {:attr :annotation-clearance + :default-value (cjk/enum-default :annotation-clearance) + :label (tr "workspace.options.text-options.annotation-clearance") + :options (:annotation-clearance options)})] + (if text-selection-active + [:> ruby-advanced-options* common-props] + [:> ruby-presentation-options* ruby-presentation-props])]])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.scss new file mode 100644 index 0000000000..b84d9971a5 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.scss @@ -0,0 +1,78 @@ +// 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 INC Sucursal en España SL + +@use "ds/typography.scss" as t; +@use "ds/_sizes.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/spacing.scss" as *; + +.writing-mode-options, +.text-orientation-options, +.text-combine-upright-options, +.warichu-options { + block-size: $sz-32; +} + +.ruby-advanced-options { + display: flex; + flex-direction: column; + overflow: visible; +} + +.ruby-options { + display: flex; + align-items: flex-end; + gap: var(--sp-xs); +} + +.ruby-input { + flex: 1; +} + +.ruby-advanced-content { + display: flex; + flex-direction: column; + gap: var(--sp-xs); + padding: var(--sp-xs); + border: $b-1 solid var(--color-background-quaternary); + border-radius: $br-8; +} + +.japanese-layout-options, +.japanese-layout-controls { + display: flex; + flex-direction: column; + gap: var(--sp-xs); +} + +.japanese-layout-options { + padding-block: var(--sp-xs); +} + +.ruby-hidden-option { + min-block-size: $sz-32; +} + +.japanese-icon-options { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--sp-xs); +} + +.japanese-select-option { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr); + align-items: center; + gap: var(--sp-s); + inline-size: 100%; +} + +.japanese-option-label { + @include t.use-typography("body-small"); + + color: var(--color-foreground-secondary); +} diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs index dd2c9dc102..fe0fa3050d 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs @@ -10,6 +10,7 @@ [app.common.data.macros :as dm] [app.common.types.shape.layout :as ctl] [app.common.types.text :as txt] + [app.common.types.text.cjk-layout :as cjk] [app.main.data.workspace.texts :as dwt] [app.main.features :as features] [app.main.refs :as refs] @@ -27,8 +28,30 @@ [app.main.ui.workspace.sidebar.options.menus.shadow :refer [shadow-menu*]] [app.main.ui.workspace.sidebar.options.menus.stroke :refer [stroke-attrs stroke-menu*]] [app.main.ui.workspace.sidebar.options.menus.text :refer [text-menu*]] + [app.render-wasm.api :as wasm.api] [rumext.v2 :as mf])) +(defn- dom-text-range-selected? + [editor] + (when editor + (let [selection (.getSelection js/window) + element (.-element editor) + anchor (some-> selection .-anchorNode) + focus (some-> selection .-focusNode)] + (and selection + (not (.-isCollapsed selection)) + element + anchor + focus + (.contains element anchor) + (.contains element focus))))) + +(defn- draft-text-range-selected? + [editor-state] + (when editor-state + (let [selection (.getSelection editor-state)] + (and selection (not (.isCollapsed selection)))))) + (mf/defc options* [{:keys [shape libraries file-id page-id]}] (let [id (dm/get-prop shape :id) @@ -128,6 +151,19 @@ (when text-editor-v2? editor) + text-selection-active + (boolean + (cond + text-editor-wasm? + (and (= id (wasm.api/text-editor-get-active-shape-id)) + (wasm.api/text-editor-has-selection?)) + + text-editor-v2? + (dom-text-range-selected? editor-instance) + + :else + (draft-text-range-selected? editor-state))) + fill-values (dwt/current-text-values {:editor-styles editor-styles @@ -154,7 +190,14 @@ :editor-state editor-state :editor-instance editor-instance :shape shape - :attrs txt/text-node-attrs}))] + :attrs txt/text-node-attrs})) + + ruby-values + (if text-selection-active + (select-keys text-values cjk/ruby-presentation-attrs) + (dwt/current-ruby-values + {:shape shape + :attrs cjk/ruby-presentation-attrs}))] [:* [:> layer-menu* {:ids ids @@ -202,6 +245,8 @@ :type type :applied-tokens applied-tokens :values text-values + :ruby-values ruby-values + :text-selection-active text-selection-active :libraries libraries :file-id file-id :typographies typographies}] @@ -237,4 +282,3 @@ :values (select-keys shape exports-attrs) :page-id page-id :file-id file-id}]])) - diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index f68303e117..19ee5bfd54 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -143,6 +143,7 @@ [frontend-tests.ui.static-test] [frontend-tests.ui.stroke-menu-test] [frontend-tests.ui.text-attrs-multiple-test] + [frontend-tests.ui.text-options-test] [frontend-tests.ui.workspace-libraries-test] [frontend-tests.util-clipboard-test] [frontend-tests.util-object-test] @@ -311,6 +312,7 @@ 'frontend-tests.ui.static-test 'frontend-tests.ui.stroke-menu-test 'frontend-tests.ui.text-attrs-multiple-test + 'frontend-tests.ui.text-options-test 'frontend-tests.ui.workspace-libraries-test 'frontend-tests.util-clipboard-test 'frontend-tests.util-object-test diff --git a/frontend/test/frontend_tests/ui/text_options_test.cljs b/frontend/test/frontend_tests/ui/text_options_test.cljs new file mode 100644 index 0000000000..e0999e870b --- /dev/null +++ b/frontend/test/frontend_tests/ui/text_options_test.cljs @@ -0,0 +1,248 @@ +;; 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 INC Sucursal en España SL + +(ns frontend-tests.ui.text-options-test + (:require + ["react-dom/server" :as rds] + [app.common.types.text.cjk-layout :as cjk] + [app.main.data.workspace.texts :as dwt] + [app.main.ui.ds.controls.select :as select] + [app.main.ui.shapes.text.html-text :as html-text] + [app.main.ui.shapes.text.styles :as text-styles] + [app.main.ui.workspace.sidebar.options.common :refer [radio-selected]] + [app.main.ui.workspace.sidebar.options.menus.text-japanese-layout :as tjl] + [app.util.text.writing-mode :as wm] + [cljs.test :as t :include-macros true] + [cuerdas.core :as str] + [rumext.v2 :as mf])) + +(def ^:private japanese-span-attrs + [:text-combine-upright + :text-emphasis + :ruby + :ruby-hidden + :ruby-size + :ruby-align + :ruby-overhang + :ruby-side + :warichu + :font-features + :annotation-clearance]) + + +(t/deftest japanese-layout-controls-follow-file-or-profile-configuration + (with-redefs [wm/vertical-layout-active? (constantly true)] + (t/is (false? (tjl/japanese-layout-config-enabled? {}))) + (t/is (false? (tjl/japanese-layout-config-enabled? {:file? false :all-files? false}))) + (t/is (true? (tjl/japanese-layout-config-enabled? {:file? true :all-files? false}))) + (t/is (true? (tjl/japanese-layout-config-enabled? {:file? false :all-files? true})))) + (with-redefs [wm/vertical-layout-active? (constantly false)] + (t/is (false? (tjl/japanese-layout-config-enabled? {:file? true :all-files? true}))))) + +(t/deftest text-emphasis-select-reports-and-restores-canonical-values + (let [options (tjl/text-emphasis-options identity) + reported-id (:id (select/get-option options "filled-dot"))] + (t/is (= "filled-dot" reported-id)) + (t/is (= "filled-dot" + (:id (select/get-option options reported-id)))))) + +(t/deftest annotation-clearance-select-reports-canonical-values + (let [options (tjl/annotation-clearance-options identity)] + (t/is (= ["none" "auto"] (mapv :id options))) + (t/is (= "auto" (:id (select/get-option options "auto")))))) + +(t/deftest unrestricted-tcy-is-only-offered-for-a-text-selection + (t/is (= ["none" "digits"] + (mapv :value (tjl/text-combine-upright-options false identity)))) + (t/is (= ["none" "all" "digits"] + (mapv :value (tjl/text-combine-upright-options true identity))))) + +(t/deftest ruby-container-styles-preserve-customization + (let [style (text-styles/generate-ruby-container-styles + {:ruby-align "space-between" + :ruby-overhang "none" + :ruby-side "under"})] + (t/is (= "space-between" (.-rubyAlign style))) + (t/is (= "none" (.-rubyOverhang style))) + (t/is (= "under" (.-rubyPosition style))))) + +(t/deftest advanced-furigana-options-are-collapsed-by-default + (let [markup (rds/renderToStaticMarkup + (mf/element tjl/ruby-presentation-options* + #js {:values {} + :on-change identity + :on-blur identity}))] + (t/is (str/includes? markup "data-testid=\"ruby-presentation-options-toggle\"")) + (t/is (str/includes? markup "aria-expanded=\"false\"")) + (t/is (str/includes? markup "workspace.options.text-options.ruby-advanced-options")))) + +(t/deftest advanced-furigana-options-include-hide-toggle + (let [markup (rds/renderToStaticMarkup + (mf/element tjl/ruby-hidden-option* + #js {:values {:ruby-hidden true} + :on-change identity + :on-blur identity}))] + (t/is (str/includes? markup "workspace.options.text-options.ruby-hidden")) + (t/is (str/includes? markup "role=\"switch\"")) + (t/is (str/includes? markup "aria-checked=\"true\"")))) + +(t/deftest hidden-furigana-renders-base-text-without-ruby-layout + (let [markup (rds/renderToStaticMarkup + (mf/element html-text/render-text* + #js {:node {:text "日" + :ruby "にち" + :ruby-hidden true} + :parent {} + :shape {}}))] + (t/is (str/includes? markup ">日")) + (t/is (not (str/includes? markup "