From 7c19ace0f0992c166253f0d5208313ac0d540621 Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Mon, 22 Jun 2026 10:39:29 +0200 Subject: [PATCH] Reapply ":tada: Add flyout and semantic improvements to main toolbar (#9480)" (#10354) This reverts commit 94119159d8c83048dd9229a2b9f2551966ac9596. --- frontend/playwright/ui/pages/WorkspacePage.js | 27 + frontend/playwright/ui/specs/toolbar.spec.js | 88 +++ frontend/playwright/ui/specs/variants.spec.js | 24 +- .../app/main/ui/ds/buttons/icon_button.cljs | 65 ++- .../app/main/ui/ds/buttons/icon_button.scss | 11 + .../ui/ds/buttons/icon_button.stories.jsx | 9 + .../app/main/ui/workspace/top_toolbar.cljs | 516 +++++++++++------- .../app/main/ui/workspace/top_toolbar.scss | 198 +++---- .../src/app/main/ui/workspace/viewport.cljs | 4 +- .../app/main/ui/workspace/viewport_wasm.cljs | 4 +- 10 files changed, 632 insertions(+), 314 deletions(-) create mode 100644 frontend/playwright/ui/specs/toolbar.spec.js diff --git a/frontend/playwright/ui/pages/WorkspacePage.js b/frontend/playwright/ui/pages/WorkspacePage.js index 891b2a4e31..f31016ead4 100644 --- a/frontend/playwright/ui/pages/WorkspacePage.js +++ b/frontend/playwright/ui/pages/WorkspacePage.js @@ -448,6 +448,33 @@ export class WorkspacePage extends BaseWebSocketPage { await pagesToggle.click(); } + async selectToolbarTool(workspacePage, toolName) { + await workspacePage.page + .getByRole("button", { name: toolName }) + .first() + .click(); + } + + async selectToolFromFlyout( + workspacePage, + { triggerToolName, targetToolName }, + ) { + const trigger = workspacePage.page + .getByRole("button", { name: triggerToolName }) + .first(); + + const option = workspacePage.page + .getByRole("menuitemradio", { name: targetToolName }) + .first(); + + await trigger.hover(); + // Flyout opening is delayed by 350ms in the toolbar component. + await workspacePage.page.waitForTimeout(450); + await expect(trigger).toHaveAttribute("aria-expanded", "true"); + await option.waitFor({ state: "visible" }); + await option.click(); + } + async moveSelectionToShape(name) { await this.page.locator("rect.viewport-selrect").hover(); await this.page.mouse.down(); diff --git a/frontend/playwright/ui/specs/toolbar.spec.js b/frontend/playwright/ui/specs/toolbar.spec.js new file mode 100644 index 0000000000..2bd9cef5c4 --- /dev/null +++ b/frontend/playwright/ui/specs/toolbar.spec.js @@ -0,0 +1,88 @@ +import { test, expect } from "@playwright/test"; +import { WasmWorkspacePage } from "../pages/WasmWorkspacePage"; +import WorkspacePage from "../pages/WorkspacePage"; + +test.beforeEach(async ({ page }) => { + await WasmWorkspacePage.init(page); +}); + +const expectLayerNamed = async (workspacePage, name) => { + await expect(workspacePage.layers.getByText(name).last()).toBeVisible(); +}; + +test("User creates a frame with the toolbar frame tool", async ({ page }) => { + const workspacePage = new WasmWorkspacePage(page); + await workspacePage.setupEmptyFile(); + await workspacePage.goToWorkspace(); + + await workspacePage.selectToolbarTool(workspacePage, "Board (B)"); + await workspacePage.clickWithDragViewportAt(100, 100, 180, 120); + await expectLayerNamed(workspacePage, "Board"); +}); + +test("User creates a rectangle with the toolbar rect tool", async ({ + page, +}) => { + const workspacePage = new WasmWorkspacePage(page); + await workspacePage.setupEmptyFile(); + await workspacePage.goToWorkspace(); + + await workspacePage.selectToolbarTool(workspacePage, "Rectangle (R)"); + await workspacePage.clickWithDragViewportAt(350, 100, 120, 80); + await expectLayerNamed(workspacePage, "Rectangle"); +}); + +test("User creates an ellipse from the shapes flyout", async ({ page }) => { + const workspacePage = new WasmWorkspacePage(page); + await workspacePage.setupEmptyFile(); + await workspacePage.goToWorkspace(); + + await workspacePage.selectToolFromFlyout(workspacePage, { + triggerToolName: "Rectangle (R)", + targetToolName: "Ellipse (E)", + }); + await workspacePage.clickWithDragViewportAt(520, 100, 100, 100); + await expectLayerNamed(workspacePage, "Ellipse"); +}); + +test("User creates a text shape with the toolbar text tool", async ({ + page, +}) => { + const workspacePage = new WorkspacePage(page); + await workspacePage.setupEmptyFile(); + await workspacePage.goToWorkspace(); + + await workspacePage.selectToolbarTool(workspacePage, "Text (T)"); + await workspacePage.clickAndMove(120, 320, 300, 380); + await workspacePage.waitForSelectedShapeName("Text"); + await workspacePage.page.keyboard.type("toolbar test"); + await workspacePage.page.keyboard.press("Escape"); + await expectLayerNamed(workspacePage, "Text"); +}); + +test.skip("User creates a path with the toolbar path tool", async ({ + page, +}) => { + const workspacePage = new WasmWorkspacePage(page); + await workspacePage.setupEmptyFile(); + await workspacePage.goToWorkspace(); + + await workspacePage.selectToolbarTool(workspacePage, "Path (P)"); + await workspacePage.clickAndMove(120, 320, 300, 380); + await workspacePage.page.keyboard.press("Enter"); + await expectLayerNamed(workspacePage, "Path"); +}); + +test("User creates a curve from the path flyout", async ({ page }) => { + const workspacePage = new WasmWorkspacePage(page); + await workspacePage.setupEmptyFile(); + await workspacePage.goToWorkspace(); + + await workspacePage.selectToolFromFlyout(workspacePage, { + triggerToolName: "Path (P)", + targetToolName: "Curve (Shift+C)", + }); + await workspacePage.clickAndMove(120, 320, 300, 380); + await workspacePage.page.keyboard.press("Enter"); + await expectLayerNamed(workspacePage, "Path"); +}); diff --git a/frontend/playwright/ui/specs/variants.spec.js b/frontend/playwright/ui/specs/variants.spec.js index 0c50f7dc8b..d9dbb687e5 100644 --- a/frontend/playwright/ui/specs/variants.spec.js +++ b/frontend/playwright/ui/specs/variants.spec.js @@ -343,7 +343,9 @@ test("User drag and drop a variant outside the container", async ({ page }) => { // and use it to calculate the target position await workspacePage.clickWithDragViewportAt(600, 500, 0, 300); - await expect(workspacePage.layers.getByText("Rectangle / Value 1")).toBeVisible(); + await expect( + workspacePage.layers.getByText("Rectangle / Value 1"), + ).toBeVisible(); }); test("User cut paste a component inside a variant", async ({ page }) => { @@ -353,7 +355,10 @@ test("User cut paste a component inside a variant", async ({ page }) => { const variant = await findVariant(workspacePage, 0); //Create a component - await workspacePage.ellipseShapeButton.click(); + await workspacePage.selectToolFromFlyout(workspacePage, { + triggerToolName: "Rectangle (R)", + targetToolName: "Ellipse (E)", + }); await workspacePage.clickWithDragViewportAt(500, 500, 20, 20); await workspacePage.clickLeafLayer("Ellipse"); await workspacePage.page.keyboard.press("ControlOrMeta+k"); @@ -384,7 +389,10 @@ test("User cut paste a component with path inside a variant", async ({ const variant = await findVariant(workspacePage, 0); // Create a component - await workspacePage.ellipseShapeButton.click(); + await workspacePage.selectToolFromFlyout(workspacePage, { + triggerToolName: "Rectangle (R)", + targetToolName: "Ellipse (E)", + }); await workspacePage.clickWithDragViewportAt(500, 500, 20, 20); await workspacePage.clickLeafLayer("Ellipse"); await workspacePage.page.keyboard.press("ControlOrMeta+k"); @@ -425,7 +433,10 @@ test("User drag and drop a component with path inside a variant", async ({ const variant = findVariantNoWait(workspacePage, 0); //Create a component - await workspacePage.ellipseShapeButton.click(); + await workspacePage.selectToolFromFlyout(workspacePage, { + triggerToolName: "Rectangle (R)", + targetToolName: "Ellipse (E)", + }); await workspacePage.clickWithDragViewportAt(500, 500, 20, 20); await workspacePage.clickLeafLayer("Ellipse"); await workspacePage.page.keyboard.press("ControlOrMeta+k"); @@ -457,7 +468,10 @@ test("User cut paste a variant into another container", async ({ page }) => { await setupVariantsFileWithVariant(workspacePage); // Create anothe variant - await workspacePage.ellipseShapeButton.click(); + await workspacePage.selectToolFromFlyout(workspacePage, { + triggerToolName: "Rectangle (R)", + targetToolName: "Ellipse (E)", + }); await workspacePage.clickWithDragViewportAt(500, 500, 20, 20); await workspacePage.clickLeafLayer("Ellipse"); await workspacePage.page.keyboard.press("ControlOrMeta+k"); diff --git a/frontend/src/app/main/ui/ds/buttons/icon_button.cljs b/frontend/src/app/main/ui/ds/buttons/icon_button.cljs index 956bc44f35..272d4288a3 100644 --- a/frontend/src/app/main/ui/ds/buttons/icon_button.cljs +++ b/frontend/src/app/main/ui/ds/buttons/icon_button.cljs @@ -23,22 +23,49 @@ [:icon [:and :string [:fn #(contains? icon-list %)]]] [:aria-label :string] + [:has-tooltip {:optional true} [:maybe :boolean]] [:tooltip-placement {:optional true} [:maybe [:enum "top" "bottom" "left" "right" "top-right" "bottom-right" "bottom-left" "top-left"]]] + ;; Indicates that the button has a flyout menu, and should display an indicator + [:flyout-indicator {:optional true} [:maybe :boolean]] [:variant {:optional true} [:maybe [:enum "primary" "secondary" "ghost" "destructive" "action"]]]]) +(def ^:private schema:icon-button-internal + [:map + [:icon-class {:optional true} [:maybe :string]] + [:icon-size {:optional true} [:maybe [:enum "s" "m" "l"]]] + [:icon + [:and :string [:fn #(contains? icon-list %)]]] + ;; Indicates that the button has a flyout menu, and should display an indicator + [:flyout-indicator {:optional true} [:maybe :boolean]]]) + +(mf/defc icon-button-internal* + {::mf/schema schema:icon-button-internal + ::mf/memo true} + [{:keys [icon icon-class icon-size flyout-indicator children] :rest props}] + [:> :button props + [:> icon* {:icon-id icon + :aria-hidden true + :class icon-class + :size icon-size}] + (when flyout-indicator + [:svg {:view-box "0 0 6 6" + :aria-hidden true + :class (stl/css :flyout-indicator)} + [:path {:d "M4,2 L4,3.15 C4,3.62 3.62,4 3.15,4 L2,4" + :stroke-linecap "round"}]]) + children]) + (mf/defc icon-button* {::mf/schema schema:icon-button ::mf/memo true} - [{:keys [class icon icon-class icon-size variant aria-label children tooltip-placement tooltip-class type] :rest props}] - (let [variant - (d/nilv variant "primary") - + [{:keys [class icon icon-class icon-size variant aria-label children tooltip-placement tooltip-class type flyout-indicator has-tooltip] :rest props}] + (let [variant (d/nilv variant "primary") + flyout-indicator (d/nilv flyout-indicator false) + has-tooltip (d/nilv has-tooltip true) button-ref (mf/use-ref nil) - - tooltip-id - (mf/use-id) + tooltip-id (mf/use-id) button-class (stl/css-case :icon-button true @@ -53,13 +80,19 @@ {:class [class button-class] :ref button-ref :type (d/nilv type "button") - :aria-labelledby tooltip-id})] + :icon icon + :icon-size icon-size + :icon-class icon-class + :flyout-indicator flyout-indicator})] - [:> tooltip* {:content aria-label - :class tooltip-class - :trigger-ref button-ref - :placement tooltip-placement - :id tooltip-id} - [:> :button props - [:> icon* {:icon-id icon :aria-hidden true :class icon-class :size icon-size}] - children]])) + (if has-tooltip + (let [tooltip-props (mf/spread-props props {:aria-labelledby tooltip-id})] + [:> tooltip* {:content aria-label + :class tooltip-class + :trigger-ref button-ref + :placement tooltip-placement + :id tooltip-id} + [:> icon-button-internal* tooltip-props children]]) + + (let [no-tooltip-props (mf/spread-props props {:aria-label aria-label})] + [:> icon-button-internal* no-tooltip-props children])))) diff --git a/frontend/src/app/main/ui/ds/buttons/icon_button.scss b/frontend/src/app/main/ui/ds/buttons/icon_button.scss index 27f66f3a19..233bb8bfe7 100644 --- a/frontend/src/app/main/ui/ds/buttons/icon_button.scss +++ b/frontend/src/app/main/ui/ds/buttons/icon_button.scss @@ -16,6 +16,7 @@ display: grid; place-content: center; + position: relative; } .icon-button-primary { @@ -49,3 +50,13 @@ --button-width: #{$sz-24}; --button-height: #{$sz-24}; } + +.flyout-indicator { + position: absolute; + inset-block-end: var(--sp-xs); + inset-inline-end: var(--sp-xs); + stroke: currentcolor; + fill: none; + inline-size: $sz-6; + block-size: $sz-6; +} diff --git a/frontend/src/app/main/ui/ds/buttons/icon_button.stories.jsx b/frontend/src/app/main/ui/ds/buttons/icon_button.stories.jsx index fd90936d34..de37e42a02 100644 --- a/frontend/src/app/main/ui/ds/buttons/icon_button.stories.jsx +++ b/frontend/src/app/main/ui/ds/buttons/icon_button.stories.jsx @@ -24,6 +24,7 @@ export default { options: iconList, control: { type: "select" }, }, + flyoutIndicator: { control: "boolean" }, disabled: { control: "boolean" }, variant: { options: ["primary", "secondary", "ghost", "destructive", "action"], @@ -35,6 +36,7 @@ export default { variant: undefined, "aria-label": "Lorem ipsum", icon: "effects", + flyoutIndicator: false, }, render: ({ ...args }) => , }; @@ -70,3 +72,10 @@ export const Destructive = { variant: "destructive", }, }; + +export const Flyout = { + args: { + variant: "ghost", + flyoutIndicator: true, + }, +}; diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.cljs b/frontend/src/app/main/ui/workspace/top_toolbar.cljs index dcce4fecc6..615e0921b1 100644 --- a/frontend/src/app/main/ui/workspace/top_toolbar.cljs +++ b/frontend/src/app/main/ui/workspace/top_toolbar.cljs @@ -20,79 +20,172 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.dropdown-menu :refer [dropdown-menu* dropdown-menu-item*]] - [app.main.ui.components.file-uploader :refer [file-uploader]] + [app.main.ui.components.file-uploader :as file-uploader] [app.main.ui.context :as ctx] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] [app.util.dom :as dom] - [app.util.i18n :as i18n :refer [tr]] + [app.util.i18n :refer [tr]] [app.util.timers :as ts] [okulary.core :as l] [rumext.v2 :as mf])) -(mf/defc mcp-indicator* - [] - (let [mcp (mf/deref refs/mcp) +(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) - conn-status (get mcp :connection-status) - has-valid-token? (get mcp :token-valid) + is-single (= (count selected) 1) + is-path-editing (and is-single (some? (get path-edit-state edition)))] - enabled? (get mcp :enabled) + (if is-path-editing true visibility))) + refs/workspace-local)) - mcp-connected? (= "connected" conn-status) - show-indicator? (and enabled? has-valid-token?) +(def grouped-tools + {:shapes {:default-tool :rect + :tools {:rect {:icon i/rectangle} + :circle {:icon i/ellipse}}} + :free-draw {:default-tool :path + :tools {:path {:icon i/path} + :curve {:icon i/curve}}}}) - menu-open* (mf/use-state false) - menu-open? (deref menu-open*) +(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)) + :image (tr "workspace.toolbar.image" (sc/get-tooltip :insert-image)) + :curve (tr "workspace.toolbar.curve" (sc/get-tooltip :draw-curve)) + :plugins (tr "workspace.toolbar.plugins" (sc/get-tooltip :plugins)) + :debug "Debugging tool" + (name tool))) - toggle-menu +(defn- active-group-tool + [group drawtool] + (if (contains? (:tools group) drawtool) + drawtool + (:default-tool group))) + +(defn- is-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)))) + +(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 group-tool* + {::mf/private true + ::mf/wrap [mf/memo]} + [{:keys [group drawtool on-select-tool]}] + (let [default-tool* (mf/use-state (active-group-tool group drawtool)) + default-tool (deref default-tool*) + + open* (mf/use-state false) + open (deref open*) + + open-timer* (mf/use-ref nil) + close-timer* (mf/use-ref nil) + + default-icon (:icon (get-in group [:tools default-tool])) + subtools (:tools group) + menu-label (group-menu-label group drawtool) + selected (boolean (is-selected-group group drawtool)) + + on-select-tool (mf/use-fn (fn [event] - (dom/stop-propagation event) - (swap! menu-open* not))) + (let [tool (-> (dom/get-current-target event) + (dom/get-data "tool") + (keyword))] + (reset! default-tool* tool) + (on-select-tool event)))) - close-menu + on-display-menu (mf/use-fn - #(reset! menu-open* false)) + (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)))))) - connect-mcp + on-hide-menu (mf/use-fn - #(st/emit! (mcp/connect-mcp) - (ev/event {::ev/name "connect-mcp-plugin" - ::ev/origin "workspace:toolbar"})))] - (when show-indicator? - [:li - [:button - {:title (tr "workspace.toolbar.mcp") - :aria-label (tr "workspace.toolbar.mcp") - :class (stl/css-case :main-toolbar-options-button true - :mcp-button true - :selected menu-open?) - :on-click toggle-menu - :data-tool "mcp" - :data-testid "mcp-btn"} - [:span {:class (stl/css-case :mcp-status-dot true - :connected mcp-connected?)}] - [:span {:class (stl/css-case :mcp-button-label true - :connected mcp-connected?)} - (tr "workspace.toolbar.mcp")]] - [:> dropdown-menu* {:show menu-open? - :on-close close-menu - :class (stl/css :mcp-menu)} - (if mcp-connected? - [:li {:class (stl/css :mcp-menu-info) - :role "presentation"} - (tr "workspace.toolbar.mcp-connected")] - [:> dropdown-menu-item* {:class (stl/css :mcp-menu-item) - :on-click connect-mcp} - (tr "workspace.toolbar.mcp-connect-here")])]]))) + (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/defc image-upload* - {::mf/wrap [mf/memo]} + (mf/with-effect [] + (fn [] + (cancel-timer! open-timer*) + (cancel-timer! close-timer*))) + + [:li {:class (stl/css :toolbar-group) + :on-pointer-enter on-display-menu + :on-pointer-leave on-hide-menu} + [:div {:role "group" + :aria-label menu-label} + [:> icon-button* {:variant "ghost" + :flyout-indicator true + :aria-label (tool-label default-tool) + :aria-haspopup true + :aria-pressed selected + :aria-expanded open + :has-tooltip false + :icon default-icon + :on-click on-select-tool + :data-tool (name default-tool)}] + + [:ul {:role "menu" + :class (stl/css-case :toolbar-group-flyout true + :open open) + :aria-label menu-label} + + (for [[id {:keys [icon]}] subtools] + [:li {:key (name id) + :role "none"} + [:> icon-button* {:variant "ghost" + :tooltip-placement "bottom" + :role "menuitemradio" + :aria-label (tool-label id) + :aria-pressed (= drawtool id) + :aria-checked (= drawtool id) + :icon icon + :on-click on-select-tool + :data-tool (name id)}]])]]])) + +(mf/defc image-upload-tool* + {::mf/private true + ::mf/wrap [mf/memo]} [] - (let [ref (mf/use-ref nil) - file-id (mf/use-ctx ctx/current-file-id) + (let [ref (mf/use-ref nil) + file-id (mf/use-ctx ctx/current-file-id) - on-click + on-display-uploader (mf/use-fn (fn [] (st/emit! :interrupt (dw/clear-edition-mode)) @@ -111,50 +204,115 @@ :blobs (seq blobs) :position (gpt/point x y)}] (st/emit! (dwm/upload-media-workspace params)))))] - [:li - [:button - {:title (tr "workspace.toolbar.image" (sc/get-tooltip :insert-image)) - :aria-label (tr "workspace.toolbar.image" (sc/get-tooltip :insert-image)) - :on-click on-click - :class (stl/css :main-toolbar-options-button)} - deprecated-icon/img - [:& file-uploader - {:input-id "image-upload" - :accept dwm/accept-image-types - :multi true - :ref ref - :on-selected on-selected}]]])) -(def ^:private toolbar-hidden-ref - (l/derived (fn [state] - (let [visibility (get state :hide-toolbar) - path-edit-state (get state :edit-path) + [:* + [:> icon-button* {:variant "ghost" + :tooltip-placement "bottom" + :aria-label (tool-label :image) + :icon i/img + :on-click on-display-uploader}] + [:& file-uploader/file-uploader {:input-id "image-upload" + :accept dwm/accept-image-types + :multi true + :ref ref + :on-selected on-selected}]])) - selected (get state :selected) - edition (get state :edition) - single? (= (count selected) 1) +(mf/defc mcp-tool* + {::mf/private true + ::mf/wrap [mf/memo]} + [{:keys [is-mcp-connected]}] + (let [menu-open* (mf/use-state false) + menu-open? (deref menu-open*) - path-editing? (and single? (some? (get path-edit-state edition)))] - (if path-editing? true visibility))) - refs/workspace-local)) + on-toggle-menu + (mf/use-fn + (fn [event] + (dom/stop-propagation event) + (swap! menu-open* not))) + + on-close-menu + (mf/use-fn + #(reset! menu-open* false)) + + on-connect + (mf/use-fn + #(st/emit! (mcp/connect-mcp) + (ev/event {::ev/name "connect-mcp-plugin" + ::ev/origin "workspace:toolbar"})))] + + [:* + [:> button* {:variant "ghost" + :on-click on-toggle-menu + :aria-pressed menu-open? + :data-tool "mcp" + :data-testid "mcp-btn"} + [:div {:class (stl/css-case :toolbar-mcp-button true + :selected menu-open?)} + [:span {:class (stl/css-case :toolbar-mcp-button-dot true + :connected is-mcp-connected)}] + [:span {:class (stl/css-case :toolbar-mcp-button-label true + :connected is-mcp-connected)} + (tr "workspace.toolbar.mcp")]]] + + [:div {:class (stl/css :toolbar-mcp-menu)} + [:> dropdown-menu* {:show menu-open? + :on-close on-close-menu + :class (stl/css :toolbar-mcp-dropdown)} + (if is-mcp-connected + [:li {:class (stl/css :toolbar-mcp-dropdown-info) + :role "presentation"} + (tr "workspace.toolbar.mcp-connected")] + [:> dropdown-menu-item* {:class (stl/css :toolbar-mcp-dropdown-item) + :on-click on-connect} + (tr "workspace.toolbar.mcp-connect-here")])]]])) (mf/defc top-toolbar* - {::mf/memo true} + {::mf/wrap [mf/memo]} [{:keys [layout]}] - (let [drawtool (mf/deref refs/selected-drawing-tool) - edition (mf/deref refs/selected-edition) + (let [selected-drawing-tool (mf/deref refs/selected-drawing-tool) + selected-edition (mf/deref refs/selected-edition) + rulers-enabled (mf/deref refs/rulers?) + toolbar-hidden (mf/deref toolbar-hidden-ref) + mcp (mf/deref refs/mcp) - profile (mf/deref refs/profile) - props (get profile :props) + plugins-enabled? (features/active-feature? @st/state "plugins/runtime") + read-only? (mf/use-ctx ctx/workspace-read-only?) - read-only? (mf/use-ctx ctx/workspace-read-only?) - rulers? (mf/deref refs/rulers?) - hide-toolbar? (mf/deref toolbar-hidden-ref) + mcp-conn-status (get mcp :connection-status) + mcp-valid-token? (get mcp :token-valid) + mcp-enabled? (get mcp :enabled) - interrupt - (mf/use-fn #(st/emit! :interrupt (dw/clear-edition-mode))) + mcp-connected? (= "connected" mcp-conn-status) + mcp-show? (and (contains? cf/flags :mcp) + mcp-enabled? + mcp-valid-token?) - select-drawtool + separator? (or plugins-enabled? *assert* mcp-show?) + + on-display-plugins-manager + (mf/use-fn + (fn [] + (st/emit! (ev/event {::ev/name "open-plugins-manager" + ::ev/origin "workspace:toolbar"}) + (modal/show :plugin-management {})))) + + on-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 + (fn [] + (st/emit! :interrupt (dw/clear-edition-mode)))) + + on-select-tool (mf/use-fn (fn [event] (let [tool (-> (dom/get-current-target event) @@ -163,131 +321,91 @@ (st/emit! :interrupt (dw/clear-edition-mode)) ;; Delay so anything that launched :interrupt can finish - (ts/schedule 100 #(st/emit! (dw/select-for-drawing tool)))))) + (ts/schedule 100 + #(st/emit! (dw/select-for-drawing tool)))))) - 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")))))) - - toggle-toolbar + on-toggle-toolbar (mf/use-fn (fn [event] (dom/blur! (dom/get-target event)) - (st/emit! (dwc/toggle-toolbar-visibility)))) - - test-tooltip-board-text - (if (not (:workspace-visited props)) - (tr "workspace.toolbar.frame-first-time" (sc/get-tooltip :draw-frame)) - (tr "workspace.toolbar.frame" (sc/get-tooltip :draw-frame)))] + (st/emit! (dwc/toggle-toolbar-visibility))))] (when-not ^boolean read-only? - [:aside {:class (stl/css-case :main-toolbar true - :main-toolbar-no-rulers (not rulers?) - :main-toolbar-hidden hide-toolbar?)} - [:ul {:class (stl/css :main-toolbar-options) + [:div {:role "toolbar" + :aria-label (tr "workspace.toolbar.label") + :tabindex "0" + :class (stl/css-case :toolbar true + :no-rulers (not rulers-enabled) + :hidden toolbar-hidden)} + [:ul {:class (stl/css :toolbar-options) :data-testid "toolbar-options"} - [:li - [:button - {:title (tr "workspace.toolbar.move" (sc/get-tooltip :move)) - :aria-label (tr "workspace.toolbar.move" (sc/get-tooltip :move)) - :class (stl/css-case :main-toolbar-options-button true - :selected (and (nil? drawtool) - (not edition))) - :on-click interrupt} - deprecated-icon/move]] - [:* - [:li - [:button - {:title test-tooltip-board-text - :aria-label (tr "workspace.toolbar.frame" (sc/get-tooltip :draw-frame)) - :class (stl/css-case :main-toolbar-options-button true :selected (= drawtool :frame)) - :on-click select-drawtool - :data-tool "frame" - :data-testid "artboard-btn"} - deprecated-icon/board]] - [:li - [:button - {:title (tr "workspace.toolbar.rect" (sc/get-tooltip :draw-rect)) - :aria-label (tr "workspace.toolbar.rect" (sc/get-tooltip :draw-rect)) - :class (stl/css-case :main-toolbar-options-button true :selected (= drawtool :rect)) - :on-click select-drawtool - :data-tool "rect" - :data-testid "rect-btn"} - deprecated-icon/rectangle]] - [:li - [:button - {:title (tr "workspace.toolbar.ellipse" (sc/get-tooltip :draw-ellipse)) - :aria-label (tr "workspace.toolbar.ellipse" (sc/get-tooltip :draw-ellipse)) - :class (stl/css-case :main-toolbar-options-button true :selected (= drawtool :circle)) - :on-click select-drawtool - :data-tool "circle" - :data-testid "ellipse-btn"} - deprecated-icon/ellipse]] - [:li - [:button - {:title (tr "workspace.toolbar.text" (sc/get-tooltip :draw-text)) - :aria-label (tr "workspace.toolbar.text" (sc/get-tooltip :draw-text)) - :class (stl/css-case :main-toolbar-options-button true :selected (= drawtool :text)) - :on-click select-drawtool - :data-tool "text"} - deprecated-icon/text]] + [:li {:class (stl/css :toolbar-option)} + [:> icon-button* {:variant "ghost" + :tooltip-placement "bottom" + :aria-pressed (and (nil? selected-drawing-tool) + (not selected-edition)) + :aria-label (tr "workspace.toolbar.move" (sc/get-tooltip :move)) + :icon i/move + :on-click on-interrupt}]] - [:> image-upload*] + [:li {:class (stl/css :toolbar-option)} + [:> icon-button* {:variant "ghost" + :tooltip-placement "bottom" + :aria-pressed (= selected-drawing-tool :frame) + :aria-label (tool-label :frame) + :icon i/board + :on-click on-select-tool + :data-tool "frame"}]] - [:li - [:button - {:title (tr "workspace.toolbar.curve" (sc/get-tooltip :draw-curve)) - :aria-label (tr "workspace.toolbar.curve" (sc/get-tooltip :draw-curve)) - :class (stl/css-case :main-toolbar-options-button true :selected (= drawtool :curve)) - :on-click select-drawtool - :data-tool "curve" - :data-testid "curve-btn"} - deprecated-icon/curve]] - [:li - [:button - {:title (tr "workspace.toolbar.path" (sc/get-tooltip :draw-path)) - :aria-label (tr "workspace.toolbar.path" (sc/get-tooltip :draw-path)) - :class (stl/css-case :main-toolbar-options-button true :selected (= drawtool :path)) - :on-click select-drawtool - :data-tool "path" - :data-testid "path-btn"} - deprecated-icon/path]] + [:> group-tool* {:key :shapes + :group (get grouped-tools :shapes) + :drawtool selected-drawing-tool + :on-select-tool on-select-tool}] - (when (features/active-feature? @st/state "plugins/runtime") - [:li - [:button - {:title (tr "workspace.toolbar.plugins" (sc/get-tooltip :plugins)) - :aria-label (tr "workspace.toolbar.plugins" (sc/get-tooltip :plugins)) - :class (stl/css :main-toolbar-options-button) - :on-click #(st/emit! - (ev/event {::ev/name "open-plugins-manager" - ::ev/origin "workspace:toolbar"}) - (modal/show :plugin-management {})) - :data-tool "plugins" - :data-testid "plugins-btn"} - deprecated-icon/puzzle]]) + [:li {:class (stl/css :toolbar-option)} + [:> icon-button* {:variant "ghost" + :tooltip-placement "bottom" + :aria-pressed (= selected-drawing-tool :text) + :aria-label (tool-label :text) + :icon i/text + :on-click on-select-tool + :data-tool "text"}]] - (when *assert* - [:li - [:button - {:title "Debugging tool" - :class (stl/css-case :main-toolbar-options-button true :selected (contains? layout :debug-panel)) - :on-click toggle-debug-panel} - deprecated-icon/bug]]) + [:li {:class (stl/css :toolbar-option)} + [:> image-upload-tool*]] - (when (contains? cf/flags :mcp) - [:> mcp-indicator*])]] + [:> group-tool* {:key :free-draw + :group (get grouped-tools :free-draw) + :drawtool selected-drawing-tool + :on-select-tool on-select-tool}] + + (when separator? + [:div {:class (stl/css :toolbar-separator)}]) + + (when plugins-enabled? + [:li {:class (stl/css :toolbar-option)} + [:> icon-button* {:variant "ghost" + :tooltip-placement "bottom" + :aria-label (tool-label :plugins) + :icon i/puzzle + :on-click on-display-plugins-manager + :data-tool "plugins"}]]) + + (when *assert* + [:li {:class (stl/css :toolbar-option)} + [:> icon-button* {:variant "ghost" + :tooltip-placement "bottom" + :aria-pressed (contains? layout :debug-panel) + :aria-label (tool-label :debug) + :icon i/bug + :on-click on-toggle-debug-panel}]]) + + (when mcp-show? + [:li {:class (stl/css :toolbar-option)} + [:> mcp-tool* {:is-mcp-connected mcp-connected?}]])] [:button {:title (tr "workspace.toolbar.toggle-toolbar") :aria-label (tr "workspace.toolbar.toggle-toolbar") :class (stl/css :toolbar-handler) - :on-click toggle-toolbar} - [:div {:class (stl/css :toolbar-handler-btn)}]]]))) + :on-click on-toggle-toolbar} + [:div {:class (stl/css :toolbar-handler-indicator)}]]]))) diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.scss b/frontend/src/app/main/ui/workspace/top_toolbar.scss index 7a185eb692..0db0002fbf 100644 --- a/frontend/src/app/main/ui/workspace/top_toolbar.scss +++ b/frontend/src/app/main/ui/workspace/top_toolbar.scss @@ -4,109 +4,118 @@ // // Copyright (c) KALEIDOS INC Sucursal en EspaƱa SL -@use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; @use "ds/_utils.scss" as *; +@use "ds/spacing.scss" as *; @use "ds/typography.scss" as t; +@use "ds/z-index.scss" as *; -.main-toolbar { - cursor: initial; +.toolbar { + --toolbar-position-y: #{$sz-28}; + --toolbar-offset-y: 0px; + --menu-border-color: var(--color-background-quaternary); + --menu-background-color: var(--color-background-primary); + + cursor: default; position: absolute; - left: 50%; + inset-inline-start: 50%; transform: translateX(-50%); display: flex; align-items: center; flex-direction: column; - height: deprecated.$s-56; - padding: deprecated.$s-8 deprecated.$s-16; - border-radius: deprecated.$s-8; - border: deprecated.$s-2 solid var(--panel-border-color); - z-index: deprecated.$z-index-1; - background-color: var(--color-background-primary); + padding: var(--sp-m); + border-radius: $br-8; + border: $b-2 solid var(--menu-border-color); + z-index: var(--z-index-panels); + background-color: var(--menu-background-color); transition: - top 0.3s, - height 0.3s, + inset-block-start 0.3s, + block-size 0.3s, opacity 0.3s; + inset-block-start: calc(var(--toolbar-position-y) + var(--toolbar-offset-y)); + will-change: auto; - --toolbar-position-y: #{deprecated.$s-28}; - --toolbar-offset-y: 0px; + &.no-rulers { + --toolbar-position-y: 0px; + --toolbar-offset-y: 8px; + } - top: calc(var(--toolbar-position-y) + var(--toolbar-offset-y)); -} + &.hidden { + --toolbar-offset-y: -4px; -.main-toolbar-no-rulers { - --toolbar-position-y: 0px; - --toolbar-offset-y: #{deprecated.$s-8}; -} + block-size: $sz-16; + z-index: 1; + border-radius: 0 0 $br-8 $br-8; + border-block-start: 0; -.main-toolbar-hidden { - --toolbar-offset-y: calc(-1 * #{deprecated.$s-4}); - - height: deprecated.$s-16; - z-index: deprecated.$z-index-1; - border-radius: 0 0 deprecated.$s-8 deprecated.$s-8; - border-block-start: 0; - - .main-toolbar-options { - opacity: deprecated.$op-0; - visibility: hidden; + .toolbar-options { + opacity: 0; + visibility: hidden; + } } } -.main-toolbar-options { +.toolbar-options { position: relative; display: flex; align-items: center; + gap: var(--sp-xs); margin: 0; - opacity: deprecated.$op-10; + opacity: 1; transition: opacity 0.3s ease; +} - li { - position: relative; +.toolbar-option { + position: relative; +} + +.toolbar-group { + position: relative; +} + +.toolbar-group-flyout { + opacity: 0; + visibility: hidden; + pointer-events: none; + position: absolute; + inset-block-start: 140%; + inset-inline-start: 50%; + transform: translateX(-50%); + margin-block-start: var(--sp-xxs); + padding: var(--sp-xxs) 0; + border-radius: $br-8; + border: #{$b-1} solid var(--menu-border-color); + background-color: var(--menu-background-color); + z-index: var(--z-index-panels); + display: flex; + transition: + opacity 80ms ease-out, + visibility 80ms linear; + + &.open { + opacity: 1; + transition-delay: 0s; + visibility: visible; + pointer-events: auto; } } -.main-toolbar-options-button { - @extend %button-tertiary; - - height: deprecated.$s-36; - width: deprecated.$s-36; - flex-shrink: 0; - border-radius: deprecated.$s-8; - margin: 0 deprecated.$s-2; - - svg { - @extend %button-icon; - - stroke: var(--color-foreground-secondary); - } - - &.selected { - @extend %button-icon-selected; - } -} - -.mcp-button { +.toolbar-mcp-button { display: flex; align-items: center; gap: var(--sp-xs); - width: fit-content; - margin-inline-start: var(--sp-xs); - padding-inline: var(--sp-s); } -.mcp-button-label { - @include t.use-typography("body-small"); - +.toolbar-mcp-button-label { &.connected { color: var(--color-accent-primary); } } -.mcp-status-dot { - // Connection indicator placed before the label, vertically centered: - // a muted gray when disconnected, the primary accent when connected. +.toolbar-mcp-button-dot { position: relative; + display: flex; flex-shrink: 0; inline-size: px2rem(6); block-size: px2rem(6); @@ -143,23 +152,23 @@ } } -.mcp-menu { - @include deprecated.menu-shadow; - +.toolbar-mcp-menu { position: absolute; - inset-block-start: calc(100% + var(--sp-xs)); - inset-inline-start: var(--sp-xs); + left: 0; + top: $sz-36; +} + +.toolbar-mcp-dropdown { + box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark); z-index: var(--z-index-dropdown); margin: 0; padding: var(--sp-xs); - list-style: none; border: $b-1 solid var(--panel-border-color); border-radius: $br-8; background-color: var(--menu-background-color); } -// Non-interactive informational text inside the menu (no hover, not focusable). -.mcp-menu-info { +.toolbar-mcp-dropdown-info { @include t.use-typography("body-small"); padding: var(--sp-s) var(--sp-m); @@ -167,7 +176,7 @@ white-space: nowrap; } -.mcp-menu-item { +.toolbar-mcp-dropdown-item { @include t.use-typography("body-small"); display: flex; @@ -182,26 +191,31 @@ } } +.toolbar-separator { + --separator-color: var(--color-background-quaternary); + + margin: 0 var(--sp-m); + border-inline-start: $b-1 solid var(--separator-color); + block-size: $sz-32; +} + .toolbar-handler { - @include deprecated.flex-center; - @include deprecated.button-style; - + block-size: $sz-12; + display: flex; + justify-content: center; + align-items: center; + appearance: none; + border: none; + background: transparent; position: absolute; - left: 0; - bottom: 0; - height: deprecated.$s-12; - width: 100%; - - .toolbar-handler-btn { - height: deprecated.$s-4; - width: 100%; - max-width: deprecated.$s-64; - padding: 0; - border-radius: deprecated.$s-4; - background-color: var(--palette-handler-background-color); - } + inset-inline: 0; + inset-block-end: 0; } -ul.main-toolbar-panels { - display: none; +.toolbar-handler-indicator { + block-size: px2rem(4); + inline-size: $sz-64; + padding: 0; + border-radius: $br-4; + background-color: var(--color-accent-default); } diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index ce03ca1f3a..f7d95b018b 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -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] diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 61f5025f81..cdc0a1e094 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -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]