✨ Add scale options panel to the right sidebar

This commit is contained in:
elhombretecla 2026-10-19 16:40:41 +02:00
parent c345c7cbaa
commit 9957cba277
13 changed files with 523 additions and 5 deletions

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
<path d="M5.88 10.12L10.12 5.88M5.88 8L5.88 10.12L8 10.12M8 5.88L10.12 5.88L10.12 8M3.4 8.35L3.4 12.6L7.65 12.6M8.35 3.4L12.6 3.4L12.6 7.65"/>
</svg>

After

Width:  |  Height:  |  Size: 262 B

View File

@ -1688,6 +1688,7 @@
(dm/export dwt/update-positions)
(dm/export dwt/flip-horizontal-selected)
(dm/export dwt/flip-vertical-selected)
(dm/export dwt/scale-selected)
(dm/export dwly/set-opacity)
;; Common

View File

@ -57,6 +57,14 @@
(.preventDefault event)
(st/emit! (toggle-layout-flag :display-guides)))))
(defn- escape
"Leave the scale content mode when it is active, keeping the
selection; otherwise clear the current drawing and selection."
[]
(if (contains? (deref refs/workspace-layout) :scale-text)
(st/emit! :interrupt (dw/remove-layout-flag :scale-text))
(st/emit! :interrupt (dwdc/clear-drawing) (dw/deselect-all true))))
(defn- emit-when-no-readonly
[& events]
(let [can-edit? (:can-edit (deref refs/permissions))
@ -178,7 +186,7 @@
:command "escape"
:subsections [:edit]
:section [:workspace]
:fn #(st/emit! :interrupt (dwdc/clear-drawing) (dw/deselect-all true))}
:fn escape}
:find {:tooltip (ds/meta "F") :command (ds/c-mod "f") :subsections [:edit]
:label (fn [] (tr "shortcuts.find"))

View File

@ -1593,6 +1593,59 @@
(rx/of (dwm/apply-wasm-modifiers modifiers {:ignore-snap-pixel true}))
(rx/of (dwm/apply-modifiers {:modifiers modifiers :ignore-snap-pixel true}))))))))
;; -- Scale ---------------------------------------------------------
(defn- anchor->point
"Return the point of `rect` that matches the `anchor`, a
`[horizontal vertical]` pair where horizontal is one of `:left`,
`:center` or `:right` and vertical one of `:top`, `:center` or
`:bottom`."
[rect [h v]]
(let [{:keys [x y width height]} rect]
(gpt/point (case h :left x :right (+ x width) (+ x (/ width 2)))
(case v :top y :bottom (+ y height) (+ y (/ height 2))))))
(defn scale-selected
"Scale the selected shapes and their content (strokes, texts,
shadows...) by `factor`, keeping the `anchor` point of the selection
bounds in place. Behaves like a resize in the scale content mode."
[factor anchor]
(assert (and (number? factor) (pos? factor)) "expected a positive factor")
(ptk/reify ::scale-selected
ptk/WatchEvent
(watch [_ state _]
(let [objects (dsh/lookup-page-objects state)
shapes (->> (dsh/lookup-selected state {:omit-blocked? true})
(map (d/getf objects))
(remove ctk/is-variant-container?))]
(if (or (empty? shapes) (mth/close? factor 1))
(rx/empty)
(let [ids (map :id shapes)
origin (anchor->point (gsh/shapes->rect shapes) anchor)
scalev (gpt/point factor factor)
get-modifier
(fn [shape]
(let [grow-type (get shape :grow-type)
new-grow-type (when (cfh/text-shape? shape)
(dwm/next-grow-type grow-type scalev))]
(cond-> (ctm/resize-modifiers scalev origin)
:always
(ctm/scale-content factor)
(and (some? new-grow-type) (not= new-grow-type grow-type))
(ctm/change-property :grow-type new-grow-type))))
modif-tree (dwm/build-modif-tree ids objects get-modifier)]
(if (features/active-feature? state "render-wasm/v1")
(rx/of (dwm/apply-wasm-modifiers modif-tree
:ignore-constraints true
:ignore-snap-pixel true))
(rx/of (dwm/apply-modifiers {:modifiers modif-tree
:ignore-constraints true
:ignore-snap-pixel true})))))))))
(defn fit-layout-modifiers
[objects frame]
;; Set temporaly the auto flag and calculate a reflow to resize and position

View File

@ -12,7 +12,7 @@
[app.main.constants :refer [max-input-length]]
[app.main.ui.ds.controls.select :refer [handle-focus-change]]
[app.main.ui.ds.controls.shared.options-dropdown :refer [options-dropdown* schema:option]]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.ds.foundations.assets.icon :refer [icon* icon-list] :as i]
[app.util.dom :as dom]
[app.util.globals :as globals]
[app.util.keyboard :as kbd]
@ -23,6 +23,9 @@
[rumext.v2.util :as mfu])
(:import goog.events.EventType))
(def ^:private schema:icon
[:and :string [:fn #(contains? icon-list %)]])
(def ^:private schema:combobox
[:map
[:id {:optional true} :string]
@ -30,6 +33,7 @@
[:class {:optional true} :string]
[:max-length {:optional true} :int]
[:placeholder {:optional true} :string]
[:icon {:optional true} [:maybe schema:icon]]
[:disabled {:optional true} :boolean]
[:default-selected {:optional true} :string]
[:select-only {:optional true} :boolean]
@ -39,7 +43,7 @@
(mf/defc combobox*
{::mf/schema schema:combobox}
[{:keys [id options class placeholder disabled has-error default-selected select-only max-length empty-to-end on-change] :rest props}]
[{:keys [id options class placeholder icon disabled has-error default-selected select-only max-length empty-to-end on-change] :rest props}]
(let [;; NOTE: we use mfu/bean here for transparently handle
;; options provide as clojure data structures or javascript
;; plain objects and lists.
@ -239,8 +243,9 @@
(d/seek #(= selected-id (get % :id)) options)))
icon
(when selected-option
(get selected-option :icon))
(or (when selected-option
(get selected-option :icon))
icon)
avatar
(when selected-option

View File

@ -53,6 +53,18 @@ These are available in the `app.main.ds.foundations.assets.icon` namespace.
]}]
```
The `combobox*` itself also accepts an optional `icon`. It shows at the start of
the input when the selected option has no icon of its own, for example when
the user types a value that matches no option.
```clj
[:> combobox*
{:icon i/scale
:default-selected "1"
:options [{ :label "1x" :id "1" }
{ :label "2x" :id "2" }]}]
```
### Avatars

View File

@ -72,6 +72,12 @@ export const WithIcons = {
},
};
export const WithIcon = {
args: {
icon: "scale",
},
};
export const EmptyToEnd = {
args: {
emptyToEnd: true,

View File

@ -235,6 +235,7 @@
(def ^:icon-id rotation "rotation")
(def ^:icon-id row "row")
(def ^:icon-id row-reverse "row-reverse")
(def ^:icon-id scale "scale")
(def ^:icon-id search "search")
(def ^:icon-id separate-nodes "separate-nodes")
(def ^:icon-id settings "settings")

View File

@ -37,6 +37,7 @@
[app.main.ui.workspace.sidebar.history :refer [history-toolbox*]]
[app.main.ui.workspace.sidebar.layers :refer [layers-toolbox*]]
[app.main.ui.workspace.sidebar.options :refer [options-toolbox*]]
[app.main.ui.workspace.sidebar.scale :refer [scale-sidebar*]]
[app.main.ui.workspace.sidebar.shortcuts :refer [shortcuts-container*]]
[app.main.ui.workspace.sidebar.sitemap :refer [sitemap*]]
[app.main.ui.workspace.sidebar.versions :refer [versions-toolbox*]]
@ -286,6 +287,7 @@
[{:keys [layout section file-id page-id drawing-tool active-tokens] :as props}]
(let [is-comments? (= drawing-tool :comments)
is-history? (contains? layout :document-history)
is-scale? (contains? layout :scale-text)
is-inspect? (= section :inspect)
is-debug? (= section :debug)
@ -295,6 +297,7 @@
can-be-expanded?
(and (not is-comments?)
(not is-history?)
(not is-scale?)
is-inspect?
(= current-section :code))
@ -355,6 +358,9 @@
is-history?
[:> history-content* {}]
is-scale?
[:> scale-sidebar* {}]
:else
(let [props (mf/spread-props props
{:on-change-section on-change-section

View File

@ -0,0 +1,228 @@
;; 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.scale
"Right sidebar panel of the scale content mode (K shortcut)."
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.geom.shapes :as gsh]
[app.common.math :as mth]
[app.main.data.workspace :as dw]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.ds.controls.combobox :refer [combobox*]]
[app.main.ui.ds.controls.numeric-input :refer [numeric-input*]]
[app.main.ui.ds.controls.utilities.label :refer [label*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.product.panel-title :refer [panel-title*]]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(def ^:private scale-presets
[0.5 0.75 1 1.5 2 3 4])
(def ^:private anchors
[[:left :top] [:center :top] [:right :top]
[:left :center] [:center :center] [:right :center]
[:left :bottom] [:center :bottom] [:right :bottom]])
(def ^:private indexed-anchors
(d/enumerate anchors))
(def ^:private default-anchor
[:center :center])
(defn- factor->id
[factor]
(str (mth/precision factor 2)))
(defn- parse-factor
"Parse a scale typed by the user, like `2`, `1.5x` or `0,5x`"
[value]
(let [factor (-> (str value)
(str/trim)
(str/rtrim "xX")
(str/replace "," ".")
(d/parse-double))]
(when (and (some? factor) (pos? factor))
factor)))
(defn- anchor-label
[anchor]
(case anchor
[:left :top] (tr "workspace.scale.anchor.top-left")
[:center :top] (tr "workspace.scale.anchor.top-center")
[:right :top] (tr "workspace.scale.anchor.top-right")
[:left :center] (tr "workspace.scale.anchor.center-left")
[:center :center] (tr "workspace.scale.anchor.center-center")
[:right :center] (tr "workspace.scale.anchor.center-right")
[:left :bottom] (tr "workspace.scale.anchor.bottom-left")
[:center :bottom] (tr "workspace.scale.anchor.bottom-center")
[:right :bottom] (tr "workspace.scale.anchor.bottom-right")))
(mf/defc anchor-point-selector*
{::mf/private true}
[{:keys [value disabled on-change aria-labelledby]}]
(let [on-click
(mf/use-fn
(mf/deps on-change)
(fn [event]
(let [index (-> (dom/get-current-target event)
(dom/get-data "index")
(d/parse-integer))]
(on-change (nth anchors index)))))]
[:div {:class (stl/css :anchor-grid)
:role "radiogroup"
:aria-labelledby aria-labelledby}
(for [[index anchor] indexed-anchors]
(let [selected? (= anchor value)]
[:button {:key index
:type "button"
:role "radio"
:class (stl/css-case :anchor-point true
:center-area (= anchor default-anchor)
:selected selected?)
:aria-checked selected?
:aria-label (anchor-label anchor)
:disabled disabled
:data-index index
:on-click on-click}
[:span {:class (stl/css :anchor-mark)}]]))]))
(mf/defc scale-sidebar*
[]
(let [selected (mf/deref refs/selected-shapes)
shapes-ref (mf/with-memo [selected]
(refs/objects-by-id selected))
shapes (mf/deref shapes-ref)
rect (mf/with-memo [shapes]
(when (seq shapes)
(gsh/shapes->rect shapes)))
width (get rect :width)
height (get rect :height)
size (when rect (max width height))
disabled (or (nil? size) (mth/almost-zero? size))
;; The scale factor is relative to the size of the selection
;; when it was selected, so it is only reset when the
;; selection changes.
base-size (mf/with-memo [selected]
size)
factor (if (and (not disabled) (some? base-size) (pos? base-size))
(/ size base-size)
1)
factor-id (factor->id factor)
anchor* (mf/use-state default-anchor)
anchor (deref anchor*)
width-id (mf/use-id)
height-id (mf/use-id)
scale-id (mf/use-id)
anchor-id (mf/use-id)
scale-options
(mf/with-memo []
(mapv (fn [factor]
{:id (factor->id factor)
:label (str (factor->id factor) "x")})
scale-presets))
default-selected
(if (some #(= factor-id (:id %)) scale-options)
factor-id
(str factor-id "x"))
scale-to
(mf/use-fn
(mf/deps size anchor)
(fn [new-size]
(when (and (some? size) (pos? size) (pos? new-size))
(st/emit! (dw/scale-selected (/ new-size size) anchor)))))
on-width-change
(mf/use-fn
(mf/deps scale-to width size)
(fn [value]
(when (and (number? value) (pos? width))
(scale-to (* size (/ value width))))))
on-height-change
(mf/use-fn
(mf/deps scale-to height size)
(fn [value]
(when (and (number? value) (pos? height))
(scale-to (* size (/ value height))))))
on-scale-change
(mf/use-fn
(mf/deps scale-to base-size factor-id)
(fn [value]
;; The combobox also reports its value on blur, skip it when
;; it is the same rounded factor we are already showing.
(when-let [factor (parse-factor value)]
(when (not= factor-id (factor->id factor))
(scale-to (* base-size factor))))))
on-anchor-change
(mf/use-fn
#(reset! anchor* %))
on-close
(mf/use-fn
#(st/emit! (dw/remove-layout-flag :scale-text)))]
[:div {:class (stl/css :scale-sidebar)}
[:> panel-title* {:class (stl/css :scale-title)
:text (tr "workspace.scale.title")
:on-close on-close}]
[:div {:class (stl/css :scale-content)}
[:div {:class (stl/css :field)}
[:> label* {:for width-id} (tr "workspace.options.width")]
[:> numeric-input* {:id width-id
:icon i/character-w
:min 0.01
:disabled disabled
:placeholder "--"
:value width
:on-change on-width-change}]]
[:div {:class (stl/css :field)}
[:> label* {:for height-id} (tr "workspace.options.height")]
[:> numeric-input* {:id height-id
:icon i/character-h
:min 0.01
:disabled disabled
:placeholder "--"
:value height
:on-change on-height-change}]]
[:div {:class (stl/css :field)}
[:> label* {:for scale-id} (tr "workspace.scale.scale")]
[:> combobox* {:id scale-id
:icon i/scale
:disabled disabled
:options scale-options
:default-selected default-selected
:on-change on-scale-change}]]
[:div {:class (stl/css :field)}
[:span {:id anchor-id
:class (stl/css :anchor-title)}
(tr "workspace.scale.anchor-point")]
[:> anchor-point-selector* {:value anchor
:disabled disabled
:aria-labelledby anchor-id
:on-change on-anchor-change}]]]]))

View File

@ -0,0 +1,99 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS SUBSIDIARY SL
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_utils.scss" as *;
@use "ds/typography.scss" as t;
.scale-sidebar {
display: flex;
flex-direction: column;
background-color: var(--panel-background-color);
}
.scale-title {
margin: var(--sp-s) var(--sp-s) 0;
}
.scale-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sp-l) var(--sp-s);
padding: var(--sp-l) var(--sp-m);
}
.field {
display: flex;
flex-direction: column;
gap: var(--sp-xs);
min-inline-size: 0;
}
.anchor-title {
@include t.use-typography("body-small");
color: var(--color-foreground-primary);
}
.anchor-grid {
--anchor-side-size: #{$sz-24};
--anchor-center-size: #{px2rem(60)};
display: grid;
grid-template-columns: var(--anchor-side-size) var(--anchor-center-size) var(--anchor-side-size);
grid-template-rows: var(--anchor-side-size) var(--anchor-center-size) var(--anchor-side-size);
inline-size: fit-content;
block-size: fit-content;
border-radius: $br-8;
background-color: var(--color-background-tertiary);
}
// The unselected marks are 2/3 of the selected one, ring included
$anchor-selected-size: px2rem(10);
.anchor-point {
--anchor-mark-size: calc((#{$anchor-selected-size} + 2 * #{$b-2}) * 2 / 3);
--anchor-mark-color: var(--color-foreground-secondary);
--anchor-mark-outline-color: transparent;
all: unset;
display: grid;
place-items: center;
cursor: pointer;
&:hover:not(:disabled) {
--anchor-mark-color: var(--color-foreground-primary);
}
&:focus-visible {
outline: $b-1 solid var(--color-accent-primary);
border-radius: $br-4;
}
&:disabled {
cursor: default;
}
&.selected {
--anchor-mark-size: #{$anchor-selected-size};
--anchor-mark-color: var(--color-accent-primary);
--anchor-mark-outline-color: var(--color-accent-primary-muted);
}
}
.center-area {
border-radius: $br-8;
background-color: var(--color-background-primary);
}
.anchor-mark {
inline-size: var(--anchor-mark-size);
block-size: var(--anchor-mark-size);
border-radius: $br-4;
background-color: var(--anchor-mark-color);
outline: $b-2 solid var(--anchor-mark-outline-color);
}

View File

@ -8340,6 +8340,54 @@ msgstr "'%s' PLUGIN IS INSTALLED FOR YOUR USER!"
msgid "workspace.plugins.try-out.try"
msgstr "TRY PLUGIN"
#: src/app/main/ui/workspace/sidebar/scale.cljs
msgid "workspace.scale.anchor-point"
msgstr "Anchor point"
#: src/app/main/ui/workspace/sidebar/scale.cljs
msgid "workspace.scale.anchor.bottom-center"
msgstr "Bottom center"
#: src/app/main/ui/workspace/sidebar/scale.cljs
msgid "workspace.scale.anchor.bottom-left"
msgstr "Bottom left"
#: src/app/main/ui/workspace/sidebar/scale.cljs
msgid "workspace.scale.anchor.bottom-right"
msgstr "Bottom right"
#: src/app/main/ui/workspace/sidebar/scale.cljs
msgid "workspace.scale.anchor.center-center"
msgstr "Center"
#: src/app/main/ui/workspace/sidebar/scale.cljs
msgid "workspace.scale.anchor.center-left"
msgstr "Center left"
#: src/app/main/ui/workspace/sidebar/scale.cljs
msgid "workspace.scale.anchor.center-right"
msgstr "Center right"
#: src/app/main/ui/workspace/sidebar/scale.cljs
msgid "workspace.scale.anchor.top-center"
msgstr "Top center"
#: src/app/main/ui/workspace/sidebar/scale.cljs
msgid "workspace.scale.anchor.top-left"
msgstr "Top left"
#: src/app/main/ui/workspace/sidebar/scale.cljs
msgid "workspace.scale.anchor.top-right"
msgstr "Top right"
#: src/app/main/ui/workspace/sidebar/scale.cljs
msgid "workspace.scale.scale"
msgstr "Scale"
#: src/app/main/ui/workspace/sidebar/scale.cljs
msgid "workspace.scale.title"
msgstr "Scale"
#: src/app/main/ui/workspace/context_menu.cljs:659
msgid "workspace.shape.menu.add-flex"
msgstr "Add flex layout"

View File

@ -8431,6 +8431,54 @@ msgstr "¡LA EXTENSIÓN '%s' HA SIDO INSTALADA PARA TU USUARIO!"
msgid "workspace.plugins.try-out.try"
msgstr "PROBAR PLUGIN"
#: src/app/main/ui/workspace/sidebar/scale.cljs
msgid "workspace.scale.anchor-point"
msgstr "Punto de anclaje"
#: src/app/main/ui/workspace/sidebar/scale.cljs
msgid "workspace.scale.anchor.bottom-center"
msgstr "Abajo en el centro"
#: src/app/main/ui/workspace/sidebar/scale.cljs
msgid "workspace.scale.anchor.bottom-left"
msgstr "Abajo a la izquierda"
#: src/app/main/ui/workspace/sidebar/scale.cljs
msgid "workspace.scale.anchor.bottom-right"
msgstr "Abajo a la derecha"
#: src/app/main/ui/workspace/sidebar/scale.cljs
msgid "workspace.scale.anchor.center-center"
msgstr "Centro"
#: src/app/main/ui/workspace/sidebar/scale.cljs
msgid "workspace.scale.anchor.center-left"
msgstr "Centro a la izquierda"
#: src/app/main/ui/workspace/sidebar/scale.cljs
msgid "workspace.scale.anchor.center-right"
msgstr "Centro a la derecha"
#: src/app/main/ui/workspace/sidebar/scale.cljs
msgid "workspace.scale.anchor.top-center"
msgstr "Arriba en el centro"
#: src/app/main/ui/workspace/sidebar/scale.cljs
msgid "workspace.scale.anchor.top-left"
msgstr "Arriba a la izquierda"
#: src/app/main/ui/workspace/sidebar/scale.cljs
msgid "workspace.scale.anchor.top-right"
msgstr "Arriba a la derecha"
#: src/app/main/ui/workspace/sidebar/scale.cljs
msgid "workspace.scale.scale"
msgstr "Escala"
#: src/app/main/ui/workspace/sidebar/scale.cljs
msgid "workspace.scale.title"
msgstr "Escalado"
#: src/app/main/ui/workspace/context_menu.cljs:659
msgid "workspace.shape.menu.add-flex"
msgstr "Añadir flex layout"