From 668c989da1572b0a2bd7d27499f59010d96a84c1 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Tue, 7 Apr 2026 23:07:08 +0500 Subject: [PATCH 1/5] [Feat]: #1755 add ability to copy the Hook components --- .../src/comps/utils/hookCompOperator.ts | 102 ++++++++++++++++++ .../src/pages/editor/editorHotKeys.tsx | 33 ++++++ 2 files changed, 135 insertions(+) create mode 100644 client/packages/lowcoder/src/comps/utils/hookCompOperator.ts diff --git a/client/packages/lowcoder/src/comps/utils/hookCompOperator.ts b/client/packages/lowcoder/src/comps/utils/hookCompOperator.ts new file mode 100644 index 000000000..46f2febf3 --- /dev/null +++ b/client/packages/lowcoder/src/comps/utils/hookCompOperator.ts @@ -0,0 +1,102 @@ +import { HookComp } from "comps/hooks/hookComp"; +import { EditorState } from "comps/editorState"; +import { wrapActionExtraInfo, type Comp } from "lowcoder-core"; +import { messageInstance } from "lowcoder-design"; +import { trans } from "i18n"; + +type CopyableHookType = "modal" | "drawer"; + +type CopyableHookComp = HookComp & { + children: HookComp["children"] & { + compType: { getView: () => CopyableHookType }; + }; +}; + +const copyableHookTypes = new Set(["modal", "drawer"]); + +export class HookCompOperator { + private static copyHooks: CopyableHookComp[] = []; + + /** + * Copy modals/drawers by name from selectedCompNames. + */ + static copyComp(editorState: EditorState, compRecords: Record) { + const selectedNames = Array.from(editorState.selectedCompNames); + if (!selectedNames.length) { + return false; + } + + const hookMap = editorState.getHooksComp().getAllCompItems(); + const selectedHookComps = Object.values(hookMap) + .filter((comp: any) => { + const name = comp.children.name.getView(); + const compType = comp.children.compType.getView(); + return selectedNames.includes(name) && copyableHookTypes.has(compType); + }) as CopyableHookComp[]; + + if (!selectedHookComps.length) { + return false; + } + + this.copyHooks = selectedHookComps; + messageInstance.success(trans("notification.copySuccess")); + return true; + } + + static clearCopy() { + this.copyHooks = []; + } + + /** + * Paste previously copied modals/drawers and re-generate nested component names. + */ + static pasteComp(editorState: EditorState) { + if (!this.copyHooks.length) { + messageInstance.info(trans("gridCompOperator.selectCompFirst")); + return false; + } + + const hooksComp = editorState.getHooksComp(); + const nameGenerator = editorState.getNameGenerator(); + const newNames = new Set(); + + this.copyHooks.forEach((hookComp) => { + const compType = hookComp.children.compType.getView(); + const newName = nameGenerator.genItemName(compType); + const childComp: any = hookComp.children.comp; + const baseValue = childComp?.toJsonValue ? childComp.toJsonValue() : {}; + const pasteValue = + childComp?.getPasteValue?.(nameGenerator) ?? {}; + + const payload = { + ...(hookComp.toJsonValue() as any), + name: newName, + comp: { + ...baseValue, + ...pasteValue, + }, + }; + + hooksComp.dispatch( + wrapActionExtraInfo( + hooksComp.pushAction(payload), + { + compInfos: [ + { + type: "add", + compName: newName, + compType, + }, + ], + } + ) + ); + newNames.add(newName); + }); + + editorState.setSelectedCompNames(newNames, "leftPanel"); + messageInstance.success(trans("notification.copySuccess")); + return true; + } +} + diff --git a/client/packages/lowcoder/src/pages/editor/editorHotKeys.tsx b/client/packages/lowcoder/src/pages/editor/editorHotKeys.tsx index 59d043baf..14799a804 100644 --- a/client/packages/lowcoder/src/pages/editor/editorHotKeys.tsx +++ b/client/packages/lowcoder/src/pages/editor/editorHotKeys.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useContext, useRef, useEffect } from "react"; import { EditorContext, EditorState } from "comps/editorState"; import { GridCompOperator } from "comps/utils/gridCompOperator"; +import { HookCompOperator } from "comps/utils/hookCompOperator"; import { ExternalEditorContext } from "util/context/ExternalEditorContext"; import { EditorHistory } from "util/editoryHistory"; import { executeQueryAction } from "lowcoder-core"; @@ -75,6 +76,31 @@ function handleGlobalKeyDown( break; } default: + // Capture component copy/paste/cut/deselect globally + if (modKeyPressed(e)) { + const key = e.key?.toLowerCase(); + if (key === "c") { + if (!HookCompOperator.copyComp(editorState, editorState.selectedComps())) { + HookCompOperator.clearCopy(); + GridCompOperator.copyComp(editorState, editorState.selectedComps()); + } + break; + } + if (key === "v") { + if (!HookCompOperator.pasteComp(editorState)) { + GridCompOperator.pasteComp(editorState); + } + break; + } + if (key === "x") { + GridCompOperator.cutComp(editorState, editorState.selectedComps()); + break; + } + } + if (e.key === "Escape") { + editorState.setSelectedCompNames(new Set()); + break; + } return; } // avoid conflicts with the browser @@ -194,9 +220,16 @@ function handleEditorKeyDown(e: React.KeyboardEvent, editorState: EditorState) { e.stopPropagation(); return; case "copyComps": + if (HookCompOperator.copyComp(editorState, editorState.selectedComps())) { + return; + } + HookCompOperator.clearCopy(); GridCompOperator.copyComp(editorState, editorState.selectedComps()); return; case "pasteComps": + if (HookCompOperator.pasteComp(editorState)) { + return; + } GridCompOperator.pasteComp(editorState); return; case "cutComps": From ff79f07632848e8fcea5dd5f75ee9264dac72a7e Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Wed, 8 Apr 2026 20:29:29 +0500 Subject: [PATCH 2/5] [Feat]: #1755 remove hardcoded hook components --- .../src/comps/utils/hookCompOperator.ts | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/client/packages/lowcoder/src/comps/utils/hookCompOperator.ts b/client/packages/lowcoder/src/comps/utils/hookCompOperator.ts index 46f2febf3..f5d7f4fc9 100644 --- a/client/packages/lowcoder/src/comps/utils/hookCompOperator.ts +++ b/client/packages/lowcoder/src/comps/utils/hookCompOperator.ts @@ -1,24 +1,15 @@ import { HookComp } from "comps/hooks/hookComp"; import { EditorState } from "comps/editorState"; +import { singletonHookComp } from "comps/hooks/hookCompTypes"; import { wrapActionExtraInfo, type Comp } from "lowcoder-core"; import { messageInstance } from "lowcoder-design"; import { trans } from "i18n"; -type CopyableHookType = "modal" | "drawer"; - -type CopyableHookComp = HookComp & { - children: HookComp["children"] & { - compType: { getView: () => CopyableHookType }; - }; -}; - -const copyableHookTypes = new Set(["modal", "drawer"]); - export class HookCompOperator { - private static copyHooks: CopyableHookComp[] = []; + private static copyHooks: HookComp[] = []; /** - * Copy modals/drawers by name from selectedCompNames. + * Copy non-singleton hook components by name from selectedCompNames. */ static copyComp(editorState: EditorState, compRecords: Record) { const selectedNames = Array.from(editorState.selectedCompNames); @@ -31,8 +22,8 @@ export class HookCompOperator { .filter((comp: any) => { const name = comp.children.name.getView(); const compType = comp.children.compType.getView(); - return selectedNames.includes(name) && copyableHookTypes.has(compType); - }) as CopyableHookComp[]; + return selectedNames.includes(name) && !singletonHookComp(compType); + }) as HookComp[]; if (!selectedHookComps.length) { return false; @@ -48,7 +39,7 @@ export class HookCompOperator { } /** - * Paste previously copied modals/drawers and re-generate nested component names. + * Paste previously copied hook components and re-generate nested component names. */ static pasteComp(editorState: EditorState) { if (!this.copyHooks.length) { From 17402785c43fedc64041dbc65e9d5dfff8a064fc Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Wed, 8 Apr 2026 23:23:34 +0500 Subject: [PATCH 3/5] fix: #1755 nested components inside hook components --- .../lowcoder/src/comps/utils/hookCompOperator.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/client/packages/lowcoder/src/comps/utils/hookCompOperator.ts b/client/packages/lowcoder/src/comps/utils/hookCompOperator.ts index f5d7f4fc9..f33f24c43 100644 --- a/client/packages/lowcoder/src/comps/utils/hookCompOperator.ts +++ b/client/packages/lowcoder/src/comps/utils/hookCompOperator.ts @@ -8,17 +8,14 @@ import { trans } from "i18n"; export class HookCompOperator { private static copyHooks: HookComp[] = []; - /** - * Copy non-singleton hook components by name from selectedCompNames. - */ static copyComp(editorState: EditorState, compRecords: Record) { const selectedNames = Array.from(editorState.selectedCompNames); if (!selectedNames.length) { return false; } - const hookMap = editorState.getHooksComp().getAllCompItems(); - const selectedHookComps = Object.values(hookMap) + const hookComps = editorState.getHooksComp().getView(); + const selectedHookComps = hookComps .filter((comp: any) => { const name = comp.children.name.getView(); const compType = comp.children.compType.getView(); @@ -30,7 +27,7 @@ export class HookCompOperator { } this.copyHooks = selectedHookComps; - messageInstance.success(trans("notification.copySuccess")); + messageInstance.success(trans("copySuccess")); return true; } @@ -38,9 +35,7 @@ export class HookCompOperator { this.copyHooks = []; } - /** - * Paste previously copied hook components and re-generate nested component names. - */ + static pasteComp(editorState: EditorState) { if (!this.copyHooks.length) { messageInstance.info(trans("gridCompOperator.selectCompFirst")); @@ -86,7 +81,7 @@ export class HookCompOperator { }); editorState.setSelectedCompNames(newNames, "leftPanel"); - messageInstance.success(trans("notification.copySuccess")); + messageInstance.success(trans("copySuccess")); return true; } } From 117c03fa9279f3684793127b825c13790d377ddd Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Thu, 9 Apr 2026 19:22:28 +0500 Subject: [PATCH 4/5] add copy component from one app to another --- .../src/comps/utils/gridCompOperator.ts | 207 ++++++++++++++---- .../src/comps/utils/hookCompOperator.ts | 58 ++--- .../src/pages/editor/editorHotKeys.tsx | 42 ++-- 3 files changed, 216 insertions(+), 91 deletions(-) diff --git a/client/packages/lowcoder/src/comps/utils/gridCompOperator.ts b/client/packages/lowcoder/src/comps/utils/gridCompOperator.ts index ff75db63f..41cabef38 100644 --- a/client/packages/lowcoder/src/comps/utils/gridCompOperator.ts +++ b/client/packages/lowcoder/src/comps/utils/gridCompOperator.ts @@ -3,9 +3,11 @@ import { SimpleContainerComp } from "comps/comps/containerBase/simpleContainerCo import { GridItemComp } from "comps/comps/gridItemComp"; import { remoteComp } from "comps/comps/remoteComp/remoteComp"; import { EditorState } from "comps/editorState"; +import { NameGenerator } from "comps/utils"; import { trans } from "i18n"; import { calcPasteBaseXY, + DEFAULT_POSITION_PARAMS, Layout, LayoutItem, moveToZero, @@ -29,15 +31,73 @@ import { genRandomKey } from "./idGenerator"; import { getLatestVersion, getRemoteCompType, parseCompType } from "./remote"; import { APPLICATION_VIEW_URL } from "@lowcoder-ee/constants/routesURL"; -export type CopyCompType = { +const CLIPBOARD_TYPE = "lowcoder-components"; +const CLIPBOARD_VERSION = 1; + +export interface ClipboardGridItem { + compType: string; + comp: any; + name: string; layout: LayoutItem; - item: Comp; -}; + isContainer: boolean; +} -export class GridCompOperator { - private static copyComps: CopyCompType[] = []; - private static sourcePositionParams: PositionParams; +export interface LowcoderClipboardPayload { + type: typeof CLIPBOARD_TYPE; + version: number; + timestamp: number; + gridItems: ClipboardGridItem[]; + hookItems: ClipboardHookItem[]; + sourcePositionParams: PositionParams; +} + +export interface ClipboardHookItem { + compType: string; + comp: any; + name: string; + fullValue: any; +} + +async function writeToClipboard(payload: LowcoderClipboardPayload): Promise { + try { + const json = JSON.stringify(payload); + await navigator.clipboard.writeText(json); + return true; + } catch { + return false; + } +} + +export async function readFromClipboard(): Promise { + try { + const text = await navigator.clipboard.readText(); + if (!text) return null; + const parsed = JSON.parse(text); + if (parsed?.type !== CLIPBOARD_TYPE || !parsed?.version) return null; + return parsed as LowcoderClipboardPayload; + } catch { + return null; + } +} + +function buildEmptyPayload(): LowcoderClipboardPayload { + return { + type: CLIPBOARD_TYPE, + version: CLIPBOARD_VERSION, + timestamp: Date.now(), + gridItems: [], + hookItems: [], + sourcePositionParams: DEFAULT_POSITION_PARAMS, + }; +} +export function writeHookOnlyToClipboard(hookItems: ClipboardHookItem[]) { + const payload = buildEmptyPayload(); + payload.hookItems = hookItems; + writeToClipboard(payload); +} + +export class GridCompOperator { static copyComp(editorState: EditorState, compRecords: Record) { const oldUi = editorState.getUIComp().getComp(); if (!oldUi) { @@ -65,24 +125,54 @@ export class GridCompOperator { layout: layout[key], })); - const toCopyComps = Object.values(compMap).filter((item) => !!item.item && !!item.layout); - if (!toCopyComps || _.size(toCopyComps) <= 0) { + const validComps = Object.values(compMap).filter((item) => !!item.item && !!item.layout); + if (!validComps || _.size(validComps) <= 0) { messageInstance.info(trans("gridCompOperator.selectAtLeastOneComponent")); return false; } - this.copyComps = toCopyComps; - this.sourcePositionParams = simpleContainer.children.positionParams.getView(); - // log.debug( "copyComp. toCopyComps: ", this.copyComps, " sourcePositionParams: ", this.sourcePositionParams); + const sourcePositionParams = simpleContainer.children.positionParams.getView(); + const nameGenerator = editorState.getNameGenerator(); + + const gridItems: ClipboardGridItem[] = validComps.map((comp) => { + const itemComp = comp.item as GridItemComp; + const compType = itemComp.children.compType.getView(); + const name = itemComp.children.name.getView(); + const innerComp = itemComp.children.comp; + const isContainerComp = isContainer(innerComp); + const compJSON = isContainerComp + ? { + ...innerComp.toJsonValue(), + ...innerComp.getPasteValue(nameGenerator) as Record, + } + : innerComp.toJsonValue(); + return { compType, comp: compJSON, name, layout: comp.layout, isContainer: isContainerComp }; + }); + + const payload = buildEmptyPayload(); + payload.sourcePositionParams = sourcePositionParams; + payload.gridItems = gridItems; + writeToClipboard(payload); + return true; } - // FALK TODO: How can we enable Copy and Paste of components across Browser Tabs / Windows? - static pasteComp(editorState: EditorState) { - if (!this.copyComps || _.size(this.copyComps) <= 0 || !this.sourcePositionParams) { - messageInstance.info(trans("gridCompOperator.selectCompFirst")); + static pasteFromPayload(editorState: EditorState, payload: LowcoderClipboardPayload): boolean { + if (payload.gridItems.length === 0) { return false; } + return this.doPaste( + editorState, + payload.gridItems, + payload.sourcePositionParams || DEFAULT_POSITION_PARAMS, + ); + } + + private static doPaste( + editorState: EditorState, + items: ClipboardGridItem[], + sourcePositionParams: PositionParams, + ): boolean { const oldUi = editorState.getUIComp().getComp(); if (!oldUi) { messageInstance.info(trans("gridCompOperator.notSupport")); @@ -93,18 +183,8 @@ export class GridCompOperator { messageInstance.warning(trans("gridCompOperator.noContainerSelected")); return false; } + const selectedComps = editorState.selectedComps(); - const isSelectingContainer = - _.size(selectedComps) === 1 && - (Object.values(selectedComps)[0] as GridItemComp)?.children?.comp === selectedContainer; - if (_.size(this.copyComps) === 1) { - const { item } = this.copyComps[0]; - // Special case: To paste a container, and the container is currently selected, paste it outside the selected container - if (isContainer((item as GridItemComp).children.comp) && isSelectingContainer) { - selectedContainer = - editorState.findContainer(Object.keys(selectedComps)[0]) ?? selectedContainer; - } - } const selectedSimpleContainer = selectedContainer.realSimpleContainer(Object.keys(selectedComps)[0]) ?? @@ -115,43 +195,39 @@ export class GridCompOperator { const multiAddActions: Array> = []; const copyLayouts: Layout = {}; const copyCompNames = new Set(); - // log.debug("pasteComps. sourceContainer: ", this.sourceContainer, " targetContainer: ", selectedContainer); - this.copyComps.forEach((comp) => { + + items.forEach((item) => { const key = genRandomKey(); const { w, h } = switchLayoutWH( - comp.layout.w, - comp.layout.h, - this.sourcePositionParams, + item.layout.w, + item.layout.h, + sourcePositionParams, selectedSimpleContainer.children.positionParams.getView() ); copyLayouts[key] = { - ...comp.layout, + ...item.layout, i: key, - x: comp.layout.x + baseX, - y: comp.layout.y + baseY, + x: item.layout.x + baseX, + y: item.layout.y + baseY, w, h, isDragging: true, }; - const itemComp = comp.item as GridItemComp; - const compType = itemComp.children.compType.getView(); - const compInfo = parseCompType(compType); + const compInfo = parseCompType(item.compType); const compName = nameGenerator.genItemName(compInfo.compName); - const compJSONValue = isContainer(itemComp.children.comp) - ? { - ...itemComp.children.comp.toJsonValue(), - ...itemComp.children.comp.getPasteValue(nameGenerator) as Record, - } - : itemComp.children.comp.toJsonValue(); + const compJSONValue = item.isContainer + ? remapContainerPasteValue(item.comp, nameGenerator) + : item.comp; copyCompNames.add(compName); multiAddActions.push( (oldUi.realSimpleContainer() as SimpleContainerComp).children.items.addAction(key, { - compType: compType, + compType: item.compType, comp: compJSONValue, name: compName, }) ); }); + selectedSimpleContainer.dispatch( multiChangeAction({ layout: selectedSimpleContainer.children.layout.changeValueAction({ @@ -281,3 +357,46 @@ export class GridCompOperator { messageInstance.success(trans("comp.upgradeSuccess")); } } + +/** + * Remap keys and names inside a serialized container JSON. + * Mirrors what SimpleContainerComp.getPasteValue() does, but operates on + * plain JSON so it works for cross-app clipboard payloads where we don't + * have live comp instances. + */ +function remapContainerPasteValue(compJson: any, nameGenerator: NameGenerator): any { + if (!compJson || typeof compJson !== "object") return compJson; + + const items = compJson.items; + const layout = compJson.layout; + if (!items || typeof items !== "object") return compJson; + + const keyMap: Record = {}; + Object.keys(items).forEach((oldKey) => { + keyMap[oldKey] = genRandomKey(); + }); + + const newItems: Record = {}; + Object.entries(items).forEach(([oldKey, itemValue]: [string, any]) => { + const newKey = keyMap[oldKey]; + const compType = itemValue?.compType; + const newName = compType ? nameGenerator.genItemName(compType) : genRandomKey(); + const innerComp = itemValue?.comp; + const remappedComp = innerComp?.items + ? remapContainerPasteValue(innerComp, nameGenerator) + : innerComp; + newItems[newKey] = { ...itemValue, name: newName, comp: remappedComp }; + }); + + let newLayout = layout; + if (layout && typeof layout === "object") { + const remapped: Record = {}; + Object.entries(layout).forEach(([oldKey, layoutItem]: [string, any]) => { + const newKey = keyMap[oldKey] || oldKey; + remapped[newKey] = { ...layoutItem, i: newKey }; + }); + newLayout = remapped; + } + + return { ...compJson, items: newItems, layout: newLayout }; +} diff --git a/client/packages/lowcoder/src/comps/utils/hookCompOperator.ts b/client/packages/lowcoder/src/comps/utils/hookCompOperator.ts index f33f24c43..ec0127c80 100644 --- a/client/packages/lowcoder/src/comps/utils/hookCompOperator.ts +++ b/client/packages/lowcoder/src/comps/utils/hookCompOperator.ts @@ -1,14 +1,17 @@ import { HookComp } from "comps/hooks/hookComp"; import { EditorState } from "comps/editorState"; import { singletonHookComp } from "comps/hooks/hookCompTypes"; -import { wrapActionExtraInfo, type Comp } from "lowcoder-core"; +import { wrapActionExtraInfo } from "lowcoder-core"; import { messageInstance } from "lowcoder-design"; import { trans } from "i18n"; +import { + writeHookOnlyToClipboard, + type ClipboardHookItem, + type LowcoderClipboardPayload, +} from "./gridCompOperator"; export class HookCompOperator { - private static copyHooks: HookComp[] = []; - - static copyComp(editorState: EditorState, compRecords: Record) { + static copyComp(editorState: EditorState): boolean { const selectedNames = Array.from(editorState.selectedCompNames); if (!selectedNames.length) { return false; @@ -26,19 +29,24 @@ export class HookCompOperator { return false; } - this.copyHooks = selectedHookComps; + const hookItems: ClipboardHookItem[] = selectedHookComps.map((hookComp) => { + const compType = hookComp.children.compType.getView(); + const name = hookComp.children.name.getView(); + const childComp: any = hookComp.children.comp; + const baseValue = childComp?.toJsonValue ? childComp.toJsonValue() : {}; + const pasteValue = childComp?.getPasteValue?.(editorState.getNameGenerator()) ?? {}; + const comp = { ...baseValue, ...pasteValue }; + const fullValue = hookComp.toJsonValue(); + return { compType, comp, name, fullValue }; + }); + + writeHookOnlyToClipboard(hookItems); messageInstance.success(trans("copySuccess")); return true; } - static clearCopy() { - this.copyHooks = []; - } - - - static pasteComp(editorState: EditorState) { - if (!this.copyHooks.length) { - messageInstance.info(trans("gridCompOperator.selectCompFirst")); + static pasteFromPayload(editorState: EditorState, payload: LowcoderClipboardPayload): boolean { + if (payload.hookItems.length === 0) { return false; } @@ -46,32 +54,25 @@ export class HookCompOperator { const nameGenerator = editorState.getNameGenerator(); const newNames = new Set(); - this.copyHooks.forEach((hookComp) => { - const compType = hookComp.children.compType.getView(); - const newName = nameGenerator.genItemName(compType); - const childComp: any = hookComp.children.comp; - const baseValue = childComp?.toJsonValue ? childComp.toJsonValue() : {}; - const pasteValue = - childComp?.getPasteValue?.(nameGenerator) ?? {}; + payload.hookItems.forEach((item) => { + const newName = nameGenerator.genItemName(item.compType); - const payload = { - ...(hookComp.toJsonValue() as any), + const dispatchPayload = { + ...(item.fullValue || {}), name: newName, - comp: { - ...baseValue, - ...pasteValue, - }, + compType: item.compType, + comp: item.comp, }; hooksComp.dispatch( wrapActionExtraInfo( - hooksComp.pushAction(payload), + hooksComp.pushAction(dispatchPayload), { compInfos: [ { type: "add", compName: newName, - compType, + compType: item.compType, }, ], } @@ -85,4 +86,3 @@ export class HookCompOperator { return true; } } - diff --git a/client/packages/lowcoder/src/pages/editor/editorHotKeys.tsx b/client/packages/lowcoder/src/pages/editor/editorHotKeys.tsx index 14799a804..ac11015f8 100644 --- a/client/packages/lowcoder/src/pages/editor/editorHotKeys.tsx +++ b/client/packages/lowcoder/src/pages/editor/editorHotKeys.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useContext, useRef, useEffect } from "react"; import { EditorContext, EditorState } from "comps/editorState"; -import { GridCompOperator } from "comps/utils/gridCompOperator"; +import { GridCompOperator, readFromClipboard } from "comps/utils/gridCompOperator"; import { HookCompOperator } from "comps/utils/hookCompOperator"; import { ExternalEditorContext } from "util/context/ExternalEditorContext"; import { EditorHistory } from "util/editoryHistory"; @@ -18,6 +18,25 @@ import { preview } from "constants/routesURL"; import { useApplicationId } from "util/hooks"; import { useUnmount } from "react-use"; +function handleCopyComps(editorState: EditorState) { + const isHook = HookCompOperator.copyComp(editorState); + if (!isHook) { + GridCompOperator.copyComp(editorState, editorState.selectedComps()); + } +} + +async function handlePasteComps(editorState: EditorState) { + const payload = await readFromClipboard(); + if (!payload) { + return; + } + + const hookPasted = HookCompOperator.pasteFromPayload(editorState, payload); + if (!hookPasted) { + GridCompOperator.pasteFromPayload(editorState, payload); + } +} + type Props = { children: React.ReactNode; disabled?: boolean; @@ -76,20 +95,14 @@ function handleGlobalKeyDown( break; } default: - // Capture component copy/paste/cut/deselect globally if (modKeyPressed(e)) { const key = e.key?.toLowerCase(); if (key === "c") { - if (!HookCompOperator.copyComp(editorState, editorState.selectedComps())) { - HookCompOperator.clearCopy(); - GridCompOperator.copyComp(editorState, editorState.selectedComps()); - } + handleCopyComps(editorState); break; } if (key === "v") { - if (!HookCompOperator.pasteComp(editorState)) { - GridCompOperator.pasteComp(editorState); - } + handlePasteComps(editorState); break; } if (key === "x") { @@ -220,17 +233,10 @@ function handleEditorKeyDown(e: React.KeyboardEvent, editorState: EditorState) { e.stopPropagation(); return; case "copyComps": - if (HookCompOperator.copyComp(editorState, editorState.selectedComps())) { - return; - } - HookCompOperator.clearCopy(); - GridCompOperator.copyComp(editorState, editorState.selectedComps()); + handleCopyComps(editorState); return; case "pasteComps": - if (HookCompOperator.pasteComp(editorState)) { - return; - } - GridCompOperator.pasteComp(editorState); + handlePasteComps(editorState); return; case "cutComps": GridCompOperator.cutComp(editorState, editorState.selectedComps()); From 9f0680767de3236ba4338df639775cbccf4e119e Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Thu, 9 Apr 2026 23:58:33 +0500 Subject: [PATCH 5/5] fix: copy/paste logic + add message --- .../src/comps/utils/gridCompOperator.ts | 26 ++++++++++++------- .../src/comps/utils/hookCompOperator.ts | 14 ++++++---- .../packages/lowcoder/src/i18n/locales/en.ts | 6 ++++- .../src/pages/editor/editorHotKeys.tsx | 9 ++++--- 4 files changed, 36 insertions(+), 19 deletions(-) diff --git a/client/packages/lowcoder/src/comps/utils/gridCompOperator.ts b/client/packages/lowcoder/src/comps/utils/gridCompOperator.ts index 41cabef38..7967b3484 100644 --- a/client/packages/lowcoder/src/comps/utils/gridCompOperator.ts +++ b/client/packages/lowcoder/src/comps/utils/gridCompOperator.ts @@ -91,14 +91,14 @@ function buildEmptyPayload(): LowcoderClipboardPayload { }; } -export function writeHookOnlyToClipboard(hookItems: ClipboardHookItem[]) { +export async function writeHookOnlyToClipboard(hookItems: ClipboardHookItem[]): Promise { const payload = buildEmptyPayload(); payload.hookItems = hookItems; - writeToClipboard(payload); + return writeToClipboard(payload); } export class GridCompOperator { - static copyComp(editorState: EditorState, compRecords: Record) { + static async copyComp(editorState: EditorState, compRecords: Record): Promise { const oldUi = editorState.getUIComp().getComp(); if (!oldUi) { messageInstance.info(trans("gridCompOperator.notSupport")); @@ -152,9 +152,13 @@ export class GridCompOperator { const payload = buildEmptyPayload(); payload.sourcePositionParams = sourcePositionParams; payload.gridItems = gridItems; - writeToClipboard(payload); - - return true; + const written = await writeToClipboard(payload); + if (written) { + messageInstance.success(trans("gridCompOperator.copyCompsSuccess", { compNum: gridItems.length })); + } else { + messageInstance.error(trans("gridCompOperator.clipboardWriteError")); + } + return written; } static pasteFromPayload(editorState: EditorState, payload: LowcoderClipboardPayload): boolean { @@ -240,6 +244,7 @@ export class GridCompOperator { }) ); editorState.setSelectedCompNames(copyCompNames); + messageInstance.success(trans("gridCompOperator.pasteCompsSuccess", { compNum: copyCompNames.size })); return true; } @@ -274,10 +279,11 @@ export class GridCompOperator { window.open(APPLICATION_VIEW_URL(applicationId, "edit")) } - static cutComp(editorState: EditorState, compRecords: Record) { - this.copyComp(editorState, compRecords) && - this.doDelete(editorState, compRecords) && - messageInstance.info(trans("gridCompOperator.cutCompsSuccess", { pasteKey, undoKey })); + static async cutComp(editorState: EditorState, compRecords: Record) { + const copied = await this.copyComp(editorState, compRecords); + if (copied && this.doDelete(editorState, compRecords)) { + messageInstance.info(trans("gridCompOperator.cutCompsSuccess", { pasteKey, undoKey })); + } } private static doDelete(editorState: EditorState, compRecords: Record): boolean { diff --git a/client/packages/lowcoder/src/comps/utils/hookCompOperator.ts b/client/packages/lowcoder/src/comps/utils/hookCompOperator.ts index ec0127c80..d99492697 100644 --- a/client/packages/lowcoder/src/comps/utils/hookCompOperator.ts +++ b/client/packages/lowcoder/src/comps/utils/hookCompOperator.ts @@ -11,7 +11,7 @@ import { } from "./gridCompOperator"; export class HookCompOperator { - static copyComp(editorState: EditorState): boolean { + static async copyComp(editorState: EditorState): Promise { const selectedNames = Array.from(editorState.selectedCompNames); if (!selectedNames.length) { return false; @@ -40,9 +40,13 @@ export class HookCompOperator { return { compType, comp, name, fullValue }; }); - writeHookOnlyToClipboard(hookItems); - messageInstance.success(trans("copySuccess")); - return true; + const written = await writeHookOnlyToClipboard(hookItems); + if (written) { + messageInstance.success(trans("gridCompOperator.copyCompsSuccess", { compNum: hookItems.length })); + } else { + messageInstance.error(trans("gridCompOperator.clipboardWriteError")); + } + return written; } static pasteFromPayload(editorState: EditorState, payload: LowcoderClipboardPayload): boolean { @@ -82,7 +86,7 @@ export class HookCompOperator { }); editorState.setSelectedCompNames(newNames, "leftPanel"); - messageInstance.success(trans("copySuccess")); + messageInstance.success(trans("gridCompOperator.pasteCompsSuccess", { compNum: newNames.size })); return true; } } diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index 9258cb98d..6f0f94ec4 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -91,8 +91,12 @@ export const en = { "gridCompOperator": { "notSupport": "Not Supported", "selectAtLeastOneComponent": "Please select at least one component", - "selectCompFirst": "Select components before copying", + "selectCompFirst": "Please copy a component first", "noContainerSelected": "[Bug] No container selected", + "copyCompsSuccess": "Copied {compNum} {compNum, plural, one {component} other {components}} to clipboard", + "pasteCompsSuccess": "Pasted {compNum} {compNum, plural, one {component} other {components}}", + "clipboardReadError": "Unable to read clipboard. Please allow clipboard access and try again", + "clipboardWriteError": "Unable to write to clipboard. Please allow clipboard access and try again", "deleteCompsSuccess": "Deleted successfully. Press {undoKey} to undo.", "deleteCompsTitle": "Delete Components", "deleteCompsBody": "Are you sure you want to delete {compNum} selected components?", diff --git a/client/packages/lowcoder/src/pages/editor/editorHotKeys.tsx b/client/packages/lowcoder/src/pages/editor/editorHotKeys.tsx index ac11015f8..3998cb401 100644 --- a/client/packages/lowcoder/src/pages/editor/editorHotKeys.tsx +++ b/client/packages/lowcoder/src/pages/editor/editorHotKeys.tsx @@ -2,6 +2,8 @@ import React, { useCallback, useContext, useRef, useEffect } from "react"; import { EditorContext, EditorState } from "comps/editorState"; import { GridCompOperator, readFromClipboard } from "comps/utils/gridCompOperator"; import { HookCompOperator } from "comps/utils/hookCompOperator"; +import { messageInstance } from "lowcoder-design"; +import { trans } from "i18n"; import { ExternalEditorContext } from "util/context/ExternalEditorContext"; import { EditorHistory } from "util/editoryHistory"; import { executeQueryAction } from "lowcoder-core"; @@ -18,16 +20,17 @@ import { preview } from "constants/routesURL"; import { useApplicationId } from "util/hooks"; import { useUnmount } from "react-use"; -function handleCopyComps(editorState: EditorState) { - const isHook = HookCompOperator.copyComp(editorState); +async function handleCopyComps(editorState: EditorState) { + const isHook = await HookCompOperator.copyComp(editorState); if (!isHook) { - GridCompOperator.copyComp(editorState, editorState.selectedComps()); + await GridCompOperator.copyComp(editorState, editorState.selectedComps()); } } async function handlePasteComps(editorState: EditorState) { const payload = await readFromClipboard(); if (!payload) { + messageInstance.info(trans("gridCompOperator.selectCompFirst")); return; }