feat: introduce frames (#6123)

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Ryan Di 2023-06-15 00:42:01 +08:00 committed by GitHub
parent 4d7d96eb7b
commit 81ebf82979
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
78 changed files with 4563 additions and 480 deletions

View file

@ -2,9 +2,15 @@ import {
ExcalidrawElement,
NonDeletedExcalidrawElement,
NonDeleted,
ExcalidrawFrameElement,
} from "../element/types";
import { getNonDeletedElements, isNonDeletedElement } from "../element";
import {
getNonDeletedElements,
getNonDeletedFrames,
isNonDeletedElement,
} from "../element";
import { LinearElementEditor } from "../element/linearElementEditor";
import { isFrameElement } from "../element/typeChecks";
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
type ElementKey = ExcalidrawElement | ElementIdKey;
@ -12,6 +18,10 @@ type ElementKey = ExcalidrawElement | ElementIdKey;
type SceneStateCallback = () => void;
type SceneStateCallbackRemover = () => void;
// ideally this would be a branded type but it'd be insanely hard to work with
// in our codebase
export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
const isIdKey = (elementKey: ElementKey): elementKey is ElementIdKey => {
if (typeof elementKey === "string") {
return true;
@ -55,6 +65,8 @@ class Scene {
private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = [];
private elements: readonly ExcalidrawElement[] = [];
private nonDeletedFrames: readonly NonDeleted<ExcalidrawFrameElement>[] = [];
private frames: readonly ExcalidrawFrameElement[] = [];
private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
getElementsIncludingDeleted() {
@ -65,6 +77,14 @@ class Scene {
return this.nonDeletedElements;
}
getFramesIncludingDeleted() {
return this.frames;
}
getNonDeletedFrames(): readonly NonDeleted<ExcalidrawFrameElement>[] {
return this.nonDeletedFrames;
}
getElement<T extends ExcalidrawElement>(id: T["id"]): T | null {
return (this.elementsMap.get(id) as T | undefined) || null;
}
@ -110,12 +130,19 @@ class Scene {
replaceAllElements(nextElements: readonly ExcalidrawElement[]) {
this.elements = nextElements;
const nextFrames: ExcalidrawFrameElement[] = [];
this.elementsMap.clear();
nextElements.forEach((element) => {
if (isFrameElement(element)) {
nextFrames.push(element);
}
this.elementsMap.set(element.id, element);
Scene.mapElementToScene(element, this);
});
this.nonDeletedElements = getNonDeletedElements(this.elements);
this.frames = nextFrames;
this.nonDeletedFrames = getNonDeletedFrames(this.frames);
this.informMutation();
}
@ -165,6 +192,29 @@ class Scene {
this.replaceAllElements(nextElements);
}
insertElementsAtIndex(elements: ExcalidrawElement[], index: number) {
if (!Number.isFinite(index) || index < 0) {
throw new Error(
"insertElementAtIndex can only be called with index >= 0",
);
}
const nextElements = [
...this.elements.slice(0, index),
...elements,
...this.elements.slice(index),
];
this.replaceAllElements(nextElements);
}
addNewElement = (element: ExcalidrawElement) => {
if (element.frameId) {
this.insertElementAtIndex(element, this.getElementIndex(element.frameId));
} else {
this.replaceAllElements([...this.elements, element]);
}
};
getElementIndex(elementId: string) {
return this.elements.findIndex((element) => element.id === elementId);
}

View file

@ -7,7 +7,8 @@ export const hasBackground = (type: string) =>
type === "line" ||
type === "freedraw";
export const hasStrokeColor = (type: string) => type !== "image";
export const hasStrokeColor = (type: string) =>
type !== "image" && type !== "frame";
export const hasStrokeWidth = (type: string) =>
type === "rectangle" ||

View file

@ -1,8 +1,8 @@
import rough from "roughjs/bin/rough";
import { NonDeletedExcalidrawElement } from "../element/types";
import { getCommonBounds } from "../element/bounds";
import { getCommonBounds, getElementAbsoluteCoords } from "../element/bounds";
import { renderScene, renderSceneToSvg } from "../renderer/renderScene";
import { distance } from "../utils";
import { distance, isOnlyExportingSingleFrame } from "../utils";
import { AppState, BinaryFiles } from "../types";
import { DEFAULT_EXPORT_PADDING, SVG_NS, THEME_FILTER } from "../constants";
import { getDefaultAppState } from "../appState";
@ -11,6 +11,7 @@ import {
getInitializedImageElements,
updateImageCache,
} from "../element/image";
import Scene from "./Scene";
export const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
@ -51,6 +52,8 @@ export const exportToCanvas = async (
files,
});
const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
renderScene({
elements,
appState,
@ -59,8 +62,8 @@ export const exportToCanvas = async (
canvas,
renderConfig: {
viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
scrollX: -minX + exportPadding,
scrollY: -minY + exportPadding,
scrollX: -minX + (onlyExportingSingleFrame ? 0 : exportPadding),
scrollY: -minY + (onlyExportingSingleFrame ? 0 : exportPadding),
zoom: defaultAppState.zoom,
remotePointerViewportCoords: {},
remoteSelectedElementIds: {},
@ -88,6 +91,7 @@ export const exportToSvg = async (
viewBackgroundColor: string;
exportWithDarkMode?: boolean;
exportEmbedScene?: boolean;
renderFrame?: boolean;
},
files: BinaryFiles | null,
opts?: {
@ -140,6 +144,39 @@ export const exportToSvg = async (
}
assetPath = `${assetPath}/dist/excalidraw-assets/`;
}
// do not apply clipping when we're exporting the whole scene
const isExportingWholeCanvas =
Scene.getScene(elements[0])?.getNonDeletedElements()?.length ===
elements.length;
const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
const offsetX = -minX + (onlyExportingSingleFrame ? 0 : exportPadding);
const offsetY = -minY + (onlyExportingSingleFrame ? 0 : exportPadding);
const exportingFrame =
isExportingWholeCanvas || !onlyExportingSingleFrame
? undefined
: elements.find((element) => element.type === "frame");
let exportingFrameClipPath = "";
if (exportingFrame) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(exportingFrame);
const cx = (x2 - x1) / 2 - (exportingFrame.x - x1);
const cy = (y2 - y1) / 2 - (exportingFrame.y - y1);
exportingFrameClipPath = `<clipPath id=${exportingFrame.id}>
<rect transform="translate(${exportingFrame.x + offsetX} ${
exportingFrame.y + offsetY
}) rotate(${exportingFrame.angle} ${cx} ${cy})"
width="${exportingFrame.width}"
height="${exportingFrame.height}"
>
</rect>
</clipPath>`;
}
svgRoot.innerHTML = `
${SVG_EXPORT_TAG}
${metadata}
@ -154,8 +191,10 @@ export const exportToSvg = async (
src: url("${assetPath}Cascadia.woff2");
}
</style>
${exportingFrameClipPath}
</defs>
`;
// render background rect
if (appState.exportBackground && viewBackgroundColor) {
const rect = svgRoot.ownerDocument!.createElementNS(SVG_NS, "rect");
@ -169,9 +208,10 @@ export const exportToSvg = async (
const rsvg = rough.svg(svgRoot);
renderSceneToSvg(elements, rsvg, svgRoot, files || {}, {
offsetX: -minX + exportPadding,
offsetY: -minY + exportPadding,
offsetX,
offsetY,
exportWithDarkMode: appState.exportWithDarkMode,
exportingFrameId: exportingFrame?.id || null,
});
return svgRoot;
@ -182,9 +222,36 @@ const getCanvasSize = (
elements: readonly NonDeletedExcalidrawElement[],
exportPadding: number,
): [number, number, number, number] => {
// we should decide if we are exporting the whole canvas
// if so, we are not clipping elements in the frame
// and therefore, we should not do anything special
const isExportingWholeCanvas =
Scene.getScene(elements[0])?.getNonDeletedElements()?.length ===
elements.length;
const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
if (!isExportingWholeCanvas || onlyExportingSingleFrame) {
const frames = elements.filter((element) => element.type === "frame");
const exportedFrameIds = frames.reduce((acc, frame) => {
acc[frame.id] = true;
return acc;
}, {} as Record<string, true>);
// elements in a frame do not affect the canvas size if we're not exporting
// the whole canvas
elements = elements.filter(
(element) => !exportedFrameIds[element.frameId ?? ""],
);
}
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
const width = distance(minX, maxX) + exportPadding * 2;
const height = distance(minY, maxY) + exportPadding + exportPadding;
const width =
distance(minX, maxX) + (onlyExportingSingleFrame ? 0 : exportPadding * 2);
const height =
distance(minY, maxY) + (onlyExportingSingleFrame ? 0 : exportPadding * 2);
return [minX, minY, width, height];
};

View file

@ -5,17 +5,61 @@ import {
import { getElementAbsoluteCoords, getElementBounds } from "../element";
import { AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks";
import {
elementOverlapsWithFrame,
getContainingFrame,
getFrameElements,
} from "../frame";
/**
* Frames and their containing elements are not to be selected at the same time.
* Given an array of selected elements, if there are frames and their containing elements
* we only keep the frames.
* @param selectedElements
*/
export const excludeElementsInFramesFromSelection = <
T extends ExcalidrawElement,
>(
selectedElements: readonly T[],
) => {
const framesInSelection = new Set<T["id"]>();
selectedElements.forEach((element) => {
if (element.type === "frame") {
framesInSelection.add(element.id);
}
});
return selectedElements.filter((element) => {
if (element.frameId && framesInSelection.has(element.frameId)) {
return false;
}
return true;
});
};
export const getElementsWithinSelection = (
elements: readonly NonDeletedExcalidrawElement[],
selection: NonDeletedExcalidrawElement,
excludeElementsInFrames: boolean = true,
) => {
const [selectionX1, selectionY1, selectionX2, selectionY2] =
getElementAbsoluteCoords(selection);
return elements.filter((element) => {
const [elementX1, elementY1, elementX2, elementY2] =
let elementsInSelection = elements.filter((element) => {
let [elementX1, elementY1, elementX2, elementY2] =
getElementBounds(element);
const containingFrame = getContainingFrame(element);
if (containingFrame) {
const [fx1, fy1, fx2, fy2] = getElementBounds(containingFrame);
elementX1 = Math.max(fx1, elementX1);
elementY1 = Math.max(fy1, elementY1);
elementX2 = Math.min(fx2, elementX2);
elementY2 = Math.min(fy2, elementY2);
}
return (
element.locked === false &&
element.type !== "selection" &&
@ -26,6 +70,22 @@ export const getElementsWithinSelection = (
selectionY2 >= elementY2
);
});
elementsInSelection = excludeElementsInFrames
? excludeElementsInFramesFromSelection(elementsInSelection)
: elementsInSelection;
elementsInSelection = elementsInSelection.filter((element) => {
const containingFrame = getContainingFrame(element);
if (containingFrame) {
return elementOverlapsWithFrame(element, containingFrame);
}
return true;
});
return elementsInSelection;
};
export const isSomeElementSelected = (
@ -56,14 +116,17 @@ export const getCommonAttributeOfSelectedElements = <T>(
export const getSelectedElements = (
elements: readonly NonDeletedExcalidrawElement[],
appState: Pick<AppState, "selectedElementIds">,
includeBoundTextElement: boolean = false,
) =>
elements.filter((element) => {
opts?: {
includeBoundTextElement?: boolean;
includeElementsInFrames?: boolean;
},
) => {
const selectedElements = elements.filter((element) => {
if (appState.selectedElementIds[element.id]) {
return element;
}
if (
includeBoundTextElement &&
opts?.includeBoundTextElement &&
isBoundToContainer(element) &&
appState.selectedElementIds[element?.containerId]
) {
@ -72,10 +135,29 @@ export const getSelectedElements = (
return null;
});
if (opts?.includeElementsInFrames) {
const elementsToInclude: ExcalidrawElement[] = [];
selectedElements.forEach((element) => {
if (element.type === "frame") {
getFrameElements(elements, element.id).forEach((e) =>
elementsToInclude.push(e),
);
}
elementsToInclude.push(element);
});
return elementsToInclude;
}
return selectedElements;
};
export const getTargetElements = (
elements: readonly NonDeletedExcalidrawElement[],
appState: Pick<AppState, "selectedElementIds" | "editingElement">,
) =>
appState.editingElement
? [appState.editingElement]
: getSelectedElements(elements, appState, true);
: getSelectedElements(elements, appState, {
includeBoundTextElement: true,
});