fix(editor): fix target element index when creating/adding elements to frames (#11257)
This commit is contained in:
@@ -101,6 +101,7 @@ export const actionDeselect = register({
|
||||
selectionElement: null,
|
||||
showHyperlinkPopup: false,
|
||||
suggestedBinding: null,
|
||||
frameToHighlight: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
@@ -118,6 +119,7 @@ export const actionDeselect = register({
|
||||
selectionElement: null,
|
||||
showHyperlinkPopup: false,
|
||||
suggestedBinding: null,
|
||||
frameToHighlight: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
|
||||
@@ -330,6 +330,7 @@ export const actionFinalize = register<FormData>({
|
||||
multiElement: null,
|
||||
editingTextElement: null,
|
||||
suggestedBinding: null,
|
||||
frameToHighlight: null,
|
||||
selectedElementIds:
|
||||
element &&
|
||||
!appState.activeTool.locked &&
|
||||
|
||||
@@ -205,7 +205,6 @@ export const actionWrapSelectionInFrame = register({
|
||||
[...app.scene.getElementsIncludingDeleted(), frame],
|
||||
selectedElements,
|
||||
frame,
|
||||
appState,
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -277,7 +277,6 @@ export const actionUngroup = register({
|
||||
elementsMap,
|
||||
),
|
||||
frame,
|
||||
app,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -176,7 +176,9 @@ import {
|
||||
isValidTextContainer,
|
||||
redrawTextBoundingBox,
|
||||
hasBoundingBox,
|
||||
getCommonFrameId,
|
||||
getFrameChildren,
|
||||
getFrameChildrenInsertionIndex,
|
||||
isCursorInFrame,
|
||||
addElementsToFrame,
|
||||
replaceAllElementsInFrame,
|
||||
@@ -259,6 +261,7 @@ import {
|
||||
maybeHandleArrowPointlikeDrag,
|
||||
getUncroppedWidthAndHeight,
|
||||
getActiveTextElement,
|
||||
isEligibleFrameChildType,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
|
||||
@@ -2092,20 +2095,30 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const selectedElements = this.scene.getSelectedElements(this.state);
|
||||
const { renderTopRightUI, renderTopLeftUI, renderCustomStats } = this.props;
|
||||
|
||||
const sceneNonce = this.scene.getSceneNonce();
|
||||
const { elementsMap, visibleElements } =
|
||||
this.renderer.getRenderableElements({
|
||||
sceneNonce,
|
||||
zoom: this.state.zoom,
|
||||
offsetLeft: this.state.offsetLeft,
|
||||
offsetTop: this.state.offsetTop,
|
||||
scrollX: this.state.scrollX,
|
||||
scrollY: this.state.scrollY,
|
||||
height: this.state.height,
|
||||
width: this.state.width,
|
||||
editingTextElement: this.state.editingTextElement,
|
||||
newElementId: this.state.newElement?.id,
|
||||
});
|
||||
const {
|
||||
elementsMap,
|
||||
visibleElements,
|
||||
canvasNonce,
|
||||
/**
|
||||
* element to draw on the <NewElementCanvas> for optimization purposes.
|
||||
* Can be null even if this.state.newElement defined
|
||||
* (e.g. when its zIndex isn't on top) */
|
||||
newElementCanvasElement,
|
||||
} = this.renderer.getRenderableElements({
|
||||
zoom: this.state.zoom,
|
||||
offsetLeft: this.state.offsetLeft,
|
||||
offsetTop: this.state.offsetTop,
|
||||
scrollX: this.state.scrollX,
|
||||
scrollY: this.state.scrollY,
|
||||
height: this.state.height,
|
||||
width: this.state.width,
|
||||
editingTextElement: this.state.editingTextElement,
|
||||
newElement: this.state.newElement,
|
||||
selectedElements,
|
||||
selectedElementsAreBeingDragged:
|
||||
this.state.selectedElementsAreBeingDragged,
|
||||
frameToHighlight: this.state.frameToHighlight,
|
||||
});
|
||||
this.visibleElements = visibleElements;
|
||||
|
||||
const allElementsMap = this.scene.getNonDeletedElementsMap();
|
||||
@@ -2324,7 +2337,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
elementsMap={elementsMap}
|
||||
allElementsMap={allElementsMap}
|
||||
visibleElements={visibleElements}
|
||||
sceneNonce={sceneNonce}
|
||||
canvasNonce={canvasNonce}
|
||||
selectionNonce={
|
||||
this.state.selectionElement?.versionNonce
|
||||
}
|
||||
@@ -2345,9 +2358,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
theme: this.state.theme,
|
||||
}}
|
||||
/>
|
||||
{this.state.newElement && (
|
||||
{newElementCanvasElement && (
|
||||
<NewElementCanvas
|
||||
appState={this.state}
|
||||
newElement={newElementCanvasElement}
|
||||
scale={window.devicePixelRatio}
|
||||
rc={this.rc}
|
||||
elementsMap={elementsMap}
|
||||
@@ -2375,7 +2389,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
visibleElements={visibleElements}
|
||||
allElementsMap={allElementsMap}
|
||||
selectedElements={selectedElements}
|
||||
sceneNonce={sceneNonce}
|
||||
canvasNonce={canvasNonce}
|
||||
selectionNonce={
|
||||
this.state.selectionElement?.versionNonce
|
||||
}
|
||||
@@ -2687,7 +2701,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
locked: false,
|
||||
});
|
||||
|
||||
this.scene.insertElement(frame);
|
||||
this.insertNewElement(frame);
|
||||
|
||||
for (const child of selectedElements) {
|
||||
this.scene.mutateElement(child, { frameId: frame.id });
|
||||
@@ -3765,6 +3779,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
position:
|
||||
this.editorInterface.formFactor === "desktop" ? "cursor" : "center",
|
||||
retainSeed: isPlainPaste,
|
||||
preserveFrameChildrenOrder: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -3908,6 +3923,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
position: { clientX: number; clientY: number } | "cursor" | "center";
|
||||
retainSeed?: boolean;
|
||||
fitToContent?: boolean;
|
||||
preserveFrameChildrenOrder?: boolean;
|
||||
}) => {
|
||||
const elements = restoreElements(opts.elements, null, {
|
||||
deleteInvisibleElements: true,
|
||||
@@ -3949,6 +3965,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
}),
|
||||
randomizeSeed: !opts.retainSeed,
|
||||
preserveFrameChildrenOrder: opts.preserveFrameChildrenOrder,
|
||||
});
|
||||
|
||||
const prevElements = this.scene.getElementsIncludingDeleted();
|
||||
@@ -3970,11 +3987,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
duplicatedElements,
|
||||
topLayerFrame,
|
||||
);
|
||||
addElementsToFrame(
|
||||
nextElements = addElementsToFrame(
|
||||
nextElements,
|
||||
eligibleElements,
|
||||
topLayerFrame,
|
||||
this.state,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4200,7 +4216,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scene.insertElements(textElements);
|
||||
this.insertNewElements(textElements);
|
||||
this.store.scheduleCapture();
|
||||
this.setState({
|
||||
selectedElementIds: makeNextSelectedElementIds(
|
||||
@@ -5456,7 +5472,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (!event[KEYS.CTRL_OR_CMD]) {
|
||||
if (this.flowChartCreator.isCreatingChart) {
|
||||
if (this.flowChartCreator.pendingNodes?.length) {
|
||||
this.scene.insertElements(this.flowChartCreator.pendingNodes);
|
||||
this.insertNewElements(this.flowChartCreator.pendingNodes);
|
||||
}
|
||||
|
||||
const firstNode = this.flowChartCreator.pendingNodes?.[0];
|
||||
@@ -5554,6 +5570,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
selectedLinearElement: isSelectionLikeTool(nextActiveTool.type)
|
||||
? prevState.selectedLinearElement
|
||||
: null,
|
||||
frameToHighlight: null,
|
||||
} as const;
|
||||
|
||||
if (nextActiveTool.type === "freedraw") {
|
||||
@@ -6151,6 +6168,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
hitElement = elements[index];
|
||||
break;
|
||||
} else if (x1 < x && x < x2 && y1 < y && y < y2) {
|
||||
// to allow binding to containers within frames,
|
||||
// ignore frames in hit testing
|
||||
if (isFrameLikeElement(elements[index])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
hitElement = elements[index];
|
||||
break;
|
||||
}
|
||||
@@ -6240,11 +6263,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
}
|
||||
|
||||
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
|
||||
x: sceneX,
|
||||
y: sceneY,
|
||||
});
|
||||
|
||||
const textCreationGridPoint = this.getTextCreationGridPoint(sceneX, sceneY);
|
||||
|
||||
const newTextElementPosition = parentCenterPosition
|
||||
@@ -6266,6 +6284,20 @@ class App extends React.Component<AppProps, AppState> {
|
||||
y: sceneY,
|
||||
};
|
||||
|
||||
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
|
||||
x: newTextElementPosition.x,
|
||||
y: newTextElementPosition.y,
|
||||
});
|
||||
|
||||
// container has higher priority. Only add to frame if container is in the same frame.
|
||||
const frameId =
|
||||
topLayerFrame &&
|
||||
(!shouldBindToContainer ||
|
||||
!container ||
|
||||
container.frameId === topLayerFrame.id)
|
||||
? topLayerFrame.id
|
||||
: null;
|
||||
|
||||
const element =
|
||||
existingTextElement ||
|
||||
newTextElement({
|
||||
@@ -6295,7 +6327,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
? (0 as Radians)
|
||||
: container.angle
|
||||
: (0 as Radians),
|
||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||
frameId,
|
||||
});
|
||||
|
||||
if (!existingTextElement && shouldBindToContainer && container) {
|
||||
@@ -6311,9 +6343,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (!existingTextElement) {
|
||||
if (container && shouldBindToContainer) {
|
||||
const containerIndex = this.scene.getElementIndex(container.id);
|
||||
this.scene.insertElementAtIndex(element, containerIndex + 1);
|
||||
// TODO should use insertNewElement, after we update it to handle
|
||||
// elements with containerId + frameId at the same time (containerId
|
||||
// should take precedence when it comes to z-index)
|
||||
this.scene.insertElementsAtIndex([element], containerIndex + 1);
|
||||
} else {
|
||||
this.scene.insertElement(element);
|
||||
this.insertNewElement(element);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6695,19 +6730,161 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
};
|
||||
|
||||
private getTopLayerFrameAtSceneCoords = (sceneCoords: {
|
||||
x: number;
|
||||
y: number;
|
||||
}) => {
|
||||
/**
|
||||
* finds candidate frame under cursor (when dragging frame children/elements
|
||||
* inside frames)
|
||||
*/
|
||||
private getTopLayerFrameAtSceneCoords = (
|
||||
/**
|
||||
* should be already grid aligned (basically should be what the call site
|
||||
* sets the element's coords to, if applicable)
|
||||
*/
|
||||
sceneCoords: {
|
||||
x: number;
|
||||
y: number;
|
||||
},
|
||||
opts?: {
|
||||
/** to exclude selected elements when dragging, etc. */
|
||||
excludeElementIds?: AppState["selectedElementIds"];
|
||||
currentFrameId?: ExcalidrawElement["frameId"];
|
||||
},
|
||||
) => {
|
||||
const elementsMap = this.scene.getNonDeletedElementsMap();
|
||||
const frames = this.scene
|
||||
const framesUnderCursor = this.scene
|
||||
.getNonDeletedFramesLikes()
|
||||
.filter(
|
||||
(frame): frame is ExcalidrawFrameLikeElement =>
|
||||
!frame.locked && isCursorInFrame(sceneCoords, frame, elementsMap),
|
||||
);
|
||||
|
||||
return frames.length ? frames[frames.length - 1] : null;
|
||||
if (!framesUnderCursor.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const topLayerFrame = framesUnderCursor.at(-1)!;
|
||||
|
||||
const hitElement = this.getElementsAtPosition(
|
||||
sceneCoords.x,
|
||||
sceneCoords.y,
|
||||
{
|
||||
includeLockedElements: true,
|
||||
},
|
||||
).findLast((element) => !opts?.excludeElementIds?.[element.id]);
|
||||
|
||||
if (hitElement) {
|
||||
if (
|
||||
isFrameLikeElement(hitElement) &&
|
||||
// case: we're hitting a locked frame itself (frame's outline
|
||||
// or later its bg once implemented)
|
||||
!hitElement.locked
|
||||
) {
|
||||
return topLayerFrame;
|
||||
}
|
||||
|
||||
const hitElementIndex = this.scene.getElementIndex(hitElement.id);
|
||||
const topLayerFrameIndex = this.scene.getElementIndex(topLayerFrame.id);
|
||||
|
||||
if (
|
||||
hitElementIndex !== -1 &&
|
||||
topLayerFrameIndex !== -1 &&
|
||||
hitElementIndex <= topLayerFrameIndex
|
||||
) {
|
||||
return topLayerFrame;
|
||||
}
|
||||
|
||||
// to support a case of dragging a pre-existing frame child underneath
|
||||
// a non-frame element covering the cursor
|
||||
const currentFrame = opts?.currentFrameId
|
||||
? framesUnderCursor.find((frame) => frame.id === opts.currentFrameId) ??
|
||||
null
|
||||
: null;
|
||||
|
||||
if (currentFrame) {
|
||||
return currentFrame;
|
||||
}
|
||||
|
||||
return hitElement.frameId
|
||||
? framesUnderCursor.find((frame) => frame.id === hitElement.frameId) ??
|
||||
null
|
||||
: null;
|
||||
}
|
||||
|
||||
return topLayerFrame;
|
||||
};
|
||||
|
||||
private updateFrameToHighlight = (
|
||||
frameToHighlight: AppState["frameToHighlight"],
|
||||
) => {
|
||||
if (this.state.frameToHighlight !== frameToHighlight) {
|
||||
this.setState({ frameToHighlight });
|
||||
}
|
||||
};
|
||||
|
||||
private maybeUpdateFrameToHighlightOnPointerMove = (
|
||||
sceneCoords: { x: number; y: number },
|
||||
isOverScrollBar: boolean,
|
||||
) => {
|
||||
// currently this function is being called even during pointerdown so we
|
||||
// need to make sure we don't re-set the state when dragging and similar
|
||||
//
|
||||
// But, we still want to reset on pointermove in case the state is stale
|
||||
// so we updte even for non-eligible tool types
|
||||
if (
|
||||
this.state.newElement ||
|
||||
this.state.multiElement ||
|
||||
this.state.selectionElement ||
|
||||
this.state.selectedElementsAreBeingDragged
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateFrameToHighlight(
|
||||
!isOverScrollBar && isEligibleFrameChildType(this.state.activeTool.type)
|
||||
? this.getTopLayerFrameAtSceneCoords(sceneCoords)
|
||||
: null,
|
||||
);
|
||||
};
|
||||
|
||||
private insertNewElements = (elements: readonly ExcalidrawElement[]) => {
|
||||
if (!elements.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chunkedElements: ExcalidrawElement[][] = [];
|
||||
|
||||
for (const element of elements) {
|
||||
const currentChunk = chunkedElements[chunkedElements.length - 1];
|
||||
|
||||
if (currentChunk?.[0].frameId === element.frameId) {
|
||||
currentChunk.push(element);
|
||||
} else {
|
||||
chunkedElements.push([element]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const chunk of chunkedElements) {
|
||||
const frameId = chunk[0].frameId;
|
||||
|
||||
const insertionIndex = frameId
|
||||
? getFrameChildrenInsertionIndex(
|
||||
this.scene.getElementsIncludingDeleted(),
|
||||
frameId,
|
||||
)
|
||||
: null;
|
||||
this.scene.insertElementsAtIndex(chunk, insertionIndex);
|
||||
}
|
||||
};
|
||||
|
||||
private insertNewElement = (element: ExcalidrawElement) => {
|
||||
this.insertNewElements([element]);
|
||||
|
||||
const frame = element.frameId
|
||||
? this.scene.getNonDeletedElement(element.frameId)
|
||||
: null;
|
||||
|
||||
this.updateFrameToHighlight(
|
||||
frame && isFrameLikeElement(frame) ? frame : null,
|
||||
);
|
||||
};
|
||||
|
||||
private handleCanvasPointerMove = (
|
||||
@@ -6809,6 +6986,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
}
|
||||
|
||||
this.maybeUpdateFrameToHighlightOnPointerMove(
|
||||
{
|
||||
x: scenePointerX,
|
||||
y: scenePointerY,
|
||||
},
|
||||
isOverScrollBar,
|
||||
);
|
||||
|
||||
if (
|
||||
!this.state.newElement &&
|
||||
isActiveToolNonLinearSnappable(this.state.activeTool.type)
|
||||
@@ -8863,7 +9048,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
pressures: simulatePressure ? [] : [event.pressure],
|
||||
});
|
||||
|
||||
this.scene.insertElement(element);
|
||||
this.insertNewElement(element);
|
||||
|
||||
this.setState((prevState) => {
|
||||
const nextSelectedElementIds = {
|
||||
@@ -8920,7 +9105,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
height,
|
||||
});
|
||||
|
||||
this.scene.insertElement(element);
|
||||
this.insertNewElement(element);
|
||||
|
||||
return element;
|
||||
};
|
||||
@@ -8974,7 +9159,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
link,
|
||||
});
|
||||
|
||||
this.scene.insertElement(element);
|
||||
this.insertNewElement(element);
|
||||
|
||||
return element;
|
||||
};
|
||||
@@ -9218,7 +9403,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(0, 0)],
|
||||
});
|
||||
|
||||
this.scene.insertElement(element);
|
||||
this.insertNewElement(element);
|
||||
|
||||
if (isBindingElement(element)) {
|
||||
// Do the initial binding so the binding strategy has the initial state
|
||||
@@ -9376,7 +9561,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
selectionElement: element,
|
||||
});
|
||||
} else {
|
||||
this.scene.insertElement(element);
|
||||
this.insertNewElement(element);
|
||||
this.setState({
|
||||
multiElement: null,
|
||||
newElement: element,
|
||||
@@ -9409,7 +9594,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
? newMagicFrameElement(constructorOpts)
|
||||
: newFrameElement(constructorOpts);
|
||||
|
||||
this.scene.insertElement(frame);
|
||||
this.insertNewElement(frame);
|
||||
|
||||
this.setState({
|
||||
multiElement: null,
|
||||
@@ -9777,18 +9962,17 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedElementsHasAFrame = selectedElements.find((e) =>
|
||||
const selectedElementsHasAFrame = selectedElements.some((e) =>
|
||||
isFrameLikeElement(e),
|
||||
);
|
||||
const topLayerFrame = this.getTopLayerFrameAtSceneCoords(pointerCoords);
|
||||
const frameToHighlight =
|
||||
topLayerFrame && !selectedElementsHasAFrame ? topLayerFrame : null;
|
||||
const frameToHighlight = selectedElementsHasAFrame
|
||||
? null
|
||||
: this.getTopLayerFrameAtSceneCoords(pointerCoords, {
|
||||
currentFrameId: getCommonFrameId(selectedElements),
|
||||
excludeElementIds: this.state.selectedElementIds,
|
||||
});
|
||||
// Only update the state if there is a difference
|
||||
if (this.state.frameToHighlight !== frameToHighlight) {
|
||||
flushSync(() => {
|
||||
this.setState({ frameToHighlight });
|
||||
});
|
||||
}
|
||||
this.updateFrameToHighlight(frameToHighlight);
|
||||
|
||||
// Marking that click was used for dragging to check
|
||||
// if elements should be deselected on pointerup
|
||||
@@ -10817,7 +11001,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scene.getElementsMapIncludingDeleted(),
|
||||
elementsInsideFrame,
|
||||
newElement,
|
||||
this.state,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -10877,9 +11060,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
} else {
|
||||
// update the relationships between selected elements and frames
|
||||
const topLayerFrame = this.getTopLayerFrameAtSceneCoords(sceneCoords);
|
||||
|
||||
const selectedElements = this.scene.getSelectedElements(this.state);
|
||||
const topLayerFrame = this.getTopLayerFrameAtSceneCoords(
|
||||
sceneCoords,
|
||||
{
|
||||
currentFrameId: getCommonFrameId(selectedElements),
|
||||
excludeElementIds: this.state.selectedElementIds,
|
||||
},
|
||||
);
|
||||
let nextElements = this.scene.getElementsMapIncludingDeleted();
|
||||
|
||||
const updateGroupIdsAfterEditingGroup = (
|
||||
@@ -10928,10 +11116,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
topLayerFrame &&
|
||||
!this.state.selectedElementIds[topLayerFrame.id]
|
||||
) {
|
||||
const elementsToAdd = selectedElements.filter(
|
||||
(element) =>
|
||||
element.frameId !== topLayerFrame.id &&
|
||||
isElementInFrame(element, nextElements, this.state),
|
||||
const elementsToAdd = selectedElements.filter((element) =>
|
||||
isElementInFrame(element, nextElements, this.state),
|
||||
);
|
||||
|
||||
if (this.state.editingGroupId) {
|
||||
@@ -10942,7 +11128,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
nextElements,
|
||||
elementsToAdd,
|
||||
topLayerFrame,
|
||||
this.state,
|
||||
);
|
||||
} else if (!topLayerFrame) {
|
||||
if (this.state.editingGroupId) {
|
||||
@@ -11004,7 +11189,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
elementsMap,
|
||||
),
|
||||
frame,
|
||||
this,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11820,7 +12004,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
sceneY,
|
||||
gridPadding,
|
||||
);
|
||||
placeholders.forEach((el) => this.scene.insertElement(el));
|
||||
this.insertNewElements(placeholders);
|
||||
|
||||
// Create, position, insert and select initialized (replacing placeholders)
|
||||
const initialized = await Promise.all(
|
||||
@@ -11948,6 +12132,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
type: "everything",
|
||||
elements: item.elements,
|
||||
randomizeSeed: true,
|
||||
preserveFrameChildrenOrder: true,
|
||||
}).duplicatedElements,
|
||||
}));
|
||||
|
||||
|
||||
@@ -199,6 +199,7 @@ export default function LibraryMenuItems({
|
||||
type: "everything",
|
||||
elements: item.elements,
|
||||
randomizeSeed: true,
|
||||
preserveFrameChildrenOrder: true,
|
||||
}).duplicatedElements,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -206,7 +206,6 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
scene.getElementsIncludingDeleted(),
|
||||
nextElementsInFrame,
|
||||
latestElement,
|
||||
app,
|
||||
);
|
||||
|
||||
scene.replaceAllElements(updatedElements);
|
||||
@@ -302,7 +301,6 @@ const handleDragFinished: DragFinishedCallbackType = ({
|
||||
app.scene.getElementsIncludingDeleted(),
|
||||
nextElementsInFrame,
|
||||
latestElement,
|
||||
app,
|
||||
);
|
||||
|
||||
app.scene.replaceAllElements(updatedElements);
|
||||
|
||||
@@ -261,7 +261,6 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
scene.getElementsIncludingDeleted(),
|
||||
nextElementsInFrame,
|
||||
latestElement,
|
||||
app,
|
||||
);
|
||||
|
||||
scene.replaceAllElements(updatedElements);
|
||||
@@ -416,7 +415,6 @@ const handleDragFinished: DragFinishedCallbackType = ({
|
||||
app.scene.getElementsIncludingDeleted(),
|
||||
nextElementsInFrame,
|
||||
latestElement,
|
||||
app,
|
||||
);
|
||||
|
||||
app.scene.replaceAllElements(updatedElements);
|
||||
|
||||
@@ -39,7 +39,7 @@ type InteractiveCanvasProps = {
|
||||
visibleElements: readonly NonDeletedExcalidrawElement[];
|
||||
selectedElements: readonly NonDeletedExcalidrawElement[];
|
||||
allElementsMap: NonDeletedSceneElementsMap;
|
||||
sceneNonce: number | undefined;
|
||||
canvasNonce: string;
|
||||
selectionNonce: number | undefined;
|
||||
scale: number;
|
||||
appState: InteractiveCanvasAppState;
|
||||
@@ -279,10 +279,10 @@ const areEqual = (
|
||||
// This could be further optimised if needed, as we don't have to render interactive canvas on each scene mutation
|
||||
if (
|
||||
prevProps.selectionNonce !== nextProps.selectionNonce ||
|
||||
prevProps.sceneNonce !== nextProps.sceneNonce ||
|
||||
prevProps.canvasNonce !== nextProps.canvasNonce ||
|
||||
prevProps.scale !== nextProps.scale ||
|
||||
// we need to memoize on elementsMap because they may have renewed
|
||||
// even if sceneNonce didn't change (e.g. we filter elements out based
|
||||
// even if canvasNonce didn't change (e.g. we filter elements out based
|
||||
// on appState)
|
||||
prevProps.elementsMap !== nextProps.elementsMap ||
|
||||
prevProps.visibleElements !== nextProps.visibleElements ||
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { RoughCanvas } from "roughjs/bin/canvas";
|
||||
|
||||
interface NewElementCanvasProps {
|
||||
appState: AppState;
|
||||
newElement: NonNullable<AppState["newElement"]>;
|
||||
elementsMap: RenderableElementsMap;
|
||||
allElementsMap: NonDeletedSceneElementsMap;
|
||||
scale: number;
|
||||
@@ -31,7 +32,7 @@ const NewElementCanvas = (props: NewElementCanvasProps) => {
|
||||
{
|
||||
canvas: canvasRef.current,
|
||||
scale: props.scale,
|
||||
newElement: props.appState.newElement,
|
||||
newElement: props.newElement,
|
||||
elementsMap: props.elementsMap,
|
||||
allElementsMap: props.allElementsMap,
|
||||
rc: props.rc,
|
||||
|
||||
@@ -23,7 +23,7 @@ type StaticCanvasProps = {
|
||||
elementsMap: RenderableElementsMap;
|
||||
allElementsMap: NonDeletedSceneElementsMap;
|
||||
visibleElements: readonly NonDeletedExcalidrawElement[];
|
||||
sceneNonce: number | undefined;
|
||||
canvasNonce: string;
|
||||
selectionNonce: number | undefined;
|
||||
scale: number;
|
||||
appState: StaticCanvasAppState;
|
||||
@@ -110,10 +110,10 @@ const areEqual = (
|
||||
nextProps: StaticCanvasProps,
|
||||
) => {
|
||||
if (
|
||||
prevProps.sceneNonce !== nextProps.sceneNonce ||
|
||||
prevProps.canvasNonce !== nextProps.canvasNonce ||
|
||||
prevProps.scale !== nextProps.scale ||
|
||||
// we need to memoize on elementsMap because they may have renewed
|
||||
// even if sceneNonce didn't change (e.g. we filter elements out based
|
||||
// even if canvasNonce didn't change (e.g. we filter elements out based
|
||||
// on appState)
|
||||
prevProps.elementsMap !== nextProps.elementsMap ||
|
||||
prevProps.visibleElements !== nextProps.visibleElements
|
||||
|
||||
@@ -23,6 +23,94 @@ const polyfill = () => {
|
||||
});
|
||||
}
|
||||
|
||||
if (!Array.prototype.findLast) {
|
||||
Object.defineProperty(Array.prototype, "findLast", {
|
||||
value: function <T>(
|
||||
this: T[],
|
||||
predicate: (value: T, index: number, array: T[]) => unknown,
|
||||
thisArg?: unknown,
|
||||
) {
|
||||
return this
|
||||
.slice()
|
||||
.reverse()
|
||||
.find((value, index) =>
|
||||
predicate.call(thisArg, value, this.length - index - 1, this),
|
||||
);
|
||||
},
|
||||
writable: true,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!Array.prototype.findIndex) {
|
||||
Object.defineProperty(Array.prototype, "findIndex", {
|
||||
value: function <T>(
|
||||
this: T[],
|
||||
predicate: (value: T, index: number, array: T[]) => unknown,
|
||||
thisArg?: unknown,
|
||||
) {
|
||||
for (let index = 0; index < this.length; index++) {
|
||||
if (predicate.call(thisArg, this[index], index, this)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
},
|
||||
writable: true,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!Array.prototype.findLastIndex) {
|
||||
Object.defineProperty(Array.prototype, "findLastIndex", {
|
||||
value: function <T>(
|
||||
this: T[],
|
||||
predicate: (value: T, index: number, array: T[]) => unknown,
|
||||
thisArg?: unknown,
|
||||
) {
|
||||
const index = this
|
||||
.slice()
|
||||
.reverse()
|
||||
.findIndex((value, index) =>
|
||||
predicate.call(thisArg, value, this.length - index - 1, this),
|
||||
);
|
||||
|
||||
return index === -1 ? -1 : this.length - index - 1;
|
||||
},
|
||||
writable: true,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!Array.prototype.toReversed) {
|
||||
Object.defineProperty(Array.prototype, "toReversed", {
|
||||
value: function <T>(this: T[]) {
|
||||
return this.slice().reverse();
|
||||
},
|
||||
writable: true,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!Array.prototype.toSorted) {
|
||||
Object.defineProperty(Array.prototype, "toSorted", {
|
||||
value: function <T>(
|
||||
this: T[],
|
||||
compareFn?: (a: T, b: T) => number,
|
||||
) {
|
||||
return this.slice().sort(compareFn);
|
||||
},
|
||||
writable: true,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!Element.prototype.replaceChildren) {
|
||||
Element.prototype.replaceChildren = function (...nodes) {
|
||||
this.innerHTML = "";
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { isElementInViewport } from "@excalidraw/element";
|
||||
import {
|
||||
getCommonFrameId,
|
||||
getFrameChildrenInsertionIndex,
|
||||
isElementInViewport,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { memoize, toBrandedType } from "@excalidraw/common";
|
||||
import { arrayToMap, memoize, toBrandedType } from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameLikeElement,
|
||||
NonDeleted,
|
||||
NonDeletedElementsMap,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
@@ -16,6 +22,21 @@ import type { RenderableElementsMap } from "./types";
|
||||
|
||||
import type { AppState } from "../types";
|
||||
|
||||
type GetRenderableElementsOpts = {
|
||||
zoom: AppState["zoom"];
|
||||
offsetLeft: AppState["offsetLeft"];
|
||||
offsetTop: AppState["offsetTop"];
|
||||
scrollX: AppState["scrollX"];
|
||||
scrollY: AppState["scrollY"];
|
||||
height: AppState["height"];
|
||||
width: AppState["width"];
|
||||
editingTextElement: AppState["editingTextElement"];
|
||||
newElement: AppState["newElement"];
|
||||
selectedElements: readonly NonDeletedExcalidrawElement[];
|
||||
selectedElementsAreBeingDragged: AppState["selectedElementsAreBeingDragged"];
|
||||
frameToHighlight: AppState["frameToHighlight"];
|
||||
};
|
||||
|
||||
export class Renderer {
|
||||
private scene: Scene;
|
||||
|
||||
@@ -23,9 +44,121 @@ export class Renderer {
|
||||
this.scene = scene;
|
||||
}
|
||||
|
||||
public getRenderableElements = (() => {
|
||||
const getVisibleCanvasElements = ({
|
||||
elementsMap,
|
||||
private getVisibleCanvasElements({
|
||||
elementsMap,
|
||||
zoom,
|
||||
offsetLeft,
|
||||
offsetTop,
|
||||
scrollX,
|
||||
scrollY,
|
||||
height,
|
||||
width,
|
||||
}: {
|
||||
elementsMap: NonDeletedElementsMap;
|
||||
zoom: AppState["zoom"];
|
||||
offsetLeft: AppState["offsetLeft"];
|
||||
offsetTop: AppState["offsetTop"];
|
||||
scrollX: AppState["scrollX"];
|
||||
scrollY: AppState["scrollY"];
|
||||
height: AppState["height"];
|
||||
width: AppState["width"];
|
||||
}): readonly NonDeletedExcalidrawElement[] {
|
||||
const visibleElements: NonDeletedExcalidrawElement[] = [];
|
||||
for (const element of elementsMap.values()) {
|
||||
if (
|
||||
isElementInViewport(
|
||||
element,
|
||||
width,
|
||||
height,
|
||||
{
|
||||
zoom,
|
||||
offsetLeft,
|
||||
offsetTop,
|
||||
scrollX,
|
||||
scrollY,
|
||||
},
|
||||
elementsMap,
|
||||
)
|
||||
) {
|
||||
visibleElements.push(element);
|
||||
}
|
||||
}
|
||||
return visibleElements;
|
||||
}
|
||||
|
||||
private getRenderableElementsMap({
|
||||
elements,
|
||||
editingTextElement,
|
||||
newElement,
|
||||
}: {
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
editingTextElement: AppState["editingTextElement"];
|
||||
newElement: AppState["newElement"];
|
||||
}) {
|
||||
const elementsMap = toBrandedType<RenderableElementsMap>(new Map());
|
||||
const newElementCanvasElement = newElement?.frameId ? null : newElement;
|
||||
|
||||
for (const element of elements) {
|
||||
if (newElementCanvasElement?.id === element.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// we don't want to render text element that's being currently edited
|
||||
// (it's rendered on remote only)
|
||||
if (
|
||||
!editingTextElement ||
|
||||
editingTextElement.type !== "text" ||
|
||||
element.id !== editingTextElement.id
|
||||
) {
|
||||
elementsMap.set(element.id, element);
|
||||
}
|
||||
}
|
||||
return { elementsMap, newElementCanvasElement };
|
||||
}
|
||||
|
||||
private sortSelectedElementsIntoHighlightedFrame<
|
||||
T extends ExcalidrawElement,
|
||||
>({
|
||||
visibleElements,
|
||||
selectedElements,
|
||||
frameToHighlight,
|
||||
}: {
|
||||
selectedElements: readonly NonDeletedExcalidrawElement[];
|
||||
visibleElements: readonly T[];
|
||||
frameToHighlight: NonDeleted<ExcalidrawFrameLikeElement>;
|
||||
}): readonly T[] {
|
||||
if (!selectedElements.length) {
|
||||
return visibleElements;
|
||||
}
|
||||
|
||||
// we assume all selected elements are eligible frame children if
|
||||
// frameToHighlight is defined
|
||||
const selectedElementsMap = arrayToMap(selectedElements);
|
||||
|
||||
// thus, all deselected elements are the ones we won't reorder
|
||||
const deselectedElements = visibleElements.filter(
|
||||
(element) => !selectedElementsMap.has(element.id),
|
||||
);
|
||||
|
||||
const insertionIndex = getFrameChildrenInsertionIndex(
|
||||
deselectedElements,
|
||||
frameToHighlight.id,
|
||||
);
|
||||
|
||||
if (insertionIndex === null) {
|
||||
return visibleElements;
|
||||
}
|
||||
|
||||
return [
|
||||
...deselectedElements.slice(0, insertionIndex),
|
||||
...selectedElements,
|
||||
...deselectedElements.slice(insertionIndex),
|
||||
] as readonly T[];
|
||||
}
|
||||
|
||||
private _getRenderableElements = memoize(
|
||||
({
|
||||
canvasNonce,
|
||||
zoom,
|
||||
offsetLeft,
|
||||
offsetTop,
|
||||
@@ -33,70 +166,27 @@ export class Renderer {
|
||||
scrollY,
|
||||
height,
|
||||
width,
|
||||
}: {
|
||||
elementsMap: NonDeletedElementsMap;
|
||||
zoom: AppState["zoom"];
|
||||
offsetLeft: AppState["offsetLeft"];
|
||||
offsetTop: AppState["offsetTop"];
|
||||
scrollX: AppState["scrollX"];
|
||||
scrollY: AppState["scrollY"];
|
||||
height: AppState["height"];
|
||||
width: AppState["width"];
|
||||
}): readonly NonDeletedExcalidrawElement[] => {
|
||||
const visibleElements: NonDeletedExcalidrawElement[] = [];
|
||||
for (const element of elementsMap.values()) {
|
||||
if (
|
||||
isElementInViewport(
|
||||
element,
|
||||
width,
|
||||
height,
|
||||
{
|
||||
zoom,
|
||||
offsetLeft,
|
||||
offsetTop,
|
||||
scrollX,
|
||||
scrollY,
|
||||
},
|
||||
elementsMap,
|
||||
)
|
||||
) {
|
||||
visibleElements.push(element);
|
||||
}
|
||||
}
|
||||
return visibleElements;
|
||||
};
|
||||
|
||||
const getRenderableElements = ({
|
||||
elements,
|
||||
editingTextElement,
|
||||
newElementId,
|
||||
}: {
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
editingTextElement: AppState["editingTextElement"];
|
||||
newElementId: ExcalidrawElement["id"] | undefined;
|
||||
newElement,
|
||||
}: Omit<
|
||||
GetRenderableElementsOpts,
|
||||
| "selectedElements"
|
||||
| "selectedElementsAreBeingDragged"
|
||||
| "frameToHighlight"
|
||||
> & {
|
||||
canvasNonce: string;
|
||||
}) => {
|
||||
const elementsMap = toBrandedType<RenderableElementsMap>(new Map());
|
||||
const elements = this.scene.getNonDeletedElements();
|
||||
|
||||
for (const element of elements) {
|
||||
if (newElementId === element.id) {
|
||||
continue;
|
||||
}
|
||||
const { elementsMap, newElementCanvasElement } =
|
||||
this.getRenderableElementsMap({
|
||||
elements,
|
||||
editingTextElement,
|
||||
newElement,
|
||||
});
|
||||
|
||||
// we don't want to render text element that's being currently edited
|
||||
// (it's rendered on remote only)
|
||||
if (
|
||||
!editingTextElement ||
|
||||
editingTextElement.type !== "text" ||
|
||||
element.id !== editingTextElement.id
|
||||
) {
|
||||
elementsMap.set(element.id, element);
|
||||
}
|
||||
}
|
||||
return elementsMap;
|
||||
};
|
||||
|
||||
return memoize(
|
||||
({
|
||||
const visibleElements = this.getVisibleCanvasElements({
|
||||
elementsMap,
|
||||
zoom,
|
||||
offsetLeft,
|
||||
offsetTop,
|
||||
@@ -104,52 +194,69 @@ export class Renderer {
|
||||
scrollY,
|
||||
height,
|
||||
width,
|
||||
editingTextElement,
|
||||
newElementId,
|
||||
// cache-invalidation nonce
|
||||
sceneNonce: _sceneNonce,
|
||||
}: {
|
||||
zoom: AppState["zoom"];
|
||||
offsetLeft: AppState["offsetLeft"];
|
||||
offsetTop: AppState["offsetTop"];
|
||||
scrollX: AppState["scrollX"];
|
||||
scrollY: AppState["scrollY"];
|
||||
height: AppState["height"];
|
||||
width: AppState["width"];
|
||||
editingTextElement: AppState["editingTextElement"];
|
||||
/** note: first render of newElement will always bust the cache
|
||||
* (we'd have to prefilter elements outside of this function) */
|
||||
newElementId: ExcalidrawElement["id"] | undefined;
|
||||
sceneNonce: ReturnType<InstanceType<typeof Scene>["getSceneNonce"]>;
|
||||
}) => {
|
||||
const elements = this.scene.getNonDeletedElements();
|
||||
});
|
||||
|
||||
const elementsMap = getRenderableElements({
|
||||
elements,
|
||||
editingTextElement,
|
||||
newElementId,
|
||||
return {
|
||||
elementsMap,
|
||||
visibleElements,
|
||||
newElementCanvasElement,
|
||||
canvasNonce,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
public getRenderableElements = (opts: GetRenderableElementsOpts) => {
|
||||
const { newElement } = opts;
|
||||
const canvasNonce = `${this.scene.getSceneNonce()}${
|
||||
newElement?.frameId ? `:${newElement.versionNonce}` : ""
|
||||
}`;
|
||||
|
||||
const ret = this._getRenderableElements({
|
||||
canvasNonce,
|
||||
|
||||
// don't spread `opts` because we don't want to memoize on some props
|
||||
|
||||
zoom: opts.zoom,
|
||||
offsetLeft: opts.offsetLeft,
|
||||
offsetTop: opts.offsetTop,
|
||||
scrollX: opts.scrollX,
|
||||
scrollY: opts.scrollY,
|
||||
height: opts.height,
|
||||
width: opts.width,
|
||||
editingTextElement: opts.editingTextElement,
|
||||
newElement: opts.newElement,
|
||||
});
|
||||
|
||||
// if we're dragging elements over a frame, reorder the selected elements
|
||||
// inside the frame during render (we don't set the `element.frameId` until
|
||||
// pointerup else we'd have to painstainly restore the orig index if user
|
||||
// didn't end up adding elements to the frame)
|
||||
if (
|
||||
opts.frameToHighlight &&
|
||||
opts.selectedElementsAreBeingDragged &&
|
||||
// if all dragged elements are already in the frame, don't reorder
|
||||
getCommonFrameId(opts.selectedElements) !== opts.frameToHighlight.id
|
||||
) {
|
||||
const reorderedVisibleElements =
|
||||
this.sortSelectedElementsIntoHighlightedFrame({
|
||||
visibleElements: ret.visibleElements,
|
||||
selectedElements: opts.selectedElements,
|
||||
frameToHighlight: opts.frameToHighlight,
|
||||
});
|
||||
|
||||
const visibleElements = getVisibleCanvasElements({
|
||||
elementsMap,
|
||||
zoom,
|
||||
offsetLeft,
|
||||
offsetTop,
|
||||
scrollX,
|
||||
scrollY,
|
||||
height,
|
||||
width,
|
||||
});
|
||||
return {
|
||||
...ret,
|
||||
visibleElements: reorderedVisibleElements,
|
||||
};
|
||||
}
|
||||
|
||||
return { elementsMap, visibleElements };
|
||||
},
|
||||
);
|
||||
})();
|
||||
return ret;
|
||||
};
|
||||
|
||||
// NOTE Doesn't destroy everything (scene, rc, etc.) because it may not be
|
||||
// safe to break TS contract here (for upstream cases)
|
||||
public destroy() {
|
||||
renderStaticSceneThrottled.cancel();
|
||||
this.getRenderableElements.clear();
|
||||
this._getRenderableElements.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,8 +305,88 @@ describe("pasting & frames", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements.length).toBe(2);
|
||||
expect(h.elements[0].type).toBe(rect.type);
|
||||
expect(h.elements[0].frameId).toBe(frame.id);
|
||||
expect(h.elements[1].id).toBe(frame.id);
|
||||
expect(h.elements[0].index! < frame.index!).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should layer pasted elements above the highest frame child", async () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const frameChild = API.createElement({
|
||||
id: "frameChild",
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 20,
|
||||
height: 20,
|
||||
frameId: frame.id,
|
||||
});
|
||||
const rect = API.createElement({ type: "rectangle" });
|
||||
|
||||
API.setElements([frameChild, frame]);
|
||||
|
||||
const clipboardJSON = await serializeAsClipboardJSON({
|
||||
elements: [rect],
|
||||
files: null,
|
||||
});
|
||||
|
||||
mouse.moveTo(50, 50);
|
||||
|
||||
pasteWithCtrlCmdV(clipboardJSON);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements.length).toBe(3);
|
||||
expect(h.elements[1].type).toBe(rect.type);
|
||||
expect(h.elements[1].frameId).toBe(frame.id);
|
||||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
frameChild.id,
|
||||
h.elements[1].id,
|
||||
frame.id,
|
||||
]);
|
||||
expect(h.elements[1].index! > frameChild.index!).toBe(true);
|
||||
expect(h.elements[1].index! < frame.index!).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should preserve denormalized pasted frame child order", async () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const frameChild = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 20,
|
||||
height: 20,
|
||||
frameId: frame.id,
|
||||
});
|
||||
|
||||
const clipboardJSON = await serializeAsClipboardJSON({
|
||||
elements: [frame, frameChild],
|
||||
files: null,
|
||||
});
|
||||
|
||||
mouse.moveTo(200, 200);
|
||||
|
||||
pasteWithCtrlCmdV(clipboardJSON);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements.length).toBe(2);
|
||||
expect(h.elements[0].type).toBe(frame.type);
|
||||
expect(h.elements[1].type).toBe(frameChild.type);
|
||||
expect(h.elements[1].frameId).toBe(h.elements[0].id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -379,8 +459,9 @@ describe("pasting & frames", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements.length).toBe(3);
|
||||
expect(h.elements[1].type).toBe(rect.type);
|
||||
expect(h.elements[1].frameId).toBe(frame.id);
|
||||
expect(h.elements[0].type).toBe(rect.type);
|
||||
expect(h.elements[0].frameId).toBe(frame.id);
|
||||
expect(h.elements[1].id).toBe(frame.id);
|
||||
expect(h.elements[2].type).toBe(rect2.type);
|
||||
expect(h.elements[2].frameId).toBe(null);
|
||||
});
|
||||
@@ -422,10 +503,11 @@ describe("pasting & frames", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements.length).toBe(3);
|
||||
expect(h.elements[1].type).toBe(rect.type);
|
||||
expect(h.elements[0].type).toBe(rect.type);
|
||||
expect(h.elements[0].frameId).toBe(frame.id);
|
||||
expect(h.elements[1].type).toBe(rect2.type);
|
||||
expect(h.elements[1].frameId).toBe(frame.id);
|
||||
expect(h.elements[2].type).toBe(rect2.type);
|
||||
expect(h.elements[2].frameId).toBe(frame.id);
|
||||
expect(h.elements[2].id).toBe(frame.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -473,8 +555,9 @@ describe("pasting & frames", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements.length).toBe(4);
|
||||
expect(h.elements[1].type).toBe(rect.type);
|
||||
expect(h.elements[1].frameId).toBe(frame.id);
|
||||
expect(h.elements[0].type).toBe(rect.type);
|
||||
expect(h.elements[0].frameId).toBe(frame.id);
|
||||
expect(h.elements[1].id).toBe(frame.id);
|
||||
expect(h.elements[2].type).toBe(rect2.type);
|
||||
expect(h.elements[2].frameId).toBe(h.elements[3].id);
|
||||
expect(h.elements[3].type).toBe(frame2.type);
|
||||
|
||||
@@ -769,6 +769,63 @@ describe("textWysiwyg", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("should not add bound text to a frame when its container is not a frame child", async () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 200,
|
||||
height: 200,
|
||||
});
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 90,
|
||||
height: 75,
|
||||
backgroundColor: "red",
|
||||
});
|
||||
API.setElements([frame, rectangle]);
|
||||
|
||||
mouse.doubleClickAt(rectangle.x + 10, rectangle.y + 10);
|
||||
|
||||
const text = h.elements[2] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
expect(text.frameId).toBe(null);
|
||||
});
|
||||
|
||||
it("should bind text to a frame child container when single clicking its center", async () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 200,
|
||||
height: 200,
|
||||
});
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 90,
|
||||
height: 75,
|
||||
backgroundColor: "red",
|
||||
frameId: frame.id,
|
||||
});
|
||||
API.setElements([rectangle, frame]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(
|
||||
rectangle.x + rectangle.width / 2,
|
||||
rectangle.y + rectangle.height / 2,
|
||||
);
|
||||
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
expect(text.frameId).toBe(frame.id);
|
||||
});
|
||||
|
||||
it("should set the text element angle to same as container angle when binding to rotated container", async () => {
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
|
||||
Reference in New Issue
Block a user