feat: introducing Web-Embeds (alias iframe element) (#6691)

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
zsviczian 2023-07-24 16:51:53 +02:00 committed by GitHub
parent 744e5b2ab3
commit b57b3b573d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 1923 additions and 234 deletions

View File

@ -29,6 +29,8 @@ All `props` are *optional*.
| [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. |
| [`autoFocus`](#autofocus) | `boolean` | `false` | indicates whether to focus the Excalidraw component on page load |
| [`generateIdForFile`](#generateidforfile) | `function` | _ | Allows you to override `id` generation for files added on canvas |
| [`validateEmbeddable`](#validateEmbeddable) | string[] | `boolean | RegExp | RegExp[] | ((link: string) => boolean | undefined)` | \_ | use for custom src url validation |
| [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` |
### Storing custom data on Excalidraw elements
@ -215,7 +217,6 @@ Indicates whether to bind keyboard events to `document`. Disabled by default, me
Enable this if you want Excalidraw to handle keyboard even if the component isn't focused (e.g. a user is interacting with the navbar, sidebar, or similar).
### autoFocus
This prop indicates whether to `focus` the Excalidraw component on page load. Defaults to false.
@ -228,3 +229,12 @@ Allows you to override `id` generation for files added on canvas (images). By de
(file: File) => string | Promise<string>
```
### validateEmbeddable
```tsx
validateEmbeddable?: boolean | string[] | RegExp | RegExp[] | ((link: string) => boolean | undefined)
```
This is an optional property. By default we support a handful of well-known sites. You may allow additional sites or disallow the default ones by supplying a custom validator. If you pass `true`, all URLs will be allowed. You can also supply a list of hostnames, RegExp (or list of RegExp objects), or a function. If the function returns `undefined`, the built-in validator will be used.
Supplying a list of hostnames (with or without `www.`) is the preferred way to allow a specific list of domains.

View File

@ -121,3 +121,16 @@ function App() {
);
}
```
## renderEmbeddable
<pre>
(element: NonDeleted&lt;ExcalidrawEmbeddableElement&gt;, appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a>) => JSX.Element | null
</pre>
Allows you to replace the renderer for embeddable elements (which renders `<iframe>` elements).
| Parameter | Type | Description |
| --- | --- | --- |
| `element` | `NonDeleted<ExcalidrawEmbeddableElement>` | The embeddable element to be rendered. |
| `appState` | `AppState` | The current state of the UI. |

View File

@ -2,6 +2,7 @@ import { register } from "./register";
import { deepCopyElement } from "../element/newElement";
import { randomId } from "../random";
import { t } from "../i18n";
import { LIBRARY_DISABLED_TYPES } from "../constants";
export const actionAddToLibrary = register({
name: "addToLibrary",
@ -12,15 +13,18 @@ export const actionAddToLibrary = register({
includeBoundTextElement: true,
includeElementsInFrames: true,
});
if (selectedElements.some((element) => element.type === "image")) {
for (const type of LIBRARY_DISABLED_TYPES) {
if (selectedElements.some((element) => element.type === type)) {
return {
commitToHistory: false,
appState: {
...appState,
errorMessage: "Support for adding images to the library coming soon!",
errorMessage: t(`errors.libraryElementTypeError.${type}`),
},
};
}
}
return app.library
.getLatestLibrary()

View File

@ -396,6 +396,7 @@ export const actionToggleEraserTool = register({
...appState,
selectedElementIds: {},
selectedGroupIds: {},
activeEmbeddable: null,
activeTool,
},
commitToHistory: true,
@ -430,6 +431,7 @@ export const actionToggleHandTool = register({
...appState,
selectedElementIds: {},
selectedGroupIds: {},
activeEmbeddable: null,
activeTool,
},
commitToHistory: true,

View File

@ -158,6 +158,7 @@ export const actionDeleteSelected = register({
...nextAppState,
activeTool: updateActiveTool(appState, { type: "selection" }),
multiElement: null,
activeEmbeddable: null,
},
commitToHistory: isSomeElementSelected(
getNonDeletedElements(elements),

View File

@ -160,6 +160,7 @@ export const actionFinalize = register({
multiPointElement
? appState.activeTool
: activeTool,
activeEmbeddable: null,
draggingElement: null,
multiElement: null,
editingElement: null,

View File

@ -121,6 +121,7 @@ export type ActionName =
| "removeAllElementsFromFrame"
| "updateFrameRendering"
| "setFrameAsActiveTool"
| "setEmbeddableAsActiveTool"
| "createContainerFromText"
| "wrapTextInContainer";

View File

@ -38,6 +38,7 @@ export const getDefaultAppState = (): Omit<
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
cursorButton: "up",
activeEmbeddable: null,
draggingElement: null,
editingElement: null,
editingGroupId: null,
@ -139,6 +140,7 @@ const APP_STATE_STORAGE_CONF = (<
currentItemStrokeWidth: { browser: true, export: false, server: false },
currentItemTextAlign: { browser: true, export: false, server: false },
cursorButton: { browser: true, export: false, server: false },
activeEmbeddable: { browser: false, export: false, server: false },
draggingElement: { browser: false, export: false, server: false },
editingElement: { browser: false, export: false, server: false },
editingGroupId: { browser: true, export: false, server: false },

View File

@ -21,7 +21,7 @@ export type ColorPickerColor =
export type ColorTuple = readonly [string, string, string, string, string];
export type ColorPalette = Merge<
Record<ColorPickerColor, ColorTuple>,
{ black: string; white: string; transparent: string }
{ black: "#1e1e1e"; white: "#ffffff"; transparent: "transparent" }
>;
// used general type instead of specific type (ColorPalette) to support custom colors

View File

@ -36,7 +36,7 @@ import {
import "./Actions.scss";
import DropdownMenu from "./dropdownMenu/DropdownMenu";
import { extraToolsIcon, frameToolIcon } from "./icons";
import { EmbedIcon, extraToolsIcon, frameToolIcon } from "./icons";
import { KEYS } from "../keys";
export const SelectedShapeActions = ({
@ -266,6 +266,7 @@ export const ShapesSwitcher = ({
});
setAppState({
activeTool: nextActiveTool,
activeEmbeddable: null,
multiElement: null,
selectedElementIds: {},
});
@ -283,6 +284,7 @@ export const ShapesSwitcher = ({
<div className="App-toolbar__divider" />
{/* TEMP HACK because dropdown doesn't work well inside mobile toolbar */}
{device.isMobile ? (
<>
<ToolButton
className={clsx("Shape", { fillable: false })}
type="radio"
@ -313,9 +315,41 @@ export const ShapesSwitcher = ({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
activeEmbeddable: null,
});
}}
/>
<ToolButton
className={clsx("Shape", { fillable: false })}
type="radio"
icon={EmbedIcon}
checked={activeTool.type === "embeddable"}
name="editor-current-shape"
title={capitalizeString(t("toolBar.embeddable"))}
aria-label={capitalizeString(t("toolBar.embeddable"))}
data-testid={`toolbar-embeddable`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
setAppState({
penDetected: true,
penMode: true,
});
}
}}
onChange={({ pointerType }) => {
trackEvent("toolbar", "embeddable", "ui");
const nextActiveTool = updateActiveTool(appState, {
type: "embeddable",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
activeEmbeddable: null,
});
}}
/>
</>
) : (
<DropdownMenu open={isExtraToolsMenuOpen}>
<DropdownMenu.Trigger
@ -347,6 +381,22 @@ export const ShapesSwitcher = ({
>
{t("toolBar.frame")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => {
const nextActiveTool = updateActiveTool(appState, {
type: "embeddable",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
});
}}
icon={EmbedIcon}
data-testid="toolbar-embeddable"
>
{t("toolBar.embeddable")}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
)}

View File

@ -83,6 +83,7 @@ import {
THEME_FILTER,
TOUCH_CTX_MENU_TIMEOUT,
VERTICAL_ALIGN,
YOUTUBE_STATES,
ZOOM_STEP,
} from "../constants";
import { exportCanvas, loadFromBlob } from "../data";
@ -136,6 +137,7 @@ import {
duplicateElements,
newFrameElement,
newFreeDrawElement,
newEmbeddableElement,
} from "../element/newElement";
import {
hasBoundTextElement,
@ -145,6 +147,7 @@ import {
isBoundToContainer,
isFrameElement,
isImageElement,
isEmbeddableElement,
isInitializedImageElement,
isLinearElement,
isLinearElementType,
@ -164,6 +167,7 @@ import {
NonDeletedExcalidrawElement,
ExcalidrawTextContainer,
ExcalidrawFrameElement,
ExcalidrawEmbeddableElement,
} from "../element/types";
import { getCenter, getDistance } from "../gesture";
import {
@ -185,7 +189,13 @@ import {
isArrowKey,
KEYS,
} from "../keys";
import { distance2d, getGridPoint, isPathALoop } from "../math";
import {
distance2d,
getCornerRadius,
getGridPoint,
isPathALoop,
} from "../math";
import { isVisibleElement, renderScene } from "../renderer/renderScene";
import { invalidateShapeForElement } from "../renderer/renderElement";
import {
@ -247,6 +257,11 @@ import {
muteFSAbortError,
easeOut,
} from "../utils";
import {
embeddableURLValidator,
extractSrc,
getEmbedLink,
} from "../element/embeddable";
import {
ContextMenu,
ContextMenuItems,
@ -295,9 +310,10 @@ import {
showHyperlinkTooltip,
hideHyperlinkToolip,
Hyperlink,
isPointHittingLink,
isPointHittingLinkIcon,
} from "../element/Hyperlink";
import { isLocalLink, normalizeLink } from "../data/url";
import { isLocalLink, normalizeLink, toValidURL } from "../data/url";
import { shouldShowBoundingBox } from "../element/transformHandles";
import { actionUnlockAllElements } from "../actions/actionElementLock";
import { Fonts } from "../scene/Fonts";
@ -330,6 +346,7 @@ import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import { actionWrapTextInContainer } from "../actions/actionBoundText";
import BraveMeasureTextError from "./BraveMeasureTextError";
import { activeEyeDropperAtom } from "./EyeDropper";
import { ValueOf } from "../utility-types";
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
const AppContext = React.createContext<AppClassProperties>(null!);
@ -400,6 +417,14 @@ let currentScrollBars: ScrollBars = { horizontal: null, vertical: null };
let touchTimeout = 0;
let invalidateContextMenu = false;
/**
* Map of youtube embed video states
*/
const YOUTUBE_VIDEO_STATES = new Map<
ExcalidrawElement["id"],
ValueOf<typeof YOUTUBE_STATES>
>();
// remove this hack when we can sync render & resizeObserver (state update)
// to rAF. See #5439
let THROTTLE_NEXT_RENDER = true;
@ -446,6 +471,7 @@ class App extends React.Component<AppProps, AppState> {
public files: BinaryFiles = {};
public imageCache: AppClassProperties["imageCache"] = new Map();
private iFrameRefs = new Map<ExcalidrawElement["id"], HTMLIFrameElement>();
hitLinkElement?: NonDeletedExcalidrawElement;
lastPointerDown: React.PointerEvent<HTMLElement> | null = null;
@ -475,7 +501,6 @@ class App extends React.Component<AppProps, AppState> {
width: window.innerWidth,
height: window.innerHeight,
};
this.id = nanoid();
this.library = new Library(this);
if (excalidrawRef) {
@ -597,6 +622,336 @@ class App extends React.Component<AppProps, AppState> {
);
}
private onWindowMessage(event: MessageEvent) {
if (
event.origin !== "https://player.vimeo.com" &&
event.origin !== "https://www.youtube.com"
) {
return;
}
let data = null;
try {
data = JSON.parse(event.data);
} catch (e) {}
if (!data) {
return;
}
switch (event.origin) {
case "https://player.vimeo.com":
//Allowing for multiple instances of Excalidraw running in the window
if (data.method === "paused") {
let source: Window | null = null;
const iframes = document.body.querySelectorAll(
"iframe.excalidraw__embeddable",
);
if (!iframes) {
break;
}
for (const iframe of iframes as NodeListOf<HTMLIFrameElement>) {
if (iframe.contentWindow === event.source) {
source = iframe.contentWindow;
}
}
source?.postMessage(
JSON.stringify({
method: data.value ? "play" : "pause",
value: true,
}),
"*",
);
}
break;
case "https://www.youtube.com":
if (
data.event === "infoDelivery" &&
data.info &&
data.id &&
typeof data.info.playerState === "number"
) {
const id = data.id;
const playerState = data.info.playerState as number;
if (
(Object.values(YOUTUBE_STATES) as number[]).includes(playerState)
) {
YOUTUBE_VIDEO_STATES.set(
id,
playerState as ValueOf<typeof YOUTUBE_STATES>,
);
}
}
break;
}
}
private updateEmbeddableRef(
id: ExcalidrawEmbeddableElement["id"],
ref: HTMLIFrameElement | null,
) {
if (ref) {
this.iFrameRefs.set(id, ref);
}
}
private getHTMLIFrameElement(
id: ExcalidrawEmbeddableElement["id"],
): HTMLIFrameElement | undefined {
return this.iFrameRefs.get(id);
}
private handleEmbeddableCenterClick(element: ExcalidrawEmbeddableElement) {
if (
this.state.activeEmbeddable?.element === element &&
this.state.activeEmbeddable?.state === "active"
) {
return;
}
// The delay serves two purposes
// 1. To prevent first click propagating to iframe on mobile,
// else the click will immediately start and stop the video
// 2. If the user double clicks the frame center to activate it
// without the delay youtube will immediately open the video
// in fullscreen mode
setTimeout(() => {
this.setState({
activeEmbeddable: { element, state: "active" },
selectedElementIds: { [element.id]: true },
draggingElement: null,
selectionElement: null,
});
}, 100);
const iframe = this.getHTMLIFrameElement(element.id);
if (!iframe?.contentWindow) {
return;
}
if (iframe.src.includes("youtube")) {
const state = YOUTUBE_VIDEO_STATES.get(element.id);
if (!state) {
YOUTUBE_VIDEO_STATES.set(element.id, YOUTUBE_STATES.UNSTARTED);
iframe.contentWindow.postMessage(
JSON.stringify({
event: "listening",
id: element.id,
}),
"*",
);
}
switch (state) {
case YOUTUBE_STATES.PLAYING:
case YOUTUBE_STATES.BUFFERING:
iframe.contentWindow?.postMessage(
JSON.stringify({
event: "command",
func: "pauseVideo",
args: "",
}),
"*",
);
break;
default:
iframe.contentWindow?.postMessage(
JSON.stringify({
event: "command",
func: "playVideo",
args: "",
}),
"*",
);
}
}
if (iframe.src.includes("player.vimeo.com")) {
iframe.contentWindow.postMessage(
JSON.stringify({
method: "paused", //video play/pause in onWindowMessage handler
}),
"*",
);
}
}
private isEmbeddableCenter(
el: ExcalidrawEmbeddableElement | null,
event: React.PointerEvent<HTMLElement> | PointerEvent,
sceneX: number,
sceneY: number,
) {
return (
el &&
!event.altKey &&
!event.shiftKey &&
!event.metaKey &&
!event.ctrlKey &&
(this.state.activeEmbeddable?.element !== el ||
this.state.activeEmbeddable?.state === "hover" ||
!this.state.activeEmbeddable) &&
sceneX >= el.x + el.width / 3 &&
sceneX <= el.x + (2 * el.width) / 3 &&
sceneY >= el.y + el.height / 3 &&
sceneY <= el.y + (2 * el.height) / 3
);
}
private updateEmbeddables = () => {
const embeddableElements = new Map<ExcalidrawElement["id"], true>();
let updated = false;
this.scene.getNonDeletedElements().filter((element) => {
if (isEmbeddableElement(element)) {
embeddableElements.set(element.id, true);
if (element.validated == null) {
updated = true;
const validated = embeddableURLValidator(
element.link,
this.props.validateEmbeddable,
);
mutateElement(element, { validated }, false);
invalidateShapeForElement(element);
}
}
return false;
});
if (updated) {
this.scene.informMutation();
}
// GC
this.iFrameRefs.forEach((ref, id) => {
if (!embeddableElements.has(id)) {
this.iFrameRefs.delete(id);
}
});
};
private renderEmbeddables() {
const scale = this.state.zoom.value;
const normalizedWidth = this.state.width;
const normalizedHeight = this.state.height;
const embeddableElements = this.scene
.getNonDeletedElements()
.filter(
(el): el is NonDeleted<ExcalidrawEmbeddableElement> =>
isEmbeddableElement(el) && !!el.validated,
);
return (
<>
{embeddableElements.map((el) => {
const { x, y } = sceneCoordsToViewportCoords(
{ sceneX: el.x, sceneY: el.y },
this.state,
);
const embedLink = getEmbedLink(toValidURL(el.link || ""));
const isVisible = isVisibleElement(
el,
normalizedWidth,
normalizedHeight,
this.state,
);
const isActive =
this.state.activeEmbeddable?.element === el &&
this.state.activeEmbeddable?.state === "active";
const isHovered =
this.state.activeEmbeddable?.element === el &&
this.state.activeEmbeddable?.state === "hover";
return (
<div
key={el.id}
className={clsx("excalidraw__embeddable-container", {
"is-hovered": isHovered,
})}
style={{
transform: isVisible
? `translate(${x - this.state.offsetLeft}px, ${
y - this.state.offsetTop
}px) scale(${scale})`
: "none",
display: isVisible ? "block" : "none",
opacity: el.opacity / 100,
["--embeddable-radius" as string]: `${getCornerRadius(
Math.min(el.width, el.height),
el,
)}px`,
}}
>
<div
//this is a hack that addresses isse with embedded excalidraw.com embeddable
//https://github.com/excalidraw/excalidraw/pull/6691#issuecomment-1607383938
/*ref={(ref) => {
if (!this.excalidrawContainerRef.current) {
return;
}
const container = this.excalidrawContainerRef.current;
const sh = container.scrollHeight;
const ch = container.clientHeight;
if (sh !== ch) {
container.style.height = `${sh}px`;
setTimeout(() => {
container.style.height = `100%`;
});
}
}}*/
className="excalidraw__embeddable-container__inner"
style={{
width: isVisible ? `${el.width}px` : 0,
height: isVisible ? `${el.height}px` : 0,
transform: isVisible ? `rotate(${el.angle}rad)` : "none",
pointerEvents: isActive ? "auto" : "none",
}}
>
{isHovered && (
<div className="excalidraw__embeddable-hint">
{t("buttons.embeddableInteractionButton")}
</div>
)}
<div
className="excalidraw__embeddable__outer"
style={{
padding: `${el.strokeWidth}px`,
}}
>
{this.props.renderEmbeddable?.(el, this.state) ?? (
<iframe
ref={(ref) => this.updateEmbeddableRef(el.id, ref)}
className="excalidraw__embeddable"
srcDoc={
embedLink?.type === "document"
? embedLink.srcdoc(this.state.theme)
: undefined
}
src={
embedLink?.type !== "document"
? embedLink?.link ?? ""
: undefined
}
// https://stackoverflow.com/q/18470015
scrolling="no"
referrerPolicy="no-referrer-when-downgrade"
title="Excalidraw Embedded Content"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation"
/>
)}
</div>
</div>
</div>
);
})}
</>
);
}
private getFrameNameDOMId = (frameElement: ExcalidrawElement) => {
return `${this.id}-frame-name-${frameElement.id}`;
};
@ -870,6 +1225,7 @@ class App extends React.Component<AppProps, AppState> {
element={selectedElement[0]}
setAppState={this.setAppState}
onLinkOpen={this.props.onLinkOpen}
setToast={this.setToast}
/>
)}
{this.state.toast !== null && (
@ -891,6 +1247,7 @@ class App extends React.Component<AppProps, AppState> {
<main>{this.renderCanvas()}</main>
{this.renderFrameNames()}
</ExcalidrawActionManagerContext.Provider>
{this.renderEmbeddables()}
</ExcalidrawElementsContext.Provider>
</ExcalidrawAppStateContext.Provider>
</ExcalidrawSetAppStateContext.Provider>
@ -1412,10 +1769,12 @@ class App extends React.Component<AppProps, AppState> {
);
this.detachIsMobileMqHandler?.();
window.removeEventListener(EVENT.MESSAGE, this.onWindowMessage, false);
}
private addEventListeners() {
this.removeEventListeners();
window.addEventListener(EVENT.MESSAGE, this.onWindowMessage, false);
document.addEventListener(EVENT.POINTER_UP, this.removePointer); // #3553
document.addEventListener(EVENT.COPY, this.onCopy);
this.excalidrawContainerRef.current?.addEventListener(
@ -1485,6 +1844,7 @@ class App extends React.Component<AppProps, AppState> {
}
componentDidUpdate(prevProps: AppProps, prevState: AppState) {
this.updateEmbeddables();
if (
!this.state.showWelcomeScreen &&
!this.scene.getElementsIncludingDeleted().length
@ -1824,6 +2184,7 @@ class App extends React.Component<AppProps, AppState> {
if (event.touches.length === 2) {
this.setState({
selectedElementIds: makeNextSelectedElementIds({}, this.state),
activeEmbeddable: null,
});
}
};
@ -1883,8 +2244,6 @@ class App extends React.Component<AppProps, AppState> {
}
}
// prefer spreadsheet data over image file (MS Office/Libre Office)
if (isSupportedImageFile(file) && !data.spreadsheet) {
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
{
clientX: this.lastViewportPosition.x,
@ -1893,6 +2252,8 @@ class App extends React.Component<AppProps, AppState> {
this.state,
);
// prefer spreadsheet data over image file (MS Office/Libre Office)
if (isSupportedImageFile(file) && !data.spreadsheet) {
const imageElement = this.createImageElement({ sceneX, sceneY });
this.insertImageElement(imageElement, file);
this.initializeImageDimensions(imageElement);
@ -1936,6 +2297,23 @@ class App extends React.Component<AppProps, AppState> {
retainSeed: isPlainPaste,
});
} else if (data.text) {
const maybeUrl = extractSrc(data.text);
if (
!isPlainPaste &&
embeddableURLValidator(maybeUrl, this.props.validateEmbeddable) &&
(/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(maybeUrl) ||
getEmbedLink(maybeUrl)?.type === "video")
) {
const embeddable = this.insertEmbeddableElement({
sceneX,
sceneY,
link: normalizeLink(maybeUrl),
});
if (embeddable) {
this.setState({ selectedElementIds: { [embeddable.id]: true } });
}
return;
}
this.addTextFromPaste(data.text, isPlainPaste);
}
this.setActiveTool({ type: "selection" });
@ -1949,7 +2327,7 @@ class App extends React.Component<AppProps, AppState> {
position: { clientX: number; clientY: number } | "cursor" | "center";
retainSeed?: boolean;
}) => {
const elements = restoreElements(opts.elements, null);
const elements = restoreElements(opts.elements, null, undefined);
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
const elementsCenterX = distance(minX, maxX) / 2;
@ -2767,6 +3145,7 @@ class App extends React.Component<AppProps, AppState> {
selectedElementIds: makeNextSelectedElementIds({}, this.state),
selectedGroupIds: {},
editingGroupId: null,
activeEmbeddable: null,
});
}
isHoldingSpace = false;
@ -2785,7 +3164,14 @@ class App extends React.Component<AppProps, AppState> {
private setActiveTool = (
tool:
| { type: typeof SHAPES[number]["value"] | "eraser" | "hand" | "frame" }
| {
type:
| typeof SHAPES[number]["value"]
| "eraser"
| "hand"
| "frame"
| "embeddable";
}
| { type: "custom"; customType: string },
) => {
const nextActiveTool = updateActiveTool(this.state, tool);
@ -2809,9 +3195,10 @@ class App extends React.Component<AppProps, AppState> {
selectedElementIds: makeNextSelectedElementIds({}, this.state),
selectedGroupIds: {},
editingGroupId: null,
activeEmbeddable: null,
});
} else {
this.setState({ activeTool: nextActiveTool });
this.setState({ activeTool: nextActiveTool, activeEmbeddable: null });
}
};
@ -2844,6 +3231,7 @@ class App extends React.Component<AppProps, AppState> {
if (this.isTouchScreenMultiTouchGesture()) {
this.setState({
selectedElementIds: makeNextSelectedElementIds({}, this.state),
activeEmbeddable: null,
});
}
gesture.initialScale = this.state.zoom.value;
@ -3001,6 +3389,7 @@ class App extends React.Component<AppProps, AppState> {
selectedElementIds: makeNextSelectedElementIds({}, this.state),
selectedGroupIds: {},
editingGroupId: null,
activeEmbeddable: null,
});
}
@ -3315,12 +3704,22 @@ class App extends React.Component<AppProps, AppState> {
resetCursor(this.canvas);
if (!event[KEYS.CTRL_OR_CMD] && !this.state.viewModeEnabled) {
const hitElement = this.getElementAtPosition(sceneX, sceneY);
if (isEmbeddableElement(hitElement)) {
this.setState({
activeEmbeddable: { element: hitElement, state: "active" },
});
return;
}
const container = getTextBindableContainerAtPosition(
this.scene.getNonDeletedElements(),
this.state,
sceneX,
sceneY,
);
if (container) {
if (
hasBoundTextElement(container) ||
@ -3338,6 +3737,7 @@ class App extends React.Component<AppProps, AppState> {
sceneY = midPoint.y;
}
}
this.startTextEditing({
sceneX,
sceneY,
@ -3363,7 +3763,7 @@ class App extends React.Component<AppProps, AppState> {
return (
element.link &&
index <= hitElementIndex &&
isPointHittingLinkIcon(
isPointHittingLink(
element,
this.state,
[scenePointer.x, scenePointer.y],
@ -3395,7 +3795,7 @@ class App extends React.Component<AppProps, AppState> {
this.lastPointerDown!,
this.state,
);
const lastPointerDownHittingLinkIcon = isPointHittingLinkIcon(
const lastPointerDownHittingLinkIcon = isPointHittingLink(
this.hitLinkElement,
this.state,
[lastPointerDownCoords.x, lastPointerDownCoords.y],
@ -3405,7 +3805,7 @@ class App extends React.Component<AppProps, AppState> {
this.lastPointerUp!,
this.state,
);
const lastPointerUpHittingLinkIcon = isPointHittingLinkIcon(
const lastPointerUpHittingLinkIcon = isPointHittingLink(
this.hitLinkElement,
this.state,
[lastPointerUpCoords.x, lastPointerUpCoords.y],
@ -3747,7 +4147,7 @@ class App extends React.Component<AppProps, AppState> {
hideHyperlinkToolip();
if (
hitElement &&
hitElement.link &&
(hitElement.link || isEmbeddableElement(hitElement)) &&
this.state.selectedElementIds[hitElement.id] &&
!this.state.contextMenu &&
!this.state.showHyperlinkPopup
@ -3780,7 +4180,26 @@ class App extends React.Component<AppProps, AppState> {
)) &&
!hitElement?.locked
) {
if (
hitElement &&
isEmbeddableElement(hitElement) &&
this.isEmbeddableCenter(
hitElement,
event,
scenePointerX,
scenePointerY,
)
) {
setCursor(this.canvas, CURSOR_TYPE.POINTER);
this.setState({
activeEmbeddable: { element: hitElement, state: "hover" },
});
} else {
setCursor(this.canvas, CURSOR_TYPE.MOVE);
if (this.state.activeEmbeddable?.state === "hover") {
this.setState({ activeEmbeddable: null });
}
}
}
} else {
setCursor(this.canvas, CURSOR_TYPE.AUTO);
@ -4199,13 +4618,35 @@ class App extends React.Component<AppProps, AppState> {
private handleCanvasPointerUp = (
event: React.PointerEvent<HTMLCanvasElement>,
) => {
this.removePointer(event);
this.lastPointerUp = event;
if (this.device.isTouchScreen) {
const scenePointer = viewportCoordsToSceneCoords(
{ clientX: event.clientX, clientY: event.clientY },
this.state,
);
const clicklength =
event.timeStamp - (this.lastPointerDown?.timeStamp ?? 0);
if (this.device.isMobile && clicklength < 300) {
const hitElement = this.getElementAtPosition(
scenePointer.x,
scenePointer.y,
);
if (
isEmbeddableElement(hitElement) &&
this.isEmbeddableCenter(
hitElement,
event,
scenePointer.x,
scenePointer.y,
)
) {
this.handleEmbeddableCenterClick(hitElement);
return;
}
}
if (this.device.isTouchScreen) {
const hitElement = this.getElementAtPosition(
scenePointer.x,
scenePointer.y,
@ -4215,14 +4656,29 @@ class App extends React.Component<AppProps, AppState> {
hitElement,
);
}
if (
this.hitLinkElement &&
!this.state.selectedElementIds[this.hitLinkElement.id]
) {
if (
clicklength < 300 &&
this.hitLinkElement.type === "embeddable" &&
!isPointHittingLinkIcon(this.hitLinkElement, this.state, [
scenePointer.x,
scenePointer.y,
])
) {
this.handleEmbeddableCenterClick(this.hitLinkElement);
} else {
this.redirectToLink(event, this.device.isTouchScreen);
}
this.removePointer(event);
} else if (this.state.viewModeEnabled) {
this.setState({
activeEmbeddable: null,
selectedElementIds: {},
});
}
};
private maybeOpenContextMenuAfterPointerDownOnTouchDevices = (
@ -4491,6 +4947,7 @@ class App extends React.Component<AppProps, AppState> {
selectedElementIds: makeNextSelectedElementIds({}, this.state),
selectedGroupIds: {},
editingGroupId: null,
activeEmbeddable: null,
});
}
};
@ -4656,6 +5113,7 @@ class App extends React.Component<AppProps, AppState> {
selectedElementIds: makeNextSelectedElementIds({}, this.state),
selectedGroupIds: {},
editingGroupId: null,
activeEmbeddable: null,
});
}
@ -4743,7 +5201,10 @@ class App extends React.Component<AppProps, AppState> {
{
...prevState,
selectedElementIds: nextSelectedElementIds,
showHyperlinkPopup: hitElement.link ? "info" : false,
showHyperlinkPopup:
hitElement.link || isEmbeddableElement(hitElement)
? "info"
: false,
},
this.scene.getNonDeletedElements(),
prevState,
@ -4803,6 +5264,7 @@ class App extends React.Component<AppProps, AppState> {
includeBoundTextElement: true,
});
// FIXME
let container = getTextBindableContainerAtPosition(
this.scene.getNonDeletedElements(),
this.state,
@ -4899,6 +5361,55 @@ class App extends React.Component<AppProps, AppState> {
});
};
//create rectangle element with youtube top left on nearest grid point width / hight 640/360
private insertEmbeddableElement = ({
sceneX,
sceneY,
link,
}: {
sceneX: number;
sceneY: number;
link: string;
}) => {
const [gridX, gridY] = getGridPoint(sceneX, sceneY, this.state.gridSize);
const embedLink = getEmbedLink(link);
if (!embedLink) {
return;
}
if (embedLink.warning) {
this.setToast({ message: embedLink.warning, closable: true });
}
const element = newEmbeddableElement({
type: "embeddable",
x: gridX,
y: gridY,
strokeColor: "transparent",
backgroundColor: "transparent",
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
roundness: this.getCurrentItemRoundness("embeddable"),
opacity: this.state.currentItemOpacity,
locked: false,
width: embedLink.aspectRatio.w,
height: embedLink.aspectRatio.h,
link,
validated: undefined,
});
this.scene.replaceAllElements([
...this.scene.getElementsIncludingDeleted(),
element,
]);
return element;
};
private createImageElement = ({
sceneX,
sceneY,
@ -5058,6 +5569,23 @@ class App extends React.Component<AppProps, AppState> {
}
};
private getCurrentItemRoundness(
elementType:
| "selection"
| "rectangle"
| "diamond"
| "ellipse"
| "embeddable",
) {
return this.state.currentItemRoundness === "round"
? {
type: isUsingAdaptiveRadius(elementType)
? ROUNDNESS.ADAPTIVE_RADIUS
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null;
}
private createGenericElementOnPointerDown = (
elementType: ExcalidrawGenericElement["type"],
pointerDownState: PointerDownState,
@ -5084,16 +5612,10 @@ class App extends React.Component<AppProps, AppState> {
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
roundness:
this.state.currentItemRoundness === "round"
? {
type: isUsingAdaptiveRadius(elementType)
? ROUNDNESS.ADAPTIVE_RADIUS
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
roundness: this.getCurrentItemRoundness(elementType),
locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null,
...(elementType === "embeddable" ? { validated: false } : {}),
});
if (element.type === "selection") {
@ -5365,7 +5887,8 @@ class App extends React.Component<AppProps, AppState> {
if (
selectedElements.length > 0 &&
!pointerDownState.withCmdOrCtrl &&
!this.state.editingElement
!this.state.editingElement &&
this.state.activeEmbeddable?.state !== "active"
) {
const [dragX, dragY] = getGridPoint(
pointerCoords.x - pointerDownState.drag.offset.x,
@ -5605,7 +6128,8 @@ class App extends React.Component<AppProps, AppState> {
selectedElementIds: nextSelectedElementIds,
showHyperlinkPopup:
elementsWithinSelection.length === 1 &&
elementsWithinSelection[0].link
(elementsWithinSelection[0].link ||
isEmbeddableElement(elementsWithinSelection[0]))
? "info"
: false,
// select linear element only when we haven't box-selected anything else
@ -5662,7 +6186,6 @@ class App extends React.Component<AppProps, AppState> {
if (pointerDownState.eventListeners.onMove) {
pointerDownState.eventListeners.onMove.flush();
}
const {
draggingElement,
resizingElement,
@ -6272,7 +6795,10 @@ class App extends React.Component<AppProps, AppState> {
{
...prevState,
selectedElementIds: nextSelectedElementIds,
showHyperlinkPopup: hitElement.link ? "info" : false,
showHyperlinkPopup:
hitElement.link || isEmbeddableElement(hitElement)
? "info"
: false,
},
this.scene.getNonDeletedElements(),
prevState,
@ -6335,6 +6861,7 @@ class App extends React.Component<AppProps, AppState> {
selectedElementIds: makeNextSelectedElementIds({}, this.state),
selectedGroupIds: {},
editingGroupId: null,
activeEmbeddable: null,
});
}
return;
@ -6354,6 +6881,10 @@ class App extends React.Component<AppProps, AppState> {
},
prevState,
),
showHyperlinkPopup:
isEmbeddableElement(draggingElement) && !draggingElement.link
? "editor"
: prevState.showHyperlinkPopup,
}));
}
@ -6383,6 +6914,23 @@ class App extends React.Component<AppProps, AppState> {
suggestedBindings: [],
});
}
if (
hitElement &&
this.lastPointerUp &&
this.lastPointerDown &&
this.lastPointerUp.timeStamp - this.lastPointerDown.timeStamp < 300 &&
gesture.pointers.size <= 1 &&
isEmbeddableElement(hitElement) &&
this.isEmbeddableCenter(
hitElement,
this.lastPointerUp,
pointerDownState.origin.x,
pointerDownState.origin.y,
)
) {
this.handleEmbeddableCenterClick(hitElement);
}
});
}
@ -6896,6 +7444,7 @@ class App extends React.Component<AppProps, AppState> {
private clearSelection(hitElement: ExcalidrawElement | null): void {
this.setState((prevState) => ({
selectedElementIds: makeNextSelectedElementIds({}, prevState),
activeEmbeddable: null,
selectedGroupIds: {},
// Continue editing the same group if the user selected a different
// element from it
@ -6908,6 +7457,7 @@ class App extends React.Component<AppProps, AppState> {
}));
this.setState({
selectedElementIds: makeNextSelectedElementIds({}, this.state),
activeEmbeddable: null,
previousSelectedElementIds: this.state.selectedElementIds,
});
}
@ -6933,6 +7483,10 @@ class App extends React.Component<AppProps, AppState> {
private handleAppOnDrop = async (event: React.DragEvent<HTMLDivElement>) => {
// must be retrieved first, in the same frame
const { file, fileHandle } = await getFileFromEvent(event);
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
event,
this.state,
);
try {
if (isSupportedImageFile(file)) {
@ -6968,11 +7522,6 @@ class App extends React.Component<AppProps, AppState> {
// to importing as regular image
// ---------------------------------------------------------------------
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
event,
this.state,
);
const imageElement = this.createImageElement({ sceneX, sceneY });
this.insertImageElement(imageElement, file);
this.initializeImageDimensions(imageElement);
@ -7011,6 +7560,25 @@ class App extends React.Component<AppProps, AppState> {
// atetmpt to parse an excalidraw/excalidrawlib file
await this.loadFileToCanvas(file, fileHandle);
}
if (event.dataTransfer?.types?.includes("text/plain")) {
const text = event.dataTransfer?.getData("text");
if (
text &&
embeddableURLValidator(text, this.props.validateEmbeddable) &&
(/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(text) ||
getEmbedLink(text)?.type === "video")
) {
const embeddable = this.insertEmbeddableElement({
sceneX,
sceneY,
link: normalizeLink(text),
});
if (embeddable) {
this.setState({ selectedElementIds: { [embeddable.id]: true } });
}
}
}
};
loadFileToCanvas = async (
@ -7209,6 +7777,7 @@ class App extends React.Component<AppProps, AppState> {
// rotating
isResizing: transformHandleType && transformHandleType !== "rotation",
isRotating: transformHandleType === "rotation",
activeEmbeddable: null,
});
const pointerCoords = pointerDownState.lastCoords;
const [resizeX, resizeY] = getGridPoint(

View File

@ -58,7 +58,7 @@ export const EyeDropper: React.FC<{
return;
}
let currentColor = COLOR_PALETTE.black;
let currentColor: string = COLOR_PALETTE.black;
let isHoldingPointerDown = false;
const ctx = app.canvas.getContext("2d")!;

View File

@ -44,6 +44,10 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
return t("hints.text");
}
if (activeTool.type === "embeddable") {
return t("hints.embeddable");
}
if (appState.activeTool.type === "image" && appState.pendingImageElementId) {
return t("hints.placeImage");
}

View File

@ -29,6 +29,7 @@ import "./LibraryMenu.scss";
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
import { isShallowEqual } from "../utils";
import { NonDeletedExcalidrawElement } from "../element/types";
import { LIBRARY_DISABLED_TYPES } from "../constants";
export const isLibraryMenuOpenAtom = atom(false);
@ -68,12 +69,13 @@ export const LibraryMenuContent = ({
libraryItems: LibraryItems,
) => {
trackEvent("element", "addToLibrary", "ui");
if (processedElements.some((element) => element.type === "image")) {
for (const type of LIBRARY_DISABLED_TYPES) {
if (processedElements.some((element) => element.type === type)) {
return setAppState({
errorMessage:
"Support for adding images to the library coming soon!",
errorMessage: t(`errors.libraryElementTypeError.${type}`),
});
}
}
const nextItems: LibraryItems = [
{
status: "unpublished",
@ -197,6 +199,7 @@ export const LibraryMenu = () => {
setAppState({
selectedElementIds: {},
selectedGroupIds: {},
activeEmbeddable: null,
});
}, [setAppState]);

View File

@ -12,6 +12,11 @@
box-sizing: border-box;
border-radius: var(--border-radius-lg);
svg {
// to prevent clicks on links and such
pointer-events: none;
}
&--hover {
border-color: var(--color-primary);
}

View File

@ -396,6 +396,14 @@ export const TrashIcon = createIcon(
modifiedTablerIconProps,
);
export const EmbedIcon = createIcon(
<g strokeWidth="1.25">
<polyline points="12 16 18 10 12 4" />
<polyline points="8 4 2 10 8 16" />
</g>,
modifiedTablerIconProps,
);
export const DuplicateIcon = createIcon(
<g strokeWidth="1.25">
<path d="M14.375 6.458H8.958a2.5 2.5 0 0 0-2.5 2.5v5.417a2.5 2.5 0 0 0 2.5 2.5h5.417a2.5 2.5 0 0 0 2.5-2.5V8.958a2.5 2.5 0 0 0-2.5-2.5Z" />

View File

@ -71,8 +71,18 @@ export enum EVENT {
// custom events
EXCALIDRAW_LINK = "excalidraw-link",
MENU_ITEM_SELECT = "menu.itemSelect",
MESSAGE = "message",
}
export const YOUTUBE_STATES = {
UNSTARTED: -1,
ENDED: 0,
PLAYING: 1,
PAUSED: 2,
BUFFERING: 3,
CUED: 5,
} as const;
export const ENV = {
TEST: "test",
DEVELOPMENT: "development",
@ -92,7 +102,7 @@ export const FONT_FAMILY = {
export const THEME = {
LIGHT: "light",
DARK: "dark",
};
} as const;
export const FRAME_STYLE = {
strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
@ -300,3 +310,5 @@ export const DEFAULT_SIDEBAR = {
name: "default",
defaultTab: LIBRARY_SIDEBAR_TAB,
} as const;
export const LIBRARY_DISABLED_TYPES = new Set(["embeddable", "image"] as const);

View File

@ -77,6 +77,19 @@
position: absolute;
}
&__embeddable {
width: 100%;
height: 100%;
border: 0;
}
&__embeddable-container {
position: absolute;
z-index: 2;
transform-origin: top left;
pointer-events: none;
}
&.theme--dark {
// The percentage is inspired by
// https://material.io/design/color/dark-theme.html#properties, which
@ -661,3 +674,33 @@
}
}
}
.excalidraw__embeddable-container {
.excalidraw__embeddable-container__inner {
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--embeddable-radius);
}
.excalidraw__embeddable__outer {
width: 100%;
height: 100%;
& > * {
border-radius: var(--embeddable-radius);
}
}
.excalidraw__embeddable-hint {
position: absolute;
z-index: 1;
background: rgba(0, 0, 0, 0.5);
padding: 1rem 1.6rem;
border-radius: 12px;
color: #fff;
font-weight: bold;
letter-spacing: 0.6px;
font-family: "Assistant";
}
}

View File

@ -64,6 +64,7 @@ export const AllowedExcalidrawActiveTools: Record<
eraser: false,
custom: true,
frame: true,
embeddable: true,
hand: true,
};
@ -275,6 +276,10 @@ const restoreElement = (
return restoreElementWithProperties(element, {});
case "diamond":
return restoreElementWithProperties(element, {});
case "embeddable":
return restoreElementWithProperties(element, {
validated: undefined,
});
case "frame":
return restoreElementWithProperties(element, {
name: element.name ?? null,

View File

@ -1,9 +1,35 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
export const normalizeLink = (link: string) => {
link = link.trim();
if (!link) {
return link;
}
return sanitizeUrl(link);
};
export const isLocalLink = (link: string | null) => {
return !!(link?.includes(location.origin) || link?.startsWith("/"));
};
/**
* Returns URL sanitized and safe for usage in places such as
* iframe's src attribute or <a> href attributes.
*/
export const toValidURL = (link: string) => {
link = normalizeLink(link);
// make relative links into fully-qualified urls
if (link.startsWith("/")) {
return `${location.origin}${link}`;
}
try {
new URL(link);
} catch {
// if link does not parse as URL, assume invalid and return blank page
return "about:blank";
}
return link;
};

View File

@ -55,10 +55,6 @@
}
}
.d-none {
display: none;
}
&--remove .ToolIcon__icon svg {
color: $oc-red-6;
}

View File

@ -5,8 +5,12 @@ import {
viewportCoordsToSceneCoords,
wrapEvent,
} from "../utils";
import { getEmbedLink, embeddableURLValidator } from "./embeddable";
import { mutateElement } from "./mutateElement";
import { NonDeletedExcalidrawElement } from "./types";
import {
ExcalidrawEmbeddableElement,
NonDeletedExcalidrawElement,
} from "./types";
import { register } from "../actions/register";
import { ToolButton } from "../components/ToolButton";
@ -21,7 +25,10 @@ import {
} from "react";
import clsx from "clsx";
import { KEYS } from "../keys";
import { DEFAULT_LINK_SIZE } from "../renderer/renderElement";
import {
DEFAULT_LINK_SIZE,
invalidateShapeForElement,
} from "../renderer/renderElement";
import { rotate } from "../math";
import { EVENT, HYPERLINK_TOOLTIP_DELAY, MIME_TYPES } from "../constants";
import { Bounds } from "./bounds";
@ -33,7 +40,8 @@ import { isLocalLink, normalizeLink } from "../data/url";
import "./Hyperlink.scss";
import { trackEvent } from "../analytics";
import { useExcalidrawAppState } from "../components/App";
import { useAppProps, useExcalidrawAppState } from "../components/App";
import { isEmbeddableElement } from "./typeChecks";
const CONTAINER_WIDTH = 320;
const SPACE_BOTTOM = 85;
@ -48,37 +56,112 @@ EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
let IS_HYPERLINK_TOOLTIP_VISIBLE = false;
const embeddableLinkCache = new Map<
ExcalidrawEmbeddableElement["id"],
string
>();
export const Hyperlink = ({
element,
setAppState,
onLinkOpen,
setToast,
}: {
element: NonDeletedExcalidrawElement;
setAppState: React.Component<any, AppState>["setState"];
onLinkOpen: ExcalidrawProps["onLinkOpen"];
setToast: (
toast: { message: string; closable?: boolean; duration?: number } | null,
) => void;
}) => {
const appState = useExcalidrawAppState();
const appProps = useAppProps();
const linkVal = element.link || "";
const [inputVal, setInputVal] = useState(linkVal);
const inputRef = useRef<HTMLInputElement>(null);
const isEditing = appState.showHyperlinkPopup === "editor" || !linkVal;
const isEditing = appState.showHyperlinkPopup === "editor";
const handleSubmit = useCallback(() => {
if (!inputRef.current) {
return;
}
const link = normalizeLink(inputRef.current.value);
const link = normalizeLink(inputRef.current.value) || null;
if (!element.link && link) {
trackEvent("hyperlink", "create");
}
if (isEmbeddableElement(element)) {
if (appState.activeEmbeddable?.element === element) {
setAppState({ activeEmbeddable: null });
}
if (!link) {
mutateElement(element, {
validated: false,
link: null,
});
return;
}
if (!embeddableURLValidator(link, appProps.validateEmbeddable)) {
if (link) {
setToast({ message: t("toast.unableToEmbed"), closable: true });
}
element.link && embeddableLinkCache.set(element.id, element.link);
mutateElement(element, {
validated: false,
link,
});
invalidateShapeForElement(element);
} else {
const { width, height } = element;
const embedLink = getEmbedLink(link);
if (embedLink?.warning) {
setToast({ message: embedLink.warning, closable: true });
}
const ar = embedLink
? embedLink.aspectRatio.w / embedLink.aspectRatio.h
: 1;
const hasLinkChanged =
embeddableLinkCache.get(element.id) !== element.link;
mutateElement(element, {
...(hasLinkChanged
? {
width:
embedLink?.type === "video"
? width > height
? width
: height * ar
: width,
height:
embedLink?.type === "video"
? width > height
? width / ar
: height
: height,
}
: {}),
validated: true,
link,
});
invalidateShapeForElement(element);
if (embeddableLinkCache.has(element.id)) {
embeddableLinkCache.delete(element.id);
}
}
} else {
mutateElement(element, { link });
setAppState({ showHyperlinkPopup: "info" });
}, [element, setAppState]);
}
}, [
element,
setToast,
appProps.validateEmbeddable,
appState.activeEmbeddable,
setAppState,
]);
useLayoutEffect(() => {
return () => {
@ -132,10 +215,12 @@ export const Hyperlink = ({
appState.draggingElement ||
appState.resizingElement ||
appState.isRotating ||
appState.openMenu
appState.openMenu ||
appState.viewModeEnabled
) {
return null;
}
return (
<div
className="excalidraw-hyperlinkContainer"
@ -145,6 +230,11 @@ export const Hyperlink = ({
width: CONTAINER_WIDTH,
padding: CONTAINER_PADDING,
}}
onClick={() => {
if (!element.link && !isEditing) {
setAppState({ showHyperlinkPopup: "editor" });
}
}}
>
{isEditing ? (
<input
@ -162,15 +252,14 @@ export const Hyperlink = ({
}
if (event.key === KEYS.ENTER || event.key === KEYS.ESCAPE) {
handleSubmit();
setAppState({ showHyperlinkPopup: "info" });
}
}}
/>
) : (
) : element.link ? (
<a
href={normalizeLink(element.link || "")}
className={clsx("excalidraw-hyperlinkContainer-link", {
"d-none": isEditing,
})}
className="excalidraw-hyperlinkContainer-link"
target={isLocalLink(element.link) ? "_self" : "_blank"}
onClick={(event) => {
if (element.link && onLinkOpen) {
@ -194,6 +283,10 @@ export const Hyperlink = ({
>
{element.link}
</a>
) : (
<div className="excalidraw-hyperlinkContainer-link">
{t("labels.link.empty")}
</div>
)}
<div className="excalidraw-hyperlinkContainer__buttons">
{!isEditing && (
@ -207,8 +300,7 @@ export const Hyperlink = ({
icon={FreedrawIcon}
/>
)}
{linkVal && (
{linkVal && !isEmbeddableElement(element) && (
<ToolButton
type="button"
title={t("buttons.remove")}
@ -271,7 +363,11 @@ export const actionLink = register({
type="button"
icon={LinkIcon}
aria-label={t(getContextMenuLabel(elements, appState))}
title={`${t("labels.link.label")} - ${getShortcutKey("CtrlOrCmd+K")}`}
title={`${
isEmbeddableElement(elements[0])
? t("labels.link.labelEmbed")
: t("labels.link.label")
} - ${getShortcutKey("CtrlOrCmd+K")}`}
onClick={() => updateData(null)}
selected={selectedElements.length === 1 && !!selectedElements[0].link}
/>
@ -285,7 +381,11 @@ export const getContextMenuLabel = (
) => {
const selectedElements = getSelectedElements(elements, appState);
const label = selectedElements[0]!.link
? "labels.link.edit"
? isEmbeddableElement(selectedElements[0])
? "labels.link.editEmbed"
: "labels.link.edit"
: isEmbeddableElement(selectedElements[0])
? "labels.link.createEmbed"
: "labels.link.create";
return label;
};
@ -327,6 +427,26 @@ export const isPointHittingLinkIcon = (
element: NonDeletedExcalidrawElement,
appState: AppState,
[x, y]: Point,
) => {
const threshold = 4 / appState.zoom.value;
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords(
[x1, y1, x2, y2],
element.angle,
appState,
);
const hitLink =
x > linkX - threshold &&
x < linkX + threshold + linkWidth &&
y > linkY - threshold &&
y < linkY + linkHeight + threshold;
return hitLink;
};
export const isPointHittingLink = (
element: NonDeletedExcalidrawElement,
appState: AppState,
[x, y]: Point,
isMobile: boolean,
) => {
if (!element.link || appState.selectedElementIds[element.id]) {
@ -340,19 +460,7 @@ export const isPointHittingLinkIcon = (
) {
return true;
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords(
[x1, y1, x2, y2],
element.angle,
appState,
);
const hitLink =
x > linkX - threshold &&
x < linkX + threshold + linkWidth &&
y > linkY - threshold &&
y < linkY + linkHeight + threshold;
return hitLink;
return isPointHittingLinkIcon(element, appState, [x, y]);
};
let HYPERLINK_TOOLTIP_TIMEOUT_ID: number | null = null;

View File

@ -18,6 +18,7 @@ import {
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawRectangleElement,
ExcalidrawEmbeddableElement,
ExcalidrawDiamondElement,
ExcalidrawTextElement,
ExcalidrawEllipseElement,
@ -39,7 +40,11 @@ import { FrameNameBoundsCache, Point } from "../types";
import { Drawable } from "roughjs/bin/core";
import { AppState } from "../types";
import { getShapeForElement } from "../renderer/renderElement";
import { hasBoundTextElement, isImageElement } from "./typeChecks";
import {
hasBoundTextElement,
isEmbeddableElement,
isImageElement,
} from "./typeChecks";
import { isTextElement } from ".";
import { isTransparent } from "../utils";
import { shouldShowBoundingBox } from "./transformHandles";
@ -57,7 +62,9 @@ const isElementDraggableFromInside = (
return true;
}
const isDraggableFromInside =
!isTransparent(element.backgroundColor) || hasBoundTextElement(element);
!isTransparent(element.backgroundColor) ||
hasBoundTextElement(element) ||
isEmbeddableElement(element);
if (element.type === "line") {
return isDraggableFromInside && isPathALoop(element.points);
}
@ -248,6 +255,7 @@ type HitTestArgs = {
const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
switch (args.element.type) {
case "rectangle":
case "embeddable":
case "image":
case "text":
case "diamond":
@ -306,6 +314,7 @@ export const distanceToBindableElement = (
case "rectangle":
case "image":
case "text":
case "embeddable":
case "frame":
return distanceToRectangle(element, point);
case "diamond":
@ -337,6 +346,7 @@ const distanceToRectangle = (
| ExcalidrawTextElement
| ExcalidrawFreeDrawElement
| ExcalidrawImageElement
| ExcalidrawEmbeddableElement
| ExcalidrawFrameElement,
point: Point,
): number => {
@ -649,6 +659,7 @@ export const determineFocusDistance = (
case "rectangle":
case "image":
case "text":
case "embeddable":
case "frame":
return c / (hwidth * (nabs + q * mabs));
case "diamond":
@ -682,6 +693,7 @@ export const determineFocusPoint = (
case "image":
case "text":
case "diamond":
case "embeddable":
case "frame":
point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
break;
@ -733,6 +745,7 @@ const getSortedElementLineIntersections = (
case "image":
case "text":
case "diamond":
case "embeddable":
case "frame":
const corners = getCorners(element);
intersections = corners
@ -768,6 +781,7 @@ const getCorners = (
| ExcalidrawImageElement
| ExcalidrawDiamondElement
| ExcalidrawTextElement
| ExcalidrawEmbeddableElement
| ExcalidrawFrameElement,
scale: number = 1,
): GA.Point[] => {
@ -777,6 +791,7 @@ const getCorners = (
case "rectangle":
case "image":
case "text":
case "embeddable":
case "frame":
return [
GA.point(hx, hy),
@ -926,6 +941,7 @@ export const findFocusPointForRectangulars = (
| ExcalidrawImageElement
| ExcalidrawDiamondElement
| ExcalidrawTextElement
| ExcalidrawEmbeddableElement
| ExcalidrawFrameElement,
// Between -1 and 1 for how far away should the focus point be relative
// to the size of the element. Sign determines orientation.

329
src/element/embeddable.ts Normal file
View File

@ -0,0 +1,329 @@
import { register } from "../actions/register";
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
import { t } from "../i18n";
import { ExcalidrawProps } from "../types";
import { getFontString, setCursorForShape, updateActiveTool } from "../utils";
import { newTextElement } from "./newElement";
import { getContainerElement, wrapText } from "./textElement";
import { isEmbeddableElement } from "./typeChecks";
import {
ExcalidrawElement,
ExcalidrawEmbeddableElement,
NonDeletedExcalidrawElement,
Theme,
} from "./types";
type EmbeddedLink =
| ({
aspectRatio: { w: number; h: number };
warning?: string;
} & (
| { type: "video" | "generic"; link: string }
| { type: "document"; srcdoc: (theme: Theme) => string }
))
| null;
const embeddedLinkCache = new Map<string, EmbeddedLink>();
const RE_YOUTUBE =
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
const RE_VIMEO =
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
const RE_FIGMA = /^https:\/\/(?:www\.)?figma\.com/;
const RE_GH_GIST = /^https:\/\/gist\.github\.com/;
const RE_GH_GIST_EMBED =
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist.github.com\/.*?)\.js["']/i;
// not anchored to start to allow <blockquote> twitter embeds
const RE_TWITTER = /(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?twitter.com/;
const RE_TWITTER_EMBED =
/^<blockquote[\s\S]*?\shref=["'](https:\/\/twitter.com\/[^"']*)/i;
const RE_GENERIC_EMBED =
/^<(?:iframe|blockquote)[\s\S]*?\s(?:src|href)=["']([^"']*)["'][\s\S]*?>$/i;
const ALLOWED_DOMAINS = new Set([
"youtube.com",
"youtu.be",
"vimeo.com",
"player.vimeo.com",
"figma.com",
"link.excalidraw.com",
"gist.github.com",
"twitter.com",
]);
const createSrcDoc = (body: string) => {
return `<html><body>${body}</body></html>`;
};
export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
if (!link) {
return null;
}
if (embeddedLinkCache.has(link)) {
return embeddedLinkCache.get(link)!;
}
const originalLink = link;
let type: "video" | "generic" = "generic";
let aspectRatio = { w: 560, h: 840 };
const ytLink = link.match(RE_YOUTUBE);
if (ytLink?.[2]) {
const time = ytLink[3] ? `&start=${ytLink[3]}` : ``;
const isPortrait = link.includes("shorts");
type = "video";
switch (ytLink[1]) {
case "embed/":
case "watch?v=":
case "shorts/":
link = `https://www.youtube.com/embed/${ytLink[2]}?enablejsapi=1${time}`;
break;
case "playlist?list=":
case "embed/videoseries?list=":
link = `https://www.youtube.com/embed/videoseries?list=${ytLink[2]}&enablejsapi=1${time}`;
break;
default:
link = `https://www.youtube.com/embed/${ytLink[2]}?enablejsapi=1${time}`;
break;
}
aspectRatio = isPortrait ? { w: 315, h: 560 } : { w: 560, h: 315 };
embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
return { link, aspectRatio, type };
}
const vimeoLink = link.match(RE_VIMEO);
if (vimeoLink?.[1]) {
const target = vimeoLink?.[1];
const warning = !/^\d+$/.test(target)
? t("toast.unrecognizedLinkFormat")
: undefined;
type = "video";
link = `https://player.vimeo.com/video/${target}?api=1`;
aspectRatio = { w: 560, h: 315 };
//warning deliberately ommited so it is displayed only once per link
//same link next time will be served from cache
embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
return { link, aspectRatio, type, warning };
}
const figmaLink = link.match(RE_FIGMA);
if (figmaLink) {
type = "generic";
link = `https://www.figma.com/embed?embed_host=share&url=${encodeURIComponent(
link,
)}`;
aspectRatio = { w: 550, h: 550 };
embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
return { link, aspectRatio, type };
}
if (RE_TWITTER.test(link)) {
let ret: EmbeddedLink;
// assume embed code
if (/<blockquote/.test(link)) {
const srcDoc = createSrcDoc(link);
ret = {
type: "document",
srcdoc: () => srcDoc,
aspectRatio: { w: 480, h: 480 },
};
// assume regular tweet url
} else {
ret = {
type: "document",
srcdoc: (theme: string) =>
createSrcDoc(
`<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}"><a href="${link}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`,
),
aspectRatio: { w: 480, h: 480 },
};
}
embeddedLinkCache.set(originalLink, ret);
return ret;
}
if (RE_GH_GIST.test(link)) {
let ret: EmbeddedLink;
// assume embed code
if (/<script>/.test(link)) {
const srcDoc = createSrcDoc(link);
ret = {
type: "document",
srcdoc: () => srcDoc,
aspectRatio: { w: 550, h: 720 },
};
// assume regular url
} else {
ret = {
type: "document",
srcdoc: () =>
createSrcDoc(`
<script src="${link}.js"></script>
<style type="text/css">
* { margin: 0px; }
table, .gist { height: 100%; }
.gist .gist-file { height: calc(100vh - 2px); padding: 0px; display: grid; grid-template-rows: 1fr auto; }
</style>
`),
aspectRatio: { w: 550, h: 720 },
};
}
embeddedLinkCache.set(link, ret);
return ret;
}
embeddedLinkCache.set(link, { link, aspectRatio, type });
return { link, aspectRatio, type };
};
export const isEmbeddableOrFrameLabel = (
element: NonDeletedExcalidrawElement,
): Boolean => {
if (isEmbeddableElement(element)) {
return true;
}
if (element.type === "text") {
const container = getContainerElement(element);
if (container && isEmbeddableElement(container)) {
return true;
}
}
return false;
};
export const createPlaceholderEmbeddableLabel = (
element: ExcalidrawEmbeddableElement,
): ExcalidrawElement => {
const text =
!element.link || element?.link === "" ? "Empty Web-Embed" : element.link;
const fontSize = Math.max(
Math.min(element.width / 2, element.width / text.length),
element.width / 30,
);
const fontFamily = FONT_FAMILY.Helvetica;
const fontString = getFontString({
fontSize,
fontFamily,
});
return newTextElement({
x: element.x + element.width / 2,
y: element.y + element.height / 2,
strokeColor:
element.strokeColor !== "transparent" ? element.strokeColor : "black",
backgroundColor: "transparent",
fontFamily,
fontSize,
text: wrapText(text, fontString, element.width - 20),
textAlign: "center",
verticalAlign: VERTICAL_ALIGN.MIDDLE,
angle: element.angle ?? 0,
});
};
export const actionSetEmbeddableAsActiveTool = register({
name: "setEmbeddableAsActiveTool",
trackEvent: { category: "toolbar" },
perform: (elements, appState, _, app) => {
const nextActiveTool = updateActiveTool(appState, {
type: "embeddable",
});
setCursorForShape(app.canvas, {
...appState,
activeTool: nextActiveTool,
});
return {
elements,
appState: {
...appState,
activeTool: updateActiveTool(appState, {
type: "embeddable",
}),
},
commitToHistory: false,
};
},
});
const validateHostname = (
url: string,
/** using a Set assumes it already contains normalized bare domains */
allowedHostnames: Set<string> | string,
): boolean => {
try {
const { hostname } = new URL(url);
const bareDomain = hostname.replace(/^www\./, "");
if (allowedHostnames instanceof Set) {
return ALLOWED_DOMAINS.has(bareDomain);
}
if (bareDomain === allowedHostnames.replace(/^www\./, "")) {
return true;
}
} catch (error) {
// ignore
}
return false;
};
export const extractSrc = (htmlString: string): string => {
const twitterMatch = htmlString.match(RE_TWITTER_EMBED);
if (twitterMatch && twitterMatch.length === 2) {
return twitterMatch[1];
}
const gistMatch = htmlString.match(RE_GH_GIST_EMBED);
if (gistMatch && gistMatch.length === 2) {
return gistMatch[1];
}
const match = htmlString.match(RE_GENERIC_EMBED);
if (match && match.length === 2) {
return match[1];
}
return htmlString;
};
export const embeddableURLValidator = (
url: string | null | undefined,
validateEmbeddable: ExcalidrawProps["validateEmbeddable"],
): boolean => {
if (!url) {
return false;
}
if (validateEmbeddable != null) {
if (typeof validateEmbeddable === "function") {
const ret = validateEmbeddable(url);
// if return value is undefined, leave validation to default
if (typeof ret === "boolean") {
return ret;
}
} else if (typeof validateEmbeddable === "boolean") {
return validateEmbeddable;
} else if (validateEmbeddable instanceof RegExp) {
return validateEmbeddable.test(url);
} else if (Array.isArray(validateEmbeddable)) {
for (const domain of validateEmbeddable) {
if (domain instanceof RegExp) {
if (url.match(domain)) {
return true;
}
} else if (validateHostname(url, domain)) {
return true;
}
}
return false;
}
}
return validateHostname(url, ALLOWED_DOMAINS);
};

View File

@ -13,6 +13,7 @@ import {
FontFamilyValues,
ExcalidrawTextContainer,
ExcalidrawFrameElement,
ExcalidrawEmbeddableElement,
} from "../element/types";
import {
arrayToMap,
@ -130,6 +131,18 @@ export const newElement = (
): NonDeleted<ExcalidrawGenericElement> =>
_newElementBase<ExcalidrawGenericElement>(opts.type, opts);
export const newEmbeddableElement = (
opts: {
type: "embeddable";
validated: boolean | undefined;
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawEmbeddableElement> => {
return {
..._newElementBase<ExcalidrawEmbeddableElement>("embeddable", opts),
validated: opts.validated,
};
};
export const newFrameElement = (
opts: ElementConstructorOpts,
): NonDeleted<ExcalidrawFrameElement> => {
@ -177,7 +190,6 @@ export const newTextElement = (
containerId?: ExcalidrawTextContainer["id"];
lineHeight?: ExcalidrawTextElement["lineHeight"];
strokeWidth?: ExcalidrawTextElement["strokeWidth"];
isFrameName?: boolean;
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawTextElement> => {
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
@ -212,7 +224,6 @@ export const newTextElement = (
containerId: opts.containerId || null,
originalText: text,
lineHeight,
isFrameName: opts.isFrameName || false,
},
{},
);

View File

@ -7,11 +7,11 @@ export const showSelectedShapeActions = (
elements: readonly NonDeletedExcalidrawElement[],
) =>
Boolean(
(!appState.viewModeEnabled &&
appState.activeTool.type !== "custom" &&
!appState.viewModeEnabled &&
((appState.activeTool.type !== "custom" &&
(appState.editingElement ||
(appState.activeTool.type !== "selection" &&
appState.activeTool.type !== "eraser" &&
appState.activeTool.type !== "hand"))) ||
getSelectedElements(elements, appState).length,
getSelectedElements(elements, appState).length),
);

View File

@ -4,6 +4,7 @@ import { MarkNonNullable } from "../utility-types";
import {
ExcalidrawElement,
ExcalidrawTextElement,
ExcalidrawEmbeddableElement,
ExcalidrawLinearElement,
ExcalidrawBindableElement,
ExcalidrawGenericElement,
@ -24,7 +25,8 @@ export const isGenericElement = (
(element.type === "selection" ||
element.type === "rectangle" ||
element.type === "diamond" ||
element.type === "ellipse")
element.type === "ellipse" ||
element.type === "embeddable")
);
};
@ -40,6 +42,12 @@ export const isImageElement = (
return !!element && element.type === "image";
};
export const isEmbeddableElement = (
element: ExcalidrawElement | null | undefined,
): element is ExcalidrawEmbeddableElement => {
return !!element && element.type === "embeddable";
};
export const isTextElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawTextElement => {
@ -112,6 +120,7 @@ export const isBindableElement = (
element.type === "diamond" ||
element.type === "ellipse" ||
element.type === "image" ||
element.type === "embeddable" ||
(element.type === "text" && !element.containerId))
);
};
@ -135,6 +144,7 @@ export const isExcalidrawElement = (element: any): boolean => {
element?.type === "text" ||
element?.type === "diamond" ||
element?.type === "rectangle" ||
element?.type === "embeddable" ||
element?.type === "ellipse" ||
element?.type === "arrow" ||
element?.type === "freedraw" ||
@ -162,7 +172,8 @@ export const isBoundToContainer = (
);
};
export const isUsingAdaptiveRadius = (type: string) => type === "rectangle";
export const isUsingAdaptiveRadius = (type: string) =>
type === "rectangle" || type === "embeddable";
export const isUsingProportionalRadius = (type: string) =>
type === "line" || type === "arrow" || type === "diamond";
@ -193,17 +204,13 @@ export const canApplyRoundnessTypeToElement = (
export const getDefaultRoundnessTypeForElement = (
element: ExcalidrawElement,
) => {
if (
element.type === "arrow" ||
element.type === "line" ||
element.type === "diamond"
) {
if (isUsingProportionalRadius(element.type)) {
return {
type: ROUNDNESS.PROPORTIONAL_RADIUS,
};
}
if (element.type === "rectangle") {
if (isUsingAdaptiveRadius(element.type)) {
return {
type: ROUNDNESS.ADAPTIVE_RADIUS,
};

View File

@ -84,6 +84,19 @@ export type ExcalidrawEllipseElement = _ExcalidrawElementBase & {
type: "ellipse";
};
export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase &
Readonly<{
/**
* indicates whether the embeddable src (url) has been validated for rendering.
* nullish value indicates that the validation is pending. We reset the
* value on each restore (or url change) so that we can guarantee
* the validation came from a trusted source (the editor). Also because we
* may not have access to host-app supplied url validator during restore.
*/
validated?: boolean;
type: "embeddable";
}>;
export type ExcalidrawImageElement = _ExcalidrawElementBase &
Readonly<{
type: "image";
@ -110,6 +123,7 @@ export type ExcalidrawFrameElement = _ExcalidrawElementBase & {
export type ExcalidrawGenericElement =
| ExcalidrawSelectionElement
| ExcalidrawRectangleElement
| ExcalidrawEmbeddableElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement;
@ -156,6 +170,7 @@ export type ExcalidrawBindableElement =
| ExcalidrawEllipseElement
| ExcalidrawTextElement
| ExcalidrawImageElement
| ExcalidrawEmbeddableElement
| ExcalidrawFrameElement;
export type ExcalidrawTextContainer =

View File

@ -100,6 +100,20 @@ polyfill();
window.EXCALIDRAW_THROTTLE_RENDER = true;
let isSelfEmbedding = false;
if (window.self !== window.top) {
try {
const parentUrl = new URL(document.referrer);
const currentUrl = new URL(window.location.href);
if (parentUrl.origin === currentUrl.origin) {
isSelfEmbedding = true;
}
} catch (error) {
// ignore
}
}
const languageDetector = new LanguageDetector();
languageDetector.init({
languageUtils: {},
@ -518,7 +532,9 @@ const ExcalidrawWrapper = () => {
const [theme, setTheme] = useState<Theme>(
() =>
localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_THEME) ||
(localStorage.getItem(
STORAGE_KEYS.LOCAL_STORAGE_THEME,
) as Theme | null) ||
// FIXME migration from old LS scheme. Can be removed later. #5660
importFromLocalStorage().appState?.theme ||
THEME.LIGHT,
@ -641,6 +657,25 @@ const ExcalidrawWrapper = () => {
const isOffline = useAtomValue(isOfflineAtom);
// browsers generally prevent infinite self-embedding, there are
// cases where it still happens, and while we disallow self-embedding
// by not whitelisting our own origin, this serves as an additional guard
if (isSelfEmbedding) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
height: "100%",
}}
>
<h1>I'm not a pretzel!</h1>
</div>
);
}
return (
<div
style={{ height: "100%" }}

View File

@ -17,6 +17,7 @@ const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => {
viewBackgroundColor: COLOR_PALETTE.white,
},
files: null,
renderEmbeddables: false,
});
};

View File

@ -65,6 +65,7 @@ export const KEYS = {
Y: "y",
Z: "z",
K: "k",
W: "w",
0: "0",
1: "1",

View File

@ -109,8 +109,12 @@
"createContainerFromText": "Wrap text in a container",
"link": {
"edit": "Edit link",
"editEmbed": "Edit link & embed",
"create": "Create link",
"label": "Link"
"createEmbed": "Create link & embed",
"label": "Link",
"labelEmbed": "Link & embed",
"empty": "No link is set"
},
"lineEditor": {
"edit": "Edit line",
@ -164,9 +168,11 @@
"cancel": "Cancel",
"clear": "Clear",
"remove": "Remove",
"embed": "Toggle embedding",
"publishLibrary": "Publish",
"submit": "Submit",
"confirm": "Confirm"
"confirm": "Confirm",
"embeddableInteractionButton": "Click to interact"
},
"alerts": {
"clearReset": "This will clear the whole canvas. Are you sure?",
@ -206,6 +212,10 @@
"line2": "This could result in breaking the <bold>Text Elements</bold> in your drawings.",
"line3": "We strongly recommend disabling this setting. You can follow <link>these steps</link> on how to do so.",
"line4": "If disabling this setting doesn't fix the display of text elements, please open an <issueLink>issue</issueLink> on our GitHub, or write us on <discordLink>Discord</discordLink>"
},
"libraryElementTypeError": {
"embeddable": "Embeddable elements cannot be added to the library.",
"image": "Support for adding images to the library coming soon!"
}
},
"toolBar": {
@ -224,6 +234,7 @@
"link": "Add/ Update link for a selected shape",
"eraser": "Eraser",
"frame": "Frame tool",
"embeddable": "Web Embed",
"hand": "Hand (panning tool)",
"extraTools": "More tools"
},
@ -237,6 +248,7 @@
"linearElement": "Click to start multiple points, drag for single line",
"freeDraw": "Click and drag, release when you're finished",
"text": "Tip: you can also add text by double-clicking anywhere with the selection tool",
"embeddable": "Click-drag to create a website embed",
"text_selected": "Double-click or press ENTER to edit text",
"text_editing": "Press Escape or CtrlOrCmd+ENTER to finish editing",
"linearElementMulti": "Click on last point or press Escape or Enter to finish",
@ -411,7 +423,9 @@
"fileSavedToFilename": "Saved to {filename}",
"canvas": "canvas",
"selection": "selection",
"pasteAsSingleElement": "Use {{shortcut}} to paste as a single element,\nor paste into an existing text editor"
"pasteAsSingleElement": "Use {{shortcut}} to paste as a single element,\nor paste into an existing text editor",
"unableToEmbed": "Embedding this url is currently not allowed. Raise an issue on GitHub to request the url whitelisted",
"unrecognizedLinkFormat": "The link you embedded does not match the expected format. Please try to paste the 'embed' string provided by the source site"
},
"colors": {
"transparent": "Transparent",

View File

@ -13,8 +13,27 @@ Please add the latest change on the top under the correct section.
## Unreleased
### renderEmbeddable
```tsx
(element: NonDeletedExcalidrawElement, radius: number, appState: UIAppState) => JSX.Element | null;`
```
The renderEmbeddable function allows you to customize the rendering of a JSX component instead of using the default `<iframe>`. By setting props.renderEmbeddable, you can provide a custom implementation for rendering the element.
#### Parameters:
- element (NonDeletedExcalidrawElement): The element to be rendered.
- radius (number): The calculated border radius in pixels.
- appState (UIAppState): The current state of the UI.
#### Return value:
JSX.Element | null: The JSX component representing the custom rendering, or null if the default `<iframe>` should be rendered.
### Features
- Added [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateEmbeddable) to customize embeddable src url validation. [#6691](https://github.com/excalidraw/excalidraw/pull/6691)
- Add support for `opts.fitToViewport` and `opts.viewportZoomFactor` in the [`ExcalidrawAPI.scrollToContent`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/ref#scrolltocontent) API. [#6581](https://github.com/excalidraw/excalidraw/pull/6581).
- Properly sanitize element `link` urls. [#6728](https://github.com/excalidraw/excalidraw/pull/6728).
- Sidebar component now supports tabs — for more detailed description of new behavior and breaking changes, see the linked PR. [#6213](https://github.com/excalidraw/excalidraw/pull/6213)

View File

@ -26,7 +26,7 @@ import {
LibraryItems,
PointerDownState as ExcalidrawPointerDownState,
} from "../../../types";
import { NonDeletedExcalidrawElement } from "../../../element/types";
import { NonDeletedExcalidrawElement, Theme } from "../../../element/types";
import { ImportedLibraryData } from "../../../data/types";
import CustomFooter from "./CustomFooter";
import MobileFooter from "./MobileFooter";
@ -96,7 +96,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
const [canvasUrl, setCanvasUrl] = useState<string>("");
const [exportWithDarkMode, setExportWithDarkMode] = useState(false);
const [exportEmbedScene, setExportEmbedScene] = useState(false);
const [theme, setTheme] = useState("light");
const [theme, setTheme] = useState<Theme>("light");
const [isCollaborating, setIsCollaborating] = useState(false);
const [commentIcons, setCommentIcons] = useState<{ [id: string]: Comment }>(
{},
@ -595,11 +595,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
type="checkbox"
checked={theme === "dark"}
onChange={() => {
let newTheme = "light";
if (theme === "light") {
newTheme = "dark";
}
setTheme(newTheme);
setTheme(theme === "light" ? "dark" : "light");
}}
/>
Switch to Dark Theme
@ -687,6 +683,8 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
onLinkOpen={onLinkOpen}
onPointerDown={onPointerDown}
onScrollChange={rerenderCommentIcons}
// allow all urls
validateEmbeddable={true}
>
{excalidrawAPI && (
<Footer>

View File

@ -42,6 +42,8 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
onPointerDown,
onScrollChange,
children,
validateEmbeddable,
renderEmbeddable,
} = props;
const canvasActions = props.UIOptions?.canvasActions;
@ -115,6 +117,8 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
onLinkOpen={onLinkOpen}
onPointerDown={onPointerDown}
onScrollChange={onScrollChange}
validateEmbeddable={validateEmbeddable}
renderEmbeddable={renderEmbeddable}
>
{children}
</App>

View File

@ -178,8 +178,10 @@ export const exportToSvg = async ({
appState = getDefaultAppState(),
files = {},
exportPadding,
renderEmbeddables,
}: Omit<ExportOpts, "getDimensions"> & {
exportPadding?: number;
renderEmbeddables?: boolean;
}): Promise<SVGSVGElement> => {
const { elements: restoredElements, appState: restoredAppState } = restore(
{ elements, appState },
@ -197,6 +199,7 @@ export const exportToSvg = async ({
exportAppState,
files,
{
renderEmbeddables,
// NOTE as long as we're using the Scene hack, we need to ensure
// we pass the original, uncloned elements when serializing
// so that we keep ids stable. Hence adding the serializeAsJSON helper

View File

@ -27,7 +27,13 @@ import { RoughSVG } from "roughjs/bin/svg";
import { RoughGenerator } from "roughjs/bin/generator";
import { RenderConfig } from "../scene/types";
import { distance, getFontString, getFontFamilyString, isRTL } from "../utils";
import {
distance,
getFontString,
getFontFamilyString,
isRTL,
isTransparent,
} from "../utils";
import { getCornerRadius, isPathALoop, isRightAngle } from "../math";
import rough from "roughjs/bin/rough";
import { AppState, BinaryFiles, Zoom } from "../types";
@ -49,8 +55,12 @@ import {
getBoundTextMaxWidth,
} from "../element/textElement";
import { LinearElementEditor } from "../element/linearElementEditor";
import {
createPlaceholderEmbeddableLabel,
getEmbedLink,
} from "../element/embeddable";
import { getContainingFrame } from "../frame";
import { normalizeLink } from "../data/url";
import { normalizeLink, toValidURL } from "../data/url";
// using a stronger invert (100% vs our regular 93%) and saturate
// as a temp hack to make images in dark theme look closer to original
@ -262,6 +272,7 @@ const drawElementOnCanvas = (
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
switch (element.type) {
case "rectangle":
case "embeddable":
case "diamond":
case "ellipse": {
context.lineJoin = "round";
@ -427,11 +438,11 @@ export const generateRoughOptions = (
switch (element.type) {
case "rectangle":
case "embeddable":
case "diamond":
case "ellipse": {
options.fillStyle = element.fillStyle;
options.fill =
element.backgroundColor === "transparent"
options.fill = isTransparent(element.backgroundColor)
? undefined
: element.backgroundColor;
if (element.type === "ellipse") {
@ -458,6 +469,26 @@ export const generateRoughOptions = (
}
};
const modifyEmbeddableForRoughOptions = (
element: NonDeletedExcalidrawElement,
isExporting: boolean,
) => {
if (
element.type === "embeddable" &&
(isExporting || !element.validated) &&
isTransparent(element.backgroundColor) &&
isTransparent(element.strokeColor)
) {
return {
...element,
roughness: 0,
backgroundColor: "#d3d3d3",
fillStyle: "solid",
} as const;
}
return element;
};
/**
* Generates the element's shape and puts it into the cache.
* @param element
@ -466,8 +497,9 @@ export const generateRoughOptions = (
const generateElementShape = (
element: NonDeletedExcalidrawElement,
generator: RoughGenerator,
isExporting: boolean = false,
) => {
let shape = shapeCache.get(element);
let shape = isExporting ? undefined : shapeCache.get(element);
// `null` indicates no rc shape applicable for this element type
// (= do not generate anything)
@ -475,7 +507,11 @@ const generateElementShape = (
elementWithCanvasCache.delete(element);
switch (element.type) {
case "rectangle": {
case "rectangle":
case "embeddable": {
// this is for rendering the stroke/bg of the embeddable, especially
// when the src url is not set
if (element.roundness) {
const w = element.width;
const h = element.height;
@ -486,7 +522,10 @@ const generateElementShape = (
} Q ${w} ${h}, ${w - r} ${h} L ${r} ${h} Q 0 ${h}, 0 ${
h - r
} L 0 ${r} Q 0 0, ${r} 0`,
generateRoughOptions(element, true),
generateRoughOptions(
modifyEmbeddableForRoughOptions(element, isExporting),
true,
),
);
} else {
shape = generator.rectangle(
@ -494,7 +533,10 @@ const generateElementShape = (
0,
element.width,
element.height,
generateRoughOptions(element),
generateRoughOptions(
modifyEmbeddableForRoughOptions(element, isExporting),
false,
),
);
}
setShapeForElement(element, shape);
@ -996,8 +1038,9 @@ export const renderElement = (
case "line":
case "arrow":
case "image":
case "text": {
generateElementShape(element, generator);
case "text":
case "embeddable": {
generateElementShape(element, generator, renderConfig.isExporting);
if (renderConfig.isExporting) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2 + renderConfig.scrollX;
@ -1180,7 +1223,9 @@ export const renderElementToSvg = (
offsetY: number,
exportWithDarkMode?: boolean,
exportingFrameId?: string | null,
renderEmbeddables?: boolean,
) => {
const offset = { x: offsetX, y: offsetY };
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
let cx = (x2 - x1) / 2 - (element.x - x1);
let cy = (y2 - y1) / 2 - (element.y - y1);
@ -1253,6 +1298,106 @@ export const renderElementToSvg = (
g ? root.appendChild(g) : root.appendChild(node);
break;
}
case "embeddable": {
// render placeholder rectangle
generateElementShape(element, generator, true);
const node = roughSVGDrawWithPrecision(
rsvg,
getShapeForElement(element)!,
MAX_DECIMALS_FOR_SVG_EXPORT,
);
const opacity = element.opacity / 100;
if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`);
node.setAttribute("fill-opacity", `${opacity}`);
}
node.setAttribute("stroke-linecap", "round");
node.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
root.appendChild(node);
const label: ExcalidrawElement =
createPlaceholderEmbeddableLabel(element);
renderElementToSvg(
label,
rsvg,
root,
files,
label.x + offset.x - element.x,
label.y + offset.y - element.y,
exportWithDarkMode,
exportingFrameId,
renderEmbeddables,
);
// render embeddable element + iframe
const embeddableNode = roughSVGDrawWithPrecision(
rsvg,
getShapeForElement(element)!,
MAX_DECIMALS_FOR_SVG_EXPORT,
);
embeddableNode.setAttribute("stroke-linecap", "round");
embeddableNode.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
while (embeddableNode.firstChild) {
embeddableNode.removeChild(embeddableNode.firstChild);
}
const radius = getCornerRadius(
Math.min(element.width, element.height),
element,
);
const embedLink = getEmbedLink(toValidURL(element.link || ""));
// if rendering embeddables explicitly disabled or
// embedding documents via srcdoc (which doesn't seem to work for SVGs)
// replace with a link instead
if (renderEmbeddables === false || embedLink?.type === "document") {
const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a");
anchorTag.setAttribute("href", normalizeLink(element.link || ""));
anchorTag.setAttribute("target", "_blank");
anchorTag.setAttribute("rel", "noopener noreferrer");
anchorTag.style.borderRadius = `${radius}px`;
embeddableNode.appendChild(anchorTag);
} else {
const foreignObject = svgRoot.ownerDocument!.createElementNS(
SVG_NS,
"foreignObject",
);
foreignObject.style.width = `${element.width}px`;
foreignObject.style.height = `${element.height}px`;
foreignObject.style.border = "none";
const div = foreignObject.ownerDocument!.createElementNS(SVG_NS, "div");
div.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
div.style.width = "100%";
div.style.height = "100%";
const iframe = div.ownerDocument!.createElement("iframe");
iframe.src = embedLink?.link ?? "";
iframe.style.width = "100%";
iframe.style.height = "100%";
iframe.style.border = "none";
iframe.style.borderRadius = `${radius}px`;
iframe.style.top = "0";
iframe.style.left = "0";
iframe.allowFullscreen = true;
div.appendChild(iframe);
foreignObject.appendChild(div);
embeddableNode.appendChild(foreignObject);
}
root.appendChild(embeddableNode);
break;
}
case "line":
case "arrow": {
const boundText = getBoundTextElement(element);

View File

@ -62,7 +62,15 @@ import {
EXTERNAL_LINK_IMG,
getLinkHandleFromCoords,
} from "../element/Hyperlink";
import { isFrameElement, isLinearElement } from "../element/typeChecks";
import {
isEmbeddableElement,
isFrameElement,
isLinearElement,
} from "../element/typeChecks";
import {
isEmbeddableOrFrameLabel,
createPlaceholderEmbeddableLabel,
} from "../element/embeddable";
import {
elementOverlapsWithFrame,
getTargetFrame,
@ -460,7 +468,9 @@ export const _renderScene = ({
let editingLinearElement: NonDeleted<ExcalidrawLinearElement> | undefined =
undefined;
visibleElements.forEach((element) => {
visibleElements
.filter((el) => !isEmbeddableOrFrameLabel(el))
.forEach((element) => {
try {
// - when exporting the whole canvas, we DO NOT apply clipping
// - when we are exporting a particular frame, apply clipping
@ -469,7 +479,8 @@ export const _renderScene = ({
if (
frameId &&
((renderConfig.isExporting && isOnlyExportingSingleFrame(elements)) ||
((renderConfig.isExporting &&
isOnlyExportingSingleFrame(elements)) ||
(!renderConfig.isExporting &&
appState.frameRendering.enabled &&
appState.frameRendering.clip))
@ -503,6 +514,57 @@ export const _renderScene = ({
}
});
// render embeddables on top
visibleElements
.filter((el) => isEmbeddableOrFrameLabel(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 &&
((renderConfig.isExporting &&
isOnlyExportingSingleFrame(elements)) ||
(!renderConfig.isExporting &&
appState.frameRendering.enabled &&
appState.frameRendering.clip))
) {
context.save();
const frame = getTargetFrame(element, appState);
if (frame && isElementInFrame(element, elements, appState)) {
frameClip(frame, context, renderConfig);
}
render();
context.restore();
} else {
render();
}
} catch (error: any) {
console.error(error);
}
});
if (editingLinearElement) {
renderLinearPointHandles(
context,
@ -640,10 +702,13 @@ export const _renderScene = ({
dashed: !!renderConfig.remoteSelectedElementIds[element.id],
cx,
cy,
activeEmbeddable:
appState.activeEmbeddable?.element === element &&
appState.activeEmbeddable.state === "active",
});
}
return acc;
}, [] as { angle: number; elementX1: number; elementY1: number; elementX2: number; elementY2: number; selectionColors: string[]; dashed?: boolean; cx: number; cy: number }[]);
}, [] as { angle: number; elementX1: number; elementY1: number; elementX2: number; elementY2: number; selectionColors: string[]; dashed?: boolean; cx: number; cy: number; activeEmbeddable: boolean }[]);
const addSelectionForGroupId = (groupId: GroupId) => {
const groupElements = getElementsInGroup(elements, groupId);
@ -659,6 +724,7 @@ export const _renderScene = ({
dashed: true,
cx: elementX1 + (elementX2 - elementX1) / 2,
cy: elementY1 + (elementY2 - elementY1) / 2,
activeEmbeddable: false,
});
};
@ -1000,6 +1066,7 @@ const renderSelectionBorder = (
dashed?: boolean;
cx: number;
cy: number;
activeEmbeddable: boolean;
},
padding = DEFAULT_SPACING * 2,
) => {
@ -1013,6 +1080,7 @@ const renderSelectionBorder = (
cx,
cy,
dashed,
activeEmbeddable,
} = elementProperties;
const elementWidth = elementX2 - elementX1;
const elementHeight = elementY2 - elementY1;
@ -1023,7 +1091,7 @@ const renderSelectionBorder = (
context.save();
context.translate(renderConfig.scrollX, renderConfig.scrollY);
context.lineWidth = 1 / renderConfig.zoom.value;
context.lineWidth = (activeEmbeddable ? 4 : 1) / renderConfig.zoom.value;
const count = selectionColors.length;
for (let index = 0; index < count; ++index) {
@ -1084,6 +1152,7 @@ const renderBindingHighlightForBindableElement = (
case "rectangle":
case "text":
case "image":
case "embeddable":
case "frame":
strokeRectWithRotation(
context,
@ -1178,6 +1247,7 @@ const renderElementsBoxHighlight = (
dashed: false,
cx: elementX1 + (elementX2 - elementX1) / 2,
cy: elementY1 + (elementY2 - elementY1) / 2,
activeEmbeddable: false,
};
};
@ -1326,11 +1396,13 @@ export const renderSceneToSvg = (
offsetY = 0,
exportWithDarkMode = false,
exportingFrameId = null,
renderEmbeddables,
}: {
offsetX?: number;
offsetY?: number;
exportWithDarkMode?: boolean;
exportingFrameId?: string | null;
renderEmbeddables?: boolean;
} = {},
) => {
if (!svgRoot) {
@ -1338,7 +1410,9 @@ export const renderSceneToSvg = (
}
// render elements
elements.forEach((element) => {
elements
.filter((el) => !isEmbeddableOrFrameLabel(el))
.forEach((element) => {
if (!element.isDeleted) {
try {
renderElementToSvg(
@ -1350,6 +1424,30 @@ export const renderSceneToSvg = (
element.y + offsetY,
exportWithDarkMode,
exportingFrameId,
renderEmbeddables,
);
} catch (error: any) {
console.error(error);
}
}
});
// render embeddables on top
elements
.filter((el) => isEmbeddableElement(el))
.forEach((element) => {
if (!element.isDeleted) {
try {
renderElementToSvg(
element,
rsvg,
svgRoot,
files,
element.x + offsetX,
element.y + offsetY,
exportWithDarkMode,
exportingFrameId,
renderEmbeddables,
);
} catch (error: any) {
console.error(error);

View File

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

View File

@ -96,6 +96,7 @@ export const exportToSvg = async (
files: BinaryFiles | null,
opts?: {
serializeAsJSON?: () => string;
renderEmbeddables?: boolean;
},
): Promise<SVGSVGElement> => {
const {
@ -212,6 +213,7 @@ export const exportToSvg = async (
offsetY,
exportWithDarkMode: appState.exportWithDarkMode,
exportingFrameId: exportingFrame?.id || null,
renderEmbeddables: opts?.renderEmbeddables,
});
return svgRoot;

View File

@ -2,6 +2,7 @@
exports[`contextMenu element right-clicking on a group should select whole group: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -470,6 +471,7 @@ exports[`contextMenu element right-clicking on a group should select whole group
exports[`contextMenu element selecting 'Add to library' in context menu adds element to library: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -668,6 +670,7 @@ exports[`contextMenu element selecting 'Add to library' in context menu adds ele
exports[`contextMenu element selecting 'Bring forward' in context menu brings element forward: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -1040,6 +1043,7 @@ exports[`contextMenu element selecting 'Bring forward' in context menu brings el
exports[`contextMenu element selecting 'Bring to front' in context menu brings element to front: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -1412,6 +1416,7 @@ exports[`contextMenu element selecting 'Bring to front' in context menu brings e
exports[`contextMenu element selecting 'Copy styles' in context menu copies styles: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -1610,6 +1615,7 @@ exports[`contextMenu element selecting 'Copy styles' in context menu copies styl
exports[`contextMenu element selecting 'Delete' in context menu deletes element: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -1845,6 +1851,7 @@ exports[`contextMenu element selecting 'Delete' in context menu deletes element:
exports[`contextMenu element selecting 'Duplicate' in context menu duplicates element: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -2145,6 +2152,7 @@ exports[`contextMenu element selecting 'Duplicate' in context menu duplicates el
exports[`contextMenu element selecting 'Group selection' in context menu groups selected elements: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -2533,6 +2541,7 @@ exports[`contextMenu element selecting 'Group selection' in context menu groups
exports[`contextMenu element selecting 'Paste styles' in context menu pastes styles: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -3411,6 +3420,7 @@ exports[`contextMenu element selecting 'Paste styles' in context menu pastes sty
exports[`contextMenu element selecting 'Send backward' in context menu sends element backward: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -3783,6 +3793,7 @@ exports[`contextMenu element selecting 'Send backward' in context menu sends ele
exports[`contextMenu element selecting 'Send to back' in context menu sends element to back: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -4155,6 +4166,7 @@ exports[`contextMenu element selecting 'Send to back' in context menu sends elem
exports[`contextMenu element selecting 'Ungroup selection' in context menu ungroups selected group: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -4610,6 +4622,7 @@ exports[`contextMenu element selecting 'Ungroup selection' in context menu ungro
exports[`contextMenu element shows 'Group selection' in context menu for multiple selected elements: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -5189,6 +5202,7 @@ exports[`contextMenu element shows 'Group selection' in context menu for multipl
exports[`contextMenu element shows 'Ungroup selection' in context menu for group inside selected elements: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -5853,6 +5867,7 @@ exports[`contextMenu element shows 'Ungroup selection' in context menu for group
exports[`contextMenu element shows context menu for canvas: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -6087,6 +6102,7 @@ exports[`contextMenu element shows context menu for canvas: [end of test] number
exports[`contextMenu element shows context menu for element: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -6460,6 +6476,7 @@ Object {
exports[`contextMenu element shows context menu for element: [end of test] appState 2`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,

View File

@ -2,6 +2,7 @@
exports[`given element A and group of elements B and given both are selected when user clicks on B, on pointer up only elements from B should be selected: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -454,6 +455,7 @@ exports[`given element A and group of elements B and given both are selected whe
exports[`given element A and group of elements B and given both are selected when user shift-clicks on B, on pointer up only element A should be selected: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -908,6 +910,7 @@ exports[`given element A and group of elements B and given both are selected whe
exports[`regression tests Cmd/Ctrl-click exclusively select element under pointer: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -1735,6 +1738,7 @@ exports[`regression tests Cmd/Ctrl-click exclusively select element under pointe
exports[`regression tests Drags selected element when hitting only bounding box and keeps element selected: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -1944,6 +1948,7 @@ exports[`regression tests Drags selected element when hitting only bounding box
exports[`regression tests adjusts z order when grouping: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -2394,6 +2399,7 @@ exports[`regression tests adjusts z order when grouping: [end of test] number of
exports[`regression tests alt-drag duplicates an element: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -2632,6 +2638,7 @@ exports[`regression tests alt-drag duplicates an element: [end of test] number o
exports[`regression tests arrow keys: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -2796,6 +2803,7 @@ exports[`regression tests arrow keys: [end of test] number of renders 1`] = `21`
exports[`regression tests can drag element that covers another element, while another elem is selected: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -3236,6 +3244,7 @@ exports[`regression tests can drag element that covers another element, while an
exports[`regression tests change the properties of a shape: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -3529,6 +3538,7 @@ exports[`regression tests change the properties of a shape: [end of test] number
exports[`regression tests click on an element and drag it: [dragged] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -3770,6 +3780,7 @@ exports[`regression tests click on an element and drag it: [dragged] number of r
exports[`regression tests click on an element and drag it: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -4022,6 +4033,7 @@ exports[`regression tests click on an element and drag it: [end of test] number
exports[`regression tests click to select a shape: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -4260,6 +4272,7 @@ exports[`regression tests click to select a shape: [end of test] number of rende
exports[`regression tests click-drag to select a group: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -4600,6 +4613,7 @@ exports[`regression tests click-drag to select a group: [end of test] number of
exports[`regression tests deselects group of selected elements on pointer down when pointer doesn't hit any element: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -4893,6 +4907,7 @@ exports[`regression tests deselects group of selected elements on pointer down w
exports[`regression tests deselects group of selected elements on pointer up when pointer hits common bounding box without hitting any element: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -5158,6 +5173,7 @@ exports[`regression tests deselects group of selected elements on pointer up whe
exports[`regression tests deselects selected element on pointer down when pointer doesn't hit any element: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -5378,6 +5394,7 @@ exports[`regression tests deselects selected element on pointer down when pointe
exports[`regression tests deselects selected element, on pointer up, when click hits element bounding box but doesn't hit the element: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -5542,6 +5559,7 @@ exports[`regression tests deselects selected element, on pointer up, when click
exports[`regression tests double click to edit a group: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -5990,6 +6008,7 @@ exports[`regression tests double click to edit a group: [end of test] number of
exports[`regression tests drags selected elements from point inside common bounding box that doesn't hit any element and keeps elements selected after dragging: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -6303,6 +6322,7 @@ exports[`regression tests drags selected elements from point inside common bound
exports[`regression tests draw every type of shape: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -8366,6 +8386,7 @@ exports[`regression tests draw every type of shape: [end of test] number of rend
exports[`regression tests given a group of selected elements with an element that is not selected inside the group common bounding box when element that is not selected is clicked should switch selection to not selected element on pointer up: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -8706,6 +8727,7 @@ exports[`regression tests given a group of selected elements with an element tha
exports[`regression tests given a selected element A and a not selected element B with higher z-index than A and given B partially overlaps A when there's a shift-click on the overlapped section B is added to the selection: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -8945,6 +8967,7 @@ exports[`regression tests given a selected element A and a not selected element
exports[`regression tests given selected element A with lower z-index than unselected element B and given B is partially over A when clicking intersection between A and B B should be selected on pointer up: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -9140,6 +9163,7 @@ exports[`regression tests given selected element A with lower z-index than unsel
exports[`regression tests given selected element A with lower z-index than unselected element B and given B is partially over A when dragging on intersection between A and B A should be dragged and keep being selected: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -9407,6 +9431,7 @@ exports[`regression tests given selected element A with lower z-index than unsel
exports[`regression tests key 2 selects rectangle tool: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -9571,6 +9596,7 @@ exports[`regression tests key 2 selects rectangle tool: [end of test] number of
exports[`regression tests key 3 selects diamond tool: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -9735,6 +9761,7 @@ exports[`regression tests key 3 selects diamond tool: [end of test] number of re
exports[`regression tests key 4 selects ellipse tool: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -9899,6 +9926,7 @@ exports[`regression tests key 4 selects ellipse tool: [end of test] number of re
exports[`regression tests key 5 selects arrow tool: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -10101,6 +10129,7 @@ exports[`regression tests key 5 selects arrow tool: [end of test] number of rend
exports[`regression tests key 6 selects line tool: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -10303,6 +10332,7 @@ exports[`regression tests key 6 selects line tool: [end of test] number of rende
exports[`regression tests key 7 selects freedraw tool: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -10485,6 +10515,7 @@ exports[`regression tests key 7 selects freedraw tool: [end of test] number of r
exports[`regression tests key a selects arrow tool: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -10687,6 +10718,7 @@ exports[`regression tests key a selects arrow tool: [end of test] number of rend
exports[`regression tests key d selects diamond tool: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -10851,6 +10883,7 @@ exports[`regression tests key d selects diamond tool: [end of test] number of re
exports[`regression tests key l selects line tool: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -11053,6 +11086,7 @@ exports[`regression tests key l selects line tool: [end of test] number of rende
exports[`regression tests key o selects ellipse tool: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -11217,6 +11251,7 @@ exports[`regression tests key o selects ellipse tool: [end of test] number of re
exports[`regression tests key p selects freedraw tool: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -11399,6 +11434,7 @@ exports[`regression tests key p selects freedraw tool: [end of test] number of r
exports[`regression tests key r selects rectangle tool: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -11563,6 +11599,7 @@ exports[`regression tests key r selects rectangle tool: [end of test] number of
exports[`regression tests make a group and duplicate it: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -12223,6 +12260,7 @@ exports[`regression tests make a group and duplicate it: [end of test] number of
exports[`regression tests noop interaction after undo shouldn't create history entry: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -12461,6 +12499,7 @@ exports[`regression tests noop interaction after undo shouldn't create history e
exports[`regression tests pinch-to-zoom works: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -12580,6 +12619,7 @@ exports[`regression tests pinch-to-zoom works: [end of test] number of renders 1
exports[`regression tests rerenders UI on language change: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -12699,6 +12739,7 @@ exports[`regression tests rerenders UI on language change: [end of test] number
exports[`regression tests shift click on selected element should deselect it on pointer up: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -12863,6 +12904,7 @@ exports[`regression tests shift click on selected element should deselect it on
exports[`regression tests shift-click to multiselect, then drag: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -13176,6 +13218,7 @@ exports[`regression tests shift-click to multiselect, then drag: [end of test] n
exports[`regression tests should group elements and ungroup them: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -13733,6 +13776,7 @@ exports[`regression tests should group elements and ungroup them: [end of test]
exports[`regression tests should show fill icons when element has non transparent background: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -13940,6 +13984,7 @@ exports[`regression tests should show fill icons when element has non transparen
exports[`regression tests single-clicking on a subgroup of a selected group should not alter selection: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -14792,6 +14837,7 @@ exports[`regression tests single-clicking on a subgroup of a selected group shou
exports[`regression tests spacebar + drag scrolls the canvas: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -14911,6 +14957,7 @@ exports[`regression tests spacebar + drag scrolls the canvas: [end of test] numb
exports[`regression tests supports nested groups: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -15694,6 +15741,7 @@ exports[`regression tests supports nested groups: [end of test] number of render
exports[`regression tests switches from group of selected elements to another element on pointer down: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -16090,6 +16138,7 @@ exports[`regression tests switches from group of selected elements to another el
exports[`regression tests switches selected element on pointer down: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -16384,6 +16433,7 @@ exports[`regression tests switches selected element on pointer down: [end of tes
exports[`regression tests two-finger scroll works: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -16503,6 +16553,7 @@ exports[`regression tests two-finger scroll works: [end of test] number of rende
exports[`regression tests undo/redo drawing an element: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -16982,6 +17033,7 @@ exports[`regression tests undo/redo drawing an element: [end of test] number of
exports[`regression tests updates fontSize & fontFamily appState: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,
@ -17101,6 +17153,7 @@ exports[`regression tests updates fontSize & fontFamily appState: [end of test]
exports[`regression tests zoom hotkeys: [end of test] appState 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,

View File

@ -245,7 +245,7 @@ describe("restoreElements", () => {
types.forEach((elType) => {
idCount += 1;
const element = API.createElement({
type: elType as "rectangle" | "ellipse" | "diamond",
type: elType as "rectangle" | "ellipse" | "diamond" | "embeddable",
id: idCount.toString(),
fillStyle: "cross-hatch",
strokeWidth: 2,

View File

@ -31,6 +31,10 @@ export const rectangleFixture: ExcalidrawElement = {
...elementBase,
type: "rectangle",
};
export const embeddableFixture: ExcalidrawElement = {
...elementBase,
type: "embeddable",
};
export const ellipseFixture: ExcalidrawElement = {
...elementBase,
type: "ellipse",

View File

@ -178,8 +178,9 @@ export class API {
case "rectangle":
case "diamond":
case "ellipse":
case "embeddable":
element = newElement({
type: type as "rectangle" | "diamond" | "ellipse",
type: type as "rectangle" | "diamond" | "ellipse" | "embeddable",
width,
height,
...base,

View File

@ -2,6 +2,7 @@
exports[`exportToSvg with default arguments 1`] = `
Object {
"activeEmbeddable": null,
"activeTool": Object {
"customType": null,
"lastActiveTool": null,

View File

@ -16,6 +16,7 @@ import {
Theme,
StrokeRoundness,
ExcalidrawFrameElement,
ExcalidrawEmbeddableElement,
} from "./element/types";
import { SHAPES } from "./shapes";
import { Point as RoughPoint } from "roughjs/bin/geometry";
@ -86,7 +87,12 @@ export type BinaryFiles = Record<ExcalidrawElement["id"], BinaryFileData>;
export type LastActiveTool =
| {
type: typeof SHAPES[number]["value"] | "eraser" | "hand" | "frame";
type:
| typeof SHAPES[number]["value"]
| "eraser"
| "hand"
| "frame"
| "embeddable";
customType: null;
}
| {
@ -107,6 +113,10 @@ export type AppState = {
showWelcomeScreen: boolean;
isLoading: boolean;
errorMessage: React.ReactNode;
activeEmbeddable: {
element: NonDeletedExcalidrawElement;
state: "hover" | "active";
} | null;
draggingElement: NonDeletedExcalidrawElement | null;
resizingElement: NonDeletedExcalidrawElement | null;
multiElement: NonDeleted<ExcalidrawLinearElement> | null;
@ -136,7 +146,12 @@ export type AppState = {
locked: boolean;
} & (
| {
type: typeof SHAPES[number]["value"] | "eraser" | "hand" | "frame";
type:
| typeof SHAPES[number]["value"]
| "eraser"
| "hand"
| "frame"
| "embeddable";
customType: null;
}
| {
@ -361,6 +376,16 @@ export interface ExcalidrawProps {
) => void;
onScrollChange?: (scrollX: number, scrollY: number) => void;
children?: React.ReactNode;
validateEmbeddable?:
| boolean
| string[]
| RegExp
| RegExp[]
| ((link: string) => boolean | undefined);
renderEmbeddable?: (
element: NonDeleted<ExcalidrawEmbeddableElement>,
appState: AppState,
) => JSX.Element | null;
}
export type SceneData = {

View File

@ -369,7 +369,14 @@ export const distance = (x: number, y: number) => Math.abs(x - y);
export const updateActiveTool = (
appState: Pick<AppState, "activeTool">,
data: (
| { type: typeof SHAPES[number]["value"] | "eraser" | "hand" | "frame" }
| {
type:
| typeof SHAPES[number]["value"]
| "eraser"
| "hand"
| "frame"
| "embeddable";
}
| { type: "custom"; customType: string }
) & { lastActiveToolBeforeEraser?: LastActiveTool },
): AppState["activeTool"] => {