From 68f23d652f6fecda5a9aa339eaa324ffa9f770f2 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Wed, 11 May 2022 13:30:15 +0530 Subject: [PATCH] feat: support custom elements in @excalidraw/excalidraw (#5164) * feat: support custom elements in @excalidraw/excalidraw * revert * fix css * fix offsets * fix overflow of custom elements in example * fix overflow in comments input * make sure comment input never overflows the viewport * remove offsetschange * expose setActiveTool * rename to onPointerDown * update docs * fix --- src/components/App.tsx | 16 +- src/packages/excalidraw/CHANGELOG.md | 6 + src/packages/excalidraw/README_NEXT.md | 29 ++- src/packages/excalidraw/example/App.js | 269 +++++++++++++++++++++-- src/packages/excalidraw/example/App.scss | 26 +++ src/packages/excalidraw/index.tsx | 4 + src/types.ts | 6 + 7 files changed, 338 insertions(+), 18 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 51b0f802..3c1f4788 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -384,6 +384,7 @@ class App extends React.Component { importLibrary: this.importLibraryFromUrl, setToastMessage: this.setToastMessage, id: this.id, + setActiveTool: this.setActiveTool, } as const; if (typeof excalidrawRef === "function") { excalidrawRef(api); @@ -1058,6 +1059,13 @@ class App extends React.Component { } componentDidUpdate(prevProps: AppProps, prevState: AppState) { + if ( + prevState.scrollX !== this.state.scrollX || + prevState.scrollY !== this.state.scrollY + ) { + this.props?.onScrollChange?.(this.state.scrollX, this.state.scrollY); + } + if ( Object.keys(this.state.selectedElementIds).length && isEraserActive(this.state) @@ -1966,11 +1974,11 @@ class App extends React.Component { } }); - private setActiveTool( + private setActiveTool = ( tool: | { type: typeof SHAPES[number]["value"] | "eraser" } | { type: "custom"; customType: string }, - ) { + ) => { const nextActiveTool = updateActiveTool(this.state, tool); if (!isHoldingSpace) { setCursorForShape(this.canvas, this.state); @@ -1994,7 +2002,7 @@ class App extends React.Component { } else { this.setState({ activeTool: nextActiveTool }); } - } + }; private onGestureStart = withBatchedUpdates((event: GestureEvent) => { event.preventDefault(); @@ -3068,6 +3076,8 @@ class App extends React.Component { ); } + this.props?.onPointerDown?.(this.state.activeTool, pointerDownState); + const onPointerMove = this.onPointerMoveFromPointerDownHandler(pointerDownState); diff --git a/src/packages/excalidraw/CHANGELOG.md b/src/packages/excalidraw/CHANGELOG.md index 40525603..fe6ceeab 100644 --- a/src/packages/excalidraw/CHANGELOG.md +++ b/src/packages/excalidraw/CHANGELOG.md @@ -17,6 +17,12 @@ Please add the latest change on the top under the correct section. #### Features +- Add support for integrating custom elements [#5164](https://github.com/excalidraw/excalidraw/pull/5164). + + - Add [`onPointerDown`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#onPointerDown) callback which gets triggered on pointer down events. + - Add [`onScrollChange`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#onScrollChange) callback which gets triggered when scrolling the canvas. + - Add API [`setActiveTool`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#setActiveTool) which host can call to set the active tool. + - Exported [`loadSceneOrLibraryFromBlob`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#loadSceneOrLibraryFromBlob) function [#5057](https://github.com/excalidraw/excalidraw/pull/5057). - Export [`MIME_TYPES`](https://github.com/excalidraw/excalidraw/blob/master/src/constants.ts#L92) supported by Excalidraw [#5135](https://github.com/excalidraw/excalidraw/pull/5135). - Support [`src`](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L50) for collaborators. Now onwards host can pass `src` to render the customized avatar for collaborators [#5114](https://github.com/excalidraw/excalidraw/pull/5114). diff --git a/src/packages/excalidraw/README_NEXT.md b/src/packages/excalidraw/README_NEXT.md index e03de702..d04bb0b0 100644 --- a/src/packages/excalidraw/README_NEXT.md +++ b/src/packages/excalidraw/README_NEXT.md @@ -405,7 +405,9 @@ 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 | +| [`onLinkOpen`](#onLinkOpen) |
(element: NonDeletedExcalidrawElement, event: CustomEvent) 
| | This prop if passed will be triggered when link of an element is clicked. | +| [`onPointerDown`](#onPointerDown) |
(activeTool:  AppState["activeTool"], pointerDownState: PointerDownState) => void
| | This prop if passed gets triggered on pointer down evenets | +| [`onScrollChange`](#onScrollChange) | (scrollX: number, scrollY: number) | | This prop if passed gets triggered when scrolling the canvas. | ### Dimensions of Excalidraw @@ -491,6 +493,7 @@ You can pass a `ref` when you want to access some excalidraw APIs. We expose the | setToastMessage | `(message: string) => void` | This API can be used to show the toast with custom message. | | [id](#id) | string | Unique ID for the excalidraw component. | | [getFiles](#getFiles) |
() => files 
| This API can be used to get the files present in the scene. It may contain files that aren't referenced by any element, so if you're persisting the files to a storage, you should compare them against stored elements. | +| [setActiveTool](#setActiveTool) |
(tool: { type: typeof SHAPES[number]["value"] | "eraser" } | { type: "custom"; customType: string }) => void
| This API can be used to set the active tool | #### `readyPromise` @@ -673,6 +676,14 @@ useEffect(() => { Try out the [Demo](#Demo) to see it in action. +#### `setActiveTool` + +This API has the below signature. It sets the `tool` passed in param as the active tool. + +
+(tool: { type: typeof SHAPES[number]["value"] | "eraser" } | { type: "custom"; customType: string }) => void
+
+ #### `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). @@ -742,6 +753,22 @@ const onLinkOpen: ExcalidrawProps["onLinkOpen"] = useCallback( ); ``` +#### `onPointerDown` + +This prop if passed will be triggered on pointer down events and has the below signature. + +
+(activeTool:  AppState["activeTool"], pointerDownState: PointerDownState) => void
+
+ +#### `onScrollChange` + +This prop if passed will be triggered when canvas is scrolled and has the below signature. + +```ts +(scrollX: number, scrollY: number) => void +``` + ### 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 90a399d7..31272a4c 100644 --- a/src/packages/excalidraw/example/App.js +++ b/src/packages/excalidraw/example/App.js @@ -5,6 +5,15 @@ import Sidebar from "./sidebar/Sidebar"; import "./App.scss"; import initialData from "./initialData"; +import { nanoid } from "nanoid"; +import { + sceneCoordsToViewportCoords, + viewportCoordsToSceneCoords, + withBatchedUpdates, + withBatchedUpdatesThrottled, +} from "../../../utils"; +import { DRAGGING_THRESHOLD, EVENT } from "../../../constants"; +import { distance2d } from "../../../math"; import { fileOpen } from "../../../data/filesystem"; import { loadSceneOrLibraryFromBlob } from "../../utils"; @@ -20,6 +29,15 @@ const { MIME_TYPES, } = window.ExcalidrawLib; +const COMMENT_SVG = ( + + + +); +const COMMENT_ICON_DIMENSION = 32; +const COMMENT_INPUT_HEIGHT = 50; +const COMMENT_INPUT_WIDTH = 150; + const resolvablePromise = () => { let resolve; let reject; @@ -44,17 +62,9 @@ const renderTopRightUI = () => { ); }; -const renderFooter = () => { - return ( - - ); -}; - export default function App() { const excalidrawRef = useRef(null); + const appRef = useRef(null); const [viewModeEnabled, setViewModeEnabled] = useState(false); const [zenModeEnabled, setZenModeEnabled] = useState(false); @@ -65,6 +75,8 @@ export default function App() { const [exportEmbedScene, setExportEmbedScene] = useState(false); const [theme, setTheme] = useState("light"); const [isCollaborating, setIsCollaborating] = useState(false); + const [commentIcons, setCommentIcons] = useState({}); + const [comment, setComment] = useState(null); const initialStatePromiseRef = useRef({ promise: null }); if (!initialStatePromiseRef.current.promise) { @@ -105,6 +117,28 @@ export default function App() { window.removeEventListener("hashchange", onHashChange); }; }, []); + const renderFooter = () => { + return ( + <> + {" "} + + + + ); + }; const loadSceneOrLibrary = async () => { const file = await fileOpen({ description: "Excalidraw or library file" }); @@ -168,8 +202,210 @@ export default function App() { }); window.alert(`Copied to clipboard as ${type} sucessfully`); }; + + const onPointerDown = (activeTool, pointerDownState) => { + if (activeTool.type === "custom" && activeTool.customType === "comment") { + const { x, y } = pointerDownState.origin; + setComment({ x, y, value: "" }); + } + }; + + const rerenderCommentIcons = () => { + const commentIconsElements = + appRef.current.querySelectorAll(".comment-icon"); + commentIconsElements.forEach((ele) => { + const id = ele.id; + const appstate = excalidrawRef.current.getAppState(); + const { x, y } = sceneCoordsToViewportCoords( + { sceneX: commentIcons[id].x, sceneY: commentIcons[id].y }, + appstate, + ); + ele.style.left = `${ + x - COMMENT_ICON_DIMENSION / 2 - appstate.offsetLeft + }px`; + ele.style.top = `${ + y - COMMENT_ICON_DIMENSION / 2 - appstate.offsetTop + }px`; + }); + }; + + const onPointerMoveFromPointerDownHandler = (pointerDownState) => { + return withBatchedUpdatesThrottled((event) => { + const { x, y } = viewportCoordsToSceneCoords( + { clientX: event.clientX, clientY: event.clientY }, + excalidrawRef.current.getAppState(), + ); + const distance = distance2d( + pointerDownState.x, + pointerDownState.y, + event.clientX, + event.clientY, + ); + if (distance > DRAGGING_THRESHOLD) { + setCommentIcons({ + ...commentIcons, + [pointerDownState.hitElement.id]: { + ...commentIcons[pointerDownState.hitElement.id], + x, + y, + }, + }); + } + }); + }; + const onPointerUpFromPointerDownHandler = (pointerDownState) => { + return withBatchedUpdates((event) => { + window.removeEventListener(EVENT.POINTER_MOVE, pointerDownState.onMove); + window.removeEventListener(EVENT.POINTER_UP, pointerDownState.onUp); + excalidrawRef.current.setActiveTool({ type: "selection" }); + const distance = distance2d( + pointerDownState.x, + pointerDownState.y, + event.clientX, + event.clientY, + ); + if (distance === 0) { + if (!comment) { + setComment({ + x: pointerDownState.hitElement.x + 60, + y: pointerDownState.hitElement.y, + value: pointerDownState.hitElement.value, + id: pointerDownState.hitElement.id, + }); + } else { + setComment(null); + } + } + }); + }; + const renderCommentIcons = () => { + return Object.values(commentIcons).map((commentIcon) => { + const appState = excalidrawRef.current.getAppState(); + const { x, y } = sceneCoordsToViewportCoords( + { sceneX: commentIcon.x, sceneY: commentIcon.y }, + excalidrawRef.current.getAppState(), + ); + return ( +
{ + event.preventDefault(); + if (comment) { + commentIcon.value = comment.value; + saveComment(); + } + const pointerDownState = { + x: event.clientX, + y: event.clientY, + hitElement: commentIcon, + }; + const onPointerMove = + onPointerMoveFromPointerDownHandler(pointerDownState); + const onPointerUp = + onPointerUpFromPointerDownHandler(pointerDownState); + window.addEventListener(EVENT.POINTER_MOVE, onPointerMove); + window.addEventListener(EVENT.POINTER_UP, onPointerUp); + + pointerDownState.onMove = onPointerMove; + pointerDownState.onUp = onPointerUp; + + excalidrawRef.current.setCustomType("comment"); + }} + > +
+ doremon +
+
+ ); + }); + }; + + const saveComment = () => { + if (!comment.id && !comment.value) { + setComment(null); + return; + } + const id = comment.id || nanoid(); + setCommentIcons({ + ...commentIcons, + [id]: { + x: comment.id ? comment.x - 60 : comment.x, + y: comment.y, + id, + value: comment.value, + }, + }); + setComment(null); + }; + + const renderComment = () => { + const appState = excalidrawRef.current.getAppState(); + const { x, y } = sceneCoordsToViewportCoords( + { sceneX: comment.x, sceneY: comment.y }, + appState, + ); + let top = y - COMMENT_ICON_DIMENSION / 2 - appState.offsetTop; + let left = x - COMMENT_ICON_DIMENSION / 2 - appState.offsetLeft; + + if ( + top + COMMENT_INPUT_HEIGHT < + appState.offsetTop + COMMENT_INPUT_HEIGHT + ) { + top = COMMENT_ICON_DIMENSION / 2; + } + if (top + COMMENT_INPUT_HEIGHT > appState.height) { + top = appState.height - COMMENT_INPUT_HEIGHT - COMMENT_ICON_DIMENSION / 2; + } + if ( + left + COMMENT_INPUT_WIDTH < + appState.offsetLeft + COMMENT_INPUT_WIDTH + ) { + left = COMMENT_ICON_DIMENSION / 2; + } + if (left + COMMENT_INPUT_WIDTH > appState.width) { + left = appState.width - COMMENT_INPUT_WIDTH - COMMENT_ICON_DIMENSION / 2; + } + return ( +