diff --git a/client/packages/lowcoder/src/comps/utils/gridCompOperator.ts b/client/packages/lowcoder/src/comps/utils/gridCompOperator.ts index ff75db63f..7967b3484 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,16 +31,74 @@ 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; + } +} - static copyComp(editorState: EditorState, compRecords: Record) { +function buildEmptyPayload(): LowcoderClipboardPayload { + return { + type: CLIPBOARD_TYPE, + version: CLIPBOARD_VERSION, + timestamp: Date.now(), + gridItems: [], + hookItems: [], + sourcePositionParams: DEFAULT_POSITION_PARAMS, + }; +} + +export async function writeHookOnlyToClipboard(hookItems: ClipboardHookItem[]): Promise { + const payload = buildEmptyPayload(); + payload.hookItems = hookItems; + return writeToClipboard(payload); +} + +export class GridCompOperator { + static async copyComp(editorState: EditorState, compRecords: Record): Promise { const oldUi = editorState.getUIComp().getComp(); if (!oldUi) { messageInstance.info(trans("gridCompOperator.notSupport")); @@ -65,24 +125,58 @@ 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); - return true; + 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; + const written = await writeToClipboard(payload); + if (written) { + messageInstance.success(trans("gridCompOperator.copyCompsSuccess", { compNum: gridItems.length })); + } else { + messageInstance.error(trans("gridCompOperator.clipboardWriteError")); + } + return written; } - // 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 +187,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 +199,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({ @@ -164,6 +244,7 @@ export class GridCompOperator { }) ); editorState.setSelectedCompNames(copyCompNames); + messageInstance.success(trans("gridCompOperator.pasteCompsSuccess", { compNum: copyCompNames.size })); return true; } @@ -198,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 { @@ -281,3 +363,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 new file mode 100644 index 000000000..d99492697 --- /dev/null +++ b/client/packages/lowcoder/src/comps/utils/hookCompOperator.ts @@ -0,0 +1,92 @@ +import { HookComp } from "comps/hooks/hookComp"; +import { EditorState } from "comps/editorState"; +import { singletonHookComp } from "comps/hooks/hookCompTypes"; +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 { + static async copyComp(editorState: EditorState): Promise { + const selectedNames = Array.from(editorState.selectedCompNames); + if (!selectedNames.length) { + return false; + } + + const hookComps = editorState.getHooksComp().getView(); + const selectedHookComps = hookComps + .filter((comp: any) => { + const name = comp.children.name.getView(); + const compType = comp.children.compType.getView(); + return selectedNames.includes(name) && !singletonHookComp(compType); + }) as HookComp[]; + + if (!selectedHookComps.length) { + return false; + } + + 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 }; + }); + + 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 { + if (payload.hookItems.length === 0) { + return false; + } + + const hooksComp = editorState.getHooksComp(); + const nameGenerator = editorState.getNameGenerator(); + const newNames = new Set(); + + payload.hookItems.forEach((item) => { + const newName = nameGenerator.genItemName(item.compType); + + const dispatchPayload = { + ...(item.fullValue || {}), + name: newName, + compType: item.compType, + comp: item.comp, + }; + + hooksComp.dispatch( + wrapActionExtraInfo( + hooksComp.pushAction(dispatchPayload), + { + compInfos: [ + { + type: "add", + compName: newName, + compType: item.compType, + }, + ], + } + ) + ); + newNames.add(newName); + }); + + editorState.setSelectedCompNames(newNames, "leftPanel"); + 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 59d043baf..3998cb401 100644 --- a/client/packages/lowcoder/src/pages/editor/editorHotKeys.tsx +++ b/client/packages/lowcoder/src/pages/editor/editorHotKeys.tsx @@ -1,6 +1,9 @@ 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 { messageInstance } from "lowcoder-design"; +import { trans } from "i18n"; import { ExternalEditorContext } from "util/context/ExternalEditorContext"; import { EditorHistory } from "util/editoryHistory"; import { executeQueryAction } from "lowcoder-core"; @@ -17,6 +20,26 @@ import { preview } from "constants/routesURL"; import { useApplicationId } from "util/hooks"; import { useUnmount } from "react-use"; +async function handleCopyComps(editorState: EditorState) { + const isHook = await HookCompOperator.copyComp(editorState); + if (!isHook) { + await GridCompOperator.copyComp(editorState, editorState.selectedComps()); + } +} + +async function handlePasteComps(editorState: EditorState) { + const payload = await readFromClipboard(); + if (!payload) { + messageInstance.info(trans("gridCompOperator.selectCompFirst")); + return; + } + + const hookPasted = HookCompOperator.pasteFromPayload(editorState, payload); + if (!hookPasted) { + GridCompOperator.pasteFromPayload(editorState, payload); + } +} + type Props = { children: React.ReactNode; disabled?: boolean; @@ -75,6 +98,25 @@ function handleGlobalKeyDown( break; } default: + if (modKeyPressed(e)) { + const key = e.key?.toLowerCase(); + if (key === "c") { + handleCopyComps(editorState); + break; + } + if (key === "v") { + handlePasteComps(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,10 +236,10 @@ function handleEditorKeyDown(e: React.KeyboardEvent, editorState: EditorState) { e.stopPropagation(); return; case "copyComps": - GridCompOperator.copyComp(editorState, editorState.selectedComps()); + handleCopyComps(editorState); return; case "pasteComps": - GridCompOperator.pasteComp(editorState); + handlePasteComps(editorState); return; case "cutComps": GridCompOperator.cutComp(editorState, editorState.selectedComps());