|
@@ -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 = (
|
|
|
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
|
|
+ <path d="M256 32C114.6 32 .0272 125.1 .0272 240c0 47.63 19.91 91.25 52.91 126.2c-14.88 39.5-45.87 72.88-46.37 73.25c-6.625 7-8.375 17.25-4.625 26C5.818 474.2 14.38 480 24 480c61.5 0 109.1-25.75 139.1-46.25C191.1 442.8 223.3 448 256 448c141.4 0 255.1-93.13 255.1-208S397.4 32 256 32zM256.1 400c-26.75 0-53.12-4.125-78.38-12.12l-22.75-7.125l-19.5 13.75c-14.25 10.12-33.88 21.38-57.5 29c7.375-12.12 14.37-25.75 19.88-40.25l10.62-28l-20.62-21.87C69.82 314.1 48.07 282.2 48.07 240c0-88.25 93.25-160 208-160s208 71.75 208 160S370.8 400 256.1 400z" />
|
|
|
+ </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 (
|
|
|
- <button onClick={() => alert("This is dummy footer")}>
|
|
|
- {" "}
|
|
|
- custom footer{" "}
|
|
|
- </button>
|
|
|
- );
|
|
|
-};
|
|
|
-
|
|
|
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 (
|
|
|
+ <>
|
|
|
+ {" "}
|
|
|
+ <button
|
|
|
+ className="custom-element"
|
|
|
+ onClick={() =>
|
|
|
+ excalidrawRef.current.setActiveTool({
|
|
|
+ type: "custom",
|
|
|
+ customType: "comment",
|
|
|
+ })
|
|
|
+ }
|
|
|
+ >
|
|
|
+ {COMMENT_SVG}
|
|
|
+ </button>
|
|
|
+ <button onClick={() => alert("This is dummy footer")}>
|
|
|
+ {" "}
|
|
|
+ custom footer{" "}
|
|
|
+ </button>
|
|
|
+ </>
|
|
|
+ );
|
|
|
+ };
|
|
|
|
|
|
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 (
|
|
|
+ <div
|
|
|
+ id={commentIcon.id}
|
|
|
+ key={commentIcon.id}
|
|
|
+ style={{
|
|
|
+ top: `${y - COMMENT_ICON_DIMENSION / 2 - appState.offsetTop}px`,
|
|
|
+ left: `${x - COMMENT_ICON_DIMENSION / 2 - appState.offsetLeft}px`,
|
|
|
+ position: "absolute",
|
|
|
+ zIndex: 1,
|
|
|
+ width: `${COMMENT_ICON_DIMENSION}px`,
|
|
|
+ height: `${COMMENT_ICON_DIMENSION}px`,
|
|
|
+ }}
|
|
|
+ className="comment-icon"
|
|
|
+ onPointerDown={(event) => {
|
|
|
+ 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");
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div className="comment-avatar">
|
|
|
+ <img src="doremon.png" alt="doremon" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ 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 (
|
|
|
+ <textarea
|
|
|
+ className="comment"
|
|
|
+ style={{
|
|
|
+ top: `${top}px`,
|
|
|
+ left: `${left}px`,
|
|
|
+ position: "absolute",
|
|
|
+ zIndex: 1,
|
|
|
+ height: `${COMMENT_INPUT_HEIGHT}px`,
|
|
|
+ width: `${COMMENT_INPUT_WIDTH}px`,
|
|
|
+ }}
|
|
|
+ ref={(ref) => {
|
|
|
+ setTimeout(() => ref?.focus());
|
|
|
+ }}
|
|
|
+ placeholder={comment.value ? "Reply" : "Comment"}
|
|
|
+ value={comment.value}
|
|
|
+ onChange={(event) => {
|
|
|
+ setComment({ ...comment, value: event.target.value });
|
|
|
+ }}
|
|
|
+ onBlur={saveComment}
|
|
|
+ onKeyDown={(event) => {
|
|
|
+ if (!event.shiftKey && event.key === "Enter") {
|
|
|
+ event.preventDefault();
|
|
|
+ saveComment();
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ );
|
|
|
+ };
|
|
|
return (
|
|
|
- <div className="App">
|
|
|
+ <div className="App" ref={appRef}>
|
|
|
<h1> Excalidraw Example</h1>
|
|
|
<Sidebar>
|
|
|
<div className="button-wrapper">
|
|
@@ -288,9 +524,9 @@ export default function App() {
|
|
|
<Excalidraw
|
|
|
ref={excalidrawRef}
|
|
|
initialData={initialStatePromiseRef.current.promise}
|
|
|
- onChange={(elements, state) =>
|
|
|
- console.info("Elements :", elements, "State : ", state)
|
|
|
- }
|
|
|
+ onChange={(elements, state) => {
|
|
|
+ console.info("Elements :", elements, "State : ", state);
|
|
|
+ }}
|
|
|
onPointerUpdate={(payload) => console.info(payload)}
|
|
|
onCollabButtonClick={() =>
|
|
|
window.alert("You clicked on collab button")
|
|
@@ -304,7 +540,11 @@ export default function App() {
|
|
|
renderTopRightUI={renderTopRightUI}
|
|
|
renderFooter={renderFooter}
|
|
|
onLinkOpen={onLinkOpen}
|
|
|
+ onPointerDown={onPointerDown}
|
|
|
+ onScrollChange={rerenderCommentIcons}
|
|
|
/>
|
|
|
+ {Object.keys(commentIcons || []).length > 0 && renderCommentIcons()}
|
|
|
+ {comment && renderComment()}
|
|
|
</div>
|
|
|
|
|
|
<div className="export-wrapper button-wrapper">
|
|
@@ -338,7 +578,8 @@ export default function App() {
|
|
|
embedScene: true,
|
|
|
files: excalidrawRef.current.getFiles(),
|
|
|
});
|
|
|
- document.querySelector(".export-svg").innerHTML = svg.outerHTML;
|
|
|
+ appRef.current.querySelector(".export-svg").innerHTML =
|
|
|
+ svg.outerHTML;
|
|
|
}}
|
|
|
>
|
|
|
Export to SVG
|