Compare commits

..

5 Commits

Author SHA1 Message Date
dwelle
72bc871b47 chore: bump caniuse-lite 2023-11-14 12:10:10 +01:00
David Luzar
9c425224c7 feat: support disabling image tool (#6320)
Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2023-11-14 10:25:41 +01:00
Aakansha Doshi
9d1d45a8ea chore: update changelog (#7279)
* chore: update changelog

* fix

* Update CHANGELOG.md
2023-11-14 13:11:05 +05:30
David Luzar
029c3c48ba fix: image insertion bugs (#7278) 2023-11-13 15:34:59 +01:00
Aakansha Doshi
adfd95be33 build: support preact 🥳 (#7255)
* build: support preact

* add log

* Simplify the config and generate prod and dev builds for preact

* update changelog

* remove logs

* use env variable so its available during build time

* update cl

* fix
2023-11-13 16:18:36 +05:30
20 changed files with 395 additions and 227 deletions

View File

@@ -2888,9 +2888,9 @@ caniuse-api@^3.0.0:
lodash.uniq "^4.5.0" lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001335, caniuse-lite@^1.0.30001366: caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001335, caniuse-lite@^1.0.30001366:
version "1.0.30001370" version "1.0.30001562"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001370.tgz#0a30d4f20d38b9e108cc5ae7cc62df9fe66cd5ba" resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001562.tgz"
integrity sha512-3PDmaP56wz/qz7G508xzjx8C+MC2qEm4SYhSEzC9IBROo+dGXFWRuaXkWti0A9tuI00g+toiriVqxtWMgl350g== integrity sha512-kfte3Hym//51EdX4239i+Rmp20EsLIYGdPkERegTgU19hQWCRhsRFGKHTliUlsry53tv17K7n077Kqa0WJU4ng==
ccount@^1.0.0: ccount@^1.0.0:
version "1.1.0" version "1.1.0"

View File

@@ -13,7 +13,7 @@ import {
hasStrokeWidth, hasStrokeWidth,
} from "../scene"; } from "../scene";
import { SHAPES } from "../shapes"; import { SHAPES } from "../shapes";
import { AppClassProperties, UIAppState, Zoom } from "../types"; import { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
import { capitalizeString, isTransparent } from "../utils"; import { capitalizeString, isTransparent } from "../utils";
import Stack from "./Stack"; import Stack from "./Stack";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
@@ -218,10 +218,12 @@ export const ShapesSwitcher = ({
activeTool, activeTool,
appState, appState,
app, app,
UIOptions,
}: { }: {
activeTool: UIAppState["activeTool"]; activeTool: UIAppState["activeTool"];
appState: UIAppState; appState: UIAppState;
app: AppClassProperties; app: AppClassProperties;
UIOptions: AppProps["UIOptions"];
}) => { }) => {
const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false); const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
@@ -232,6 +234,14 @@ export const ShapesSwitcher = ({
return ( return (
<> <>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => { {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
if (
UIOptions.tools?.[
value as Extract<typeof value, keyof AppProps["UIOptions"]["tools"]>
] === false
) {
return null;
}
const label = t(`toolBar.${value}`); const label = t(`toolBar.${value}`);
const letter = const letter =
key && capitalizeString(typeof key === "string" ? key : key[0]); key && capitalizeString(typeof key === "string" ? key : key[0]);

View File

@@ -341,6 +341,7 @@ import {
import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas"; import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
import { jotaiStore } from "../jotai"; import { jotaiStore } from "../jotai";
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog"; import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import { ImageSceneDataError } from "../errors";
import { import {
getSnapLinesAtPointer, getSnapLinesAtPointer,
snapDraggedElements, snapDraggedElements,
@@ -2272,6 +2273,11 @@ class App extends React.Component<AppProps, AppState> {
// prefer spreadsheet data over image file (MS Office/Libre Office) // prefer spreadsheet data over image file (MS Office/Libre Office)
if (isSupportedImageFile(file) && !data.spreadsheet) { if (isSupportedImageFile(file) && !data.spreadsheet) {
if (!this.isToolSupported("image")) {
this.setState({ errorMessage: t("errors.imageToolNotSupported") });
return;
}
const imageElement = this.createImageElement({ sceneX, sceneY }); const imageElement = this.createImageElement({ sceneX, sceneY });
this.insertImageElement(imageElement, file); this.insertImageElement(imageElement, file);
this.initializeImageDimensions(imageElement); this.initializeImageDimensions(imageElement);
@@ -2477,7 +2483,8 @@ class App extends React.Component<AppProps, AppState> {
) { ) {
if ( if (
!isPlainPaste && !isPlainPaste &&
mixedContent.some((node) => node.type === "imageUrl") mixedContent.some((node) => node.type === "imageUrl") &&
this.isToolSupported("image")
) { ) {
const imageURLs = mixedContent const imageURLs = mixedContent
.filter((node) => node.type === "imageUrl") .filter((node) => node.type === "imageUrl")
@@ -3284,6 +3291,16 @@ class App extends React.Component<AppProps, AppState> {
} }
}); });
// We purposely widen the `tool` type so this helper can be called with
// any tool without having to type check it
private isToolSupported = <T extends ToolType | "custom">(tool: T) => {
return (
this.props.UIOptions.tools?.[
tool as Extract<T, keyof AppProps["UIOptions"]["tools"]>
] !== false
);
};
setActiveTool = ( setActiveTool = (
tool: ( tool: (
| ( | (
@@ -3296,6 +3313,13 @@ class App extends React.Component<AppProps, AppState> {
| { type: "custom"; customType: string } | { type: "custom"; customType: string }
) & { locked?: boolean }, ) & { locked?: boolean },
) => { ) => {
if (!this.isToolSupported(tool.type)) {
console.warn(
`"${tool.type}" tool is disabled via "UIOptions.canvasActions.tools.${tool.type}"`,
);
return;
}
const nextActiveTool = updateActiveTool(this.state, tool); const nextActiveTool = updateActiveTool(this.state, tool);
if (nextActiveTool.type === "hand") { if (nextActiveTool.type === "hand") {
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB); setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
@@ -4740,9 +4764,13 @@ class App extends React.Component<AppProps, AppState> {
}); });
const { x, y } = viewportCoordsToSceneCoords(event, this.state); const { x, y } = viewportCoordsToSceneCoords(event, this.state);
const frame = this.getTopLayerFrameAtSceneCoords({ x, y });
mutateElement(pendingImageElement, { mutateElement(pendingImageElement, {
x, x,
y, y,
frameId: frame ? frame.id : null,
}); });
} else if (this.state.activeTool.type === "freedraw") { } else if (this.state.activeTool.type === "freedraw") {
this.handleFreeDrawElementOnPointerDown( this.handleFreeDrawElementOnPointerDown(
@@ -5609,9 +5637,11 @@ class App extends React.Component<AppProps, AppState> {
private createImageElement = ({ private createImageElement = ({
sceneX, sceneX,
sceneY, sceneY,
addToFrameUnderCursor = true,
}: { }: {
sceneX: number; sceneX: number;
sceneY: number; sceneY: number;
addToFrameUnderCursor?: boolean;
}) => { }) => {
const [gridX, gridY] = getGridPoint( const [gridX, gridY] = getGridPoint(
sceneX, sceneX,
@@ -5621,10 +5651,12 @@ class App extends React.Component<AppProps, AppState> {
: this.state.gridSize, : this.state.gridSize,
); );
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({ const topLayerFrame = addToFrameUnderCursor
x: gridX, ? this.getTopLayerFrameAtSceneCoords({
y: gridY, x: gridX,
}); y: gridY,
})
: null;
const element = newImageElement({ const element = newImageElement({
type: "image", type: "image",
@@ -6868,13 +6900,10 @@ class App extends React.Component<AppProps, AppState> {
topLayerFrame && topLayerFrame &&
!this.state.selectedElementIds[topLayerFrame.id] !this.state.selectedElementIds[topLayerFrame.id]
) { ) {
const processedGroupIds = new Map<string, boolean>();
const elementsToAdd = selectedElements.filter( const elementsToAdd = selectedElements.filter(
(element) => (element) =>
element.frameId !== topLayerFrame.id && element.frameId !== topLayerFrame.id &&
isElementInFrame(element, nextElements, this.state, { isElementInFrame(element, nextElements, this.state),
processedGroupIds,
}),
); );
if (this.state.editingGroupId) { if (this.state.editingGroupId) {
@@ -7474,6 +7503,13 @@ class App extends React.Component<AppProps, AppState> {
imageFile: File, imageFile: File,
showCursorImagePreview?: boolean, showCursorImagePreview?: boolean,
) => { ) => {
// we should be handling all cases upstream, but in case we forget to handle
// a future case, let's throw here
if (!this.isToolSupported("image")) {
this.setState({ errorMessage: t("errors.imageToolNotSupported") });
return;
}
this.scene.addNewElement(imageElement); this.scene.addNewElement(imageElement);
try { try {
@@ -7557,6 +7593,7 @@ class App extends React.Component<AppProps, AppState> {
const imageElement = this.createImageElement({ const imageElement = this.createImageElement({
sceneX: x, sceneX: x,
sceneY: y, sceneY: y,
addToFrameUnderCursor: false,
}); });
if (insertOnCanvasDirectly) { if (insertOnCanvasDirectly) {
@@ -7857,7 +7894,10 @@ class App extends React.Component<AppProps, AppState> {
); );
try { try {
if (isSupportedImageFile(file)) { // if image tool not supported, don't show an error here and let it fall
// through so we still support importing scene data from images. If no
// scene data encoded, we'll show an error then
if (isSupportedImageFile(file) && this.isToolSupported("image")) {
// first attempt to decode scene from the image if it's embedded // first attempt to decode scene from the image if it's embedded
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@@ -7985,6 +8025,17 @@ class App extends React.Component<AppProps, AppState> {
}); });
} }
} catch (error: any) { } catch (error: any) {
if (
error instanceof ImageSceneDataError &&
error.code === "IMAGE_NOT_CONTAINS_SCENE_DATA" &&
!this.isToolSupported("image")
) {
this.setState({
isLoading: false,
errorMessage: t("errors.imageToolNotSupported"),
});
return;
}
this.setState({ isLoading: false, errorMessage: error.message }); this.setState({ isLoading: false, errorMessage: error.message });
} }
}; };

View File

@@ -280,6 +280,7 @@ const LayerUI = ({
<ShapesSwitcher <ShapesSwitcher
appState={appState} appState={appState}
activeTool={appState.activeTool} activeTool={appState.activeTool}
UIOptions={UIOptions}
app={app} app={app}
/> />
</Stack.Row> </Stack.Row>
@@ -470,6 +471,7 @@ const LayerUI = ({
renderSidebars={renderSidebars} renderSidebars={renderSidebars}
device={device} device={device}
renderWelcomeScreen={renderWelcomeScreen} renderWelcomeScreen={renderWelcomeScreen}
UIOptions={UIOptions}
/> />
)} )}
{!device.editor.isMobile && ( {!device.editor.isMobile && (

View File

@@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import { import {
AppClassProperties, AppClassProperties,
AppProps,
AppState, AppState,
Device, Device,
ExcalidrawProps, ExcalidrawProps,
@@ -45,6 +46,7 @@ type MobileMenuProps = {
renderSidebars: () => JSX.Element | null; renderSidebars: () => JSX.Element | null;
device: Device; device: Device;
renderWelcomeScreen: boolean; renderWelcomeScreen: boolean;
UIOptions: AppProps["UIOptions"];
app: AppClassProperties; app: AppClassProperties;
}; };
@@ -62,6 +64,7 @@ export const MobileMenu = ({
renderSidebars, renderSidebars,
device, device,
renderWelcomeScreen, renderWelcomeScreen,
UIOptions,
app, app,
}: MobileMenuProps) => { }: MobileMenuProps) => {
const { const {
@@ -83,6 +86,7 @@ export const MobileMenu = ({
<ShapesSwitcher <ShapesSwitcher
appState={appState} appState={appState}
activeTool={appState.activeTool} activeTool={appState.activeTool}
UIOptions={UIOptions}
app={app} app={app}
/> />
</Stack.Row> </Stack.Row>

View File

@@ -222,6 +222,9 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
toggleTheme: null, toggleTheme: null,
saveAsImage: true, saveAsImage: true,
}, },
tools: {
image: true,
},
}; };
// breakpoints // breakpoints

View File

@@ -99,7 +99,7 @@ export const setCursorForShape = (
interactiveCanvas.style.cursor = `url(${url}), auto`; interactiveCanvas.style.cursor = `url(${url}), auto`;
} else if (!["image", "custom"].includes(appState.activeTool.type)) { } else if (!["image", "custom"].includes(appState.activeTool.type)) {
interactiveCanvas.style.cursor = CURSOR_TYPE.CROSSHAIR; interactiveCanvas.style.cursor = CURSOR_TYPE.CROSSHAIR;
} else { } else if (appState.activeTool.type !== "image") {
interactiveCanvas.style.cursor = CURSOR_TYPE.AUTO; interactiveCanvas.style.cursor = CURSOR_TYPE.AUTO;
} }
}; };

View File

@@ -3,7 +3,7 @@ import { cleanAppStateForExport } from "../appState";
import { IMAGE_MIME_TYPES, MIME_TYPES } from "../constants"; import { IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
import { clearElementsForExport } from "../element"; import { clearElementsForExport } from "../element";
import { ExcalidrawElement, FileId } from "../element/types"; import { ExcalidrawElement, FileId } from "../element/types";
import { CanvasError } from "../errors"; import { CanvasError, ImageSceneDataError } from "../errors";
import { t } from "../i18n"; import { t } from "../i18n";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter } from "../scene";
import { AppState, DataURL, LibraryItem } from "../types"; import { AppState, DataURL, LibraryItem } from "../types";
@@ -24,15 +24,12 @@ const parseFileContents = async (blob: Blob | File) => {
).decodePngMetadata(blob); ).decodePngMetadata(blob);
} catch (error: any) { } catch (error: any) {
if (error.message === "INVALID") { if (error.message === "INVALID") {
throw new DOMException( throw new ImageSceneDataError(
t("alerts.imageDoesNotContainScene"), t("alerts.imageDoesNotContainScene"),
"EncodingError", "IMAGE_NOT_CONTAINS_SCENE_DATA",
); );
} else { } else {
throw new DOMException( throw new ImageSceneDataError(t("alerts.cannotRestoreFromImage"));
t("alerts.cannotRestoreFromImage"),
"EncodingError",
);
} }
} }
} else { } else {
@@ -58,15 +55,12 @@ const parseFileContents = async (blob: Blob | File) => {
}); });
} catch (error: any) { } catch (error: any) {
if (error.message === "INVALID") { if (error.message === "INVALID") {
throw new DOMException( throw new ImageSceneDataError(
t("alerts.imageDoesNotContainScene"), t("alerts.imageDoesNotContainScene"),
"EncodingError", "IMAGE_NOT_CONTAINS_SCENE_DATA",
); );
} else { } else {
throw new DOMException( throw new ImageSceneDataError(t("alerts.cannotRestoreFromImage"));
t("alerts.cannotRestoreFromImage"),
"EncodingError",
);
} }
} }
} }
@@ -131,8 +125,19 @@ export const loadSceneOrLibraryFromBlob = async (
fileHandle?: FileSystemHandle | null, fileHandle?: FileSystemHandle | null,
) => { ) => {
const contents = await parseFileContents(blob); const contents = await parseFileContents(blob);
let data;
try { try {
const data = JSON.parse(contents); try {
data = JSON.parse(contents);
} catch (error: any) {
if (isSupportedImageFile(blob)) {
throw new ImageSceneDataError(
t("alerts.imageDoesNotContainScene"),
"IMAGE_NOT_CONTAINS_SCENE_DATA",
);
}
throw error;
}
if (isValidExcalidrawData(data)) { if (isValidExcalidrawData(data)) {
return { return {
type: MIME_TYPES.excalidraw, type: MIME_TYPES.excalidraw,
@@ -162,7 +167,9 @@ export const loadSceneOrLibraryFromBlob = async (
} }
throw new Error(t("alerts.couldNotLoadInvalidFile")); throw new Error(t("alerts.couldNotLoadInvalidFile"));
} catch (error: any) { } catch (error: any) {
console.error(error.message); if (error instanceof ImageSceneDataError) {
throw error;
}
throw new Error(t("alerts.couldNotLoadInvalidFile")); throw new Error(t("alerts.couldNotLoadInvalidFile"));
} }
}; };

View File

@@ -16,3 +16,19 @@ export class AbortError extends DOMException {
super(message, "AbortError"); super(message, "AbortError");
} }
} }
type ImageSceneDataErrorCode =
| "IMAGE_NOT_CONTAINS_SCENE_DATA"
| "IMAGE_SCENE_DATA_ERROR";
export class ImageSceneDataError extends Error {
public code;
constructor(
message = "Image Scene Data Error",
code: ImageSceneDataErrorCode = "IMAGE_SCENE_DATA_ERROR",
) {
super(message);
this.name = "EncodingError";
this.code = code;
}
}

View File

@@ -1,4 +1,8 @@
import { getCommonBounds, getElementBounds, isTextElement } from "./element"; import {
getCommonBounds,
getElementAbsoluteCoords,
isTextElement,
} from "./element";
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFrameElement, ExcalidrawFrameElement,
@@ -52,7 +56,6 @@ export const bindElementsToFramesAfterDuplication = (
} }
}; };
// --------------------------- Frame Geometry ---------------------------------
export function isElementIntersectingFrame( export function isElementIntersectingFrame(
element: ExcalidrawElement, element: ExcalidrawElement,
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameElement,
@@ -82,27 +85,36 @@ export const getElementsCompletelyInFrame = (
element.frameId === frame.id, element.frameId === frame.id,
); );
export const isElementContainingFrame = (
elements: readonly ExcalidrawElement[],
element: ExcalidrawElement,
frame: ExcalidrawFrameElement,
) => {
return getElementsWithinSelection(elements, element).some(
(e) => e.id === frame.id,
);
};
export const getElementsIntersectingFrame = ( export const getElementsIntersectingFrame = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameElement,
) => elements.filter((element) => isElementIntersectingFrame(element, frame)); ) => elements.filter((element) => isElementIntersectingFrame(element, frame));
export const elementsAreInBounds = ( export const elementsAreInFrameBounds = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
element: ExcalidrawElement, frame: ExcalidrawFrameElement,
tolerance = 0,
) => { ) => {
const [elementX1, elementY1, elementX2, elementY2] = const [selectionX1, selectionY1, selectionX2, selectionY2] =
getElementBounds(element); getElementAbsoluteCoords(frame);
const [elementsX1, elementsY1, elementsX2, elementsY2] = const [elementX1, elementY1, elementX2, elementY2] =
getCommonBounds(elements); getCommonBounds(elements);
return ( return (
elementX1 <= elementsX1 - tolerance && selectionX1 <= elementX1 &&
elementY1 <= elementsY1 - tolerance && selectionY1 <= elementY1 &&
elementX2 >= elementsX2 + tolerance && selectionX2 >= elementX2 &&
elementY2 >= elementsY2 + tolerance selectionY2 >= elementY2
); );
}; };
@@ -111,12 +123,9 @@ export const elementOverlapsWithFrame = (
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameElement,
) => { ) => {
return ( return (
// frame contains element elementsAreInFrameBounds([element], frame) ||
elementsAreInBounds([element], frame) || isElementIntersectingFrame(element, frame) ||
// element contains frame isElementContainingFrame([frame], element, frame)
(elementsAreInBounds([frame], element) && element.frameId === frame.id) ||
// element intersects with frame
isElementIntersectingFrame(element, frame)
); );
}; };
@@ -127,7 +136,7 @@ export const isCursorInFrame = (
}, },
frame: NonDeleted<ExcalidrawFrameElement>, frame: NonDeleted<ExcalidrawFrameElement>,
) => { ) => {
const [fx1, fy1, fx2, fy2] = getElementBounds(frame); const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame);
return isPointWithinBounds( return isPointWithinBounds(
[fx1, fy1], [fx1, fy1],
@@ -151,7 +160,7 @@ export const groupsAreAtLeastIntersectingTheFrame = (
return !!elementsInGroup.find( return !!elementsInGroup.find(
(element) => (element) =>
elementsAreInBounds([element], frame) || elementsAreInFrameBounds([element], frame) ||
isElementIntersectingFrame(element, frame), isElementIntersectingFrame(element, frame),
); );
}; };
@@ -172,7 +181,7 @@ export const groupsAreCompletelyOutOfFrame = (
return ( return (
elementsInGroup.find( elementsInGroup.find(
(element) => (element) =>
elementsAreInBounds([element], frame) || elementsAreInFrameBounds([element], frame) ||
isElementIntersectingFrame(element, frame), isElementIntersectingFrame(element, frame),
) === undefined ) === undefined
); );
@@ -240,18 +249,12 @@ export const getElementsInResizingFrame = (
const prevElementsInFrame = getFrameChildren(allElements, frame.id); const prevElementsInFrame = getFrameChildren(allElements, frame.id);
const nextElementsInFrame = new Set<ExcalidrawElement>(prevElementsInFrame); const nextElementsInFrame = new Set<ExcalidrawElement>(prevElementsInFrame);
const elementsCompletelyInFrame = new Set<ExcalidrawElement>( const elementsCompletelyInFrame = new Set([
getElementsCompletelyInFrame(allElements, frame), ...getElementsCompletelyInFrame(allElements, frame),
); ...prevElementsInFrame.filter((element) =>
isElementContainingFrame(allElements, element, frame),
for (const element of prevElementsInFrame) { ),
if (!elementsCompletelyInFrame.has(element)) { ]);
// element contains the frame
if (elementsAreInBounds([frame], element)) {
elementsCompletelyInFrame.add(element);
}
}
}
const elementsNotCompletelyInFrame = prevElementsInFrame.filter( const elementsNotCompletelyInFrame = prevElementsInFrame.filter(
(element) => !elementsCompletelyInFrame.has(element), (element) => !elementsCompletelyInFrame.has(element),
@@ -318,7 +321,7 @@ export const getElementsInResizingFrame = (
if (isSelected) { if (isSelected) {
const elementsInGroup = getElementsInGroup(allElements, id); const elementsInGroup = getElementsInGroup(allElements, id);
if (elementsAreInBounds(elementsInGroup, frame)) { if (elementsAreInFrameBounds(elementsInGroup, frame)) {
for (const element of elementsInGroup) { for (const element of elementsInGroup) {
nextElementsInFrame.add(element); nextElementsInFrame.add(element);
} }
@@ -367,7 +370,7 @@ export const getContainingFrame = (
// --------------------------- Frame Operations ------------------------------- // --------------------------- Frame Operations -------------------------------
/** /**
* Retains (or repairs for target frame) the ordering invariant where children * Retains (or repairs for target frame) the ordering invriant where children
* elements come right before the parent frame: * elements come right before the parent frame:
* [el, el, child, child, frame, el] * [el, el, child, child, frame, el]
*/ */
@@ -434,14 +437,25 @@ export const removeElementsFromFrame = (
ExcalidrawElement ExcalidrawElement
>(); >();
const toRemoveElementsByFrame = new Map<
ExcalidrawFrameElement["id"],
ExcalidrawElement[]
>();
for (const element of elementsToRemove) { for (const element of elementsToRemove) {
if (element.frameId) { if (element.frameId) {
_elementsToRemove.set(element.id, element); _elementsToRemove.set(element.id, element);
const arr = toRemoveElementsByFrame.get(element.frameId) || [];
arr.push(element);
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
if (boundTextElement) { if (boundTextElement) {
_elementsToRemove.set(boundTextElement.id, boundTextElement); _elementsToRemove.set(boundTextElement.id, boundTextElement);
arr.push(boundTextElement);
} }
toRemoveElementsByFrame.set(element.frameId, arr);
} }
} }
@@ -506,15 +520,12 @@ export const updateFrameMembershipOfSelectedElements = (
} }
const elementsToRemove = new Set<ExcalidrawElement>(); const elementsToRemove = new Set<ExcalidrawElement>();
const processedGroupIds = new Map<string, boolean>();
elementsToFilter.forEach((element) => { elementsToFilter.forEach((element) => {
if ( if (
element.frameId && element.frameId &&
!isFrameElement(element) && !isFrameElement(element) &&
!isElementInFrame(element, allElements, appState, { !isElementInFrame(element, allElements, appState)
processedGroupIds,
})
) { ) {
elementsToRemove.add(element); elementsToRemove.add(element);
} }
@@ -576,36 +587,26 @@ export const getTargetFrame = (
: getContainingFrame(_element); : getContainingFrame(_element);
}; };
// TODO: this a huge bottleneck for large scenes, optimise
// given an element, return if the element is in some frame // given an element, return if the element is in some frame
export const isElementInFrame = ( export const isElementInFrame = (
element: ExcalidrawElement, element: ExcalidrawElement,
allElements: ExcalidrawElementsIncludingDeleted, allElements: ExcalidrawElementsIncludingDeleted,
appState: StaticCanvasAppState, appState: StaticCanvasAppState,
opts?: {
targetFrame?: ExcalidrawFrameElement;
processedGroupIds?: Map<string, boolean>;
},
) => { ) => {
const frame = opts?.targetFrame ?? getTargetFrame(element, appState); const frame = getTargetFrame(element, appState);
const _element = isTextElement(element) const _element = isTextElement(element)
? getContainerElement(element) || element ? getContainerElement(element) || element
: element; : element;
const groupsInFrame = (yes: boolean) => {
if (opts?.processedGroupIds) {
_element.groupIds.forEach((gid) => {
opts.processedGroupIds?.set(gid, yes);
});
}
};
if (frame) { if (frame) {
// Perf improvement: // Perf improvement:
// For an element that's already in a frame, if it's not being selected // For an element that's already in a frame, if it's not being dragged
// and its frame is not being selected, it has to be in its containing frame. // then there is no need to refer to geometry (which, yes, is slow) to check if it's in a frame.
// It has to be in its containing frame.
if ( if (
!appState.selectedElementIds[element.id] && !appState.selectedElementIds[element.id] ||
!appState.selectedElementIds[frame.id] !appState.selectedElementsAreBeingDragged
) { ) {
return true; return true;
} }
@@ -614,21 +615,8 @@ export const isElementInFrame = (
return elementOverlapsWithFrame(_element, frame); return elementOverlapsWithFrame(_element, frame);
} }
for (const gid of _element.groupIds) {
if (opts?.processedGroupIds?.has(gid)) {
return opts.processedGroupIds.get(gid);
}
}
const allElementsInGroup = new Set( const allElementsInGroup = new Set(
_element.groupIds _element.groupIds.flatMap((gid) => getElementsInGroup(allElements, gid)),
.filter((gid) => {
if (opts?.processedGroupIds) {
return !opts.processedGroupIds.has(gid);
}
return true;
})
.flatMap((gid) => getElementsInGroup(allElements, gid)),
); );
if (appState.editingGroupId && appState.selectedElementsAreBeingDragged) { if (appState.editingGroupId && appState.selectedElementsAreBeingDragged) {
@@ -649,22 +637,16 @@ export const isElementInFrame = (
for (const elementInGroup of allElementsInGroup) { for (const elementInGroup of allElementsInGroup) {
if (isFrameElement(elementInGroup)) { if (isFrameElement(elementInGroup)) {
groupsInFrame(false);
return false; return false;
} }
} }
for (const elementInGroup of allElementsInGroup) { for (const elementInGroup of allElementsInGroup) {
if (elementOverlapsWithFrame(elementInGroup, frame)) { if (elementOverlapsWithFrame(elementInGroup, frame)) {
groupsInFrame(true);
return true; return true;
} }
} }
} }
if (_element.groupIds.length > 0) {
groupsInFrame(false);
}
return false; return false;
}; };

View File

@@ -232,8 +232,6 @@ export const selectGroupsFromGivenElements = (
selectedGroupIds: {}, selectedGroupIds: {},
}; };
const processedGroupIds = new Set<string>();
for (const element of elements) { for (const element of elements) {
let groupIds = element.groupIds; let groupIds = element.groupIds;
if (appState.editingGroupId) { if (appState.editingGroupId) {
@@ -244,13 +242,10 @@ export const selectGroupsFromGivenElements = (
} }
if (groupIds.length > 0) { if (groupIds.length > 0) {
const groupId = groupIds[groupIds.length - 1]; const groupId = groupIds[groupIds.length - 1];
if (!processedGroupIds.has(groupId)) { nextAppState = {
nextAppState = { ...nextAppState,
...nextAppState, ...selectGroup(groupId, nextAppState, elements),
...selectGroup(groupId, nextAppState, elements), };
};
processedGroupIds.add(groupId);
}
} }
} }

View File

@@ -209,6 +209,7 @@
"importLibraryError": "Couldn't load library", "importLibraryError": "Couldn't load library",
"collabSaveFailed": "Couldn't save to the backend database. If problems persist, you should save your file locally to ensure you don't lose your work.", "collabSaveFailed": "Couldn't save to the backend database. If problems persist, you should save your file locally to ensure you don't lose your work.",
"collabSaveFailed_sizeExceeded": "Couldn't save to the backend database, the canvas seems to be too big. You should save the file locally to ensure you don't lose your work.", "collabSaveFailed_sizeExceeded": "Couldn't save to the backend database, the canvas seems to be too big. You should save the file locally to ensure you don't lose your work.",
"imageToolNotSupported": "Images are disabled.",
"brave_measure_text_error": { "brave_measure_text_error": {
"line1": "Looks like you are using Brave browser with the <bold>Aggressively Block Fingerprinting</bold> setting enabled.", "line1": "Looks like you are using Brave browser with the <bold>Aggressively Block Fingerprinting</bold> setting enabled.",
"line2": "This could result in breaking the <bold>Text Elements</bold> in your drawings.", "line2": "This could result in breaking the <bold>Text Elements</bold> in your drawings.",

View File

@@ -15,28 +15,54 @@ Please add the latest change on the top under the correct section.
### Features ### Features
- Added support for disabling `image` tool (also disabling image insertion in general, though keeps support for importing from `.excalidraw` files) [#6320](https://github.com/excalidraw/excalidraw/pull/6320).
For disabling `image` you need to set 👇
```
UIOptions.tools = {
image: false
}
```
- Support `excalidrawAPI` prop for accessing the Excalidraw API [#7251](https://github.com/excalidraw/excalidraw/pull/7251). - Support `excalidrawAPI` prop for accessing the Excalidraw API [#7251](https://github.com/excalidraw/excalidraw/pull/7251).
#### BREAKING CHANGE
- The `Ref` support has been removed in v0.17.0 so if you are using refs, please update the integration to use the [`excalidrawAPI`](http://localhost:3003/docs/@excalidraw/excalidraw/api/props/excalidraw-api)
- Additionally `ready` and `readyPromise` from the API have been discontinued. These APIs were found to be superfluous, and as part of the effort to streamline the APIs and maintain simplicity, they were removed in version v0.17.0.
- Export [`getCommonBounds`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/utils#getcommonbounds) helper from the package [#7247](https://github.com/excalidraw/excalidraw/pull/7247). - Export [`getCommonBounds`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/utils#getcommonbounds) helper from the package [#7247](https://github.com/excalidraw/excalidraw/pull/7247).
- Support frames via programmatic API [#7205](https://github.com/excalidraw/excalidraw/pull/7205).
- Export `elementsOverlappingBBox`, `isElementInsideBBox`, `elementPartiallyOverlapsWithOrContainsBBox` helpers for filtering/checking if elements within bounds. [#6727](https://github.com/excalidraw/excalidraw/pull/6727) - Export `elementsOverlappingBBox`, `isElementInsideBBox`, `elementPartiallyOverlapsWithOrContainsBBox` helpers for filtering/checking if elements within bounds. [#6727](https://github.com/excalidraw/excalidraw/pull/6727)
- Regenerate ids by default when using transform api and also update bindings by 0.5px to avoid possible overlapping [#7195](https://github.com/excalidraw/excalidraw/pull/7195) - Regenerate ids by default when using transform api and also update bindings by 0.5px to avoid possible overlapping [#7195](https://github.com/excalidraw/excalidraw/pull/7195)
- Add onChange, onPointerDown, onPointerUp api subscribers [#7154](https://github.com/excalidraw/excalidraw/pull/7154).
- Support props.locked for setActiveTool [#7153](https://github.com/excalidraw/excalidraw/pull/7153).
- Add `selected` prop for `MainMenu.Item` and `MainMenu.ItemCustom` components to indicate active state. [#7078](https://github.com/excalidraw/excalidraw/pull/7078) - Add `selected` prop for `MainMenu.Item` and `MainMenu.ItemCustom` components to indicate active state. [#7078](https://github.com/excalidraw/excalidraw/pull/7078)
#### BREAKING CHANGES ### Fixes
- Double image dialog on image insertion [#7152](https://github.com/excalidraw/excalidraw/pull/7152).
### Breaking Changes
- The `Ref` support has been removed in v0.17.0 so if you are using refs, please update the integration to use the [`excalidrawAPI`](http://localhost:3003/docs/@excalidraw/excalidraw/api/props/excalidraw-api) [#7251](https://github.com/excalidraw/excalidraw/pull/7251).
- Additionally `ready` and `readyPromise` from the API have been discontinued. These APIs were found to be superfluous, and as part of the effort to streamline the APIs and maintain simplicity, they were removed in version v0.17.0 [#7251](https://github.com/excalidraw/excalidraw/pull/7251).
- [`useDevice`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/utils#usedevice) hook's return value was changed to differentiate between `editor` and `viewport` breakpoints. [#7243](https://github.com/excalidraw/excalidraw/pull/7243) - [`useDevice`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/utils#usedevice) hook's return value was changed to differentiate between `editor` and `viewport` breakpoints. [#7243](https://github.com/excalidraw/excalidraw/pull/7243)
### Build
- Support Preact [#7255](https://github.com/excalidraw/excalidraw/pull/7255). The host needs to set `process.env.IS_PREACT` to `true`
When using vite, you will have to make sure the variable process.env.IS_PREACT is available at runtime since Vite removes it by default, so you can update the vite config to ensure its available
```js
define: {
"process.env.IS_PREACT": process.env.IS_PREACT,
}
```
## 0.16.1 (2023-09-21) ## 0.16.1 (2023-09-21)
## Excalidraw Library ## Excalidraw Library

View File

@@ -98,6 +98,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
const [exportWithDarkMode, setExportWithDarkMode] = useState(false); const [exportWithDarkMode, setExportWithDarkMode] = useState(false);
const [exportEmbedScene, setExportEmbedScene] = useState(false); const [exportEmbedScene, setExportEmbedScene] = useState(false);
const [theme, setTheme] = useState<Theme>("light"); const [theme, setTheme] = useState<Theme>("light");
const [disableImageTool, setDisableImageTool] = useState(false);
const [isCollaborating, setIsCollaborating] = useState(false); const [isCollaborating, setIsCollaborating] = useState(false);
const [commentIcons, setCommentIcons] = useState<{ [id: string]: Comment }>( const [commentIcons, setCommentIcons] = useState<{ [id: string]: Comment }>(
{}, {},
@@ -606,6 +607,16 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
/> />
Switch to Dark Theme Switch to Dark Theme
</label> </label>
<label>
<input
type="checkbox"
checked={disableImageTool === true}
onChange={() => {
setDisableImageTool(!disableImageTool);
}}
/>
Disable Image Tool
</label>
<label> <label>
<input <input
type="checkbox" type="checkbox"
@@ -686,6 +697,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
canvasActions: { canvasActions: {
loadScene: false, loadScene: false,
}, },
tools: { image: !disableImageTool },
}} }}
renderTopRightUI={renderTopRightUI} renderTopRightUI={renderTopRightUI}
onLinkOpen={onLinkOpen} onLinkOpen={onLinkOpen}

View File

@@ -56,6 +56,9 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
...DEFAULT_UI_OPTIONS.canvasActions, ...DEFAULT_UI_OPTIONS.canvasActions,
...canvasActions, ...canvasActions,
}, },
tools: {
image: props.UIOptions?.tools?.image ?? true,
},
}; };
if (canvasActions?.export) { if (canvasActions?.export) {

View File

@@ -1,4 +1,10 @@
if (process.env.NODE_ENV === "production") { if (process.env.IS_PREACT === "true") {
if (process.env.NODE_ENV === "production") {
module.exports = require("./dist/excalidraw-with-preact.production.min.js");
} else {
module.exports = require("./dist/excalidraw-with-preact.development.js");
}
} else if (process.env.NODE_ENV === "production") {
module.exports = require("./dist/excalidraw.production.min.js"); module.exports = require("./dist/excalidraw.production.min.js");
} else { } else {
module.exports = require("./dist/excalidraw.development.js"); module.exports = require("./dist/excalidraw.development.js");

View File

@@ -78,7 +78,7 @@
"homepage": "https://github.com/excalidraw/excalidraw/tree/master/src/packages/excalidraw", "homepage": "https://github.com/excalidraw/excalidraw/tree/master/src/packages/excalidraw",
"scripts": { "scripts": {
"gen:types": "tsc --project ../../../tsconfig-types.json", "gen:types": "tsc --project ../../../tsconfig-types.json",
"build:umd": "rm -rf dist && cross-env NODE_ENV=production webpack --config webpack.prod.config.js && cross-env NODE_ENV=development webpack --config webpack.dev.config.js && yarn gen:types", "build:umd": "rm -rf dist && cross-env NODE_ENV=production webpack --config webpack.prod.config.js && cross-env NODE_ENV=development webpack --config webpack.dev.config.js && NODE_ENV=development webpack --config webpack.preact.config.js && NODE_ENV=production webpack --config webpack.preact.config.js && yarn gen:types",
"build:umd:withAnalyzer": "cross-env NODE_ENV=production ANALYZER=true webpack --config webpack.prod.config.js", "build:umd:withAnalyzer": "cross-env NODE_ENV=production ANALYZER=true webpack --config webpack.prod.config.js",
"pack": "yarn build:umd && yarn pack", "pack": "yarn build:umd && yarn pack",
"start": "webpack serve --config webpack.dev-server.config.js", "start": "webpack serve --config webpack.dev-server.config.js",

View File

@@ -0,0 +1,33 @@
const { merge } = require("webpack-merge");
const prodConfig = require("./webpack.prod.config");
const devConfig = require("./webpack.dev.config");
const isProd = process.env.NODE_ENV === "production";
const config = isProd ? prodConfig : devConfig;
const outputFile = isProd
? "excalidraw-with-preact.production.min"
: "excalidraw-with-preact.development";
const preactWebpackConfig = {
entry: {
[outputFile]: "./entry.js",
},
externals: {
...config.externals,
"react-dom/client": {
root: "ReactDOMClient",
commonjs2: "react-dom/client",
commonjs: "react-dom/client",
amd: "react-dom/client",
},
"react/jsx-runtime": {
root: "ReactJSXRuntime",
commonjs2: "react/jsx-runtime",
commonjs: "react/jsx-runtime",
amd: "react/jsx-runtime",
},
},
};
module.exports = merge(config, preactWebpackConfig);

View File

@@ -71,7 +71,6 @@ import { renderSnaps } from "./renderSnaps";
import { import {
isEmbeddableElement, isEmbeddableElement,
isFrameElement, isFrameElement,
isFreeDrawElement,
isLinearElement, isLinearElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
import { import {
@@ -79,7 +78,7 @@ import {
createPlaceholderEmbeddableLabel, createPlaceholderEmbeddableLabel,
} from "../element/embeddable"; } from "../element/embeddable";
import { import {
elementsAreInBounds, elementOverlapsWithFrame,
getTargetFrame, getTargetFrame,
isElementInFrame, isElementInFrame,
} from "../frame"; } from "../frame";
@@ -946,79 +945,102 @@ const _renderStaticScene = ({
); );
} }
// Paint visible elements with embeddables on top const groupsToBeAddedToFrame = new Set<string>();
const visibleNonEmbeddableOrLabelElements = visibleElements.filter(
(el) => !isEmbeddableOrLabel(el),
);
const visibleEmbeddableOrLabelElements = visibleElements.filter((el) =>
isEmbeddableOrLabel(el),
);
const visibleElementsToRender = [
...visibleNonEmbeddableOrLabelElements,
...visibleEmbeddableOrLabelElements,
];
const _renderElement = (element: ExcalidrawElement) => {
try {
renderElement(element, rc, context, renderConfig, appState);
if (
isEmbeddableElement(element) &&
(isExporting || !element.validated) &&
element.width &&
element.height
) {
const label = createPlaceholderEmbeddableLabel(element);
renderElement(label, rc, context, renderConfig, appState);
}
if (!isExporting) {
renderLinkIcon(element, context, appState);
}
} catch (error: any) {
console.error(error);
}
};
const processedGroupIds = new Map<string, boolean>();
for (const element of visibleElementsToRender) {
const frameId = element.frameId || appState.frameToHighlight?.id;
visibleElements.forEach((element) => {
if ( if (
frameId && element.groupIds.length > 0 &&
appState.frameRendering.enabled && appState.frameToHighlight &&
appState.frameRendering.clip appState.selectedElementIds[element.id] &&
(elementOverlapsWithFrame(element, appState.frameToHighlight) ||
element.groupIds.find((groupId) => groupsToBeAddedToFrame.has(groupId)))
) { ) {
const targetFrame = getTargetFrame(element, appState); element.groupIds.forEach((groupId) =>
// for perf: groupsToBeAddedToFrame.add(groupId),
// only clip elements that are not completely in the target frame );
if (
targetFrame &&
!elementsAreInBounds(
[element],
targetFrame,
isFreeDrawElement(element)
? element.strokeWidth * 8
: element.roughness * (isLinearElement(element) ? 8 : 4),
) &&
isElementInFrame(element, elements, appState, {
targetFrame,
processedGroupIds,
})
) {
context.save();
frameClip(targetFrame, context, renderConfig, appState);
_renderElement(element);
context.restore();
} else {
_renderElement(element);
}
} else {
_renderElement(element);
} }
} });
// Paint visible elements
visibleElements
.filter((el) => !isEmbeddableOrLabel(el))
.forEach((element) => {
try {
const frameId = element.frameId || appState.frameToHighlight?.id;
if (
frameId &&
appState.frameRendering.enabled &&
appState.frameRendering.clip
) {
context.save();
const frame = getTargetFrame(element, appState);
// TODO do we need to check isElementInFrame here?
if (frame && isElementInFrame(element, elements, appState)) {
frameClip(frame, context, renderConfig, appState);
}
renderElement(element, rc, context, renderConfig, appState);
context.restore();
} else {
renderElement(element, rc, context, renderConfig, appState);
}
if (!isExporting) {
renderLinkIcon(element, context, appState);
}
} catch (error: any) {
console.error(error);
}
});
// render embeddables on top
visibleElements
.filter((el) => isEmbeddableOrLabel(el))
.forEach((element) => {
try {
const render = () => {
renderElement(element, rc, context, renderConfig, appState);
if (
isEmbeddableElement(element) &&
(isExporting || !element.validated) &&
element.width &&
element.height
) {
const label = createPlaceholderEmbeddableLabel(element);
renderElement(label, rc, context, renderConfig, appState);
}
if (!isExporting) {
renderLinkIcon(element, context, appState);
}
};
// - when exporting the whole canvas, we DO NOT apply clipping
// - when we are exporting a particular frame, apply clipping
// if the containing frame is not selected, apply clipping
const frameId = element.frameId || appState.frameToHighlight?.id;
if (
frameId &&
appState.frameRendering.enabled &&
appState.frameRendering.clip
) {
context.save();
const frame = getTargetFrame(element, appState);
if (frame && isElementInFrame(element, elements, appState)) {
frameClip(frame, context, renderConfig, appState);
}
render();
context.restore();
} else {
render();
}
} catch (error: any) {
console.error(error);
}
});
}; };
/** throttled to animation framerate */ /** throttled to animation framerate */
@@ -1123,7 +1145,7 @@ const renderTransformHandles = (
const renderSelectionBorder = ( const renderSelectionBorder = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState | StaticCanvasAppState, appState: InteractiveCanvasAppState,
elementProperties: { elementProperties: {
angle: number; angle: number;
elementX1: number; elementX1: number;
@@ -1288,23 +1310,6 @@ const renderFrameHighlight = (
context.restore(); context.restore();
}; };
const getSelectionFromElements = (elements: ExcalidrawElement[]) => {
const [elementX1, elementY1, elementX2, elementY2] =
getCommonBounds(elements);
return {
angle: 0,
elementX1,
elementX2,
elementY1,
elementY2,
selectionColors: ["rgb(0,118,255)"],
dashed: false,
cx: elementX1 + (elementX2 - elementX1) / 2,
cy: elementY1 + (elementY2 - elementY1) / 2,
activeEmbeddable: false,
};
};
const renderElementsBoxHighlight = ( const renderElementsBoxHighlight = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState, appState: InteractiveCanvasAppState,
@@ -1318,28 +1323,37 @@ const renderElementsBoxHighlight = (
(element) => element.groupIds.length > 0, (element) => element.groupIds.length > 0,
); );
const processedGroupIds = new Set<string>(); const getSelectionFromElements = (elements: ExcalidrawElement[]) => {
const [elementX1, elementY1, elementX2, elementY2] =
getCommonBounds(elements);
return {
angle: 0,
elementX1,
elementX2,
elementY1,
elementY2,
selectionColors: ["rgb(0,118,255)"],
dashed: false,
cx: elementX1 + (elementX2 - elementX1) / 2,
cy: elementY1 + (elementY2 - elementY1) / 2,
activeEmbeddable: false,
};
};
const getSelectionForGroupId = (groupId: GroupId) => { const getSelectionForGroupId = (groupId: GroupId) => {
if (!processedGroupIds.has(groupId)) { const groupElements = getElementsInGroup(elements, groupId);
const groupElements = getElementsInGroup(elements, groupId); return getSelectionFromElements(groupElements);
processedGroupIds.add(groupId);
return getSelectionFromElements(groupElements);
}
return null;
}; };
Object.entries(selectGroupsFromGivenElements(elementsInGroups, appState)) Object.entries(selectGroupsFromGivenElements(elementsInGroups, appState))
.filter(([id, isSelected]) => isSelected) .filter(([id, isSelected]) => isSelected)
.map(([id, isSelected]) => id) .map(([id, isSelected]) => id)
.map((groupId) => getSelectionForGroupId(groupId)) .map((groupId) => getSelectionForGroupId(groupId))
.filter((selection) => selection)
.concat( .concat(
individualElements.map((element) => getSelectionFromElements([element])), individualElements.map((element) => getSelectionFromElements([element])),
) )
.forEach((selection) => .forEach((selection) =>
renderSelectionBorder(context, appState, selection!), renderSelectionBorder(context, appState, selection),
); );
}; };

View File

@@ -471,7 +471,7 @@ export type ExportOpts = {
// truthiness value will determine whether the action is rendered or not // truthiness value will determine whether the action is rendered or not
// (see manager renderAction). We also override canvasAction values in // (see manager renderAction). We also override canvasAction values in
// excalidraw package index.tsx. // excalidraw package index.tsx.
type CanvasActions = Partial<{ export type CanvasActions = Partial<{
changeViewBackgroundColor: boolean; changeViewBackgroundColor: boolean;
clearCanvas: boolean; clearCanvas: boolean;
export: false | ExportOpts; export: false | ExportOpts;
@@ -481,9 +481,12 @@ type CanvasActions = Partial<{
saveAsImage: boolean; saveAsImage: boolean;
}>; }>;
type UIOptions = Partial<{ export type UIOptions = Partial<{
dockedSidebarBreakpoint: number; dockedSidebarBreakpoint: number;
canvasActions: CanvasActions; canvasActions: CanvasActions;
tools: {
image: boolean;
};
/** @deprecated does nothing. Will be removed in 0.15 */ /** @deprecated does nothing. Will be removed in 0.15 */
welcomeScreen?: boolean; welcomeScreen?: boolean;
}>; }>;