mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-09-30 04:40:03 +02:00
Compare commits
5 Commits
frame-grou
...
dwelle/bum
Author | SHA1 | Date | |
---|---|---|---|
![]() |
72bc871b47 | ||
![]() |
9c425224c7 | ||
![]() |
9d1d45a8ea | ||
![]() |
029c3c48ba | ||
![]() |
adfd95be33 |
@@ -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"
|
||||||
|
@@ -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]);
|
||||||
|
@@ -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 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@@ -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 && (
|
||||||
|
@@ -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>
|
||||||
|
@@ -222,6 +222,9 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
|||||||
toggleTheme: null,
|
toggleTheme: null,
|
||||||
saveAsImage: true,
|
saveAsImage: true,
|
||||||
},
|
},
|
||||||
|
tools: {
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// breakpoints
|
// breakpoints
|
||||||
|
@@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@@ -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"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
134
src/frame.ts
134
src/frame.ts
@@ -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;
|
||||||
};
|
};
|
||||||
|
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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.",
|
||||||
|
@@ -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
|
||||||
|
@@ -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}
|
||||||
|
@@ -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) {
|
||||||
|
@@ -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");
|
||||||
|
@@ -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",
|
||||||
|
33
src/packages/excalidraw/webpack.preact.config.js
Normal file
33
src/packages/excalidraw/webpack.preact.config.js
Normal 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);
|
@@ -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),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -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;
|
||||||
}>;
|
}>;
|
||||||
|
Reference in New Issue
Block a user