Skip to content

Commit 8a61009

Browse files
Merge pull request #2141 from iamfaran/feat/copy-components
[Feat]: Copy components
2 parents ab5d711 + 9f06807 commit 8a61009

4 files changed

Lines changed: 317 additions & 54 deletions

File tree

client/packages/lowcoder/src/comps/utils/gridCompOperator.ts

Lines changed: 175 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import { SimpleContainerComp } from "comps/comps/containerBase/simpleContainerCo
33
import { GridItemComp } from "comps/comps/gridItemComp";
44
import { remoteComp } from "comps/comps/remoteComp/remoteComp";
55
import { EditorState } from "comps/editorState";
6+
import { NameGenerator } from "comps/utils";
67
import { trans } from "i18n";
78
import {
89
calcPasteBaseXY,
10+
DEFAULT_POSITION_PARAMS,
911
Layout,
1012
LayoutItem,
1113
moveToZero,
@@ -29,16 +31,74 @@ import { genRandomKey } from "./idGenerator";
2931
import { getLatestVersion, getRemoteCompType, parseCompType } from "./remote";
3032
import { APPLICATION_VIEW_URL } from "@lowcoder-ee/constants/routesURL";
3133

32-
export type CopyCompType = {
34+
const CLIPBOARD_TYPE = "lowcoder-components";
35+
const CLIPBOARD_VERSION = 1;
36+
37+
export interface ClipboardGridItem {
38+
compType: string;
39+
comp: any;
40+
name: string;
3341
layout: LayoutItem;
34-
item: Comp;
35-
};
42+
isContainer: boolean;
43+
}
3644

37-
export class GridCompOperator {
38-
private static copyComps: CopyCompType[] = [];
39-
private static sourcePositionParams: PositionParams;
45+
export interface LowcoderClipboardPayload {
46+
type: typeof CLIPBOARD_TYPE;
47+
version: number;
48+
timestamp: number;
49+
gridItems: ClipboardGridItem[];
50+
hookItems: ClipboardHookItem[];
51+
sourcePositionParams: PositionParams;
52+
}
53+
54+
export interface ClipboardHookItem {
55+
compType: string;
56+
comp: any;
57+
name: string;
58+
fullValue: any;
59+
}
60+
61+
async function writeToClipboard(payload: LowcoderClipboardPayload): Promise<boolean> {
62+
try {
63+
const json = JSON.stringify(payload);
64+
await navigator.clipboard.writeText(json);
65+
return true;
66+
} catch {
67+
return false;
68+
}
69+
}
70+
71+
export async function readFromClipboard(): Promise<LowcoderClipboardPayload | null> {
72+
try {
73+
const text = await navigator.clipboard.readText();
74+
if (!text) return null;
75+
const parsed = JSON.parse(text);
76+
if (parsed?.type !== CLIPBOARD_TYPE || !parsed?.version) return null;
77+
return parsed as LowcoderClipboardPayload;
78+
} catch {
79+
return null;
80+
}
81+
}
4082

41-
static copyComp(editorState: EditorState, compRecords: Record<string, Comp>) {
83+
function buildEmptyPayload(): LowcoderClipboardPayload {
84+
return {
85+
type: CLIPBOARD_TYPE,
86+
version: CLIPBOARD_VERSION,
87+
timestamp: Date.now(),
88+
gridItems: [],
89+
hookItems: [],
90+
sourcePositionParams: DEFAULT_POSITION_PARAMS,
91+
};
92+
}
93+
94+
export async function writeHookOnlyToClipboard(hookItems: ClipboardHookItem[]): Promise<boolean> {
95+
const payload = buildEmptyPayload();
96+
payload.hookItems = hookItems;
97+
return writeToClipboard(payload);
98+
}
99+
100+
export class GridCompOperator {
101+
static async copyComp(editorState: EditorState, compRecords: Record<string, Comp>): Promise<boolean> {
42102
const oldUi = editorState.getUIComp().getComp();
43103
if (!oldUi) {
44104
messageInstance.info(trans("gridCompOperator.notSupport"));
@@ -65,24 +125,58 @@ export class GridCompOperator {
65125
layout: layout[key],
66126
}));
67127

68-
const toCopyComps = Object.values(compMap).filter((item) => !!item.item && !!item.layout);
69-
if (!toCopyComps || _.size(toCopyComps) <= 0) {
128+
const validComps = Object.values(compMap).filter((item) => !!item.item && !!item.layout);
129+
if (!validComps || _.size(validComps) <= 0) {
70130
messageInstance.info(trans("gridCompOperator.selectAtLeastOneComponent"));
71131
return false;
72132
}
73-
this.copyComps = toCopyComps;
74-
this.sourcePositionParams = simpleContainer.children.positionParams.getView();
75133

76-
// log.debug( "copyComp. toCopyComps: ", this.copyComps, " sourcePositionParams: ", this.sourcePositionParams);
77-
return true;
134+
const sourcePositionParams = simpleContainer.children.positionParams.getView();
135+
const nameGenerator = editorState.getNameGenerator();
136+
137+
const gridItems: ClipboardGridItem[] = validComps.map((comp) => {
138+
const itemComp = comp.item as GridItemComp;
139+
const compType = itemComp.children.compType.getView();
140+
const name = itemComp.children.name.getView();
141+
const innerComp = itemComp.children.comp;
142+
const isContainerComp = isContainer(innerComp);
143+
const compJSON = isContainerComp
144+
? {
145+
...innerComp.toJsonValue(),
146+
...innerComp.getPasteValue(nameGenerator) as Record<string, any>,
147+
}
148+
: innerComp.toJsonValue();
149+
return { compType, comp: compJSON, name, layout: comp.layout, isContainer: isContainerComp };
150+
});
151+
152+
const payload = buildEmptyPayload();
153+
payload.sourcePositionParams = sourcePositionParams;
154+
payload.gridItems = gridItems;
155+
const written = await writeToClipboard(payload);
156+
if (written) {
157+
messageInstance.success(trans("gridCompOperator.copyCompsSuccess", { compNum: gridItems.length }));
158+
} else {
159+
messageInstance.error(trans("gridCompOperator.clipboardWriteError"));
160+
}
161+
return written;
78162
}
79163

80-
// FALK TODO: How can we enable Copy and Paste of components across Browser Tabs / Windows?
81-
static pasteComp(editorState: EditorState) {
82-
if (!this.copyComps || _.size(this.copyComps) <= 0 || !this.sourcePositionParams) {
83-
messageInstance.info(trans("gridCompOperator.selectCompFirst"));
164+
static pasteFromPayload(editorState: EditorState, payload: LowcoderClipboardPayload): boolean {
165+
if (payload.gridItems.length === 0) {
84166
return false;
85167
}
168+
return this.doPaste(
169+
editorState,
170+
payload.gridItems,
171+
payload.sourcePositionParams || DEFAULT_POSITION_PARAMS,
172+
);
173+
}
174+
175+
private static doPaste(
176+
editorState: EditorState,
177+
items: ClipboardGridItem[],
178+
sourcePositionParams: PositionParams,
179+
): boolean {
86180
const oldUi = editorState.getUIComp().getComp();
87181
if (!oldUi) {
88182
messageInstance.info(trans("gridCompOperator.notSupport"));
@@ -93,18 +187,8 @@ export class GridCompOperator {
93187
messageInstance.warning(trans("gridCompOperator.noContainerSelected"));
94188
return false;
95189
}
190+
96191
const selectedComps = editorState.selectedComps();
97-
const isSelectingContainer =
98-
_.size(selectedComps) === 1 &&
99-
(Object.values(selectedComps)[0] as GridItemComp)?.children?.comp === selectedContainer;
100-
if (_.size(this.copyComps) === 1) {
101-
const { item } = this.copyComps[0];
102-
// Special case: To paste a container, and the container is currently selected, paste it outside the selected container
103-
if (isContainer((item as GridItemComp).children.comp) && isSelectingContainer) {
104-
selectedContainer =
105-
editorState.findContainer(Object.keys(selectedComps)[0]) ?? selectedContainer;
106-
}
107-
}
108192

109193
const selectedSimpleContainer =
110194
selectedContainer.realSimpleContainer(Object.keys(selectedComps)[0]) ??
@@ -115,43 +199,39 @@ export class GridCompOperator {
115199
const multiAddActions: Array<CustomAction<any>> = [];
116200
const copyLayouts: Layout = {};
117201
const copyCompNames = new Set<string>();
118-
// log.debug("pasteComps. sourceContainer: ", this.sourceContainer, " targetContainer: ", selectedContainer);
119-
this.copyComps.forEach((comp) => {
202+
203+
items.forEach((item) => {
120204
const key = genRandomKey();
121205
const { w, h } = switchLayoutWH(
122-
comp.layout.w,
123-
comp.layout.h,
124-
this.sourcePositionParams,
206+
item.layout.w,
207+
item.layout.h,
208+
sourcePositionParams,
125209
selectedSimpleContainer.children.positionParams.getView()
126210
);
127211
copyLayouts[key] = {
128-
...comp.layout,
212+
...item.layout,
129213
i: key,
130-
x: comp.layout.x + baseX,
131-
y: comp.layout.y + baseY,
214+
x: item.layout.x + baseX,
215+
y: item.layout.y + baseY,
132216
w,
133217
h,
134218
isDragging: true,
135219
};
136-
const itemComp = comp.item as GridItemComp;
137-
const compType = itemComp.children.compType.getView();
138-
const compInfo = parseCompType(compType);
220+
const compInfo = parseCompType(item.compType);
139221
const compName = nameGenerator.genItemName(compInfo.compName);
140-
const compJSONValue = isContainer(itemComp.children.comp)
141-
? {
142-
...itemComp.children.comp.toJsonValue(),
143-
...itemComp.children.comp.getPasteValue(nameGenerator) as Record<string, any>,
144-
}
145-
: itemComp.children.comp.toJsonValue();
222+
const compJSONValue = item.isContainer
223+
? remapContainerPasteValue(item.comp, nameGenerator)
224+
: item.comp;
146225
copyCompNames.add(compName);
147226
multiAddActions.push(
148227
(oldUi.realSimpleContainer() as SimpleContainerComp).children.items.addAction(key, {
149-
compType: compType,
228+
compType: item.compType,
150229
comp: compJSONValue,
151230
name: compName,
152231
})
153232
);
154233
});
234+
155235
selectedSimpleContainer.dispatch(
156236
multiChangeAction({
157237
layout: selectedSimpleContainer.children.layout.changeValueAction({
@@ -164,6 +244,7 @@ export class GridCompOperator {
164244
})
165245
);
166246
editorState.setSelectedCompNames(copyCompNames);
247+
messageInstance.success(trans("gridCompOperator.pasteCompsSuccess", { compNum: copyCompNames.size }));
167248
return true;
168249
}
169250

@@ -198,10 +279,11 @@ export class GridCompOperator {
198279
window.open(APPLICATION_VIEW_URL(applicationId, "edit"))
199280
}
200281

201-
static cutComp(editorState: EditorState, compRecords: Record<string, Comp>) {
202-
this.copyComp(editorState, compRecords) &&
203-
this.doDelete(editorState, compRecords) &&
204-
messageInstance.info(trans("gridCompOperator.cutCompsSuccess", { pasteKey, undoKey }));
282+
static async cutComp(editorState: EditorState, compRecords: Record<string, Comp>) {
283+
const copied = await this.copyComp(editorState, compRecords);
284+
if (copied && this.doDelete(editorState, compRecords)) {
285+
messageInstance.info(trans("gridCompOperator.cutCompsSuccess", { pasteKey, undoKey }));
286+
}
205287
}
206288

207289
private static doDelete(editorState: EditorState, compRecords: Record<string, Comp>): boolean {
@@ -281,3 +363,46 @@ export class GridCompOperator {
281363
messageInstance.success(trans("comp.upgradeSuccess"));
282364
}
283365
}
366+
367+
/**
368+
* Remap keys and names inside a serialized container JSON.
369+
* Mirrors what SimpleContainerComp.getPasteValue() does, but operates on
370+
* plain JSON so it works for cross-app clipboard payloads where we don't
371+
* have live comp instances.
372+
*/
373+
function remapContainerPasteValue(compJson: any, nameGenerator: NameGenerator): any {
374+
if (!compJson || typeof compJson !== "object") return compJson;
375+
376+
const items = compJson.items;
377+
const layout = compJson.layout;
378+
if (!items || typeof items !== "object") return compJson;
379+
380+
const keyMap: Record<string, string> = {};
381+
Object.keys(items).forEach((oldKey) => {
382+
keyMap[oldKey] = genRandomKey();
383+
});
384+
385+
const newItems: Record<string, any> = {};
386+
Object.entries(items).forEach(([oldKey, itemValue]: [string, any]) => {
387+
const newKey = keyMap[oldKey];
388+
const compType = itemValue?.compType;
389+
const newName = compType ? nameGenerator.genItemName(compType) : genRandomKey();
390+
const innerComp = itemValue?.comp;
391+
const remappedComp = innerComp?.items
392+
? remapContainerPasteValue(innerComp, nameGenerator)
393+
: innerComp;
394+
newItems[newKey] = { ...itemValue, name: newName, comp: remappedComp };
395+
});
396+
397+
let newLayout = layout;
398+
if (layout && typeof layout === "object") {
399+
const remapped: Record<string, any> = {};
400+
Object.entries(layout).forEach(([oldKey, layoutItem]: [string, any]) => {
401+
const newKey = keyMap[oldKey] || oldKey;
402+
remapped[newKey] = { ...layoutItem, i: newKey };
403+
});
404+
newLayout = remapped;
405+
}
406+
407+
return { ...compJson, items: newItems, layout: newLayout };
408+
}

0 commit comments

Comments
 (0)