Alt-drag duplication

This commit is contained in:
Mark Tolmacs 2025-03-16 21:23:07 +01:00
parent 49dcf23101
commit 637cb82c9c
2 changed files with 18 additions and 138 deletions

View file

@ -126,7 +126,6 @@ import { restore, restoreElements } from "../data/restore";
import { import {
dragNewElement, dragNewElement,
dragSelectedElements, dragSelectedElements,
duplicateElement,
getCommonBounds, getCommonBounds,
getCursorForResizingElement, getCursorForResizingElement,
getDragOffsetXY, getDragOffsetXY,
@ -152,7 +151,6 @@ import {
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
bindOrUnbindLinearElements, bindOrUnbindLinearElements,
fixBindingsAfterDeletion, fixBindingsAfterDeletion,
fixBindingsAfterDuplication,
getHoveredElementForBinding, getHoveredElementForBinding,
isBindingEnabled, isBindingEnabled,
isLinearElementSimpleAndAlreadyBound, isLinearElementSimpleAndAlreadyBound,
@ -291,7 +289,6 @@ import {
} from "../element/image"; } from "../element/image";
import { fileOpen } from "../data/filesystem"; import { fileOpen } from "../data/filesystem";
import { import {
bindTextToShapeAfterDuplication,
getBoundTextElement, getBoundTextElement,
getContainerCenter, getContainerCenter,
getContainerElement, getContainerElement,
@ -308,7 +305,6 @@ import { Fonts, getLineHeight } from "../fonts";
import { import {
getFrameChildren, getFrameChildren,
isCursorInFrame, isCursorInFrame,
bindElementsToFramesAfterDuplication,
addElementsToFrame, addElementsToFrame,
replaceAllElementsInFrame, replaceAllElementsInFrame,
removeElementsFromFrame, removeElementsFromFrame,
@ -8423,129 +8419,26 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.hit.hasBeenDuplicated = true; pointerDownState.hit.hasBeenDuplicated = true;
const nextElements = [];
const elementsToAppend = [];
const groupIdMap = new Map();
const oldIdToDuplicatedId = new Map();
const hitElement = pointerDownState.hit.element; const hitElement = pointerDownState.hit.element;
const selectedElementIds = new Set( const selectedElements = this.scene.getSelectedElements({
this.scene
.getSelectedElements({
selectedElementIds: this.state.selectedElementIds, selectedElementIds: this.state.selectedElementIds,
includeBoundTextElement: true, includeBoundTextElement: true,
includeElementsInFrames: true, includeElementsInFrames: true,
}) });
.map((element) => element.id),
);
const elements = this.scene.getElementsIncludingDeleted();
for (const element of elements) {
const isInSelection =
selectedElementIds.has(element.id) ||
// case: the state.selectedElementIds might not have been
// updated yet by the time this mousemove event is fired
(element.id === hitElement?.id &&
pointerDownState.hit.wasAddedToSelection);
if (isInSelection) {
const duplicatedElement = duplicateElement(
this.state.editingGroupId,
groupIdMap,
element,
undefined,
true,
);
// NOTE (mtolmacs): This is a temporary fix for very large scenes
if ( if (
Math.abs(duplicatedElement.x) > 1e7 || hitElement &&
Math.abs(duplicatedElement.x) > 1e7 || !selectedElements.find((el) => el.id === hitElement.id)
Math.abs(duplicatedElement.width) > 1e7 ||
Math.abs(duplicatedElement.height) > 1e7
) { ) {
console.error( selectedElements.push(hitElement);
`Alt+dragging duplicated element with invalid dimensions`,
duplicatedElement.x,
duplicatedElement.y,
duplicatedElement.width,
duplicatedElement.height,
);
return;
} }
const clonedElements = duplicateElements(selectedElements, {
const origElement = pointerDownState.originalElements.get( appState: this.state,
element.id, randomizeSeed: true,
)!;
// NOTE (mtolmacs): This is a temporary fix for very large scenes
if (
Math.abs(origElement.x) > 1e7 ||
Math.abs(origElement.x) > 1e7 ||
Math.abs(origElement.width) > 1e7 ||
Math.abs(origElement.height) > 1e7
) {
console.error(
`Alt+dragging duplicated element with invalid dimensions`,
origElement.x,
origElement.y,
origElement.width,
origElement.height,
);
return;
}
mutateElement(duplicatedElement, {
x: origElement.x,
y: origElement.y,
}); });
// put duplicated element to pointerDownState.originalElements const nextSceneElements = syncMovedIndices(
// so that we can snap to the duplicated element without releasing [...clonedElements, ...this.scene.getElementsIncludingDeleted()],
pointerDownState.originalElements.set( arrayToMap(clonedElements),
duplicatedElement.id,
duplicatedElement,
);
nextElements.push(duplicatedElement);
elementsToAppend.push(element);
oldIdToDuplicatedId.set(element.id, duplicatedElement.id);
} else {
nextElements.push(element);
}
}
let nextSceneElements: ExcalidrawElement[] = [
...nextElements,
...elementsToAppend,
];
const mappedNewSceneElements = this.props.onDuplicate?.(
nextSceneElements,
elements,
);
nextSceneElements = mappedNewSceneElements || nextSceneElements;
syncMovedIndices(nextSceneElements, arrayToMap(elementsToAppend));
bindTextToShapeAfterDuplication(
nextElements,
elementsToAppend,
oldIdToDuplicatedId,
);
fixBindingsAfterDuplication(
nextSceneElements,
elementsToAppend,
oldIdToDuplicatedId,
"duplicatesServeAsOld",
);
bindElementsToFramesAfterDuplication(
nextSceneElements,
elementsToAppend,
oldIdToDuplicatedId,
); );
this.scene.replaceAllElements(nextSceneElements); this.scene.replaceAllElements(nextSceneElements);

View file

@ -51,13 +51,7 @@ import {
} from "../scene/scrollbars"; } from "../scene/scrollbars";
import { getCornerRadius } from "../shapes"; import { getCornerRadius } from "../shapes";
import { type InteractiveCanvasAppState } from "../types"; import { type InteractiveCanvasAppState } from "../types";
import { import { arrayToMap, invariant, throttleRAF } from "../utils";
arrayToMap,
invariant,
isDevEnv,
isTestEnv,
throttleRAF,
} from "../utils";
import { import {
bootstrapCanvas, bootstrapCanvas,
@ -895,13 +889,6 @@ const _renderInteractiveScene = ({
// Arrows have a different highlight behavior when // Arrows have a different highlight behavior when
// they are the only selected element // they are the only selected element
if (appState.selectedLinearElement) { if (appState.selectedLinearElement) {
if (isTestEnv() || isDevEnv()) {
invariant(
selectedElements.length <= 1,
`There is an active selectedLinearElement on app state but the selectedElements length is ${selectedElements?.length} not 1`,
);
}
const editor = appState.selectedLinearElement; const editor = appState.selectedLinearElement;
const firstSelectedLinear = selectedElements.find( const firstSelectedLinear = selectedElements.find(
(el) => el.id === editor.elementId, // Don't forget bound text elements! (el) => el.id === editor.elementId, // Don't forget bound text elements!