✨ Add language-specific CJK layout controls

Add independent Japanese, Chinese and Korean file switches and all-files
preferences under the shared vertical-text-layout feature flag. Keep
control availability separate from saved paragraph profiles and rendering.

Expose explicit language profiles in separate sidebar sections. Preserve
mixed values, report the active paragraph profile at the caret, and keep
shared writing-mode controls consistent without radio group collisions.

Validation: common JVM/JS and frontend suites, backend profile tests,
exporter tests and lint pass. Existing runner formatting and Spanish
translation QA issues remain unchanged.

AI-assisted-by: GPT-6
This commit is contained in:
alonso.torres 2026-10-19 21:48:59 +02:00
parent 0dd12b09e9
commit 1ca964e438
25 changed files with 672 additions and 145 deletions

View File

@ -63,6 +63,8 @@
[: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]
[:mcp-enabled {:optional true} ::sm/boolean]
[:newsletter-updates {:optional true} ::sm/boolean]
[:newsletter-news {:optional true} ::sm/boolean]

View File

@ -179,6 +179,29 @@
(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)

View File

@ -42,6 +42,11 @@
:multiple)
(get obj attr ::unset)))
;; An absent language is the plain profile, so it must participate
;; when reporting mixed text selections.
(#{:text-language :korean-wrap} attr)
(get obj attr)
:else
(get obj attr ::unset))))

View File

@ -57,6 +57,7 @@
"text-editor/v2"
"text-editor-wasm/v1"
"text-editor-wasm/v1-html-paste"
"vertical-text-layout"
"render-wasm/v1"
"variants/v1"})
@ -84,6 +85,7 @@
"text-editor-wasm/v1"
"text-editor-wasm/v1-html-paste"
"tokens/numeric-input"
"vertical-text-layout"
"render-wasm/v1"})
;; Features that are mainly backend only or there are a proper
@ -134,6 +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"
:feature-render-wasm "render-wasm/v1"
:feature-variants "variants/v1"
:feature-token-input "tokens/numeric-input"

View File

@ -440,9 +440,10 @@
[:file-id ::sm/uuid]
[:library-id [:maybe ::sm/uuid]]]]
[:set-japanese-layout
[:map {:title "SetJapaneseLayout"}
[:type [:= :set-japanese-layout]]
[:set-text-layout
[:map {:title "SetTextLayout"}
[:type [:= :set-text-layout]]
[:language [:enum :japanese :chinese :korean]]
[:enabled ::sm/boolean]]]])
(def schema:changes
@ -1097,9 +1098,9 @@
[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))
(defmethod process-change :set-text-layout
[data {:keys [language enabled]}]
(ctf/set-text-layout-enabled data language enabled))
;; === Operations

View File

@ -1150,15 +1150,17 @@
:library-id prev-val})
(apply-changes-local))))
(defn set-japanese-layout
[changes enabled?]
(defn set-text-layout
[changes language enabled?]
(assert-file-data! changes)
(let [file-data (::file-data (meta changes))
previous-enabled (ctf/japanese-layout-enabled? file-data)]
(let [file-data (::file-data (meta changes))
previous-enabled (ctf/text-layout-enabled? file-data language)]
(-> changes
(update :redo-changes conj {:type :set-japanese-layout
(update :redo-changes conj {:type :set-text-layout
:language language
:enabled enabled?})
(update :undo-changes conj {:type :set-japanese-layout
(update :undo-changes conj {:type :set-text-layout
:language language
:enabled previous-enabled})
(apply-changes-local))))

View File

@ -156,6 +156,7 @@
;; Show WASM renderer info label (hidden by default).
:render-wasm-info
:render-switch
:vertical-text-layout
:hide-release-modal
:subscriptions
:inspect-styles

View File

@ -78,7 +78,9 @@
[:map {:title "FileOptions"}
[:components-v2 {:optional true} ::sm/boolean]
[:base-font-size {:optional true} :string]
[:japanese-layout {:optional true} ::sm/boolean]])
[:japanese-layout {:optional true} ::sm/boolean]
[:chinese-layout {:optional true} ::sm/boolean]
[:korean-layout {:optional true} ::sm/boolean]])
(def schema:data
[:map {:title "FileData"}
@ -1256,17 +1258,32 @@
(assoc-in file-data [:options :base-font-size] base-font-size))
;; Japanese text layout
;; Text layout controls
(defn japanese-layout-enabled?
[file-data]
(true? (get-in file-data [:options :japanese-layout])))
(defn text-layout-option
[language]
(case language
:japanese :japanese-layout
:chinese :chinese-layout
:korean :korean-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)))
(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?
[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))))
;; Ref Chains

View File

@ -148,4 +148,12 @@
{:x -153}]
result (attrs/get-attrs-multi objs [:x])]
(t/is (= {:x :multiple} result)
"Different positions should be :multiple"))))
"Different positions should be :multiple"))))
(t/deftest unset-language-profile-counts-as-a-distinct-value
(t/is (= {:text-language :multiple :korean-wrap :multiple}
(attrs/get-attrs-multi [{} {:text-language "ko" :korean-wrap "spaces-only"}]
[:text-language :korean-wrap])))
(t/is (= {:text-language :multiple}
(attrs/get-attrs-multi [{:text-language "ko"} {}] [:text-language])))
(t/is (= {:text-language nil :korean-wrap nil}
(attrs/get-attrs-multi [{} {}] [:text-language :korean-wrap]))))

View File

@ -251,25 +251,39 @@
"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 language-layout-changes-are-independent
(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))
restored (ch/process-changes edited (:undo-changes changes))]
(t/is (= enabled? (ctf/text-layout-enabled? edited language)))
(t/is (= data restored))
(t/is (sm/validate ctf/schema:options (json-roundtrip (:options edited))))
(t/is (= (:options edited) (json-roundtrip (:options edited))))))))
(t/deftest add-obj
(let [file-id (uuid/custom 2 2)

View File

@ -679,15 +679,15 @@
(dm/export layout/toggle-layout-flag)
(dm/export layout/remove-layout-flag)
(defn set-japanese-layout
[enabled?]
(ptk/reify ::set-japanese-layout
(defn set-text-layout
[language enabled?]
(ptk/reify ::set-text-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?))]
changes (-> (pcb/empty-changes it)
(pcb/with-file-data file-data)
(pcb/set-text-layout language enabled?))]
(rx/of (dch/commit-changes changes))))))
;; --- Profile

View File

@ -290,13 +290,21 @@
"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 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}]

View File

@ -7,10 +7,12 @@
(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]
[app.main.data.profile :as du]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.router :as rt]
[app.main.store :as st]
@ -115,35 +117,46 @@
: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]}]
(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 (if enabled?
"enable-japanese-layout-all-files"
"disable-japanese-layout-all-files")
(st/emit! (ev/event {::ev/name (str (if enabled? "enable-" "disable-")
(name language) "-layout-all-files")
::ev/origin "settings"})
(du/update-profile-props
{:japanese-layout-all-files enabled?})
{(ctf/text-layout-preference language) 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}]]]))
[:> switch* {:label label
:default-checked enabled
:on-change handle-change}]))
(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 options-page*
[]
(let [profile (mf/deref refs/profile)
renderer (or (-> profile :props :renderer) :svg)
japanese-layout-enabled (:all-files? (mf/deref refs/japanese-layout-config))]
layout-config (mf/deref refs/text-layout-config)
layout-feature? (features/use-feature "vertical-text-layout")]
(mf/use-effect
#(dom/set-html-title (tr "title.settings.options")))
@ -157,6 +170,7 @@
:id "options-section-title"}
(tr "labels.settings")]
[:> options-form*]]
[:> japanese-layout-settings* {:enabled japanese-layout-enabled}]
(when layout-feature?
[:> language-layout-settings* {:config layout-config}])
(when (contains? cf/flags :render-switch)
[:> webgl-settings* {:renderer renderer}])]]))

View File

@ -23,7 +23,7 @@
/* Settings sections with standalone controls. */
.webgl-container,
.japanese-layout-container {
.text-layout-container {
display: grid;
grid-auto-rows: auto;
gap: var(--sp-s);
@ -34,7 +34,10 @@
color: var(--color-foreground-primary);
}
.japanese-layout-form {
.text-layout-form {
display: flex;
flex-direction: column;
gap: var(--sp-s);
padding-block-start: var(--sp-xs);
}

View File

@ -45,7 +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]
[app.util.text.layout :as tl]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]
[rumext.v2 :as mf]))
@ -231,13 +231,28 @@
[: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 [japanese-layout-enabled japanese-layout-all-files layout profile toggle-flag
on-close toggle-theme toggle-render toggle-japanese-layout]}]
[{: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))
show-nudge-options
(mf/use-fn
@ -320,20 +335,24 @@
: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"))]])
[:> 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)
@ -904,8 +923,7 @@
(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)
layout-config (mf/deref refs/text-layout-config)
show-menu* (mf/use-state false)
show-menu? (deref show-menu*)
@ -967,12 +985,12 @@
(dom/stop-propagation event)
(st/emit! (du/toggle-theme))))
toggle-japanese-layout
toggle-text-layout
(mf/use-fn
(mf/deps japanese-layout-enabled)
(fn [event]
(mf/deps layout-config)
(fn [language event]
(dom/stop-propagation event)
(st/emit! (dw/set-japanese-layout (not japanese-layout-enabled)))
(st/emit! (dw/set-text-layout language (not (get-in layout-config [language :file?]))))
(reset! show-menu* false)
(reset! selected-sub-menu* nil)))
@ -1175,13 +1193,12 @@
:preferences
[:> preferences-menu* {:layout layout
:profile profile
:japanese-layout-enabled japanese-layout-enabled
:japanese-layout-all-files (:all-files? japanese-layout)
:layout-config layout-config
:toggle-flag toggle-flag
:toggle-theme toggle-theme
:toggle-render toggle-render
:show-shortcuts show-shortcuts
:toggle-japanese-layout toggle-japanese-layout
:toggle-text-layout toggle-text-layout
:on-close close-sub-menu}]
:plugins

View File

@ -32,6 +32,7 @@
[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*]]
[app.main.ui.workspace.tokens.management.forms.controls.utils :as csu]
@ -39,6 +40,7 @@
[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]
@ -314,6 +316,10 @@
(get n-values :text-decoration))
(identical? (get o-values :text-direction)
(get n-values :text-direction))
(identical? (get o-values :text-language)
(get n-values :text-language))
(identical? (get o-values :korean-wrap)
(get n-values :korean-wrap))
(identical? (get o-values :writing-mode)
(get n-values :writing-mode))
(identical? (get o-values :text-orientation)
@ -355,6 +361,26 @@
;; 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
@ -362,15 +388,11 @@
(let [;; --- UI state
menu-state* (mf/use-state {:main-menu true
:more-options false
:japanese-layout true})
:more-options false})
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))
layout-config (mf/deref refs/text-layout-config)
font-id (or (:font-id values) (:font-id txt/default-typography))
@ -461,10 +483,6 @@
(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))
@ -563,7 +581,7 @@
:show-recent true
:on-blur on-text-blur})
japanese-layout-props
language-layout-props
(mf/props
{:ids ids
:values values
@ -683,17 +701,15 @@
[:> 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]])])]))
[:> 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}]]))

View File

@ -18,7 +18,6 @@
[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
@ -49,14 +48,6 @@
: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]
@ -93,6 +84,10 @@
"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)
group-id (mf/use-id)
options (mf/with-memo [options group-id]
(mapv #(update % :id (fn [id] (str group-id "-" id))) options))
name (str group-id "-" name)
handle-change
(mf/use-fn
(mf/deps attr on-change on-blur)
@ -127,6 +122,24 @@
:label (translate "workspace.options.text-options.text-orientation-upright")
:icon i/text-orientation-upright}])
(mf/defc writing-mode-controls*
[{:keys [values on-change on-blur]}]
(let [props (mf/props {:values values :on-change on-change :on-blur on-blur})]
[:div {:class (stl/css :japanese-icon-options)}
[:> attr-radio-options* (mf/spread-props 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 tr)})]
(when (vertical-japanese-layout? values)
[:> attr-radio-options* (mf/spread-props 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 tr)})])]))
;; Warichu (割注): renders the selection as two half-size lines in one inline
;; position (top/bottom horizontally, right/left vertically).
(defn warichu-options

View File

@ -0,0 +1,69 @@
;; 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.workspace.sidebar.options.menus.text-language-layout
(:require-macros [app.main.style :as stl])
(:require
[app.main.ui.ds.controls.select :refer [select*]]
[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]))
(defn profile-options
[language value translate]
(let [options (into [{:id "" :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")}]
:korean [{:id "ko" :label (translate "workspace.options.text-options.profile-korean")}]))
mixed? (= :multiple value)
id (if mixed? "multiple" (or value ""))]
(cond-> options
(not (some #(= (:id %) id) options))
(conj {:id id
:label (if mixed? (translate "labels.mixed-values") value)
:disabled true
:dimmed true}))))
(defn profile-change
[value]
{:text-language (when (seq value) value)})
(mf/defc language-layout-options*
[{:keys [language values ruby-values text-selection-active
on-change on-ruby-presentation-change on-blur]}]
(let [profile (:text-language values)
selected (if (= :multiple profile) "multiple" (or 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})]
[: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}]]
(if (and (= :japanese language) matches?)
[:> 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 (= :korean language) matches?)
[:> tjl/span-select-option*
(mf/spread-props props
{:attr :korean-wrap
:default-value "syllable"
:label (tr "workspace.options.text-options.korean-wrap")
:options [{:id "syllable" :label (tr "workspace.options.text-options.korean-wrap-syllable")}
{:id "spaces-only" :label (tr "workspace.options.text-options.korean-wrap-spaces-only")}]})])]))

View File

@ -0,0 +1,27 @@
// 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;
.language-layout-options {
display: flex;
flex-direction: column;
gap: var(--sp-xs);
padding-block: var(--sp-xs);
}
.profile-option {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr);
align-items: center;
gap: var(--sp-s);
}
.option-label {
@include t.use-typography("body-small");
color: var(--color-foreground-secondary);
}

View File

@ -808,13 +808,36 @@
{}
spans)))))
(defn selection-paragraph-styles
"Profile attrs of the selected paragraphs, including unset and mixed values."
[content selection]
(when (and content selection)
(let [{:keys [start-para end-para]} (normalize-selection selection)
paragraphs (-> content :children first :children)]
(reduce
(fn [result paragraph]
(reduce
(fn [result attr]
(let [value (get paragraph attr)]
(assoc result attr
(if (and (contains? result attr)
(not= (get result attr) value))
:multiple
value))))
result
[:text-language :korean-wrap]))
{}
(subvec paragraphs start-para (inc end-para))))))
(defn text-editor-get-current-japanese-styles
"Japanese span styles of the active WASM editor selection."
"Japanese span styles and paragraph profiles of the active editor selection."
[]
(when (wasm/ready?)
(let [shape-id (text-editor-get-active-shape-id)
selection (text-editor-get-selection)]
(selection-japanese-styles (get-cached-content shape-id) selection))))
selection (text-editor-get-selection)
content (get-cached-content shape-id)]
(merge (selection-japanese-styles content selection)
(selection-paragraph-styles content selection)))))
(defn apply-attrs-to-paragraph
"Apply `styles` (attrs map, or a fn per span) within [sel-start, sel-end), splitting spans."

View File

@ -0,0 +1,30 @@
;; 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.util.text.layout
(:require
[app.util.text.writing-mode :as wm]
[clojure.string :as str]))
(defn controls-enabled?
[{:keys [feature-enabled file? all-files?]}]
(and (true? feature-enabled)
^boolean (wm/vertical-layout-active?)
(boolean (or file? all-files?))))
(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 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) #"-")))))

View File

@ -81,6 +81,22 @@
(text-editor/selection-japanese-styles
content (selection 0 1 0 2))))))
(t/deftest paragraph-profiles-follow-the-editor-selection
(let [content {:type "root"
:children [{:type "paragraph-set"
:children [{:type "paragraph" :children [{:text "Plain"}]}
{:type "paragraph" :text-language "ko"
:korean-wrap "spaces-only"
:children [{:text "한국어"}]}]}]}]
(t/is (= {:text-language nil :korean-wrap nil}
(text-editor/selection-paragraph-styles content (selection 0 0 0 0))))
(t/is (= {:text-language "ko" :korean-wrap "spaces-only"}
(text-editor/selection-paragraph-styles content (selection 1 1 1 1))))
(t/is (= {:text-language :multiple :korean-wrap :multiple}
(text-editor/selection-paragraph-styles content (selection 0 0 1 1))))
(t/is (= {:text-language :multiple :korean-wrap :multiple}
(text-editor/selection-paragraph-styles content (selection 1 1 0 0))))))
(t/deftest japanese-styles-report-mixed-ranges-and-follow-the-caret
(let [content (editor-content
[{:text "平成"

View File

@ -16,6 +16,8 @@
[app.main.ui.workspace.sidebar.options.common :refer [radio-selected]]
[app.main.ui.workspace.sidebar.options.menus.text :as text-menu]
[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.util.text.layout :as tl]
[app.util.text.writing-mode :as wm]
[cljs.test :as t :include-macros true]
[cuerdas.core :as str]
@ -35,14 +37,75 @@
:annotation-clearance])
(t/deftest japanese-layout-controls-follow-file-or-profile-configuration
(t/deftest 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}))))
(t/is (false? (tl/controls-enabled? {})))
(t/is (false? (tl/controls-enabled? {:feature-enabled true :file? false :all-files? false})))
(t/is (true? (tl/controls-enabled? {:feature-enabled true :file? true :all-files? false})))
(t/is (true? (tl/controls-enabled? {:feature-enabled true :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/is (false? (tl/controls-enabled? {:feature-enabled true :file? true :all-files? true})))))
(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)))))
(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 "")))
(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 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 "labels.mixed-values" :disabled true :dimmed true} mixed))
(t/is (= {:id "ja" :label "ja" :disabled true :dimmed true} other))
(t/is (= ["" "zh-Hans" "zh-Hant"]
(mapv :id (tll/profile-options :chinese nil identity))))))
(t/deftest language-specific-controls-require-an-explicit-matching-profile
(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]]
(let [changes (atom [])
markup (rds/renderToStaticMarkup
(mf/element tll/language-layout-options*
#js {:language language
:values {:text-language profile}
:on-change #(swap! changes conj %)}))]
(t/is (= (= profile attr) (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/deftest shared-writing-mode-controls-have-independent-radio-groups
(with-redefs [hooks/use-portal-container (fn ([] nil) ([_] nil))]
(let [markup (rds/renderToStaticMarkup
(mf/html
[:div
[:> tll/language-layout-options* {:language :japanese :values {} :on-change identity}]
[:> tll/language-layout-options* {:language :chinese :values {} :on-change identity}]
[:> tll/language-layout-options* {:language :korean :values {} :on-change identity}]]))
inputs (re-seq #"<input[^>]+>" markup)
ids (map #(second (re-find #"id=\"([^\"]+)\"" %)) inputs)
names (map #(second (re-find #"name=\"([^\"]+)\"" %)) inputs)]
(t/is (= 6 (count (set ids))))
(t/is (= 3 (count (set names))))
(t/is (= 3 (count (filter #(str/includes? % "checked=\"\"") inputs)))))))
(t/deftest text-emphasis-select-reports-and-restores-canonical-values
(let [options (tjl/text-emphasis-options identity)

View File

@ -9945,3 +9945,79 @@ msgstr ""
#: src/app/main/ui/workspace/sidebar/versions.cljs:462
msgid "workspace.versions.warning.text"
msgstr "Autosaved versions will be kept for %s days."
#: src/app/main/ui/settings/options.cljs
msgid "dashboard.text-layout.title"
msgstr "Text layout by language"
#: src/app/main/ui/settings/options.cljs
msgid "dashboard.text-layout.description"
msgstr "Choose which text layout controls are available in all files."
#: src/app/main/ui/settings/options.cljs
msgid "dashboard.chinese-layout.enable-all-files"
msgstr "Enable Chinese text layout for all files"
#: src/app/main/ui/settings/options.cljs
msgid "dashboard.korean-layout.enable-all-files"
msgstr "Enable Korean text layout for all files"
#: src/app/main/ui/workspace/main_menu.cljs
msgid "workspace.header.menu.enable-chinese-layout"
msgstr "Enable Chinese text layout"
#: src/app/main/ui/workspace/main_menu.cljs
msgid "workspace.header.menu.disable-chinese-layout"
msgstr "Disable Chinese text layout"
#: src/app/main/ui/workspace/main_menu.cljs
msgid "workspace.header.menu.enable-korean-layout"
msgstr "Enable Korean text layout"
#: src/app/main/ui/workspace/main_menu.cljs
msgid "workspace.header.menu.disable-korean-layout"
msgstr "Disable Korean text layout"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.chinese-layout"
msgstr "Chinese text layout"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.korean-layout"
msgstr "Korean text layout"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.language-profile"
msgstr "Language profile"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.profile-plain"
msgstr "No language profile"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.profile-japanese"
msgstr "Japanese"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.profile-chinese-simplified"
msgstr "Simplified Chinese"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.profile-chinese-traditional"
msgstr "Traditional Chinese"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.profile-korean"
msgstr "Korean"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.korean-wrap"
msgstr "Line wrapping"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.korean-wrap-syllable"
msgstr "By syllable"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.korean-wrap-spaces-only"
msgstr "At spaces (experimental)"

View File

@ -9821,3 +9821,79 @@ msgstr "Si quieres aumentar este límite, contáctanos en [support@penpot.app](%
#: src/app/main/ui/workspace/sidebar/versions.cljs:462
msgid "workspace.versions.warning.text"
msgstr "Los autoguardados duran %s días."
#: src/app/main/ui/settings/options.cljs
msgid "dashboard.text-layout.title"
msgstr "Disposición del texto por idioma"
#: src/app/main/ui/settings/options.cljs
msgid "dashboard.text-layout.description"
msgstr "Elige qué controles de disposición del texto están disponibles en todos los archivos."
#: src/app/main/ui/settings/options.cljs
msgid "dashboard.chinese-layout.enable-all-files"
msgstr "Activar la disposición del texto chino en todos los archivos"
#: src/app/main/ui/settings/options.cljs
msgid "dashboard.korean-layout.enable-all-files"
msgstr "Activar la disposición del texto coreano en todos los archivos"
#: src/app/main/ui/workspace/main_menu.cljs
msgid "workspace.header.menu.enable-chinese-layout"
msgstr "Activar la disposición del texto chino"
#: src/app/main/ui/workspace/main_menu.cljs
msgid "workspace.header.menu.disable-chinese-layout"
msgstr "Desactivar la disposición del texto chino"
#: src/app/main/ui/workspace/main_menu.cljs
msgid "workspace.header.menu.enable-korean-layout"
msgstr "Activar la disposición del texto coreano"
#: src/app/main/ui/workspace/main_menu.cljs
msgid "workspace.header.menu.disable-korean-layout"
msgstr "Desactivar la disposición del texto coreano"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.chinese-layout"
msgstr "Disposición del texto chino"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.korean-layout"
msgstr "Disposición del texto coreano"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.language-profile"
msgstr "Perfil de idioma"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.profile-plain"
msgstr "Sin perfil de idioma"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.profile-japanese"
msgstr "Japonés"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.profile-chinese-simplified"
msgstr "Chino simplificado"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.profile-chinese-traditional"
msgstr "Chino tradicional"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.profile-korean"
msgstr "Coreano"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.korean-wrap"
msgstr "Salto de línea"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.korean-wrap-syllable"
msgstr "Por sílaba"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.korean-wrap-spaces-only"
msgstr "En los espacios (experimental)"