Merge branch 'master' into arnost/scroll-in-read-only-links

This commit is contained in:
Arnost Pleskot 2023-07-31 09:26:14 +02:00 committed by GitHub
commit af6e64ffc2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
171 changed files with 9637 additions and 13847 deletions

View file

@ -11,6 +11,9 @@ import {
} from "../element";
import { LinearElementEditor } from "../element/linearElementEditor";
import { isFrameElement } from "../element/typeChecks";
import { getSelectedElements } from "./selection";
import { AppState } from "../types";
import { Assert, SameType } from "../utility-types";
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
type ElementKey = ExcalidrawElement | ElementIdKey;
@ -18,6 +21,31 @@ type ElementKey = ExcalidrawElement | ElementIdKey;
type SceneStateCallback = () => void;
type SceneStateCallbackRemover = () => void;
type SelectionHash = string & { __brand: "selectionHash" };
const hashSelectionOpts = (
opts: Parameters<InstanceType<typeof Scene>["getSelectedElements"]>[0],
) => {
const keys = ["includeBoundTextElement", "includeElementsInFrames"] as const;
type HashableKeys = Omit<typeof opts, "selectedElementIds" | "elements">;
// just to ensure we're hashing all expected keys
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type _ = Assert<
SameType<
Required<HashableKeys>,
Pick<Required<HashableKeys>, typeof keys[number]>
>
>;
let hash = "";
for (const key of keys) {
hash += `${key}:${opts[key] ? "1" : "0"}`;
}
return hash as SelectionHash;
};
// ideally this would be a branded type but it'd be insanely hard to work with
// in our codebase
export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
@ -68,6 +96,15 @@ class Scene {
private nonDeletedFrames: readonly NonDeleted<ExcalidrawFrameElement>[] = [];
private frames: readonly ExcalidrawFrameElement[] = [];
private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
private selectedElementsCache: {
selectedElementIds: AppState["selectedElementIds"] | null;
elements: readonly NonDeletedExcalidrawElement[] | null;
cache: Map<SelectionHash, NonDeletedExcalidrawElement[]>;
} = {
selectedElementIds: null,
elements: null,
cache: new Map(),
};
getElementsIncludingDeleted() {
return this.elements;
@ -81,6 +118,52 @@ class Scene {
return this.frames;
}
getSelectedElements(opts: {
// NOTE can be ommitted by making Scene constructor require App instance
selectedElementIds: AppState["selectedElementIds"];
/**
* for specific cases where you need to use elements not from current
* scene state. This in effect will likely result in cache-miss, and
* the cache won't be updated in this case.
*/
elements?: readonly ExcalidrawElement[];
// selection-related options
includeBoundTextElement?: boolean;
includeElementsInFrames?: boolean;
}): NonDeleted<ExcalidrawElement>[] {
const hash = hashSelectionOpts(opts);
const elements = opts?.elements || this.nonDeletedElements;
if (
this.selectedElementsCache.elements === elements &&
this.selectedElementsCache.selectedElementIds === opts.selectedElementIds
) {
const cached = this.selectedElementsCache.cache.get(hash);
if (cached) {
return cached;
}
} else if (opts?.elements == null) {
// if we're operating on latest scene elements and the cache is not
// storing the latest elements, clear the cache
this.selectedElementsCache.cache.clear();
}
const selectedElements = getSelectedElements(
elements,
{ selectedElementIds: opts.selectedElementIds },
opts,
);
// cache only if we're not using custom elements
if (opts?.elements == null) {
this.selectedElementsCache.selectedElementIds = opts.selectedElementIds;
this.selectedElementsCache.elements = this.nonDeletedElements;
this.selectedElementsCache.cache.set(hash, selectedElements);
}
return selectedElements;
}
getNonDeletedFrames(): readonly NonDeleted<ExcalidrawFrameElement>[] {
return this.nonDeletedFrames;
}
@ -168,11 +251,21 @@ class Scene {
}
destroy() {
this.nonDeletedElements = [];
this.elements = [];
this.nonDeletedFrames = [];
this.frames = [];
this.elementsMap.clear();
this.selectedElementsCache.selectedElementIds = null;
this.selectedElementsCache.elements = null;
this.selectedElementsCache.cache.clear();
Scene.sceneMapById.forEach((scene, elementKey) => {
if (scene === this) {
Scene.sceneMapById.delete(elementKey);
}
});
// done not for memory leaks, but to guard against possible late fires
// (I guess?)
this.callbacks.clear();

View file

@ -1,7 +1,12 @@
import { NonDeletedExcalidrawElement } from "../element/types";
import { isEmbeddableElement } from "../element/typeChecks";
import {
ExcalidrawEmbeddableElement,
NonDeletedExcalidrawElement,
} from "../element/types";
export const hasBackground = (type: string) =>
type === "rectangle" ||
type === "embeddable" ||
type === "ellipse" ||
type === "diamond" ||
type === "line" ||
@ -12,6 +17,7 @@ export const hasStrokeColor = (type: string) =>
export const hasStrokeWidth = (type: string) =>
type === "rectangle" ||
type === "embeddable" ||
type === "ellipse" ||
type === "diamond" ||
type === "freedraw" ||
@ -20,6 +26,7 @@ export const hasStrokeWidth = (type: string) =>
export const hasStrokeStyle = (type: string) =>
type === "rectangle" ||
type === "embeddable" ||
type === "ellipse" ||
type === "diamond" ||
type === "arrow" ||
@ -27,6 +34,7 @@ export const hasStrokeStyle = (type: string) =>
export const canChangeRoundness = (type: string) =>
type === "rectangle" ||
type === "embeddable" ||
type === "arrow" ||
type === "line" ||
type === "diamond";
@ -61,9 +69,21 @@ export const getElementsAtPosition = (
elements: readonly NonDeletedExcalidrawElement[],
isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,
) => {
const embeddables: ExcalidrawEmbeddableElement[] = [];
// The parameter elements comes ordered from lower z-index to higher.
// We want to preserve that order on the returned array.
return elements.filter(
(element) => !element.isDeleted && isAtPositionFn(element),
);
// Exception being embeddables which should be on top of everything else in
// terms of hit testing.
const elsAtPos = elements.filter((element) => {
const hit = !element.isDeleted && isAtPositionFn(element);
if (hit) {
if (isEmbeddableElement(element)) {
embeddables.push(element);
return false;
}
return true;
}
return false;
});
return elsAtPos.concat(embeddables);
};

View file

@ -96,6 +96,7 @@ export const exportToSvg = async (
files: BinaryFiles | null,
opts?: {
serializeAsJSON?: () => string;
renderEmbeddables?: boolean;
},
): Promise<SVGSVGElement> => {
const {
@ -132,12 +133,13 @@ export const exportToSvg = async (
}
let assetPath = "https://excalidraw.com/";
// Asset path needs to be determined only when using package
if (process.env.IS_EXCALIDRAW_NPM_PACKAGE) {
if (import.meta.env.VITE_IS_EXCALIDRAW_NPM_PACKAGE) {
assetPath =
window.EXCALIDRAW_ASSET_PATH ||
`https://unpkg.com/${process.env.PKG_NAME}@${process.env.PKG_VERSION}`;
`https://unpkg.com/${import.meta.env.VITE_PKG_NAME}@${
import.meta.env.PKG_VERSION
}`;
if (assetPath?.startsWith("/")) {
assetPath = assetPath.replace("/", `${window.location.origin}/`);
@ -212,6 +214,7 @@ export const exportToSvg = async (
offsetY,
exportWithDarkMode: appState.exportWithDarkMode,
exportingFrameId: exportingFrame?.id || null,
renderEmbeddables: opts?.renderEmbeddables,
});
return svgRoot;