feat: add onLinkOpen component prop (#4694)

Co-authored-by: ad1992 <aakansha1216@gmail.com>
This commit is contained in:
David Luzar 2022-02-08 11:25:35 +01:00 committed by GitHub
parent 050bc1ce2b
commit a066317d3c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 128 additions and 32 deletions

View File

@ -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"

View File

@ -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<AppProps, AppState> {
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<AppProps, AppState> {
});
};
private redirectToLink = () => {
private redirectToLink = (event: React.PointerEvent<HTMLCanvasElement>) => {
if (
!this.hitLinkElement ||
this.lastPointerDown!.clientX !== this.lastPointerUp!.clientX ||
this.lastPointerDown!.clientY !== this.lastPointerUp!.clientY
) {
@ -2412,14 +2415,21 @@ class App extends React.Component<AppProps, AppState> {
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<AppProps, AppState> {
this.hitLinkElement &&
!this.state.selectedElementIds[this.hitLinkElement.id]
) {
this.redirectToLink();
this.redirectToLink(event);
}
this.removePointer(event);

View File

@ -52,6 +52,8 @@ export enum EVENT {
HASHCHANGE = "hashchange",
VISIBILITY_CHANGE = "visibilitychange",
SCROLL = "scroll",
// custom events
EXCALIDRAW_LINK = "excalidraw-link",
}
export const ENV = {

View File

@ -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<any, AppState>["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}

View File

@ -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.

View File

@ -405,6 +405,7 @@ For a complete list of variables, check [theme.scss](https://github.com/excalidr
| [`onLibraryChange`](#onLibraryChange) | <pre>(items: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200">LibraryItems</a>) => void &#124; Promise&lt;any&gt; </pre> | | 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<string>` | Allows you to override `id` generation for files added on canvas |
| [`onLinkOpen`](#onLinkOpen) | <pre>(element: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">NonDeletedExcalidrawElement</a>, event: CustomEvent) </pre> | | 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<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()`.
```
(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).

View File

@ -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 (
<div className="App">
<h1> Excalidraw Example</h1>
@ -179,6 +194,7 @@ export default function App() {
UIOptions={{ canvasActions: { loadScene: false } }}
renderTopRightUI={renderTopRightUI}
renderFooter={renderFooter}
onLinkOpen={onLinkOpen}
/>
</div>

View File

@ -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}
/>
</InitializeApp>
);

View File

@ -247,6 +247,12 @@ export interface ExcalidrawProps {
onLibraryChange?: (libraryItems: LibraryItems) => void | Promise<any>;
autoFocus?: boolean;
generateIdForFile?: (file: File) => string | Promise<string>;
onLinkOpen?: (
element: NonDeletedExcalidrawElement,
event: CustomEvent<{
nativeEvent: MouseEvent | React.PointerEvent<HTMLCanvasElement>;
}>,
) => void;
}
export type SceneData = {

View File

@ -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 = <T extends { id: string } | string>(
export const isTestEnv = () =>
typeof process !== "undefined" && process.env?.NODE_ENV === "test";
export const wrapEvent = <T extends Event>(name: EVENT, nativeEvent: T) => {
return new CustomEvent(name, {
detail: {
nativeEvent,
},
cancelable: true,
});
};

View File

@ -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"