From c52e130f84714049471808bc4f2d99d2a778fbd2 Mon Sep 17 00:00:00 2001 From: Xavier Julian Date: Fri, 8 May 2026 13:57:26 +0200 Subject: [PATCH] :tada: Add flyout and semantic improvements to main toolbar --- .../workspace/tool_toolbar/tool_toolbar.cljs | 296 ++++++++++++++++++ .../workspace/tool_toolbar/tool_toolbar.scss | 139 ++++++++ .../src/app/main/ui/workspace/viewport.cljs | 8 +- .../app/main/ui/workspace/viewport_wasm.cljs | 8 +- 4 files changed, 445 insertions(+), 6 deletions(-) create mode 100644 frontend/src/app/main/ui/workspace/tool_toolbar/tool_toolbar.cljs create mode 100644 frontend/src/app/main/ui/workspace/tool_toolbar/tool_toolbar.scss diff --git a/frontend/src/app/main/ui/workspace/tool_toolbar/tool_toolbar.cljs b/frontend/src/app/main/ui/workspace/tool_toolbar/tool_toolbar.cljs new file mode 100644 index 0000000000..96391d166a --- /dev/null +++ b/frontend/src/app/main/ui/workspace/tool_toolbar/tool_toolbar.cljs @@ -0,0 +1,296 @@ +;; 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 + +(ns app.main.ui.workspace.tool-toolbar.tool-toolbar + (:require-macros [app.main.style :as stl]) + (:require + [app.common.geom.point :as gpt] + [app.main.data.event :as ev] + [app.main.data.modal :as modal] + [app.main.data.workspace :as dw] + [app.main.data.workspace.media :as dwm] + [app.main.data.workspace.shortcuts :as sc] + [app.main.features :as features] + [app.main.refs :as refs] + [app.main.store :as st] + [app.main.ui.components.file-uploader :as file-uploader] + [app.main.ui.context :as ctx] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] + [app.util.dom :as dom] + [app.util.i18n :refer [tr]] + [app.util.timers :as ts] + [okulary.core :as l] + [potok.v2.core :as ptk] + [rumext.v2 :as mf])) + +(def ^:private toolbar-hidden-ref + (l/derived (fn [state] + (let [visibility (get state :hide-toolbar) + path-edit-state (get state :edit-path) + + selected (get state :selected) + edition (get state :edition) + single? (= (count selected) 1) + + path-editing? (and single? (some? (get path-edit-state edition)))] + (if path-editing? true visibility))) + refs/workspace-local)) + +(defn- tool-label + [tool] + (case tool + :move (tr "workspace.toolbar.move" (sc/get-tooltip :move)) + :frame (tr "workspace.toolbar.frame" (sc/get-tooltip :draw-frame)) + :rect (tr "workspace.toolbar.rect" (sc/get-tooltip :draw-rect)) + :circle (tr "workspace.toolbar.ellipse" (sc/get-tooltip :draw-ellipse)) + :text (tr "workspace.toolbar.text" (sc/get-tooltip :draw-text)) + :path (tr "workspace.toolbar.path" (sc/get-tooltip :draw-path)) + :curve (tr "workspace.toolbar.curve" (sc/get-tooltip :draw-curve)) + :plugins (tr "workspace.toolbar.plugins" (sc/get-tooltip :plugins)) + :debug "Debugging tool" + (name tool))) + +(defn- active-group-tool + [group drawtool] + (if (contains? (:tools group) drawtool) + drawtool + (:default-tool group))) + +(defn- selected-group? + [group drawtool] + (contains? (:tools group) drawtool)) + +(defn- group-menu-label + [group drawtool] + (let [tool-id (active-group-tool group drawtool)] + (str (tr "labels.options") ": " (tool-label tool-id)))) + +(mf/defc tool-button* + {::mf/wrap [mf/memo]} + [{:keys [selected title icon on-click aria-haspopup aria-expanded role data-tool]}] + [:button {:type "button" + :title title + :aria-label title + :aria-haspopup aria-haspopup + :aria-expanded aria-expanded + :aria-pressed selected + :role role + :class (stl/css :main-toolbar-options-button) + :on-click on-click + :data-tool data-tool} + [:> icon* {:icon-id icon + :aria-hidden true + :class (stl/css :main-toolbar-icon)}]]) + +(def grouped-tools + {:shapes {:default-tool :rect + :tools {:rect {:icon i/rectangle} + :circle {:icon i/elipse}}} + :free-draw {:default-tool :path + :tools {:path {:icon i/path} + :curve {:icon i/curve}}}}) + +(defn- cancel-timer! + [timer-ref*] + (when-let [timer (mf/ref-val timer-ref*)] + (ts/dispose! timer) + (mf/set-ref-val! timer-ref* nil))) + +(mf/defc grouped-tool-flyout* + {::mf/wrap [mf/memo]} + [{:keys [group drawtool on-select-tool]}] + (let [default-tool (active-group-tool group drawtool) + default-icon (:icon (get-in group [:tools default-tool])) + subtools (:tools group) + open* (mf/use-state false) + open-timer* (mf/use-ref nil) + close-timer* (mf/use-ref nil) + open (deref open*) + menu-label (group-menu-label group drawtool) + selected (boolean (selected-group? group drawtool)) + + on-display-menu (mf/use-fn + (fn [] + (cancel-timer! close-timer*) + (cancel-timer! open-timer*) + (mf/set-ref-val! + open-timer* + (ts/schedule 350 + #(do + (reset! open* true) + (mf/set-ref-val! open-timer* nil)))))) + on-hide-menu (mf/use-fn + (fn [] + (cancel-timer! open-timer*) + (cancel-timer! close-timer*) + (mf/set-ref-val! + close-timer* + (ts/schedule 350 + #(do + (reset! open* false) + (mf/set-ref-val! close-timer* nil))))))] + (mf/with-effect [] + (fn [] + (cancel-timer! open-timer*) + (cancel-timer! close-timer*))) + + [:li {:on-pointer-enter on-display-menu + :on-pointer-leave on-hide-menu + :class (stl/css :main-toolbar-group)} + [:div {:role "group" + :aria-label menu-label} + [:> tool-button* {:title (tool-label default-tool) + :selected selected + :icon default-icon + :on-click on-select-tool + :data-tool (name default-tool) + :aria-haspopup true + :aria-expanded open}] + + (when open + [:ul {:role "menu" + :class (stl/css :main-toolbar-flyout) + :aria-label menu-label} + (for [[id {:keys [icon]}] subtools] + [:li {:key (name id) + :role "none"} + [:> tool-button* {:title (tool-label id) + :selected (= drawtool id) + :icon icon + :on-click on-select-tool + :data-tool (name id) + :role "menuitemradio" + :aria-checked (= drawtool id)}]])])]])) + +(mf/defc image-upload-tool + {::mf/wrap [mf/memo]} + [] + (let [ref (mf/use-ref nil) + file-id (mf/use-ctx ctx/current-file-id) + + display-uploader + (mf/use-fn + (fn [] + (st/emit! :interrupt (dw/clear-edition-mode)) + (dom/click (mf/ref-val ref)))) + + on-selected + (mf/use-fn + (mf/deps file-id) + (fn [blobs] + ;; We don't want to add a ref because that redraws the component + ;; for everychange. Better direct access on the callback. + (let [vbox (deref refs/vbox) + x (+ (:x vbox) (/ (:width vbox) 2)) + y (+ (:y vbox) (/ (:height vbox) 2)) + params {:file-id file-id + :blobs (seq blobs) + :position (gpt/point x y)}] + (st/emit! (dwm/upload-media-workspace params)))))] + [:li + [:> tool-button* {:title (tool-label :image) + :selected nil + :icon i/img + :on-click display-uploader}] + [:& file-uploader/file-uploader + {:input-id "image-upload" + :accept dwm/accept-image-types + :multi true + :ref ref + :on-selected on-selected}]])) + +(mf/defc tool-toolbar* + {::mf/wrap [mf/memo]} + [{:keys [layout]}] + (let [selected-drawing-tool (mf/deref refs/selected-drawing-tool) + selected-edition (mf/deref refs/selected-edition) + plugins-enabled (features/active-feature? @st/state "plugins/runtime") + rulers-enabled (mf/deref refs/rulers?) + toolbar-hidden (mf/deref toolbar-hidden-ref) + display-plugins-manager (mf/use-fn + (fn [] + (st/emit! + (ptk/data-event ::ev/event {::ev/name "open-plugins-manager" + ::ev/origin "workspace:toolbar"}) + (modal/show :plugin-management {})))) + toggle-debug-panel (mf/use-fn + (mf/deps layout) + (fn [] + (let [is-sidebar-closed? (contains? layout :collapse-left-sidebar)] + (when is-sidebar-closed? + (st/emit! (dw/toggle-layout-flag :collapse-left-sidebar))) + (st/emit! + (dw/remove-layout-flag :shortcuts) + (-> (dw/toggle-layout-flag :debug-panel) + (vary-meta assoc ::ev/origin "workspace-left-toolbar")))))) + + on-interrupt (mf/use-fn #(st/emit! :interrupt (dw/clear-edition-mode))) + + on-select-tool (mf/use-fn + (fn [event] + (let [tool (-> (dom/get-current-target event) + (dom/get-data "tool") + (keyword))] + (on-interrupt) + + ;; Delay so anything that launched :interrupt can finish + (ts/schedule 100 + #(st/emit! (dw/select-for-drawing tool))))))] + + [:div {:role "toolbar" + :aria-label (tr "workspace.toolbar.label") + :tabindex "0" + :class (stl/css-case :main-toolbar true + :main-toolbar-no-rulers (not rulers-enabled) + :main-toolbar-hidden toolbar-hidden)} + [:ul {:class (stl/css :main-toolbar-options)} + [:li {:class (stl/css :main-toolbar-option)} + [:> tool-button* {:title (tr "workspace.toolbar.move" (sc/get-tooltip :move)) + :selected (and (nil? selected-drawing-tool) + (not selected-edition)) + :icon i/move + :on-click on-interrupt}]] + + [:li {:class (stl/css :main-toolbar-option)} + [:> tool-button* {:title (tool-label :frame) + :selected (= selected-drawing-tool :frame) + :icon i/board + :on-click on-select-tool + :data-tool "frame"}]] + + + [:> grouped-tool-flyout* {:key :shapes + :group (get grouped-tools :shapes) + :drawtool selected-drawing-tool + :on-select-tool on-select-tool}] + + [:li {:class (stl/css :main-toolbar-option)} + [:> tool-button* {:title (tool-label :text) + :selected (= selected-drawing-tool :text) + :icon i/text + :on-click on-select-tool + :data-tool "text"}]] + + [:> image-upload-tool] + + [:> grouped-tool-flyout* {:key :free-draw + :group (get grouped-tools :free-draw) + :drawtool selected-drawing-tool + :on-select-tool on-select-tool}] + + (when plugins-enabled + [:li {:class (stl/css :main-toolbar-option :main-toolbar-option-plugins)} + [:> tool-button* {:title (tool-label :plugins) + :icon i/puzzle + :on-click display-plugins-manager + :data-tool "plugins"}]]) + + (when *assert* + [:li {:class (stl/css :main-toolbar-option :main-toolbar-option-debug)} + [:> tool-button* {:title (tool-label :debug) + :selected (contains? layout :debug-panel) + :icon i/bug + :on-click toggle-debug-panel}]])]])) diff --git a/frontend/src/app/main/ui/workspace/tool_toolbar/tool_toolbar.scss b/frontend/src/app/main/ui/workspace/tool_toolbar/tool_toolbar.scss new file mode 100644 index 0000000000..a337560430 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/tool_toolbar/tool_toolbar.scss @@ -0,0 +1,139 @@ +// 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 + +@use "ds/spacing.scss" as *; +@use "ds/borders.scss" as *; +@use "ds/mixins.scss" as *; +@use "ds/z-index.scss" as *; + +.main-toolbar { + --toolbar-position-y: 28px; + --toolbar-offset-y: 0px; + --menu-border-color: var(--color-background-quaternary); + --menu-background-color: var(--color-background-primary); + + cursor: initial; + position: absolute; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + flex-direction: column; + + // height: 56px; + padding: var(--sp-s) var(--sp-l); + border-radius: $br-8; + border: 2px solid var(--menu-border-color); + z-index: var(--z-index-panels); + background-color: var(--menu-background-color); + transition: + top 0.3s, + height 0.3s, + opacity 0.3s; + top: calc(var(--toolbar-position-y) + var(--toolbar-offset-y)); +} + +.main-toolbar-no-rulers { + --toolbar-position-y: 0px; + --toolbar-offset-y: 8px; +} + +.main-toolbar-hidden { + --toolbar-offset-y: -4px; + + height: 16px; + z-index: 1; + border-radius: 0 0 $br-8 $br-8; + border-block-start: 0; + + .main-toolbar-options { + opacity: 0; + visibility: hidden; + } +} + +.main-toolbar-options { + position: relative; + display: flex; + align-items: center; + gap: var(--sp-xs); + margin: 0; + opacity: 1; + transition: opacity 0.3s ease; +} + +.main-toolbar-option { + position: relative; + + &.main-toolbar-option-plugins { + --separator-color: var(--color-background-quaternary); + + margin-inline-start: var(--sp-l); + padding-inline-start: var(--sp-l); + border-inline-start: 1px solid var(--separator-color); + } +} + +.main-toolbar-options-button { + --toolbar-option-button-background: transparent; + + appearance: none; + border: none; + height: 32px; + width: 32px; + flex-shrink: 0; + border-radius: $br-8; + margin: 0 2px; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--toolbar-option-button-background); + + &:hover { + cursor: pointer; + } + + &:focus-visible { + outline: none; + } + + &[aria-pressed="true"], + &[aria-pressed="true"], + &:focus-visible, + &:hover { + --toolbar-option-button-background: var(--color-background-quaternary); + + & .main-toolbar-icon { + --stroke-color: var(--color-accent-primary); + } + } +} + +.main-toolbar-icon { + --stroke-color: var(--color-foreground-secondary); + + stroke: var(--stroke-color); + inline-size: 16px; + block-size: 16px; +} + +.main-toolbar-group { + position: relative; +} + +.main-toolbar-flyout { + position: absolute; + top: 130%; + left: 50%; + transform: translateX(-50%); + margin-top: var(--sp-xxs); + padding: var(--sp-xxs) 0; + border-radius: $br-8; + border: 1px solid var(--menu-border-color); + background-color: var(--menu-background-color); + z-index: var(--z-index-panels); + display: flex; +} diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index 3c5fb33114..8e9ab46470 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -33,7 +33,7 @@ [app.main.ui.workspace.shapes.text.text-edition-outline :refer [text-edition-outline*]] [app.main.ui.workspace.shapes.text.v2-editor :as editor-v2] [app.main.ui.workspace.shapes.text.viewport-texts-html :as stvh] - [app.main.ui.workspace.top-toolbar :refer [top-toolbar*]] + [app.main.ui.workspace.tool-toolbar.tool-toolbar :refer [tool-toolbar*]] [app.main.ui.workspace.viewport-wasm :as viewport.wasm] [app.main.ui.workspace.viewport.actions :as actions] [app.main.ui.workspace.viewport.comments :as comments] @@ -53,7 +53,9 @@ [app.main.ui.workspace.viewport.selection :as selection] [app.main.ui.workspace.viewport.snap-distances :as snap-distances] [app.main.ui.workspace.viewport.snap-points :as snap-points] - [app.main.ui.workspace.viewport.top-bar :refer [grid-edition-bar* path-edition-bar* view-only-bar*]] + [app.main.ui.workspace.viewport.top-bar :refer [grid-edition-bar* + path-edition-bar* + view-only-bar*]] [app.main.ui.workspace.viewport.utils :as utils] [app.main.ui.workspace.viewport.viewport-ref :refer [create-viewport-ref]] [app.main.ui.workspace.viewport.widgets :as widgets] @@ -326,7 +328,7 @@ :else [:* (when-not hide-ui? - [:> top-toolbar* {:layout layout}]) + [:> tool-toolbar* {:layout layout}]) (when (and ^boolean path-editing? ^boolean single-select?) diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 2972b58ead..488e62fa62 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -32,7 +32,7 @@ [app.main.ui.workspace.shapes.text.text-edition-outline :refer [text-edition-outline*]] [app.main.ui.workspace.shapes.text.v2-editor :as editor-v2] [app.main.ui.workspace.shapes.text.v3-editor :as editor-v3] - [app.main.ui.workspace.top-toolbar :refer [top-toolbar*]] + [app.main.ui.workspace.tool-toolbar.tool-toolbar :refer [tool-toolbar*]] [app.main.ui.workspace.viewport.actions :as actions] [app.main.ui.workspace.viewport.comments :as comments] [app.main.ui.workspace.viewport.debug :as wvd] @@ -51,7 +51,9 @@ [app.main.ui.workspace.viewport.selection :as selection] [app.main.ui.workspace.viewport.snap-distances :as snap-distances] [app.main.ui.workspace.viewport.snap-points :as snap-points] - [app.main.ui.workspace.viewport.top-bar :refer [path-edition-bar* grid-edition-bar* view-only-bar*]] + [app.main.ui.workspace.viewport.top-bar :refer [grid-edition-bar* + path-edition-bar* + view-only-bar*]] [app.main.ui.workspace.viewport.utils :as utils] [app.main.ui.workspace.viewport.viewport-ref :as vp-ref :refer [create-viewport-ref]] [app.main.ui.workspace.viewport.widgets :as widgets] @@ -559,7 +561,7 @@ :else [:* (when-not hide-ui? - [:> top-toolbar* {:layout layout}]) + [:> tool-toolbar* {:layout layout}]) (when (and ^boolean path-editing? ^boolean single-select?)