From a066317d3c2256d7b6f1b253a2c38f4a3ee33d99 Mon Sep 17 00:00:00 2001 From: David Luzar Date: Tue, 8 Feb 2022 11:25:35 +0100 Subject: [PATCH] feat: add `onLinkOpen` component prop (#4694) Co-authored-by: ad1992 --- package.json | 4 +-- src/components/App.tsx | 28 +++++++++++++------ src/constants.ts | 2 ++ src/element/Hyperlink.tsx | 17 +++++++++++- src/packages/excalidraw/CHANGELOG.md | 1 + src/packages/excalidraw/README_NEXT.md | 34 +++++++++++++++++++++++ src/packages/excalidraw/example/App.js | 18 +++++++++++- src/packages/excalidraw/index.tsx | 2 ++ src/types.ts | 6 ++++ src/utils.ts | 10 +++++++ yarn.lock | 38 +++++++++++++------------- 11 files changed, 128 insertions(+), 32 deletions(-) diff --git a/package.json b/package.json index f714895f3..9487ab2ee 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 f49c0ca3e..d9facb00b 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 c5a8afb89..8cfff0c57 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 36599d2c5..fa0ca8df6 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 a231cf294..dc003b5e6 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 a5f0cb9e4..091229667 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 ecf6ab0ef..500802947 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 60cc4ec20..f56711650 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 0fa47e95e..57ba63712 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 5fcbb5791..5dc9333c2 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 813cb708e..2a24d5804 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"