From 9e4c8981be3634d4515fe605319c0c0fecd0e2cb Mon Sep 17 00:00:00 2001 From: Xaviju Date: Fri, 10 Apr 2026 10:42:35 +0200 Subject: [PATCH] :tada: Duplicate token group (#8886) --- CHANGES.md | 3 +- .../playwright/ui/specs/tokens/crud.spec.js | 44 ----- .../playwright/ui/specs/tokens/helpers.js | 29 ++++ .../ui/specs/tokens/remapping.spec.js | 85 +--------- .../playwright/ui/specs/tokens/tree.spec.js | 159 +++++++++++++++++- .../data/workspace/tokens/library_edit.cljs | 33 ++++ .../main/ui/workspace/tokens/management.cljs | 28 ++- .../management/forms/rename_node_modal.cljs | 23 +-- .../tokens/management/node_context_menu.cljs | 14 +- 9 files changed, 271 insertions(+), 147 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index db692c6c4b..c703c050b3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -18,6 +18,7 @@ - Add Tab/Shift+Tab navigation to rename layers sequentially (by @bittoby) [Github #8474](https://github.com/penpot/penpot/pull/8474) - Copy and paste entire rows in existing table (by @bittoby) [Github #8474](https://github.com/penpot/penpot/pull/8498) - Rename token group [Taiga #13137](https://tree.taiga.io/project/penpot/us/13137) +- Duplicate token group [Taiga #10653](https://tree.taiga.io/project/penpot/us/10653) - Copy token name from contextual menu [Taiga #13568](https://tree.taiga.io/project/penpot/issue/13568) - Add natural sorting on token names [Taiga #13713](https://tree.taiga.io/project/penpot/issue/13713) - Add drag-to-change for numeric inputs in workspace sidebar [Github #2466](https://github.com/penpot/penpot/issues/2466) @@ -84,7 +85,6 @@ - Guard delete undo against missing sibling order [Github #8858](https://github.com/penpot/penpot/pull/8858) - Fix ICounted error on numeric-input token dropdown keyboard nav [Github #8803](https://github.com/penpot/penpot/pull/8803) - ## 2.14.1 ### :sparkles: New features & Enhancements @@ -108,7 +108,6 @@ - Ensure path content is always PathData when saving - Fix error when get-parent-with-data encounters non-Element nodes - ## 2.14.0 ### :boom: Breaking changes & Deprecations diff --git a/frontend/playwright/ui/specs/tokens/crud.spec.js b/frontend/playwright/ui/specs/tokens/crud.spec.js index 58e67be6e5..4b9266fa60 100644 --- a/frontend/playwright/ui/specs/tokens/crud.spec.js +++ b/frontend/playwright/ui/specs/tokens/crud.spec.js @@ -2011,48 +2011,4 @@ test.describe("Tokens tab - delete", () => { await expect(tokenContextMenuForToken).not.toBeVisible(); await expect(colorToken).not.toBeVisible(); }); - - test("User removes node and all child tokens", async ({ page }) => { - const { tokensSidebar } = await setupTokensFileRender(page); - - await expect(tokensSidebar).toBeVisible(); - - // Expand color tokens - await unfoldTokenType(tokensSidebar, "color"); - - // Verify that the node and child token are visible before deletion - const colorNode = tokensSidebar.getByRole("button", { - name: "colors", - exact: true, - }); - const colorNodeToken = tokensSidebar.getByRole("button", { - name: "colors.blue.100", - }); - - // Select a node and right click on it to open context menu - await expect(colorNode).toBeVisible(); - await expect(colorNodeToken).toBeVisible(); - await colorNode.click({ button: "right" }); - - // select "Delete" from the context menu - const deleteNodeButton = page.getByRole("button", { - name: "Delete", - exact: true, - }); - await expect(deleteNodeButton).toBeVisible(); - await deleteNodeButton.click(); - - // Verify that the node is removed - await expect(colorNode).not.toBeVisible(); - // Verify that child token is also removed - await expect(colorNodeToken).not.toBeVisible(); - - // Save the type button to verify that expands/folds - const tokenTypeButton = await tokensSidebar.getByRole("button", { - name: "Color", - exact: true, - }); - - await expect(tokenTypeButton).toHaveAttribute("aria-expanded", "false"); - }); }); diff --git a/frontend/playwright/ui/specs/tokens/helpers.js b/frontend/playwright/ui/specs/tokens/helpers.js index 657bce8b54..937a268242 100644 --- a/frontend/playwright/ui/specs/tokens/helpers.js +++ b/frontend/playwright/ui/specs/tokens/helpers.js @@ -332,6 +332,34 @@ const unfoldTokenType = async (tokensTabPanel, type) => { } }; +const createToken = async (page, type, name, textFieldName, value) => { + const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); + + const { tokensUpdateCreateModal } = await setupTokensFileRender(page, { + flags: ["enable-token-shadow"], + }); + + // Create base token + await tokensTabPanel + .getByRole("button", { name: `Add Token: ${type}` }) + .click(); + await expect(tokensUpdateCreateModal).toBeVisible(); + + const nameField = tokensUpdateCreateModal.getByLabel("Name"); + await nameField.fill(name); + + const colorField = tokensUpdateCreateModal.getByRole("textbox", { + name: textFieldName, + }); + await colorField.fill(value); + + const submitButton = tokensUpdateCreateModal.getByRole("button", { + name: "Save", + }); + await submitButton.click(); + await expect(tokensUpdateCreateModal).not.toBeVisible(); +}; + export { setupEmptyTokensFile, setupEmptyTokensFileRender, @@ -341,4 +369,5 @@ export { setupTypographyTokensFileRender, testTokenCreationFlow, unfoldTokenType, + createToken, }; diff --git a/frontend/playwright/ui/specs/tokens/remapping.spec.js b/frontend/playwright/ui/specs/tokens/remapping.spec.js index 90eb658a77..44163bfdfa 100644 --- a/frontend/playwright/ui/specs/tokens/remapping.spec.js +++ b/frontend/playwright/ui/specs/tokens/remapping.spec.js @@ -2,6 +2,7 @@ import { test, expect } from "@playwright/test"; import { WorkspacePage } from "../../pages/WorkspacePage"; import { WasmWorkspacePage } from "../../pages/WasmWorkspacePage"; import { + createToken, setupTokensFileRender, setupTypographyTokensFileRender, } from "./helpers"; @@ -14,34 +15,6 @@ test.beforeEach(async ({ page }) => { await WasmWorkspacePage.mockRPC(page, "get-teams", "get-teams-tokens.json"); }); -const createToken = async (page, type, name, textFieldName, value) => { - const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); - - const { tokensUpdateCreateModal } = await setupTokensFileRender(page, { - flags: ["enable-token-shadow"], - }); - - // Create base token - await tokensTabPanel - .getByRole("button", { name: `Add Token: ${type}` }) - .click(); - await expect(tokensUpdateCreateModal).toBeVisible(); - - const nameField = tokensUpdateCreateModal.getByLabel("Name"); - await nameField.fill(name); - - const colorField = tokensUpdateCreateModal.getByRole("textbox", { - name: textFieldName, - }); - await colorField.fill(value); - - const submitButton = tokensUpdateCreateModal.getByRole("button", { - name: "Save", - }); - await submitButton.click(); - await expect(tokensUpdateCreateModal).not.toBeVisible(); -}; - const createTokenCombobox = async (page, type, name, textFieldName, value) => { const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); @@ -636,62 +609,6 @@ test.describe("Remapping a single token", () => { }); test.describe("Remapping group of tokens", () => { - test("User renames a group - no remap", async ({ page }) => { - const { tokensSidebar } = await setupTokensFileRender(page); - - // Create multiple tokens in a group - await createToken(page, "Color", "dark.primary", "Value", "#000000"); - await createToken(page, "Color", "dark.secondary", "Value", "#111111"); - - // Verify that the node and child token are visible before deletion - const darkNode = tokensSidebar.getByRole("button", { - name: "dark", - exact: true, - }); - const darkNodeToken = tokensSidebar.getByRole("button", { - name: "primary", - }); - - // Select a node and right click on it to open context menu - await expect(darkNode).toBeVisible(); - await expect(darkNodeToken).toBeVisible(); - await darkNode.click({ button: "right" }); - - // select "Rename" from the context menu - const renameNodeButton = page.getByRole("button", { - name: "Rename", - exact: true, - }); - await expect(renameNodeButton).toBeVisible(); - await renameNodeButton.click(); - - // Expect the rename modal to be visible, fill in the new name and submit - const tokenRenameNodeModal = page.getByTestId("token-rename-node-modal"); - await expect(tokenRenameNodeModal).toBeVisible(); - - const nameField = tokenRenameNodeModal.getByRole("textbox", { - name: "Name", - }); - await nameField.fill("darker"); - - const submitButton = tokenRenameNodeModal.getByRole("button", { - name: "Rename", - }); - await submitButton.click(); - - // Ensure that the remapping modal does not appear - const remappingModal = page.getByTestId("token-remapping-modal"); - await expect(remappingModal).not.toBeVisible(); - - // Verify that the node has been renamed and tokens are still visible - const darkerNode = tokensSidebar.getByRole("button", { - name: "darker", - exact: true, - }); - - await expect(darkerNode).toBeVisible(); - }); - test("User renames a group - and remaps", async ({ page }) => { const { tokensSidebar } = await setupTokensFileRender(page); const workspacePage = new WasmWorkspacePage(page); diff --git a/frontend/playwright/ui/specs/tokens/tree.spec.js b/frontend/playwright/ui/specs/tokens/tree.spec.js index 6228b52a2a..243a539432 100644 --- a/frontend/playwright/ui/specs/tokens/tree.spec.js +++ b/frontend/playwright/ui/specs/tokens/tree.spec.js @@ -1,7 +1,7 @@ import { test, expect } from "@playwright/test"; import { WasmWorkspacePage } from "../../pages/WasmWorkspacePage"; import { BaseWebSocketPage } from "../../pages/BaseWebSocketPage"; -import { setupTokensFileRender } from "./helpers"; +import { createToken, setupTokensFileRender, unfoldTokenType } from "./helpers"; test.beforeEach(async ({ page }) => { await WasmWorkspacePage.init(page); @@ -27,4 +27,161 @@ test.describe("Tokens - node tree", () => { await tokensColorGroup.click(); await expect(colorToken).not.toBeVisible(); }); + + test("User renames a group", async ({ page }) => { + const { tokensSidebar } = await setupTokensFileRender(page); + + // Create multiple tokens in a group + await createToken(page, "Color", "dark.primary", "Value", "#000000"); + await createToken(page, "Color", "dark.secondary", "Value", "#111111"); + + // Verify that the node and child token are visible before deletion + const darkNode = tokensSidebar.getByRole("button", { + name: "dark", + exact: true, + }); + const darkNodeToken = tokensSidebar.getByRole("button", { + name: "primary", + }); + + // Select a node and right click on it to open context menu + await expect(darkNode).toBeVisible(); + await expect(darkNodeToken).toBeVisible(); + await darkNode.click({ button: "right" }); + + // select "Rename" from the context menu + const renameNodeButton = page.getByRole("button", { + name: "Rename", + exact: true, + }); + await expect(renameNodeButton).toBeVisible(); + await renameNodeButton.click(); + + // Expect the rename modal to be visible, fill in the new name and submit + const tokenRenameNodeModal = page.getByTestId("token-rename-node-modal"); + await expect(tokenRenameNodeModal).toBeVisible(); + + const nameField = tokenRenameNodeModal.getByRole("textbox", { + name: "Name", + }); + await nameField.fill("darker"); + + const submitButton = tokenRenameNodeModal.getByRole("button", { + name: "Rename", + }); + await submitButton.click(); + + // Ensure that the remapping modal does not appear + const remappingModal = page.getByTestId("token-remapping-modal"); + await expect(remappingModal).not.toBeVisible(); + + // Verify that the node has been renamed and tokens are still visible + const darkerNode = tokensSidebar.getByRole("button", { + name: "darker", + exact: true, + }); + + await expect(darkerNode).toBeVisible(); + }); + + test("User duplicates a group", async ({ page }) => { + const { tokensSidebar } = await setupTokensFileRender(page); + + // Create multiple tokens in a group + await createToken(page, "Color", "dark.primary", "Value", "#000000"); + await createToken(page, "Color", "dark.secondary", "Value", "#111111"); + + // Verify that the node and child token are visible before deletion + const darkNode = tokensSidebar.getByRole("button", { + name: "dark", + exact: true, + }); + const darkNodeToken = tokensSidebar.getByRole("button", { + name: "primary", + }); + + // Select a node and right click on it to open context menu + await expect(darkNode).toBeVisible(); + await expect(darkNodeToken).toBeVisible(); + await darkNode.click({ button: "right" }); + + // select "Duplicate" from the context menu + const duplicateNodeButton = page.getByRole("button", { + name: "Duplicate", + exact: true, + }); + await expect(duplicateNodeButton).toBeVisible(); + await duplicateNodeButton.click(); + + // Expect the duplicate modal to be visible, fill in the new name and submit + const tokenDuplicateNodeModal = page.getByTestId("token-rename-node-modal"); + await expect(tokenDuplicateNodeModal).toBeVisible(); + + const nameField = tokenDuplicateNodeModal.getByRole("textbox", { + name: "Name", + }); + await nameField.fill("darker"); + + const submitButton = tokenDuplicateNodeModal.getByRole("button", { + name: "Duplicate", + }); + await submitButton.click(); + + // Verify that the node has been duplicated and tokens are visible + const darkerNode = tokensSidebar.getByRole("button", { + name: "darker", + exact: true, + }); + + const darkerNodeToken = tokensSidebar.getByRole("button", { + name: "darker.primary", + }); + + await expect(darkerNode).toBeVisible(); + await expect(darkerNodeToken).toBeVisible(); + }); + + test("User removes node and all child tokens", async ({ page }) => { + const { tokensSidebar } = await setupTokensFileRender(page); + + await expect(tokensSidebar).toBeVisible(); + + // Expand color tokens + await unfoldTokenType(tokensSidebar, "color"); + + // Verify that the node and child token are visible before deletion + const colorNode = tokensSidebar.getByRole("button", { + name: "colors", + exact: true, + }); + const colorNodeToken = tokensSidebar.getByRole("button", { + name: "colors.blue.100", + }); + + // Select a node and right click on it to open context menu + await expect(colorNode).toBeVisible(); + await expect(colorNodeToken).toBeVisible(); + await colorNode.click({ button: "right" }); + + // select "Delete" from the context menu + const deleteNodeButton = page.getByRole("button", { + name: "Delete", + exact: true, + }); + await expect(deleteNodeButton).toBeVisible(); + await deleteNodeButton.click(); + + // Verify that the node is removed + await expect(colorNode).not.toBeVisible(); + // Verify that child token is also removed + await expect(colorNodeToken).not.toBeVisible(); + + // Save the type button to verify that expands/folds + const tokenTypeButton = await tokensSidebar.getByRole("button", { + name: "Color", + exact: true, + }); + + await expect(tokenTypeButton).toHaveAttribute("aria-expanded", "false"); + }); }); diff --git a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs index 595ec4a86d..947e8ad456 100644 --- a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs +++ b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs @@ -11,6 +11,8 @@ [app.common.files.helpers :as cfh] [app.common.geom.point :as gpt] [app.common.logic.tokens :as clt] + [app.common.path-names :as cpn] + [app.common.test-helpers.ids-map :as cthi] [app.common.types.shape :as cts] [app.common.types.tokens-lib :as ctob] [app.common.uuid :as uuid] @@ -461,6 +463,37 @@ (rx/of (create-token-with-set token))))))) +(defn bulk-create-tokens + [set-id token-ids type node new-node-name] + (assert (uuid? set-id) "expected uuid for `set-id`") + (assert (every? uuid? token-ids) "expected a collection of uuids for `token-ids`") + (assert (keyword? type) "expected keyword for `type`") + (assert (string? new-node-name) "expected string for `new-node-name`") + + (ptk/reify ::bulk-create-tokens + ptk/WatchEvent + (watch [it state _] + (let [token-set (lookup-token-set state set-id) + data (dsh/lookup-file-data state) + changes (reduce (fn [changes token-id] + (let [token (-> (get-tokens-lib state) + (ctob/get-token (ctob/get-id token-set) token-id)) + new-name (-> + (cpn/split-path (:name token) :separator ".") + (assoc (:depth node) new-node-name) + (cpn/join-path :separator "." :with-spaces? false)) + token' (->> (merge token {:name new-name + :id (cthi/new-id! (:name new-name))}) + (into {}) + (ctob/make-token))] + (pcb/set-token changes (ctob/get-id token-set) (:id token') token'))) + (-> (pcb/empty-changes it) + (pcb/with-library-data data)) + token-ids)] + (rx/of + (dch/commit-changes changes) + (ptk/data-event ::ev/event {::ev/name "bulk-create-tokens" :type type})))))) + (defn update-token ([id params] (update-token nil id params)) ([set-id id params] diff --git a/frontend/src/app/main/ui/workspace/tokens/management.cljs b/frontend/src/app/main/ui/workspace/tokens/management.cljs index f462b48e5e..e3a0dafed1 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management.cljs @@ -22,7 +22,6 @@ [app.main.ui.workspace.tokens.management.node-context-menu :refer [token-node-context-menu*]] [app.util.array :as array] [app.util.i18n :refer [tr]] - [cljs.pprint :as pp] [cuerdas.core :as str] [rumext.v2 :as mf])) @@ -136,8 +135,7 @@ (fn [tokens-filtered-by-type node] (->> tokens-filtered-by-type (filter (fn [token] - (let [token-path (cpn/split-path (:name token) :separator ".") - _ (pp/pprint {:token-path token-path :count (count token-path)})] + (let [token-path (cpn/split-path (:name token) :separator ".")] (and (> (count token-path) 0) (str/starts-with? (:name token) (str (:path node) "."))))))))) @@ -192,6 +190,8 @@ (st/emit! (dwtl/toggle-token-path (str (name type) "." path))) (st/emit! (dwtl/close-token-type type)))))) + + bulk-rename-tokens-in-path ;; Rename tokens in bulk affected by a node rename. (mf/use-fn @@ -254,7 +254,6 @@ tokens-by-type (ctob/group-by-type selected-token-set-tokens) tokens-filtered-by-type (get tokens-by-type type) tokens-in-current-path (filter-tokens-by-path tokens-filtered-by-type node) - _ (pp/pprint {:tokens-in-current-path tokens-in-current-path}) token-references-count (reduce (fn [count token] (+ count (remap/count-token-references file-data (:name token)))) 0 @@ -263,6 +262,13 @@ (on-remap-node-warning node type new-node-name) (bulk-rename-tokens-in-path node type new-node-name))))) + on-duplicate-node + (fn [node type new-node-name] + (let [tokens-in-path-ids (filter-tokens-by-path-ids type (:path node))] + (st/emit! + (modal/hide) + (dwtl/bulk-create-tokens selected-token-set-id tokens-in-path-ids type node new-node-name)))) + open-rename-node-modal ;; When user renames a node, we display a form modal (mf/use-fn @@ -271,7 +277,18 @@ (let [on-rename-node-handler #(on-rename-node node type %)] (st/emit! (modal/show :tokens/rename-node {:node node :tokens-in-active-set selected-token-set-tokens - :on-rename on-rename-node-handler})))))] + :on-rename on-rename-node-handler}))))) + + open-duplicate-node-modal + (mf/use-fn + (mf/deps selected-token-set-tokens on-duplicate-node) + (fn [node type] + (let [on-duplicate-node-handler #(on-duplicate-node node type %)] + (st/emit! (modal/show :tokens/rename-node {:new-node-name (str (:name node) "-copy") + :node node + :variant "duplicate" + :tokens-in-active-set selected-token-set-tokens + :on-rename on-duplicate-node-handler})))))] (mf/with-effect [tokens-lib selected-token-set-id] (when (and tokens-lib @@ -286,6 +303,7 @@ [:* [:& token-context-menu {:on-delete-token delete-token}] [:> token-node-context-menu* {:on-rename-node open-rename-node-modal + :on-duplicate-node open-duplicate-node-modal :on-delete-node delete-node}] [:> selected-set-info* {:tokens-lib tokens-lib diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs index cb630d5b55..c786852441 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs @@ -18,15 +18,15 @@ [rumext.v2 :as mf])) (mf/defc rename-node-form* - [{:keys [node active-tokens tokens-tree on-close on-submit]}] + [{:keys [new-node-name node active-tokens tokens-tree variant on-close on-submit]}] (let [make-schema #(cfo/make-node-token-schema active-tokens tokens-tree node) schema (mf/with-memo [active-tokens] (make-schema)) - initial (mf/with-memo [node] - {:name (:name node)}) + initial (mf/with-memo [node new-node-name] + {:name (d/nilv new-node-name (:name node))}) form (fm/use-form :schema schema :initial initial) @@ -35,11 +35,10 @@ (mf/deps form on-submit) (fn [] (let [name (get-in @form [:clean-data :name])] - (when (and (get-in @form [:touched :name]) (not= name (:name node))) + (when (not= name (:name node)) (on-submit name))))) is-disabled? (or (not (:valid @form)) - (not (get-in @form [:touched :name])) (= (get-in @form [:clean-data :name]) (:name node))) hint-path (mf/with-memo [@form node] @@ -64,7 +63,7 @@ :max-length 255 :variant "comfortable" :hint-type "hint" - :hint-message (tr "workspace.tokens.rename-group-name-hint" hint-path) + :hint-message (when (= variant "rename") (tr "workspace.tokens.rename-group-name-hint" hint-path)) :auto-focus true}] [:div {:class (stl/css :form-actions)} [:> button* {:variant "secondary" @@ -72,14 +71,16 @@ :on-click on-close} (tr "labels.cancel")] [:> fc/form-submit* {:variant "primary" :disabled is-disabled? - :name "rename"} (tr "labels.rename")]]])) + :name "rename"} (if (= variant "rename") (tr "labels.rename") (tr "labels.duplicate"))]]])) (mf/defc rename-node-modal {::mf/register modal/components ::mf/register-as :tokens/rename-node} - [{:keys [node tokens-in-active-set on-rename]}] + [{:keys [new-node-name node tokens-in-active-set on-rename variant]}] - (let [tokens-tree-in-selected-set + (let [variant (d/nilv variant "rename") ;; "rename" or "duplicate" + + tokens-tree-in-selected-set (mf/with-memo [tokens-in-active-set node] (-> (ctob/tokens-tree tokens-in-active-set) (d/dissoc-in (:name node)))) @@ -111,7 +112,9 @@ :aria-label (tr "labels.close") :variant "ghost" :icon i/close}] - [:> rename-node-form* {:node node + [:> rename-node-form* {:new-node-name new-node-name + :node node + :variant variant :active-tokens tokens-in-active-set :tokens-tree tokens-tree-in-selected-set :on-close close-modal diff --git a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs index a75966f9e4..6b49e7df0a 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs @@ -27,7 +27,7 @@ (mf/defc token-node-context-menu* {::mf/schema schema:token-node-context-menu} - [{:keys [on-rename-node on-delete-node]}] + [{:keys [on-rename-node on-duplicate-node on-delete-node]}] (let [mdata (mf/deref tokens-node-menu-ref) is-open? (boolean mdata) dropdown-ref (mf/use-ref) @@ -44,6 +44,13 @@ type (get mdata :type)] (when node (on-rename-node node type))))) + duplicate-node (mf/use-fn + (mf/deps mdata on-duplicate-node) + (fn [] + (let [node (get mdata :node) + type (get mdata :type)] + (when node + (on-duplicate-node node type))))) container (hooks/use-portal-container) delete-node (mf/use-fn @@ -90,6 +97,11 @@ :type "button" :on-click rename-node} (tr "labels.rename")]] + [:li {:class (stl/css :token-node-context-menu-listitem)} + [:button {:class (stl/css :token-node-context-menu-action) + :type "button" + :on-click duplicate-node} + (tr "labels.duplicate")]] [:li {:class (stl/css :token-node-context-menu-listitem)} [:button {:class (stl/css :token-node-context-menu-action) :type "button"