Skip to content

Commit

Permalink
Separate paste into a action so that it can be undone/redone
Browse files Browse the repository at this point in the history
  • Loading branch information
hlxid committed Oct 19, 2023
1 parent d5a2a88 commit 3655e37
Show file tree
Hide file tree
Showing 2 changed files with 105 additions and 34 deletions.
133 changes: 101 additions & 32 deletions src/common/copyPaste.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import {
Command,
CommandExecutionContext,
CommandReturn,
CommitModelAction,
IModelFactory,
KeyListener,
SChildElementImpl,
SEdgeImpl,
SModelElementImpl,
SModelRootImpl,
SNodeImpl,
TYPES,
isSelectable,
isSelected,
} from "sprotty";
import { Action, SPort, SelectAction } from "sprotty-protocol";
import { Action, SPort } from "sprotty-protocol";
import { matchesKeystroke } from "sprotty/lib/utils/keyboard";
import { generateRandomSprottyId } from "../utils";
import { DynamicChildrenProcessor } from "../features/dfdElements/dynamicChildren";
Expand All @@ -28,11 +31,6 @@ import { ArrowEdge, ArrowEdgeImpl } from "../features/dfdElements/edges";
export class CopyPasteFeature implements KeyListener {
private copyElements: SModelElementImpl[] = [];

constructor(
@inject(DynamicChildrenProcessor) private readonly dynamicChildrenProcessor: DynamicChildrenProcessor,
@inject(TYPES.IModelFactory) private readonly modelFactory: IModelFactory,
) {}

keyUp(_element: SModelElementImpl, _event: KeyboardEvent): Action[] {
return [];
}
Expand All @@ -41,7 +39,7 @@ export class CopyPasteFeature implements KeyListener {
if (matchesKeystroke(event, "KeyC", "ctrl")) {
return this.copy(element.root);
} else if (matchesKeystroke(event, "KeyV", "ctrl")) {
return this.paste(element.root);
return this.paste();
}

return [];
Expand All @@ -64,14 +62,67 @@ export class CopyPasteFeature implements KeyListener {

/**
* Pastes elements by creating new elements and copying the properties of the copied elements.
* This is done inside a command, so that it can be undone/redone.
*/
private paste(): Action[] {
return [PasteClipboardAction.create(this.copyElements), CommitModelAction.create()];
}
}

interface PasteClipboardAction extends Action {
kind: typeof PasteClipboardAction.KIND;
copyElements: SModelElementImpl[];
}
export namespace PasteClipboardAction {
export const KIND = "paste-clipboard";
export function create(copyElements: SModelElementImpl[]): PasteClipboardAction {
return {
kind: KIND,
copyElements,
};
}
}

/**
* This command is used to paste elements that were copied by the CopyPasteFeature.
* It creates new elements and copies the properties of the copied elements.
* This is done inside a command, so that it can be undone/redone.
*/
@injectable()
export class PasteClipboardCommand extends Command {
public static readonly KIND = PasteClipboardAction.KIND;

@inject(DynamicChildrenProcessor)
private dynamicChildrenProcessor: DynamicChildrenProcessor = new DynamicChildrenProcessor();
private newElements: SChildElementImpl[] = [];
// This maps the element id of the copy source element to the
// id that the newly created copy target element has.
private copyElementIdMapping: Record<string, string> = {};

constructor(@inject(TYPES.Action) private readonly action: PasteClipboardAction) {
super();
}

/**
* Selectes the newly created copy and deselects the copy source.
*/
private paste(root: SModelRootImpl): Action[] {
// This maps the element id of the copy source element to the
// id that the newly created copy target element has.
const copyElementIdMapping: Record<string, string> = {};
private setSelection(context: CommandExecutionContext, selection: "old" | "new"): void {
Object.entries(this.copyElementIdMapping).forEach(([oldId, newId]) => {
const oldElement = context.root.index.getById(oldId);
const newElement = context.root.index.getById(newId);

if (oldElement && isSelectable(oldElement)) {
oldElement.selected = selection === "old";
}
if (newElement && isSelectable(newElement)) {
newElement.selected = selection === "new";
}
});
}

execute(context: CommandExecutionContext): CommandReturn {
// Step 1: copy nodes and their ports
this.copyElements.forEach((element) => {
this.action.copyElements.forEach((element) => {
if (!(element instanceof SNodeImpl)) {
return;
}
Expand All @@ -86,7 +137,7 @@ export class CopyPasteFeature implements KeyListener {
ports: [],
} as DfdNode;

copyElementIdMapping[element.id] = schema.id;
this.copyElementIdMapping[element.id] = schema.id;

if (element instanceof DfdNodeImpl) {
schema.text = element.text;
Expand All @@ -97,7 +148,7 @@ export class CopyPasteFeature implements KeyListener {
id: generateRandomSprottyId(),
} as SPort;

copyElementIdMapping[port.id] = portSchema.id;
this.copyElementIdMapping[port.id] = portSchema.id;

if ("position" in port && port.position) {
portSchema.position = { x: port.position.x, y: port.position.y };
Expand All @@ -110,20 +161,20 @@ export class CopyPasteFeature implements KeyListener {
// Generate dynamic sub elements
this.dynamicChildrenProcessor.processGraphChildren(schema, "set");

const newElement = this.modelFactory.createElement(schema);
root.add(newElement as SChildElementImpl);
const newElement = context.modelFactory.createElement(schema);
this.newElements.push(newElement);
});

// Step 2: copy edges
// If the source and target element of an edge are copied, the edge can be copied as well.
// If only one of them is copied, the edge is not copied.
this.copyElements.forEach((element) => {
this.action.copyElements.forEach((element) => {
if (!(element instanceof SEdgeImpl)) {
return;
}

const newSourceId = copyElementIdMapping[element.sourceId];
const newTargetId = copyElementIdMapping[element.targetId];
const newSourceId = this.copyElementIdMapping[element.sourceId];
const newTargetId = this.copyElementIdMapping[element.targetId];

console.log("edge", newSourceId, newTargetId, element);

Expand All @@ -138,7 +189,7 @@ export class CopyPasteFeature implements KeyListener {
sourceId: newSourceId,
targetId: newTargetId,
} as ArrowEdge;
copyElementIdMapping[element.id] = schema.id;
this.copyElementIdMapping[element.id] = schema.id;

if (element instanceof ArrowEdgeImpl) {
schema.text = element.editableLabel?.text ?? "";
Expand All @@ -147,18 +198,36 @@ export class CopyPasteFeature implements KeyListener {
// Generate dynamic sub elements (the edge label)
this.dynamicChildrenProcessor.processGraphChildren(schema, "set");

const newElement = this.modelFactory.createElement(schema);
root.add(newElement as SChildElementImpl);
const newElement = context.modelFactory.createElement(schema);
this.newElements.push(newElement);
});

// Step 3: add new elements to the model and select them
this.newElements.forEach((element) => {
context.root.add(element);
});
this.setSelection(context, "new");

return context.root;
}

undo(context: CommandExecutionContext): CommandReturn {
// Remove elements from the model
this.newElements.forEach((element) => {
context.root.remove(element);
});
// Select the old elements
this.setSelection(context, "old");

return context.root;
}

redo(context: CommandExecutionContext): CommandReturn {
this.newElements.forEach((element) => {
context.root.add(element);
});
this.setSelection(context, "new");

return [
CommitModelAction.create(),
SelectAction.create({
// Select newly created elements
selectedElementsIDs: Object.values(copyElementIdMapping),
// Deselect all old elements that were used as a source for the copy
deselectedElementsIDs: Object.keys(copyElementIdMapping),
}),
];
return context.root;
}
}
6 changes: 4 additions & 2 deletions src/common/di.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ import { DeleteKeyListener } from "./deleteKeyListener";
import { EDITOR_TYPES } from "../utils";
import { DynamicChildrenProcessor } from "../features/dfdElements/dynamicChildren";
import { FitToScreenKeyListener as CenterDiagramKeyListener } from "./fitToScreenKeyListener";
import { CopyPasteFeature, PasteClipboardCommand } from "./copyPaste";

import "./commonStyling.css";
import { CopyPasteFeature } from "./copyPaste";

export const dfdCommonModule = new ContainerModule((bind, unbind, isBound, rebind) => {
const context = { bind, unbind, isBound, rebind };

bind(ServerCommandPaletteActionProvider).toSelf().inSingletonScope();
bind(TYPES.ICommandPaletteActionProvider).toService(ServerCommandPaletteActionProvider);

Expand All @@ -30,6 +32,7 @@ export const dfdCommonModule = new ContainerModule((bind, unbind, isBound, rebin
rebind(CenterKeyboardListener).toService(CenterDiagramKeyListener);

bind(TYPES.KeyListener).to(CopyPasteFeature).inSingletonScope();
configureCommand(context, PasteClipboardCommand);

bind(HelpUI).toSelf().inSingletonScope();
bind(TYPES.IUIExtension).toService(HelpUI);
Expand All @@ -42,7 +45,6 @@ export const dfdCommonModule = new ContainerModule((bind, unbind, isBound, rebin
bind(DynamicChildrenProcessor).toSelf().inSingletonScope();

// For some reason the CreateElementAction and Command exist but in no sprotty module is the command registered, so we need to do this here.
const context = { bind, unbind, isBound, rebind };
configureCommand(context, CreateElementCommand);

configureViewerOptions(context, {
Expand Down

0 comments on commit 3655e37

Please sign in to comment.