diff --git a/package.json b/package.json index f714895f3e..9487ab2eea 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 f49c0ca3eb..d9facb00b4 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 c5a8afb89b..8cfff0c574 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 36599d2c5c..fa0ca8df6c 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 a231cf294d..dc003b5e67 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 a5f0cb9e44..0912296672 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 ecf6ab0ef9..500802947e 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 60cc4ec209..f567116506 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 0fa47e95ef..57ba637120 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 5fcbb5791c..5dc9333c24 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 813cb708e3..2a24d5804f 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"