diff --git a/package.json b/package.json index f714895f..9487ab2e 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "roughjs": "4.5.2", "sass": "1.47.0", "socket.io-client": "2.3.1", - "typescript": "4.5.4" + "typescript": "4.5.5" }, "devDependencies": { "@excalidraw/eslint-config": "1.0.0", @@ -74,7 +74,7 @@ "rewire": "5.0.0" }, "resolutions": { - "@typescript-eslint/typescript-estree": "5.3.0" + "@typescript-eslint/typescript-estree": "5.10.2" }, "engines": { "node": ">=14.0.0" diff --git a/src/components/App.tsx b/src/components/App.tsx index f49c0ca3..d9facb00 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -213,6 +213,7 @@ import { tupleToCoors, viewportCoordsToSceneCoords, withBatchedUpdates, + wrapEvent, withBatchedUpdatesThrottled, } from "../utils"; import ContextMenu, { ContextMenuOption } from "./ContextMenu"; @@ -526,6 +527,7 @@ class App extends React.Component { element={selectedElement[0]} appState={this.state} setAppState={this.setAppState} + onLinkOpen={this.props.onLinkOpen} /> )} {this.state.showStats && ( @@ -2384,8 +2386,9 @@ class App extends React.Component { }); }; - private redirectToLink = () => { + private redirectToLink = (event: React.PointerEvent) => { if ( + !this.hitLinkElement || this.lastPointerDown!.clientX !== this.lastPointerUp!.clientX || this.lastPointerDown!.clientY !== this.lastPointerUp!.clientY ) { @@ -2412,14 +2415,21 @@ class App extends React.Component { this.isMobile, ); if (lastPointerDownHittingLinkIcon && LastPointerUpHittingLinkIcon) { - const url = this.hitLinkElement?.link; + const url = this.hitLinkElement.link; if (url) { - const target = isLocalLink(url) ? "_self" : "_blank"; - const newWindow = window.open(undefined, target); - // https://mathiasbynens.github.io/rel-noopener/ - if (newWindow) { - newWindow.opener = null; - newWindow.location = normalizeLink(url); + let customEvent; + if (this.props.onLinkOpen) { + customEvent = wrapEvent(EVENT.EXCALIDRAW_LINK, event.nativeEvent); + this.props.onLinkOpen(this.hitLinkElement, customEvent); + } + if (!customEvent?.defaultPrevented) { + const target = isLocalLink(url) ? "_self" : "_blank"; + const newWindow = window.open(undefined, target); + // https://mathiasbynens.github.io/rel-noopener/ + if (newWindow) { + newWindow.opener = null; + newWindow.location = normalizeLink(url); + } } } } @@ -2896,7 +2906,7 @@ class App extends React.Component { this.hitLinkElement && !this.state.selectedElementIds[this.hitLinkElement.id] ) { - this.redirectToLink(); + this.redirectToLink(event); } this.removePointer(event); diff --git a/src/constants.ts b/src/constants.ts index c5a8afb8..8cfff0c5 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -52,6 +52,8 @@ export enum EVENT { HASHCHANGE = "hashchange", VISIBILITY_CHANGE = "visibilitychange", SCROLL = "scroll", + // custom events + EXCALIDRAW_LINK = "excalidraw-link", } export const ENV = { diff --git a/src/element/Hyperlink.tsx b/src/element/Hyperlink.tsx index 36599d2c..fa0ca8df 100644 --- a/src/element/Hyperlink.tsx +++ b/src/element/Hyperlink.tsx @@ -1,8 +1,9 @@ -import { AppState, Point } from "../types"; +import { AppState, ExcalidrawProps, Point } from "../types"; import { getShortcutKey, sceneCoordsToViewportCoords, viewportCoordsToSceneCoords, + wrapEvent, } from "../utils"; import { mutateElement } from "./mutateElement"; import { NonDeletedExcalidrawElement } from "./types"; @@ -48,10 +49,12 @@ export const Hyperlink = ({ element, appState, setAppState, + onLinkOpen, }: { element: NonDeletedExcalidrawElement; appState: AppState; setAppState: React.Component["setState"]; + onLinkOpen: ExcalidrawProps["onLinkOpen"]; }) => { const linkVal = element.link || ""; @@ -159,6 +162,18 @@ export const Hyperlink = ({ "d-none": isEditing, })} target={isLocalLink(element.link) ? "_self" : "_blank"} + onClick={(event) => { + if (element.link && onLinkOpen) { + const customEvent = wrapEvent( + EVENT.EXCALIDRAW_LINK, + event.nativeEvent, + ); + onLinkOpen(element, customEvent); + if (customEvent.defaultPrevented) { + event.preventDefault(); + } + } + }} rel="noopener noreferrer" > {element.link} diff --git a/src/packages/excalidraw/CHANGELOG.md b/src/packages/excalidraw/CHANGELOG.md index a231cf29..dc003b5e 100644 --- a/src/packages/excalidraw/CHANGELOG.md +++ b/src/packages/excalidraw/CHANGELOG.md @@ -19,6 +19,7 @@ Please add the latest change on the top under the correct section. ### Features +- Add `onLinkOpen` prop which will be triggered when clicked on element hyperlink if present [#4694](https://github.com/excalidraw/excalidraw/pull/4694). - Support updating library using [`updateScene`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#updateScene) API [#4546](https://github.com/excalidraw/excalidraw/pull/4546). - Introduced primary colors to the app. The colors can be overriden. Check [readme](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#customizing-styles) on how to do so. diff --git a/src/packages/excalidraw/README_NEXT.md b/src/packages/excalidraw/README_NEXT.md index a5f0cb9e..09122966 100644 --- a/src/packages/excalidraw/README_NEXT.md +++ b/src/packages/excalidraw/README_NEXT.md @@ -405,6 +405,7 @@ For a complete list of variables, check [theme.scss](https://github.com/excalidr | [`onLibraryChange`](#onLibraryChange) |
(items: LibraryItems) => void | Promise<any> 
| | The callback if supplied is triggered when the library is updated and receives the library items. | | [`autoFocus`](#autoFocus) | boolean | false | Implies whether to focus the Excalidraw component on page load | | [`generateIdForFile`](#generateIdForFile) | `(file: File) => string | Promise` | Allows you to override `id` generation for files added on canvas | +| [`onLinkOpen`](#onLinkOpen) |
(element: NonDeletedExcalidrawElement, event: CustomEvent) 
| | This prop if passed will be triggered when link of an element is clicked | ### Dimensions of Excalidraw @@ -704,6 +705,39 @@ Allows you to override `id` generation for files added on canvas (images). By de (file: File) => string | Promise ``` +#### `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()`. + +``` +(element: ExcalidrawElement, event: CustomEvent<{ nativeEvent: MouseEvent }>) => void +``` + +Example: + +```ts +const history = useHistory(); + +// open internal links using the app's router, but opens external links in +// a new tab/window +const onLinkOpen: ExcalidrawProps["onLinkOpen"] = useCallback( + (element, event) => { + const link = element.link; + const { nativeEvent } = event.detail; + const isNewTab = nativeEvent.ctrlKey || nativeEvent.metaKey; + const isNewWindow = nativeEvent.shiftKey; + const isInternalLink = + link.startsWith("/") || link.includes(window.location.origin); + if (isInternalLink && !isNewTab && !isNewWindow) { + history.push(link.replace(window.location.origin, "")); + // signal that we're handling the redirect ourselves + event.preventDefault(); + } + }, + [history], +); +``` + ### Does it support collaboration ? No, Excalidraw package doesn't come with collaboration built in, since the implementation is specific to each host app. We expose APIs which you can use to communicate with Excalidraw which you can use to implement it. You can check our own implementation [here](https://github.com/excalidraw/excalidraw/blob/master/src/excalidraw-app/index.tsx). diff --git a/src/packages/excalidraw/example/App.js b/src/packages/excalidraw/example/App.js index ecf6ab0e..50080294 100644 --- a/src/packages/excalidraw/example/App.js +++ b/src/packages/excalidraw/example/App.js @@ -1,4 +1,4 @@ -import { useEffect, useState, useRef } from "react"; +import { useEffect, useState, useRef, useCallback } from "react"; import InitialData from "./initialData"; import Sidebar from "./sidebar/Sidebar"; @@ -87,6 +87,21 @@ export default function App() { excalidrawRef.current.updateScene(sceneData); }; + const onLinkOpen = useCallback((element, event) => { + const link = element.link; + const { nativeEvent } = event.detail; + const isNewTab = nativeEvent.ctrlKey || nativeEvent.metaKey; + const isNewWindow = nativeEvent.shiftKey; + const isInternalLink = + link.startsWith("/") || link.includes(window.location.origin); + if (isInternalLink && !isNewTab && !isNewWindow) { + // signal that we're handling the redirect ourselves + event.preventDefault(); + // do a custom redirect, such as passing to react-router + // ... + } + }, []); + return (

Excalidraw Example

@@ -179,6 +194,7 @@ export default function App() { UIOptions={{ canvasActions: { loadScene: false } }} renderTopRightUI={renderTopRightUI} renderFooter={renderFooter} + onLinkOpen={onLinkOpen} />
diff --git a/src/packages/excalidraw/index.tsx b/src/packages/excalidraw/index.tsx index 60cc4ec2..f5671165 100644 --- a/src/packages/excalidraw/index.tsx +++ b/src/packages/excalidraw/index.tsx @@ -35,6 +35,7 @@ const Excalidraw = (props: ExcalidrawProps) => { onLibraryChange, autoFocus = false, generateIdForFile, + onLinkOpen, } = props; const canvasActions = props.UIOptions?.canvasActions; @@ -96,6 +97,7 @@ const Excalidraw = (props: ExcalidrawProps) => { onLibraryChange={onLibraryChange} autoFocus={autoFocus} generateIdForFile={generateIdForFile} + onLinkOpen={onLinkOpen} /> ); diff --git a/src/types.ts b/src/types.ts index 0fa47e95..57ba6371 100644 --- a/src/types.ts +++ b/src/types.ts @@ -247,6 +247,12 @@ export interface ExcalidrawProps { onLibraryChange?: (libraryItems: LibraryItems) => void | Promise; autoFocus?: boolean; generateIdForFile?: (file: File) => string | Promise; + onLinkOpen?: ( + element: NonDeletedExcalidrawElement, + event: CustomEvent<{ + nativeEvent: MouseEvent | React.PointerEvent; + }>, + ) => void; } export type SceneData = { diff --git a/src/utils.ts b/src/utils.ts index 5fcbb579..5dc9333c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,6 +2,7 @@ import colors from "./colors"; import { CURSOR_TYPE, DEFAULT_VERSION, + EVENT, FONT_FAMILY, WINDOWS_EMOJI_FALLBACK_FONT, } from "./constants"; @@ -523,3 +524,12 @@ export const arrayToMap = ( export const isTestEnv = () => typeof process !== "undefined" && process.env?.NODE_ENV === "test"; + +export const wrapEvent = (name: EVENT, nativeEvent: T) => { + return new CustomEvent(name, { + detail: { + nativeEvent, + }, + cancelable: true, + }); +}; diff --git a/yarn.lock b/yarn.lock index 813cb708..2a24d580 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2540,18 +2540,18 @@ resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.19.0.tgz" integrity sha512-A4iAlexVvd4IBsSTNxdvdepW0D4uR/fwxDrKUa+iEY9UWvGREu2ZyB8ylTENM1SH8F7bVC9ac9+si3LWNxcBuA== -"@typescript-eslint/types@5.3.0": - version "5.3.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.3.0.tgz#af29fd53867c2df0028c57c36a655bd7e9e05416" - integrity sha512-fce5pG41/w8O6ahQEhXmMV+xuh4+GayzqEogN24EK+vECA3I6pUwKuLi5QbXO721EMitpQne5VKXofPonYlAQg== +"@typescript-eslint/types@5.10.2": + version "5.10.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.10.2.tgz#604d15d795c4601fffba6ecb4587ff9fdec68ce8" + integrity sha512-Qfp0qk/5j2Rz3p3/WhWgu4S1JtMcPgFLnmAKAW061uXxKSa7VWKZsDXVaMXh2N60CX9h6YLaBoy9PJAfCOjk3w== -"@typescript-eslint/typescript-estree@3.10.1", "@typescript-eslint/typescript-estree@4.19.0", "@typescript-eslint/typescript-estree@5.3.0": - version "5.3.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.3.0.tgz#4f68ddd46dc2983182402d2ab21fb44ad94988cf" - integrity sha512-FJ0nqcaUOpn/6Z4Jwbtf+o0valjBLkqc3MWkMvrhA2TvzFXtcclIM8F4MBEmYa2kgcI8EZeSAzwoSrIC8JYkug== +"@typescript-eslint/typescript-estree@3.10.1", "@typescript-eslint/typescript-estree@4.19.0", "@typescript-eslint/typescript-estree@5.10.2": + version "5.10.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.10.2.tgz#810906056cd3ddcb35aa333fdbbef3713b0fe4a7" + integrity sha512-WHHw6a9vvZls6JkTgGljwCsMkv8wu8XU8WaYKeYhxhWXH/atZeiMW6uDFPLZOvzNOGmuSMvHtZKd6AuC8PrwKQ== dependencies: - "@typescript-eslint/types" "5.3.0" - "@typescript-eslint/visitor-keys" "5.3.0" + "@typescript-eslint/types" "5.10.2" + "@typescript-eslint/visitor-keys" "5.10.2" debug "^4.3.2" globby "^11.0.4" is-glob "^4.0.3" @@ -2566,12 +2566,12 @@ "@typescript-eslint/types" "4.19.0" eslint-visitor-keys "^2.0.0" -"@typescript-eslint/visitor-keys@5.3.0": - version "5.3.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.3.0.tgz#a6258790f3b7b2547f70ed8d4a1e0c3499994523" - integrity sha512-oVIAfIQuq0x2TFDNLVavUn548WL+7hdhxYn+9j3YdJJXB7mH9dAmZNJsPDa7Jc+B9WGqoiex7GUDbyMxV0a/aw== +"@typescript-eslint/visitor-keys@5.10.2": + version "5.10.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.10.2.tgz#fdbf272d8e61c045d865bd6c8b41bea73d222f3d" + integrity sha512-zHIhYGGGrFJvvyfwHk5M08C5B5K4bewkm+rrvNTKk1/S15YHR+SA/QUF8ZWscXSfEaB8Nn2puZj+iHcoxVOD/Q== dependencies: - "@typescript-eslint/types" "5.3.0" + "@typescript-eslint/types" "5.10.2" eslint-visitor-keys "^3.0.0" "@webassemblyjs/ast@1.9.0": @@ -14392,10 +14392,10 @@ typedarray@^0.0.6: resolved "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= -typescript@4.5.4: - version "4.5.4" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.4.tgz#a17d3a0263bf5c8723b9c52f43c5084edf13c2e8" - integrity sha512-VgYs2A2QIRuGphtzFV7aQJduJ2gyfTljngLzjpfW9FoYZF6xuw1W0vW9ghCKLfcWrCFxK81CSGRAvS1pn4fIUg== +typescript@4.5.5: + version "4.5.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.5.tgz#d8c953832d28924a9e3d37c73d729c846c5896f3" + integrity sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA== typeson-registry@^1.0.0-alpha.20: version "1.0.0-alpha.39"