diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/props/props.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/props/props.mdx index a3af78938c..e25aedcf40 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/api/props/props.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/api/props/props.mdx @@ -3,31 +3,32 @@ All `props` are _optional_. | Name | Type | Default | Description | -| --- | --- | --- | --- | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | | [`initialData`](/docs/@excalidraw/excalidraw/api/props/initialdata) | `object` | `null` | <code>Promise<object | null></code> | `null` | The initial data with which app loads. | -| [`excalidrawAPI`](/docs/@excalidraw/excalidraw/api/props/excalidraw-api) | `function` | _ | Callback triggered with the excalidraw api once rendered | -| [`isCollaborating`](#iscollaborating) | `boolean` | _ | This indicates if the app is in `collaboration` mode | -| [`onChange`](#onchange) | `function` | _ | This callback is triggered whenever the component updates due to any change. This callback will receive the excalidraw `elements` and the current `app state`. | -| [`onPointerUpdate`](#onpointerupdate) | `function` | _ | Callback triggered when mouse pointer is updated. | -| [`onPointerDown`](#onpointerdown) | `function` | _ | This prop if passed gets triggered on pointer down events | -| [`onScrollChange`](#onscrollchange) | `function` | _ | This prop if passed gets triggered when scrolling the canvas. | -| [`onPaste`](#onpaste) | `function` | _ | Callback to be triggered if passed when something is pasted into the scene | -| [`onLibraryChange`](#onlibrarychange) | `function` | _ | The callback if supplied is triggered when the library is updated and receives the library items. | -| [`onLinkOpen`](#onlinkopen) | `function` | _ | The callback if supplied is triggered when any link is opened. | +| [`excalidrawAPI`](/docs/@excalidraw/excalidraw/api/props/excalidraw-api) | `function` | \_ | Callback triggered with the excalidraw api once rendered | +| [`isCollaborating`](#iscollaborating) | `boolean` | \_ | This indicates if the app is in `collaboration` mode | +| [`onChange`](#onchange) | `function` | \_ | This callback is triggered whenever the component updates due to any change. This callback will receive the excalidraw `elements` and the current `app state`. | +| [`onPointerUpdate`](#onpointerupdate) | `function` | \_ | Callback triggered when mouse pointer is updated. | +| [`onPointerDown`](#onpointerdown) | `function` | \_ | This prop if passed gets triggered on pointer down events | +| [`onScrollChange`](#onscrollchange) | `function` | \_ | This prop if passed gets triggered when scrolling the canvas. | +| [`onPaste`](#onpaste) | `function` | \_ | Callback to be triggered if passed when something is pasted into the scene | +| [`onLibraryChange`](#onlibrarychange) | `function` | \_ | The callback if supplied is triggered when the library is updated and receives the library items. | +| [`generateLinkForSelection`](#generateLinkForSelection) | `function` | \_ | Allows you to override `url` generation when linking to Excalidraw elements. | +| [`onLinkOpen`](#onlinkopen) | `function` | \_ | The callback if supplied is triggered when any link is opened. | | [`langCode`](#langcode) | `string` | `en` | Language code string to be used in Excalidraw | -| [`renderTopRightUI`](/docs/@excalidraw/excalidraw/api/props/render-props#rendertoprightui) | `function` | _ | Render function that renders custom UI in top right corner | -| [`renderCustomStats`](/docs/@excalidraw/excalidraw/api/props/render-props#rendercustomstats) | `function` | _ | Render function that can be used to render custom stats on the stats dialog. | -| [`viewModeEnabled`](#viewmodeenabled) | `boolean` | _ | This indicates if the app is in `view` mode. | -| [`zenModeEnabled`](#zenmodeenabled) | `boolean` | _ | This indicates if the `zen` mode is enabled | -| [`gridModeEnabled`](#gridmodeenabled) | `boolean` | _ | This indicates if the `grid` mode is enabled | -| [`libraryReturnUrl`](#libraryreturnurl) | `string` | _ | What URL should [libraries.excalidraw.com](https://libraries.excalidraw.com) be installed to | +| [`renderTopRightUI`](/docs/@excalidraw/excalidraw/api/props/render-props#rendertoprightui) | `function` | \_ | Render function that renders custom UI in top right corner | +| [`renderCustomStats`](/docs/@excalidraw/excalidraw/api/props/render-props#rendercustomstats) | `function` | \_ | Render function that can be used to render custom stats on the stats dialog. | +| [`viewModeEnabled`](#viewmodeenabled) | `boolean` | \_ | This indicates if the app is in `view` mode. | +| [`zenModeEnabled`](#zenmodeenabled) | `boolean` | \_ | This indicates if the `zen` mode is enabled | +| [`gridModeEnabled`](#gridmodeenabled) | `boolean` | \_ | This indicates if the `grid` mode is enabled | +| [`libraryReturnUrl`](#libraryreturnurl) | `string` | \_ | What URL should [libraries.excalidraw.com](https://libraries.excalidraw.com) be installed to | | [`theme`](#theme) | `"light"` | `"dark"` | `"light"` | The theme of the Excalidraw component | | [`name`](#name) | `string` | | Name of the drawing | | [`UIOptions`](/docs/@excalidraw/excalidraw/api/props/ui-options) | `object` | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/constants.ts#L151) | To customise UI options. Currently we support customising [`canvas actions`](/docs/@excalidraw/excalidraw/api/props/ui-options#canvasactions) | | [`detectScroll`](#detectscroll) | `boolean` | `true` | Indicates whether to update the offsets when nearest ancestor is scrolled. | | [`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 | +| [`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>` | @@ -93,9 +94,8 @@ This callback is triggered when mouse pointer is updated. This prop if passed will be triggered on pointer down events and has the below signature. - <pre> -(activeTool:{" "} + (activeTool:{" "} <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L115"> {" "} AppState["activeTool"] @@ -143,6 +143,14 @@ This callback if supplied will get triggered when the library is updated and has It is invoked with empty items when user clears the library. You can use this callback when you want to do something additional when library is updated for example persisting it to local storage. +### generateLinkForSelection + +This prop if passed will be used to replace the default link generation function. The idea is that the host app can take over the creation of element links, which can be used to navigate to a particular element or a group. If the host app chooses a different key for element link id, then the host app should also take care of the handling and the navigation in `onLinkOpen`. + +```tsx +(id: string, type: "element" | "group") => string; +``` + ### onLinkOpen This prop if passed will be triggered when clicked on `link`. To handle the redirect yourself (such as when using your own router for internal links), you must call `event.preventDefault()`. @@ -207,8 +215,7 @@ This prop indicates whether the shows the grid. When supplied, the value takes p ### libraryReturnUrl -If supplied, this URL will be used when user tries to install a library from [libraries.excalidraw.com](https://libraries.excalidraw.com). -Defaults to _window.location.origin + window.location.pathname_. To install the libraries in the same tab from which it was opened, you need to set `window.name` (to any alphanumeric string) — if it's not set it will open in a new tab. +If supplied, this URL will be used when user tries to install a library from [libraries.excalidraw.com](https://libraries.excalidraw.com). Defaults to _window.location.origin + window.location.pathname_. To install the libraries in the same tab from which it was opened, you need to set `window.name` (to any alphanumeric string) — if it's not set it will open in a new tab. ### theme @@ -220,7 +227,6 @@ You can use [`THEME`](/docs/@excalidraw/excalidraw/api/utils#theme) to specify t This prop sets the `name` of the drawing which will be used when exporting the drawing. When supplied, the value takes precedence over _intialData.appState.name_, the `name` will be fully controlled by host app and the users won't be able to edit from within Excalidraw. - ### detectScroll Indicates whether Excalidraw should listen for `scroll` event on the nearest scrollable container in the DOM tree and recompute the coordinates (e.g. to correctly handle the cursor) when the component's position changes. You can disable this when you either know this doesn't affect your app or you want to take care of it yourself (calling the [`refresh()`](#ref) method). diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index d7a93bbea0..8834822864 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -127,6 +127,7 @@ import DebugCanvas, { } from "./components/DebugCanvas"; import { AIComponents } from "./components/AI"; import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport"; +import { isElementLink } from "../packages/excalidraw/element/elementLink"; polyfill(); @@ -848,6 +849,12 @@ const ExcalidrawWrapper = () => { </div> ); }} + onLinkOpen={(element, event) => { + if (element.link && isElementLink(element.link)) { + event.preventDefault(); + excalidrawAPI?.scrollToContent(element.link, { animate: true }); + } + }} > <AppMainMenu onCollabDialogOpen={onCollabDialogOpen} diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index 83b0ad5290..3b3a12b98a 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -87,7 +87,8 @@ export const actionClearCanvas = register({ predicate: (elements, appState, props, app) => { return ( !!app.props.UIOptions.canvasActions.clearCanvas && - !appState.viewModeEnabled + !appState.viewModeEnabled && + appState.openDialog?.name !== "elementLinkSelector" ); }, perform: (elements, appState, _, app) => { diff --git a/packages/excalidraw/actions/actionElementLink.ts b/packages/excalidraw/actions/actionElementLink.ts new file mode 100644 index 0000000000..504ad14fbb --- /dev/null +++ b/packages/excalidraw/actions/actionElementLink.ts @@ -0,0 +1,105 @@ +import { copyTextToSystemClipboard } from "../clipboard"; +import { copyIcon, elementLinkIcon } from "../components/icons"; +import { + canCreateLinkFromElements, + defaultGetElementLinkFromSelection, + getLinkIdAndTypeFromSelection, +} from "../element/elementLink"; +import { t } from "../i18n"; +import { getSelectedElements } from "../scene"; +import { StoreAction } from "../store"; +import { register } from "./register"; + +export const actionCopyElementLink = register({ + name: "copyElementLink", + label: "labels.copyElementLink", + icon: copyIcon, + trackEvent: { category: "element" }, + perform: async (elements, appState, _, app) => { + const selectedElements = getSelectedElements(elements, appState); + + try { + if (window.location) { + const idAndType = getLinkIdAndTypeFromSelection( + selectedElements, + appState, + ); + + if (idAndType) { + await copyTextToSystemClipboard( + app.props.generateLinkForSelection + ? app.props.generateLinkForSelection(idAndType.id, idAndType.type) + : defaultGetElementLinkFromSelection( + idAndType.id, + idAndType.type, + ), + ); + + return { + appState: { + toast: { + message: t("toast.elementLinkCopied"), + closable: true, + }, + }, + storeAction: StoreAction.NONE, + }; + } + return { + appState, + elements, + app, + storeAction: StoreAction.NONE, + }; + } + } catch (error: any) { + console.error(error); + } + + return { + appState, + elements, + app, + storeAction: StoreAction.NONE, + }; + }, + predicate: (elements, appState) => + canCreateLinkFromElements(getSelectedElements(elements, appState)), +}); + +export const actionLinkToElement = register({ + name: "linkToElement", + label: "labels.linkToElement", + icon: elementLinkIcon, + perform: (elements, appState, _, app) => { + const selectedElements = getSelectedElements(elements, appState); + + if ( + selectedElements.length !== 1 || + !canCreateLinkFromElements(selectedElements) + ) { + return { elements, appState, app, storeAction: StoreAction.NONE }; + } + + return { + appState: { + ...appState, + openDialog: { + name: "elementLinkSelector", + sourceElementId: getSelectedElements(elements, appState)[0].id, + }, + }, + storeAction: StoreAction.CAPTURE, + }; + }, + predicate: (elements, appState, appProps, app) => { + const selectedElements = getSelectedElements(elements, appState); + + return ( + appState.openDialog?.name !== "elementLinkSelector" && + selectedElements.length === 1 && + canCreateLinkFromElements(selectedElements) + ); + }, + trackEvent: false, +}); diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index 9c5aa6d215..bb504b9d64 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -135,6 +135,8 @@ export type ActionName = | "autoResize" | "elementStats" | "searchMenu" + | "copyElementLink" + | "linkToElement" | "cropEditor"; export type PanelComponentProps = { diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 355bfe5063..644949e7c8 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -84,6 +84,7 @@ export const getDefaultAppState = (): Omit< scrollX: 0, scrollY: 0, selectedElementIds: {}, + hoveredElementIds: {}, selectedGroupIds: {}, selectedElementsAreBeingDragged: false, selectionElement: null, @@ -210,6 +211,7 @@ const APP_STATE_STORAGE_CONF = (< scrollX: { browser: true, export: false, server: false }, scrollY: { browser: true, export: false, server: false }, selectedElementIds: { browser: true, export: false, server: false }, + hoveredElementIds: { browser: false, export: false, server: false }, selectedGroupIds: { browser: true, export: false, server: false }, selectedElementsAreBeingDragged: { browser: false, diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index c4b4f71e20..5b5a3a3c1c 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -49,7 +49,6 @@ import { } from "../appState"; import type { PastedMixedContent } from "../clipboard"; import { copyTextToSystemClipboard, parseClipboard } from "../clipboard"; -import { ARROW_TYPE, isSafari, type EXPORT_IMAGE_TYPES } from "../constants"; import { APP_NAME, CURSOR_TYPE, @@ -88,6 +87,10 @@ import { supportsResizeObserver, DEFAULT_COLLISION_THRESHOLD, DEFAULT_TEXT_ALIGN, + ARROW_TYPE, + DEFAULT_REDUCED_GLOBAL_ALPHA, + isSafari, + type EXPORT_IMAGE_TYPES, } from "../constants"; import type { ExportedElements } from "../data"; import { exportCanvas, loadFromBlob } from "../data"; @@ -461,6 +464,8 @@ import { } from "../../math"; import { cropElement } from "../element/cropElement"; import { wrapText } from "../element/textWrapping"; +import { actionCopyElementLink } from "../actions/actionElementLink"; +import { isElementLink, parseElementLinkFromURL } from "../element/elementLink"; const AppContext = React.createContext<AppClassProperties>(null!); const AppPropsContext = React.createContext<AppProps>(null!); @@ -1202,6 +1207,9 @@ class App extends React.Component<AppProps, AppState> { getContainingFrame(el, this.scene.getNonDeletedElementsMap()), this.elementsPendingErasure, null, + this.state.openDialog?.name === "elementLinkSelector" + ? DEFAULT_REDUCED_GLOBAL_ALPHA + : 1, ), ["--embeddable-radius" as string]: `${getCornerRadius( Math.min(el.width, el.height), @@ -1520,7 +1528,9 @@ class App extends React.Component<AppProps, AppState> { return ( <div className={clsx("excalidraw excalidraw-container", { - "excalidraw--view-mode": this.state.viewModeEnabled, + "excalidraw--view-mode": + this.state.viewModeEnabled || + this.state.openDialog?.name === "elementLinkSelector", "excalidraw--mobile": this.device.editor.isMobile, })} style={{ @@ -1579,6 +1589,9 @@ class App extends React.Component<AppProps, AppState> { } app={this} isCollaborating={this.props.isCollaborating} + generateLinkForSelection={ + this.props.generateLinkForSelection + } > {this.props.children} </LayerUI> @@ -1590,6 +1603,8 @@ class App extends React.Component<AppProps, AppState> { trails={[this.laserTrails, this.eraserTrail]} /> {selectedElements.length === 1 && + this.state.openDialog?.name !== + "elementLinkSelector" && this.state.showHyperlinkPopup && ( <Hyperlink key={firstSelectedElement.id} @@ -2325,6 +2340,10 @@ class App extends React.Component<AppProps, AppState> { this.fonts.loadSceneFonts().then((fontFaces) => { this.fonts.onLoaded(fontFaces); }); + + if (isElementLink(window.location.href)) { + this.scrollToContent(window.location.href, { animate: false }); + } }; private isMobileBreakpoint = (width: number, height: number) => { @@ -2761,6 +2780,18 @@ class App extends React.Component<AppProps, AppState> { this.deselectElements(); } + // cleanup + if ( + (prevState.openDialog?.name === "elementLinkSelector" || + this.state.openDialog?.name === "elementLinkSelector") && + prevState.openDialog?.name !== this.state.openDialog?.name + ) { + this.deselectElements(); + this.setState({ + hoveredElementIds: {}, + }); + } + if (prevProps.zenModeEnabled !== this.props.zenModeEnabled) { this.setState({ zenModeEnabled: !!this.props.zenModeEnabled }); } @@ -3623,7 +3654,14 @@ class App extends React.Component<AppProps, AppState> { private cancelInProgressAnimation: (() => void) | null = null; scrollToContent = ( + /** + * target to scroll to + * + * - string - id of element or group, or url containing elementLink + * - ExcalidrawElement | ExcalidrawElement[] - element(s) objects + */ target: + | string | ExcalidrawElement | readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(), opts?: ( @@ -3650,6 +3688,34 @@ class App extends React.Component<AppProps, AppState> { canvasOffsets?: Offsets; }, ) => { + if (typeof target === "string") { + let id: string | null; + if (isElementLink(target)) { + id = parseElementLinkFromURL(target); + } else { + id = target; + } + if (id) { + const elements = this.scene.getElementsFromId(id); + + if (elements?.length) { + this.scrollToContent(elements, { + fitToContent: opts?.fitToContent ?? true, + animate: opts?.animate ?? true, + }); + } else if (isElementLink(target)) { + this.setState({ + toast: { + message: t("elementLink.notFound"), + duration: 3000, + closable: true, + }, + }); + } + } + return; + } + this.cancelInProgressAnimation?.(); // convert provided target into ExcalidrawElement[] if necessary @@ -4214,6 +4280,10 @@ class App extends React.Component<AppProps, AppState> { } } + if (this.state.openDialog?.name === "elementLinkSelector") { + return; + } + if (this.actionManager.handleKeyDown(event)) { return; } @@ -4485,7 +4555,10 @@ class App extends React.Component<AppProps, AppState> { private onKeyUp = withBatchedUpdates((event: KeyboardEvent) => { if (event.key === KEYS.SPACE) { - if (this.state.viewModeEnabled) { + if ( + this.state.viewModeEnabled || + this.state.openDialog?.name === "elementLinkSelector" + ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB); } else if (this.state.activeTool.type === "selection") { resetCursor(this.interactiveCanvas); @@ -5372,18 +5445,17 @@ class App extends React.Component<AppProps, AppState> { scenePointer: Readonly<{ x: number; y: number }>, hitElement: NonDeletedExcalidrawElement | null, ): ExcalidrawElement | undefined => { - // Reversing so we traverse the elements in decreasing order - // of z-index - const elements = this.scene.getNonDeletedElements().slice().reverse(); - let hitElementIndex = Infinity; + const elements = this.scene.getNonDeletedElements(); + let hitElementIndex = -1; - return elements.find((element, index) => { + for (let index = elements.length - 1; index >= 0; index--) { + const element = elements[index]; if (hitElement && element.id === hitElement.id) { hitElementIndex = index; } - return ( + if ( element.link && - index <= hitElementIndex && + index >= hitElementIndex && isPointHittingLink( element, this.scene.getNonDeletedElementsMap(), @@ -5391,8 +5463,10 @@ class App extends React.Component<AppProps, AppState> { pointFrom(scenePointer.x, scenePointer.y), this.device.editor.isMobile, ) - ); - }); + ) { + return element; + } + } }; private redirectToLink = ( @@ -5409,12 +5483,7 @@ class App extends React.Component<AppProps, AppState> { this.lastPointerUpEvent!.clientY, ), ); - if ( - !this.hitLinkElement || - // For touch screen allow dragging threshold else strict check - (isTouchScreen && draggedDistance > DRAGGING_THRESHOLD) || - (!isTouchScreen && draggedDistance !== 0) - ) { + if (!this.hitLinkElement || draggedDistance > DRAGGING_THRESHOLD) { return; } const lastPointerDownCoords = viewportCoordsToSceneCoords( @@ -5441,6 +5510,7 @@ class App extends React.Component<AppProps, AppState> { this.device.editor.isMobile, ); if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) { + hideHyperlinkToolip(); let url = this.hitLinkElement.link; if (url) { url = normalizeLink(url); @@ -5827,6 +5897,7 @@ class App extends React.Component<AppProps, AppState> { if ( (!this.state.selectedLinearElement || this.state.selectedLinearElement.hoverPointIndex === -1) && + this.state.openDialog?.name !== "elementLinkSelector" && !(selectedElements.length === 1 && isElbowArrow(selectedElements[0])) ) { const elementWithTransformHandleType = @@ -5851,7 +5922,11 @@ class App extends React.Component<AppProps, AppState> { return; } } - } else if (selectedElements.length > 1 && !isOverScrollBar) { + } else if ( + selectedElements.length > 1 && + !isOverScrollBar && + this.state.openDialog?.name !== "elementLinkSelector" + ) { const transformHandleType = getTransformHandleTypeFromCoords( getCommonBounds(selectedElements), scenePointerX, @@ -5910,6 +5985,8 @@ class App extends React.Component<AppProps, AppState> { ); } else if (this.state.viewModeEnabled) { setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB); + } else if (this.state.openDialog?.name === "elementLinkSelector") { + setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO); } else if (isOverScrollBar) { setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO); } else if (this.state.selectedLinearElement) { @@ -5955,6 +6032,32 @@ class App extends React.Component<AppProps, AppState> { setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO); } } + + if (this.state.openDialog?.name === "elementLinkSelector" && hitElement) { + this.setState((prevState) => { + return { + hoveredElementIds: updateStable( + prevState.hoveredElementIds, + selectGroupsForSelectedElements( + { + editingGroupId: prevState.editingGroupId, + selectedElementIds: { [hitElement.id]: true }, + }, + this.scene.getNonDeletedElements(), + prevState, + this, + ).selectedElementIds, + ), + }; + }); + } else if ( + this.state.openDialog?.name === "elementLinkSelector" && + !hitElement + ) { + this.setState((prevState) => ({ + hoveredElementIds: updateStable(prevState.hoveredElementIds, {}), + })); + } }; private handleEraser = ( @@ -6212,7 +6315,10 @@ class App extends React.Component<AppProps, AppState> { this.state, ), }, - storeAction: StoreAction.UPDATE, + storeAction: + this.state.openDialog?.name === "elementLinkSelector" + ? StoreAction.NONE + : StoreAction.UPDATE, }); return; } @@ -6939,6 +7045,15 @@ class App extends React.Component<AppProps, AppState> { pointerDownState.origin.y, ); + this.hitLinkElement = this.getElementLinkAtPosition( + pointerDownState.origin, + pointerDownState.hit.element, + ); + + if (this.hitLinkElement) { + return true; + } + if ( this.state.croppingElementId && pointerDownState.hit.element?.id !== this.state.croppingElementId @@ -7032,7 +7147,7 @@ class App extends React.Component<AppProps, AppState> { !pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements ) { this.setState((prevState) => { - const nextSelectedElementIds: { [id: string]: true } = { + let nextSelectedElementIds: { [id: string]: true } = { ...prevState.selectedElementIds, [hitElement.id]: true, }; @@ -7103,6 +7218,23 @@ class App extends React.Component<AppProps, AppState> { } } + // Finally, in shape selection mode, we'd like to + // keep only one shape or group selected at a time. + // This means, if the hitElement is a different shape or group + // than the previously selected ones, we deselect the previous ones + // and select the hitElement + if (prevState.openDialog?.name === "elementLinkSelector") { + if ( + !hitElement.groupIds.some( + (gid) => prevState.selectedGroupIds[gid], + ) + ) { + nextSelectedElementIds = { + [hitElement.id]: true, + }; + } + } + return { ...selectGroupsForSelectedElements( { @@ -7747,6 +7879,9 @@ class App extends React.Component<AppProps, AppState> { pointerDownState: PointerDownState, ) { return withBatchedUpdatesThrottled((event: PointerEvent) => { + if (this.state.openDialog?.name === "elementLinkSelector") { + return; + } const pointerCoords = viewportCoordsToSceneCoords(event, this.state); const lastPointerCoords = this.lastPointerMoveCoords ?? pointerDownState.origin; @@ -10498,7 +10633,10 @@ class App extends React.Component<AppProps, AppState> { actionFlipVertical, CONTEXT_MENU_SEPARATOR, actionToggleLinearEditor, + CONTEXT_MENU_SEPARATOR, actionLink, + actionCopyElementLink, + CONTEXT_MENU_SEPARATOR, actionDuplicateSelection, actionToggleElementLock, CONTEXT_MENU_SEPARATOR, diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx index 9333c8f654..039ac88c11 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx @@ -56,6 +56,10 @@ import { trackEvent } from "../../analytics"; import { useStable } from "../../hooks/useStable"; import "./CommandPalette.scss"; +import { + actionCopyElementLink, + actionLinkToElement, +} from "../../actions/actionElementLink"; const lastUsedPaletteItem = atom<CommandPaletteItem | null>(null); @@ -281,6 +285,8 @@ function CommandPaletteInner({ actionManager.actions.toggleLinearEditor, actionManager.actions.cropEditor, actionLink, + actionCopyElementLink, + actionLinkToElement, ].map((action: Action) => actionToCommand( action, diff --git a/packages/excalidraw/components/ElementLinkDialog.scss b/packages/excalidraw/components/ElementLinkDialog.scss new file mode 100644 index 0000000000..bd99e81965 --- /dev/null +++ b/packages/excalidraw/components/ElementLinkDialog.scss @@ -0,0 +1,87 @@ +@import "../css/variables.module.scss"; + +.excalidraw { + .ElementLinkDialog { + position: absolute; + top: var(--editor-container-padding); + left: calc(var(--editor-container-padding) * 4); + + z-index: 3; + + border-radius: 10px; + padding: 1.5rem; + + display: flex; + flex-direction: column; + justify-content: space-between; + box-shadow: var(--shadow-island); + background-color: var(--island-bg-color); + + @include isMobile { + left: 0; + margin-left: 0.5rem; + margin-right: 0.5rem; + width: calc(100% - 1rem); + box-sizing: border-box; + z-index: 5; + } + + .ElementLinkDialog__header { + h2 { + margin-top: 0; + margin-bottom: 0.5rem; + + @include isMobile { + font-size: 1.25rem; + } + } + + p { + margin: 0; + + @include isMobile { + font-size: 0.875rem; + } + } + + margin-bottom: 1.5rem; + + @include isMobile { + margin-bottom: 1rem; + } + } + + .ElementLinkDialog__input { + display: flex; + + .ElementLinkDialog__input-field { + flex: 1; + } + + .ElementLinkDialog__remove { + color: $oc-red-9; + margin-left: 1rem; + + .ToolIcon__icon { + width: 2rem; + height: 2rem; + } + + .ToolIcon__icon svg { + color: $oc-red-6; + } + } + } + + .ElementLinkDialog__actions { + display: flex; + justify-content: flex-end; + margin-top: 1.5rem; + + @include isMobile { + font-size: 0.875rem; + margin-top: 1rem; + } + } + } +} diff --git a/packages/excalidraw/components/ElementLinkDialog.tsx b/packages/excalidraw/components/ElementLinkDialog.tsx new file mode 100644 index 0000000000..2ec3eaa0b6 --- /dev/null +++ b/packages/excalidraw/components/ElementLinkDialog.tsx @@ -0,0 +1,174 @@ +import { TextField } from "./TextField"; +import type { AppProps, AppState, UIAppState } from "../types"; +import DialogActionButton from "./DialogActionButton"; +import { getSelectedElements } from "../scene"; +import { + defaultGetElementLinkFromSelection, + getLinkIdAndTypeFromSelection, +} from "../element/elementLink"; +import { mutateElement } from "../element/mutateElement"; +import { useCallback, useEffect, useState } from "react"; +import { t } from "../i18n"; +import type { ElementsMap, ExcalidrawElement } from "../element/types"; +import { ToolButton } from "./ToolButton"; +import { TrashIcon } from "./icons"; +import { KEYS } from "../keys"; + +import "./ElementLinkDialog.scss"; +import { normalizeLink } from "../data/url"; + +const ElementLinkDialog = ({ + sourceElementId, + onClose, + elementsMap, + appState, + generateLinkForSelection = defaultGetElementLinkFromSelection, +}: { + sourceElementId: ExcalidrawElement["id"]; + elementsMap: ElementsMap; + appState: UIAppState; + onClose?: () => void; + generateLinkForSelection: AppProps["generateLinkForSelection"]; +}) => { + const originalLink = elementsMap.get(sourceElementId)?.link ?? null; + + const [nextLink, setNextLink] = useState<string | null>(originalLink); + const [linkEdited, setLinkEdited] = useState(false); + + useEffect(() => { + const selectedElements = getSelectedElements(elementsMap, appState); + let nextLink = originalLink; + + if (selectedElements.length > 0 && generateLinkForSelection) { + const idAndType = getLinkIdAndTypeFromSelection( + selectedElements, + appState as AppState, + ); + + if (idAndType) { + nextLink = normalizeLink( + generateLinkForSelection(idAndType.id, idAndType.type), + ); + } + } + + setNextLink(nextLink); + }, [ + elementsMap, + appState, + appState.selectedElementIds, + originalLink, + generateLinkForSelection, + ]); + + const handleConfirm = useCallback(() => { + if (nextLink && nextLink !== elementsMap.get(sourceElementId)?.link) { + const elementToLink = elementsMap.get(sourceElementId); + elementToLink && + mutateElement(elementToLink, { + link: nextLink, + }); + } + + if (!nextLink && linkEdited && sourceElementId) { + const elementToLink = elementsMap.get(sourceElementId); + elementToLink && + mutateElement(elementToLink, { + link: null, + }); + } + + onClose?.(); + }, [sourceElementId, nextLink, elementsMap, linkEdited, onClose]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + appState.openDialog?.name === "elementLinkSelector" && + event.key === KEYS.ENTER + ) { + handleConfirm(); + } + + if ( + appState.openDialog?.name === "elementLinkSelector" && + event.key === KEYS.ESCAPE + ) { + onClose?.(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [appState, onClose, handleConfirm]); + + return ( + <div className="ElementLinkDialog"> + <div className="ElementLinkDialog__header"> + <h2>{t("elementLink.title")}</h2> + <p>{t("elementLink.desc")}</p> + </div> + + <div className="ElementLinkDialog__input"> + <TextField + value={nextLink ?? ""} + onChange={(value) => { + if (!linkEdited) { + setLinkEdited(true); + } + setNextLink(value); + }} + onKeyDown={(event) => { + if (event.key === KEYS.ENTER) { + handleConfirm(); + } + }} + className="ElementLinkDialog__input-field" + selectOnRender + /> + + {originalLink && nextLink && ( + <ToolButton + type="button" + title={t("buttons.remove")} + aria-label={t("buttons.remove")} + label={t("buttons.remove")} + onClick={() => { + // removes the link from the input + // but doesn't update the element + + // when confirmed, will remove the link from the element + setNextLink(null); + setLinkEdited(true); + }} + className="ElementLinkDialog__remove" + icon={TrashIcon} + /> + )} + </div> + + <div className="ElementLinkDialog__actions"> + <DialogActionButton + label={t("buttons.cancel")} + onClick={() => { + onClose?.(); + }} + style={{ + marginRight: 10, + }} + /> + + <DialogActionButton + label={t("buttons.confirm")} + onClick={handleConfirm} + actionType="primary" + /> + </div> + </div> + ); +}; + +export default ElementLinkDialog; diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index 64c34dd1ca..f51ba37ae5 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -60,6 +60,7 @@ import { LaserPointerButton } from "./LaserPointerButton"; import { TTDDialog } from "./TTDDialog/TTDDialog"; import { Stats } from "./Stats"; import { actionToggleStats } from "../actions"; +import ElementLinkDialog from "./ElementLinkDialog"; import "./LayerUI.scss"; import "./Toolbar.scss"; @@ -84,6 +85,7 @@ interface LayerUIProps { children?: React.ReactNode; app: AppClassProperties; isCollaborating: boolean; + generateLinkForSelection?: AppProps["generateLinkForSelection"]; } const DefaultMainMenu: React.FC<{ @@ -141,6 +143,7 @@ const LayerUI = ({ children, app, isCollaborating, + generateLinkForSelection, }: LayerUIProps) => { const device = useDevice(); const tunnels = useInitializeTunnels(); @@ -232,7 +235,8 @@ const LayerUI = ({ const shouldShowStats = appState.stats.open && !appState.zenModeEnabled && - !appState.viewModeEnabled; + !appState.viewModeEnabled && + appState.openDialog?.name !== "elementLinkSelector"; return ( <FixedSideContainer side="top"> @@ -241,90 +245,91 @@ const LayerUI = ({ {renderCanvasActions()} {shouldRenderSelectedShapeActions && renderSelectedShapeActions()} </Stack.Col> - {!appState.viewModeEnabled && ( - <Section heading="shapes" className="shapes-section"> - {(heading: React.ReactNode) => ( - <div style={{ position: "relative" }}> - {renderWelcomeScreen && ( - <tunnels.WelcomeScreenToolbarHintTunnel.Out /> - )} - <Stack.Col gap={4} align="start"> - <Stack.Row - gap={1} - className={clsx("App-toolbar-container", { - "zen-mode": appState.zenModeEnabled, - })} - > - <Island - padding={1} - className={clsx("App-toolbar", { + {!appState.viewModeEnabled && + appState.openDialog?.name !== "elementLinkSelector" && ( + <Section heading="shapes" className="shapes-section"> + {(heading: React.ReactNode) => ( + <div style={{ position: "relative" }}> + {renderWelcomeScreen && ( + <tunnels.WelcomeScreenToolbarHintTunnel.Out /> + )} + <Stack.Col gap={4} align="start"> + <Stack.Row + gap={1} + className={clsx("App-toolbar-container", { "zen-mode": appState.zenModeEnabled, })} > - <HintViewer - appState={appState} - isMobile={device.editor.isMobile} - device={device} - app={app} - /> - {heading} - <Stack.Row gap={1}> - <PenModeButton - zenModeEnabled={appState.zenModeEnabled} - checked={appState.penMode} - onChange={() => onPenModeToggle(null)} - title={t("toolBar.penMode")} - penDetected={appState.penDetected} - /> - <LockButton - checked={appState.activeTool.locked} - onChange={onLockToggle} - title={t("toolBar.lock")} + <Island + padding={1} + className={clsx("App-toolbar", { + "zen-mode": appState.zenModeEnabled, + })} + > + <HintViewer + appState={appState} + isMobile={device.editor.isMobile} + device={device} + app={app} /> + {heading} + <Stack.Row gap={1}> + <PenModeButton + zenModeEnabled={appState.zenModeEnabled} + checked={appState.penMode} + onChange={() => onPenModeToggle(null)} + title={t("toolBar.penMode")} + penDetected={appState.penDetected} + /> + <LockButton + checked={appState.activeTool.locked} + onChange={onLockToggle} + title={t("toolBar.lock")} + /> - <div className="App-toolbar__divider" /> + <div className="App-toolbar__divider" /> - <HandButton - checked={isHandToolActive(appState)} - onChange={() => onHandToolToggle()} - title={t("toolBar.hand")} - isMobile - /> + <HandButton + checked={isHandToolActive(appState)} + onChange={() => onHandToolToggle()} + title={t("toolBar.hand")} + isMobile + /> - <ShapesSwitcher - appState={appState} - activeTool={appState.activeTool} - UIOptions={UIOptions} - app={app} - /> - </Stack.Row> - </Island> - {isCollaborating && ( - <Island - style={{ - marginLeft: 8, - alignSelf: "center", - height: "fit-content", - }} - > - <LaserPointerButton - title={t("toolBar.laser")} - checked={ - appState.activeTool.type === TOOL_TYPE.laser - } - onChange={() => - app.setActiveTool({ type: TOOL_TYPE.laser }) - } - isMobile - /> + <ShapesSwitcher + appState={appState} + activeTool={appState.activeTool} + UIOptions={UIOptions} + app={app} + /> + </Stack.Row> </Island> - )} - </Stack.Row> - </Stack.Col> - </div> - )} - </Section> - )} + {isCollaborating && ( + <Island + style={{ + marginLeft: 8, + alignSelf: "center", + height: "fit-content", + }} + > + <LaserPointerButton + title={t("toolBar.laser")} + checked={ + appState.activeTool.type === TOOL_TYPE.laser + } + onChange={() => + app.setActiveTool({ type: TOOL_TYPE.laser }) + } + isMobile + /> + </Island> + )} + </Stack.Row> + </Stack.Col> + </div> + )} + </Section> + )} <div className={clsx( "layer-ui__wrapper__top-right zen-mode-transition", @@ -341,6 +346,7 @@ const LayerUI = ({ )} {renderTopRightUI?.(device.editor.isMobile, appState)} {!appState.viewModeEnabled && + appState.openDialog?.name !== "elementLinkSelector" && // hide button when sidebar docked (!isSidebarDocked || appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && ( @@ -471,6 +477,19 @@ const LayerUI = ({ /> )} <ActiveConfirmDialog /> + {appState.openDialog?.name === "elementLinkSelector" && ( + <ElementLinkDialog + sourceElementId={appState.openDialog.sourceElementId} + onClose={() => { + setAppState({ + openDialog: null, + }); + }} + elementsMap={app.scene.getNonDeletedElementsMap()} + appState={appState} + generateLinkForSelection={generateLinkForSelection} + /> + )} <tunnels.OverwriteConfirmDialogTunnel.Out /> {renderImageExportDialog()} {renderJSONExportDialog()} diff --git a/packages/excalidraw/components/MobileMenu.tsx b/packages/excalidraw/components/MobileMenu.tsx index c5dabeee75..91e67d7476 100644 --- a/packages/excalidraw/components/MobileMenu.tsx +++ b/packages/excalidraw/components/MobileMenu.tsx @@ -91,9 +91,10 @@ export const MobileMenu = ({ </Island> {renderTopRightUI && renderTopRightUI(true, appState)} <div className="mobile-misc-tools-container"> - {!appState.viewModeEnabled && ( - <DefaultSidebarTriggerTunnel.Out /> - )} + {!appState.viewModeEnabled && + appState.openDialog?.name !== "elementLinkSelector" && ( + <DefaultSidebarTriggerTunnel.Out /> + )} <PenModeButton checked={appState.penMode} onChange={() => onPenModeToggle(null)} @@ -129,7 +130,10 @@ export const MobileMenu = ({ }; const renderAppToolbar = () => { - if (appState.viewModeEnabled) { + if ( + appState.viewModeEnabled || + appState.openDialog?.name === "elementLinkSelector" + ) { return ( <div className="App-toolbar-content"> <MainMenuTunnel.Out /> @@ -154,7 +158,9 @@ export const MobileMenu = ({ return ( <> {renderSidebars()} - {!appState.viewModeEnabled && renderToolbar()} + {!appState.viewModeEnabled && + appState.openDialog?.name !== "elementLinkSelector" && + renderToolbar()} <div className="App-bottom-bar" style={{ @@ -166,6 +172,7 @@ export const MobileMenu = ({ <Island padding={0}> {appState.openMenu === "shape" && !appState.viewModeEnabled && + appState.openDialog?.name !== "elementLinkSelector" && showSelectedShapeActions(appState, elements) ? ( <Section className="App-mobile-menu" heading="selectedShapeActions"> <SelectedShapeActions diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index c9ba8f3eb7..7b8003332c 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -182,6 +182,7 @@ const getRelevantAppStateProps = ( width: appState.width, height: appState.height, viewModeEnabled: appState.viewModeEnabled, + openDialog: appState.openDialog, editingGroupId: appState.editingGroupId, editingLinearElement: appState.editingLinearElement, selectedElementIds: appState.selectedElementIds, diff --git a/packages/excalidraw/components/canvases/StaticCanvas.tsx b/packages/excalidraw/components/canvases/StaticCanvas.tsx index 8ec38ac6bc..9185bdd5c1 100644 --- a/packages/excalidraw/components/canvases/StaticCanvas.tsx +++ b/packages/excalidraw/components/canvases/StaticCanvas.tsx @@ -92,6 +92,8 @@ const getRelevantAppStateProps = ( width: appState.width, height: appState.height, viewModeEnabled: appState.viewModeEnabled, + openDialog: appState.openDialog, + hoveredElementIds: appState.hoveredElementIds, offsetLeft: appState.offsetLeft, offsetTop: appState.offsetTop, theme: appState.theme, diff --git a/packages/excalidraw/components/hyperlink/Hyperlink.tsx b/packages/excalidraw/components/hyperlink/Hyperlink.tsx index 9e642fa44f..f94f6f392d 100644 --- a/packages/excalidraw/components/hyperlink/Hyperlink.tsx +++ b/packages/excalidraw/components/hyperlink/Hyperlink.tsx @@ -13,7 +13,7 @@ import type { } from "../../element/types"; import { ToolButton } from "../ToolButton"; -import { FreedrawIcon, TrashIcon } from "../icons"; +import { FreedrawIcon, TrashIcon, elementLinkIcon } from "../icons"; import { t } from "../../i18n"; import { useCallback, @@ -30,18 +30,19 @@ import { getTooltipDiv, updateTooltipPosition } from "../../components/Tooltip"; import { getSelectedElements } from "../../scene"; import { hitElementBoundingBox } from "../../element/collision"; import { isLocalLink, normalizeLink } from "../../data/url"; - -import "./Hyperlink.scss"; import { trackEvent } from "../../analytics"; -import { useAppProps, useExcalidrawAppState } from "../App"; +import { useAppProps, useDevice, useExcalidrawAppState } from "../App"; import { isEmbeddableElement } from "../../element/typeChecks"; import { getLinkHandleFromCoords } from "./helpers"; import { pointFrom, type GlobalPoint } from "../../../math"; +import { isElementLink } from "../../element/elementLink"; -const CONTAINER_WIDTH = 320; +import "./Hyperlink.scss"; + +const POPUP_WIDTH = 380; +const POPUP_HEIGHT = 42; +const POPUP_PADDING = 5; const SPACE_BOTTOM = 85; -const CONTAINER_PADDING = 5; -const CONTAINER_HEIGHT = 42; const AUTO_HIDE_TIMEOUT = 500; let IS_HYPERLINK_TOOLTIP_VISIBLE = false; @@ -73,6 +74,7 @@ export const Hyperlink = ({ }) => { const appState = useExcalidrawAppState(); const appProps = useAppProps(); + const device = useDevice(); const linkVal = element.link || ""; @@ -170,6 +172,15 @@ export const Hyperlink = ({ useEffect(() => { let timeoutId: number | null = null; + + if ( + inputRef && + inputRef.current && + !(device.viewport.isMobile || device.isTouchScreen) + ) { + inputRef.current.select(); + } + const handlePointerMove = (event: PointerEvent) => { if (isEditing) { return; @@ -196,16 +207,21 @@ export const Hyperlink = ({ clearTimeout(timeoutId); } }; - }, [appState, element, isEditing, setAppState, elementsMap]); + }, [ + appState, + element, + isEditing, + setAppState, + elementsMap, + device.viewport.isMobile, + device.isTouchScreen, + ]); const handleRemove = useCallback(() => { trackEvent("hyperlink", "delete"); mutateElement(element, { link: null }); - if (isEditing) { - inputRef.current!.value = ""; - } setAppState({ showHyperlinkPopup: false }); - }, [setAppState, element, isEditing]); + }, [setAppState, element]); const onEdit = () => { trackEvent("hyperlink", "edit", "popup-ui"); @@ -229,19 +245,14 @@ export const Hyperlink = ({ style={{ top: `${y}px`, left: `${x}px`, - width: CONTAINER_WIDTH, - padding: CONTAINER_PADDING, - }} - onClick={() => { - if (!element.link && !isEditing) { - setAppState({ showHyperlinkPopup: "editor" }); - } + width: POPUP_WIDTH, + padding: POPUP_PADDING, }} > {isEditing ? ( <input className={clsx("excalidraw-hyperlinkContainer-input")} - placeholder="Type or paste your link here" + placeholder={t("labels.link.hint")} ref={inputRef} value={inputVal} onChange={(event) => setInputVal(event.target.value)} @@ -302,6 +313,21 @@ export const Hyperlink = ({ icon={FreedrawIcon} /> )} + <ToolButton + type="button" + title={t("labels.linkToElement")} + aria-label={t("labels.linkToElement")} + label={t("labels.linkToElement")} + onClick={() => { + setAppState({ + openDialog: { + name: "elementLinkSelector", + sourceElementId: element.id, + }, + }); + }} + icon={elementLinkIcon} + /> {linkVal && !isEmbeddableElement(element) && ( <ToolButton type="button" @@ -328,7 +354,7 @@ const getCoordsForPopover = ( { sceneX: x1 + element.width / 2, sceneY: y1 }, appState, ); - const x = viewportX - appState.offsetLeft - CONTAINER_WIDTH / 2; + const x = viewportX - appState.offsetLeft - POPUP_WIDTH / 2; const y = viewportY - appState.offsetTop - SPACE_BOTTOM; return { x, y }; }; @@ -338,12 +364,10 @@ export const getContextMenuLabel = ( appState: UIAppState, ) => { const selectedElements = getSelectedElements(elements, appState); - const label = selectedElements[0]?.link - ? isEmbeddableElement(selectedElements[0]) - ? "labels.link.editEmbed" - : "labels.link.edit" - : isEmbeddableElement(selectedElements[0]) - ? "labels.link.createEmbed" + const label = isEmbeddableElement(selectedElements[0]) + ? "labels.link.editEmbed" + : selectedElements[0]?.link + ? "labels.link.edit" : "labels.link.create"; return label; }; @@ -376,7 +400,9 @@ const renderTooltip = ( tooltipDiv.classList.add("excalidraw-tooltip--visible"); tooltipDiv.style.maxWidth = "20rem"; - tooltipDiv.textContent = element.link; + tooltipDiv.textContent = isElementLink(element.link) + ? t("labels.link.goToElement") + : element.link; const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); @@ -450,9 +476,9 @@ const shouldHideLinkPopup = ( if ( clientX >= popoverX - threshold && - clientX <= popoverX + CONTAINER_WIDTH + CONTAINER_PADDING * 2 + threshold && + clientX <= popoverX + POPUP_WIDTH + POPUP_PADDING * 2 + threshold && clientY >= popoverY - threshold && - clientY <= popoverY + threshold + CONTAINER_PADDING * 2 + CONTAINER_HEIGHT + clientY <= popoverY + threshold + POPUP_PADDING * 2 + POPUP_HEIGHT ) { return false; } diff --git a/packages/excalidraw/components/hyperlink/helpers.ts b/packages/excalidraw/components/hyperlink/helpers.ts index bc470422c9..15c06a9f4e 100644 --- a/packages/excalidraw/components/hyperlink/helpers.ts +++ b/packages/excalidraw/components/hyperlink/helpers.ts @@ -16,6 +16,11 @@ EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent( `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1971c2" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-external-link"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>`, )}`; +export const ELEMENT_LINK_IMG = document.createElement("img"); +ELEMENT_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent( + `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1971c2" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-arrow-big-right-line"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 9v-3.586a1 1 0 0 1 1.707 -.707l6.586 6.586a1 1 0 0 1 0 1.414l-6.586 6.586a1 1 0 0 1 -1.707 -.707v-3.586h-6v-6h6z" /><path d="M3 9v6" /></svg>`, +)}`; + export const getLinkHandleFromCoords = ( [x1, y1, x2, y2]: Bounds, angle: Radians, diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index a58ae8cf4c..7ba3f618b7 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -2156,3 +2156,18 @@ export const cropIcon = createIcon( </g>, tablerIconProps, ); + +export const elementLinkIcon = createIcon( + <g> + <path stroke="none" d="M0 0h24v24H0z" fill="none" /> + <path d="M5 5m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" /> + <path d="M19 5m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" /> + <path d="M5 19m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" /> + <path d="M19 19m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" /> + <path d="M5 7l0 10" /> + <path d="M7 5l10 0" /> + <path d="M7 19l10 0" /> + <path d="M19 7l0 10" /> + </g>, + tablerIconProps, +); diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index 6bd8f1e99f..b722028c23 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -449,3 +449,6 @@ export const ARROW_TYPE: { [T in AppState["currentItemArrowType"]]: T } = { round: "round", elbow: "elbow", }; + +export const DEFAULT_REDUCED_GLOBAL_ALPHA = 0.3; +export const ELEMENT_LINK_KEY = "element"; diff --git a/packages/excalidraw/element/elementLink.ts b/packages/excalidraw/element/elementLink.ts new file mode 100644 index 0000000000..991f9caec7 --- /dev/null +++ b/packages/excalidraw/element/elementLink.ts @@ -0,0 +1,102 @@ +/** + * Create and link between shapes. + */ + +import { ELEMENT_LINK_KEY } from "../constants"; +import { normalizeLink } from "../data/url"; +import { elementsAreInSameGroup } from "../groups"; +import type { AppProps, AppState } from "../types"; +import type { ExcalidrawElement } from "./types"; + +export const defaultGetElementLinkFromSelection: Exclude< + AppProps["generateLinkForSelection"], + undefined +> = (id, type) => { + const url = window.location.href; + + try { + const link = new URL(url); + link.searchParams.set(ELEMENT_LINK_KEY, id); + + return normalizeLink(link.toString()); + } catch (error) { + console.error(error); + } + + return normalizeLink(url); +}; + +export const getLinkIdAndTypeFromSelection = ( + selectedElements: ExcalidrawElement[], + appState: AppState, +): { + id: string; + type: "element" | "group"; +} | null => { + if ( + selectedElements.length > 0 && + canCreateLinkFromElements(selectedElements) + ) { + if (selectedElements.length === 1) { + return { + id: selectedElements[0].id, + type: "element", + }; + } + + if (selectedElements.length > 1) { + const selectedGroupId = Object.keys(appState.selectedGroupIds)[0]; + + if (selectedGroupId) { + return { + id: selectedGroupId, + type: "group", + }; + } + return { + id: selectedElements[0].groupIds[0], + type: "group", + }; + } + } + + return null; +}; + +export const canCreateLinkFromElements = ( + selectedElements: ExcalidrawElement[], +) => { + if (selectedElements.length === 1) { + return true; + } + + if (selectedElements.length > 1 && elementsAreInSameGroup(selectedElements)) { + return true; + } + + return false; +}; + +export const isElementLink = (url: string) => { + try { + const _url = new URL(url); + return ( + _url.searchParams.has(ELEMENT_LINK_KEY) && + _url.host === window.location.host + ); + } catch (error) { + return false; + } +}; + +export const parseElementLinkFromURL = (url: string) => { + try { + const { searchParams } = new URL(url); + if (searchParams.has(ELEMENT_LINK_KEY)) { + const id = searchParams.get(ELEMENT_LINK_KEY); + return id; + } + } catch {} + + return null; +}; diff --git a/packages/excalidraw/element/showSelectedShapeActions.ts b/packages/excalidraw/element/showSelectedShapeActions.ts index eea9336749..bbf313d01b 100644 --- a/packages/excalidraw/element/showSelectedShapeActions.ts +++ b/packages/excalidraw/element/showSelectedShapeActions.ts @@ -8,6 +8,7 @@ export const showSelectedShapeActions = ( ) => Boolean( !appState.viewModeEnabled && + appState.openDialog?.name !== "elementLinkSelector" && ((appState.activeTool.type !== "custom" && (appState.editingTextElement || (appState.activeTool.type !== "selection" && diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index 0335a9f388..c83bd7c16b 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -43,6 +43,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { autoFocus = false, generateIdForFile, onLinkOpen, + generateLinkForSelection, onPointerDown, onPointerUp, onScrollChange, @@ -132,6 +133,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { autoFocus={autoFocus} generateIdForFile={generateIdForFile} onLinkOpen={onLinkOpen} + generateLinkForSelection={generateLinkForSelection} onPointerDown={onPointerDown} onPointerUp={onPointerUp} onScrollChange={onScrollChange} @@ -291,3 +293,4 @@ export { export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToCodePlugin"; export { getDataURL } from "./data/blob"; +export { isElementLink } from "./element/elementLink"; diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index d9115cfce6..85c5076f3e 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -125,12 +125,13 @@ "createContainerFromText": "Wrap text in a container", "link": { "edit": "Edit link", - "editEmbed": "Edit link & embed", - "create": "Create link", - "createEmbed": "Create link & embed", + "editEmbed": "Edit embeddable link", + "create": "Add link", "label": "Link", "labelEmbed": "Link & embed", - "empty": "No link is set" + "empty": "No link is set", + "hint": "Type or paste your link here", + "goToElement": "Go to target element" }, "lineEditor": { "edit": "Edit line", @@ -155,7 +156,14 @@ "zoomToFitSelection": "Zoom to fit selection", "zoomToFit": "Zoom to fit all elements", "installPWA": "Install Excalidraw locally (PWA)", - "autoResize": "Enable text auto-resizing" + "autoResize": "Enable text auto-resizing", + "copyElementLink": "Copy link to object", + "linkToElement": "Link to object" + }, + "elementLink": { + "title": "Link to object", + "desc": "Click on a shape on canvas or paste a link.", + "notFound": "Linked object wasn't found on canvas." }, "library": { "noItems": "No items added yet...", @@ -501,7 +509,8 @@ "selection": "selection", "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" + "unrecognizedLinkFormat": "The link you embedded does not match the expected format. Please try to paste the 'embed' string provided by the source site", + "elementLinkCopied": "Link copied to clipboard" }, "colors": { "transparent": "Transparent", diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index b48e3bd830..50da857a25 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -40,6 +40,7 @@ import type { import { getDefaultAppState } from "../appState"; import { BOUND_TEXT_PADDING, + DEFAULT_REDUCED_GLOBAL_ALPHA, ELEMENT_READY_TO_ERASE_OPACITY, FRAME_STYLE, MIME_TYPES, @@ -109,10 +110,13 @@ export const getRenderOpacity = ( containingFrame: ExcalidrawFrameLikeElement | null, elementsPendingErasure: ElementsPendingErasure, pendingNodes: Readonly<PendingExcalidrawElements> | null, + globalAlpha: number = 1, ) => { // multiplying frame opacity with element opacity to combine them // (e.g. frame 50% and element 50% opacity should result in 25% opacity) - let opacity = ((containingFrame?.opacity ?? 100) * element.opacity) / 10000; + let opacity = + (((containingFrame?.opacity ?? 100) * element.opacity) / 10000) * + globalAlpha; // if pending erasure, multiply again to combine further // (so that erasing always results in lower opacity than original) @@ -700,11 +704,17 @@ export const renderElement = ( renderConfig: StaticCanvasRenderConfig, appState: StaticCanvasAppState, ) => { + const reduceAlphaForSelection = + appState.openDialog?.name === "elementLinkSelector" && + !appState.selectedElementIds[element.id] && + !appState.hoveredElementIds[element.id]; + context.globalAlpha = getRenderOpacity( element, getContainingFrame(element, elementsMap), renderConfig.elementsPendingErasure, renderConfig.pendingFlowchartNodes, + reduceAlphaForSelection ? DEFAULT_REDUCED_GLOBAL_ALPHA : 1, ); switch (element.type) { diff --git a/packages/excalidraw/renderer/staticScene.ts b/packages/excalidraw/renderer/staticScene.ts index 90c07e8cbe..f18c5c3740 100644 --- a/packages/excalidraw/renderer/staticScene.ts +++ b/packages/excalidraw/renderer/staticScene.ts @@ -25,11 +25,13 @@ import type { } from "../scene/types"; import { EXTERNAL_LINK_IMG, + ELEMENT_LINK_IMG, getLinkHandleFromCoords, } from "../components/hyperlink/helpers"; import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers"; import { throttleRAF } from "../utils"; import { getBoundTextElement } from "../element/textElement"; +import { isElementLink } from "../element/elementLink"; const GridLineColor = { Bold: "#dddddd", @@ -133,7 +135,16 @@ const frameClip = ( ); }; -let linkCanvasCache: any; +type LinkIconCanvas = HTMLCanvasElement & { zoom: number }; + +const linkIconCanvasCache: { + regularLink: LinkIconCanvas | null; + elementLink: LinkIconCanvas | null; +} = { + regularLink: null, + elementLink: null, +}; + const renderLinkIcon = ( element: NonDeletedExcalidrawElement, context: CanvasRenderingContext2D, @@ -153,38 +164,44 @@ const renderLinkIcon = ( context.translate(appState.scrollX + centerX, appState.scrollY + centerY); context.rotate(element.angle); - if (!linkCanvasCache || linkCanvasCache.zoom !== appState.zoom.value) { - linkCanvasCache = document.createElement("canvas"); - linkCanvasCache.zoom = appState.zoom.value; - linkCanvasCache.width = - width * window.devicePixelRatio * appState.zoom.value; - linkCanvasCache.height = + const canvasKey = isElementLink(element.link) + ? "elementLink" + : "regularLink"; + + let linkCanvas = linkIconCanvasCache[canvasKey]; + + if (!linkCanvas || linkCanvas.zoom !== appState.zoom.value) { + linkCanvas = Object.assign(document.createElement("canvas"), { + zoom: appState.zoom.value, + }); + linkCanvas.width = width * window.devicePixelRatio * appState.zoom.value; + linkCanvas.height = height * window.devicePixelRatio * appState.zoom.value; - const linkCanvasCacheContext = linkCanvasCache.getContext("2d")!; + linkIconCanvasCache[canvasKey] = linkCanvas; + + const linkCanvasCacheContext = linkCanvas.getContext("2d")!; linkCanvasCacheContext.scale( window.devicePixelRatio * appState.zoom.value, window.devicePixelRatio * appState.zoom.value, ); linkCanvasCacheContext.fillStyle = "#fff"; linkCanvasCacheContext.fillRect(0, 0, width, height); - linkCanvasCacheContext.drawImage(EXTERNAL_LINK_IMG, 0, 0, width, height); + + if (canvasKey === "elementLink") { + linkCanvasCacheContext.drawImage(ELEMENT_LINK_IMG, 0, 0, width, height); + } else { + linkCanvasCacheContext.drawImage( + EXTERNAL_LINK_IMG, + 0, + 0, + width, + height, + ); + } + linkCanvasCacheContext.restore(); - context.drawImage( - linkCanvasCache, - x - centerX, - y - centerY, - width, - height, - ); - } else { - context.drawImage( - linkCanvasCache, - x - centerX, - y - centerY, - width, - height, - ); } + context.drawImage(linkCanvas, x - centerX, y - centerY, width, height); context.restore(); } }; diff --git a/packages/excalidraw/scene/Scene.ts b/packages/excalidraw/scene/Scene.ts index cac23f96dc..99bb9e1e4a 100644 --- a/packages/excalidraw/scene/Scene.ts +++ b/packages/excalidraw/scene/Scene.ts @@ -25,6 +25,7 @@ import { import { arrayToMap } from "../utils"; import { toBrandedType } from "../utils"; import { ENV } from "../constants"; +import { getElementsInGroup } from "../groups"; type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"]; type ElementKey = ExcalidrawElement | ElementIdKey; @@ -437,6 +438,18 @@ class Scene { } return null; }; + + getElementsFromId = (id: string): ExcalidrawElement[] => { + const elementsMap = this.getNonDeletedElementsMap(); + // first check if the id is an element + const el = elementsMap.get(id); + if (el) { + return [el]; + } + + // then, check if the id is a group + return getElementsInGroup(elementsMap, id); + }; } export default Scene; diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index afd6bd2b7a..a1d45e0a1b 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -728,6 +728,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "category": "element", }, }, + "separator", { "PanelComponent": [Function], "icon": <svg @@ -762,6 +763,42 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "category": "hyperlink", }, }, + { + "icon": <svg + aria-hidden="true" + className="" + fill="none" + focusable="false" + role="img" + stroke="currentColor" + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + viewBox="0 0 24 24" + > + <React.Fragment> + <path + d="M0 0h24v24H0z" + fill="none" + stroke="none" + /> + <path + d="M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z" + /> + <path + d="M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2" + /> + </React.Fragment> + </svg>, + "label": "labels.copyElementLink", + "name": "copyElementLink", + "perform": [Function], + "predicate": [Function], + "trackEvent": { + "category": "element", + }, + }, + "separator", { "PanelComponent": [Function], "icon": <svg @@ -880,6 +917,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "gridSize": 20, "gridStep": 5, "height": 100, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -1088,6 +1126,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "gridSize": 20, "gridStep": 5, "height": 100, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -1306,6 +1345,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "gridSize": 20, "gridStep": 5, "height": 100, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -1639,6 +1679,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "gridSize": 20, "gridStep": 5, "height": 100, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -1972,6 +2013,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "gridSize": 20, "gridStep": 5, "height": 100, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -2190,6 +2232,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "gridSize": 20, "gridStep": 5, "height": 100, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -2432,6 +2475,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "gridSize": 20, "gridStep": 5, "height": 100, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -2735,6 +2779,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "gridSize": 20, "gridStep": 5, "height": 100, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -3106,6 +3151,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "gridSize": 20, "gridStep": 5, "height": 100, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -3583,6 +3629,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "gridSize": 20, "gridStep": 5, "height": 100, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -3908,6 +3955,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "gridSize": 20, "gridStep": 5, "height": 100, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -4233,6 +4281,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "gridSize": 20, "gridStep": 5, "height": 100, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -5313,6 +5362,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "category": "element", }, }, + "separator", { "PanelComponent": [Function], "icon": <svg @@ -5347,6 +5397,42 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "category": "hyperlink", }, }, + { + "icon": <svg + aria-hidden="true" + className="" + fill="none" + focusable="false" + role="img" + stroke="currentColor" + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + viewBox="0 0 24 24" + > + <React.Fragment> + <path + d="M0 0h24v24H0z" + fill="none" + stroke="none" + /> + <path + d="M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z" + /> + <path + d="M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2" + /> + </React.Fragment> + </svg>, + "label": "labels.copyElementLink", + "name": "copyElementLink", + "perform": [Function], + "predicate": [Function], + "trackEvent": { + "category": "element", + }, + }, + "separator", { "PanelComponent": [Function], "icon": <svg @@ -5465,6 +5551,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "gridSize": 20, "gridStep": 5, "height": 100, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -6486,6 +6573,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "category": "element", }, }, + "separator", { "PanelComponent": [Function], "icon": <svg @@ -6520,6 +6608,42 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "category": "hyperlink", }, }, + { + "icon": <svg + aria-hidden="true" + className="" + fill="none" + focusable="false" + role="img" + stroke="currentColor" + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + viewBox="0 0 24 24" + > + <React.Fragment> + <path + d="M0 0h24v24H0z" + fill="none" + stroke="none" + /> + <path + d="M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z" + /> + <path + d="M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2" + /> + </React.Fragment> + </svg>, + "label": "labels.copyElementLink", + "name": "copyElementLink", + "perform": [Function], + "predicate": [Function], + "trackEvent": { + "category": "element", + }, + }, + "separator", { "PanelComponent": [Function], "icon": <svg @@ -6638,6 +6762,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "gridSize": 20, "gridStep": 5, "height": 100, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -7575,6 +7700,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app "gridSize": 20, "gridStep": 5, "height": 100, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -8381,6 +8507,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "category": "element", }, }, + "separator", { "PanelComponent": [Function], "icon": <svg @@ -8415,6 +8542,42 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "category": "hyperlink", }, }, + { + "icon": <svg + aria-hidden="true" + className="" + fill="none" + focusable="false" + role="img" + stroke="currentColor" + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + viewBox="0 0 24 24" + > + <React.Fragment> + <path + d="M0 0h24v24H0z" + fill="none" + stroke="none" + /> + <path + d="M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z" + /> + <path + d="M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2" + /> + </React.Fragment> + </svg>, + "label": "labels.copyElementLink", + "name": "copyElementLink", + "perform": [Function], + "predicate": [Function], + "trackEvent": { + "category": "element", + }, + }, + "separator", { "PanelComponent": [Function], "icon": <svg @@ -8533,6 +8696,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "gridSize": 20, "gridStep": 5, "height": 100, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -9321,6 +9485,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "category": "element", }, }, + "separator", { "PanelComponent": [Function], "icon": <svg @@ -9355,6 +9520,42 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "category": "hyperlink", }, }, + { + "icon": <svg + aria-hidden="true" + className="" + fill="none" + focusable="false" + role="img" + stroke="currentColor" + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + viewBox="0 0 24 24" + > + <React.Fragment> + <path + d="M0 0h24v24H0z" + fill="none" + stroke="none" + /> + <path + d="M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z" + /> + <path + d="M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2" + /> + </React.Fragment> + </svg>, + "label": "labels.copyElementLink", + "name": "copyElementLink", + "perform": [Function], + "predicate": [Function], + "trackEvent": { + "category": "element", + }, + }, + "separator", { "PanelComponent": [Function], "icon": <svg @@ -9473,6 +9674,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "gridSize": 20, "gridStep": 5, "height": 100, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index 4e843dc042..1d14665473 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -53,6 +53,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -657,6 +658,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -1165,6 +1167,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -1535,6 +1538,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -1906,6 +1910,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -2175,6 +2180,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -2617,6 +2623,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -2918,6 +2925,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -3204,6 +3212,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -3500,6 +3509,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -3788,6 +3798,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -4025,6 +4036,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -4286,6 +4298,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -4561,6 +4574,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -4794,6 +4808,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -5027,6 +5042,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -5258,6 +5274,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -5489,6 +5506,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -5750,6 +5768,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -6083,6 +6102,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -6510,6 +6530,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -6890,6 +6911,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -7211,6 +7233,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -7511,6 +7534,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -7742,6 +7766,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -8099,6 +8124,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -8456,6 +8482,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -8862,6 +8889,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -9151,6 +9179,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -9418,6 +9447,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -9684,6 +9714,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -9917,6 +9948,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -10220,6 +10252,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -10562,6 +10595,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -10799,6 +10833,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -11254,6 +11289,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -11510,6 +11546,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -11751,6 +11788,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -11994,6 +12032,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -12397,6 +12436,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -12646,6 +12686,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -12889,6 +12930,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -13132,6 +13174,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -13381,6 +13424,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -13715,6 +13759,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -13889,6 +13934,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -14179,6 +14225,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -14448,6 +14495,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -14725,6 +14773,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -14888,6 +14937,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -15586,6 +15636,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -16208,6 +16259,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -16830,6 +16882,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -17544,6 +17597,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -18296,6 +18350,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements' "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -18772,6 +18827,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -19296,6 +19352,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -19754,6 +19811,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati "gridSize": 20, "gridStep": 5, "height": 0, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap index 14c22a5202..8d357b8fb0 100644 --- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap @@ -53,6 +53,7 @@ exports[`given element A and group of elements B and given both are selected whe "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -467,6 +468,7 @@ exports[`given element A and group of elements B and given both are selected whe "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -872,6 +874,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": false, "isCropping": false, "isLoading": false, @@ -1416,6 +1419,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -1619,6 +1623,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -1993,6 +1998,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -2232,6 +2238,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = ` "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -2411,6 +2418,7 @@ exports[`regression tests > can drag element that covers another element, while "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -2730,6 +2738,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -2975,6 +2984,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -3217,6 +3227,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -3446,6 +3457,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`] "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -3701,6 +3713,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -4011,6 +4024,7 @@ exports[`regression tests > deleting last but one element in editing group shoul "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -4424,6 +4438,7 @@ exports[`regression tests > deselects group of selected elements on pointer down "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -4706,6 +4721,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -4958,6 +4974,7 @@ exports[`regression tests > deselects selected element on pointer down when poin "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -5167,6 +5184,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -5365,6 +5383,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -5746,6 +5765,7 @@ exports[`regression tests > drags selected elements from point inside common bou "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -6035,6 +6055,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1` "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -6842,6 +6863,7 @@ exports[`regression tests > given a group of selected elements with an element t "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -7171,6 +7193,7 @@ exports[`regression tests > given a selected element A and a not selected elemen "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -7446,6 +7469,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -7679,6 +7703,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -7915,6 +7940,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -8094,6 +8120,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -8273,6 +8300,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -8452,6 +8480,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -8674,6 +8703,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -8895,6 +8925,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -9088,6 +9119,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -9310,6 +9342,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -9489,6 +9522,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -9710,6 +9744,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -9889,6 +9924,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -10082,6 +10118,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -10261,6 +10298,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -10774,6 +10812,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -11050,6 +11089,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = ` "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -11175,6 +11215,7 @@ exports[`regression tests > shift click on selected element should deselect it o "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -11373,6 +11414,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -11683,6 +11725,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -12094,6 +12137,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -12706,6 +12750,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -12834,6 +12879,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`] "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -13417,6 +13463,7 @@ exports[`regression tests > switches from group of selected elements to another "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -13754,6 +13801,7 @@ exports[`regression tests > switches selected element on pointer down > [end of "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -14018,6 +14066,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`] "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -14143,6 +14192,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -14521,6 +14571,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, @@ -14646,6 +14697,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = ` "gridSize": 20, "gridStep": 5, "height": 768, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, diff --git a/packages/excalidraw/tests/contextmenu.test.tsx b/packages/excalidraw/tests/contextmenu.test.tsx index e37c26b9d6..3c4c1d6d2c 100644 --- a/packages/excalidraw/tests/contextmenu.test.tsx +++ b/packages/excalidraw/tests/contextmenu.test.tsx @@ -22,6 +22,7 @@ import { copiedStyles } from "../actions/actionStyles"; import { API } from "./helpers/api"; import { setDateTimeForTests } from "../utils"; import { vi } from "vitest"; +import type { ActionName } from "../actions/types"; const checkpoint = (name: string) => { expect(renderStaticScene.mock.calls.length).toMatchSnapshot( @@ -115,7 +116,7 @@ describe("contextMenu element", () => { const contextMenu = UI.queryContextMenu(); const contextMenuOptions = contextMenu?.querySelectorAll(".context-menu li"); - const expectedShortcutNames: ShortcutName[] = [ + const expectedContextMenuItems: ActionName[] = [ "cut", "copy", "paste", @@ -131,14 +132,15 @@ describe("contextMenu element", () => { "bringToFront", "duplicateSelection", "hyperlink", + "copyElementLink", "toggleElementLock", ]; expect(contextMenu).not.toBeNull(); - expect(contextMenuOptions?.length).toBe(expectedShortcutNames.length); - expectedShortcutNames.forEach((shortcutName) => { + expect(contextMenuOptions?.length).toBe(expectedContextMenuItems.length); + expectedContextMenuItems.forEach((item) => { expect( - contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`), + contextMenu?.querySelector(`li[data-testid="${item}"]`), ).not.toBeNull(); }); }); @@ -263,13 +265,14 @@ describe("contextMenu element", () => { const contextMenu = UI.queryContextMenu(); const contextMenuOptions = contextMenu?.querySelectorAll(".context-menu li"); - const expectedShortcutNames: ShortcutName[] = [ + const expectedContextMenuItems: ActionName[] = [ "cut", "copy", "paste", "copyStyles", "pasteStyles", "deleteSelectedElements", + "copyElementLink", "ungroup", "addToLibrary", "flipHorizontal", @@ -283,10 +286,10 @@ describe("contextMenu element", () => { ]; expect(contextMenu).not.toBeNull(); - expect(contextMenuOptions?.length).toBe(expectedShortcutNames.length); - expectedShortcutNames.forEach((shortcutName) => { + expect(contextMenuOptions?.length).toBe(expectedContextMenuItems.length); + expectedContextMenuItems.forEach((item) => { expect( - contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`), + contextMenu?.querySelector(`li[data-testid="${item}"]`), ).not.toBeNull(); }); }); diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 453851a0e8..d1d1824f09 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -161,6 +161,7 @@ type _CommonCanvasAppState = { width: AppState["width"]; height: AppState["height"]; viewModeEnabled: AppState["viewModeEnabled"]; + openDialog: AppState["openDialog"]; editingGroupId: AppState["editingGroupId"]; // TODO: move to interactive canvas if possible selectedElementIds: AppState["selectedElementIds"]; // TODO: move to interactive canvas if possible frameToHighlight: AppState["frameToHighlight"]; // TODO: move to interactive canvas if possible @@ -181,6 +182,7 @@ export type StaticCanvasAppState = Readonly< gridStep: AppState["gridStep"]; frameRendering: AppState["frameRendering"]; currentHoveredFontFamily: AppState["currentHoveredFontFamily"]; + hoveredElementIds: AppState["hoveredElementIds"]; // Cropping croppingElementId: AppState["croppingElementId"]; } @@ -332,7 +334,9 @@ export interface AppState { | null | { name: "imageExport" | "help" | "jsonExport" } | { name: "ttd"; tab: "text-to-diagram" | "mermaid" } - | { name: "commandPalette" }; + | { name: "commandPalette" } + | { name: "elementLinkSelector"; sourceElementId: ExcalidrawElement["id"] }; + /** * Reflects user preference for whether the default sidebar should be docked. * @@ -344,6 +348,7 @@ export interface AppState { lastPointerDownWith: PointerType; selectedElementIds: Readonly<{ [id: string]: true }>; + hoveredElementIds: Readonly<{ [id: string]: true }>; previousSelectedElementIds: { [id: string]: true }; selectedElementsAreBeingDragged: boolean; shouldCacheIgnoreZoom: boolean; @@ -530,6 +535,7 @@ export interface ExcalidrawProps { onLibraryChange?: (libraryItems: LibraryItems) => void | Promise<any>; autoFocus?: boolean; generateIdForFile?: (file: File) => string | Promise<string>; + generateLinkForSelection?: (id: string, type: "element" | "group") => string; onLinkOpen?: ( element: NonDeletedExcalidrawElement, event: CustomEvent<{ diff --git a/packages/utils/__snapshots__/export.test.ts.snap b/packages/utils/__snapshots__/export.test.ts.snap index a9f98719c8..54d4af4bc3 100644 --- a/packages/utils/__snapshots__/export.test.ts.snap +++ b/packages/utils/__snapshots__/export.test.ts.snap @@ -53,6 +53,7 @@ exports[`exportToSvg > with default arguments 1`] = ` "gridModeEnabled": false, "gridSize": 20, "gridStep": 5, + "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false, "isLoading": false, diff --git a/yarn.lock b/yarn.lock index c67e3d8b21..b97d9a706a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3887,11 +3887,6 @@ ansi-regex@^5.0.1: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== -ansi-regex@^6.0.1: - version "6.1.0" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.1.0.tgz#95ec409c69619d6cb1b8b34f14b660ef28ebd654" - integrity sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA== - ansi-styles@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" @@ -9743,27 +9738,13 @@ stringify-object@^3.3.0: dependencies: ansi-regex "^5.0.1" -strip-ansi@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" - integrity sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg== - dependencies: - ansi-regex "^2.0.0" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +strip-ansi@6.0.1, strip-ansi@^3.0.0, strip-ansi@^6.0.0, strip-ansi@^6.0.1, strip-ansi@^7.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== dependencies: ansi-regex "^5.0.1" -strip-ansi@^7.0.1: - version "7.1.0" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" - integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ== - dependencies: - ansi-regex "^6.0.1" - strip-bom@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3"