ShareDialog.tsx 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. import { useEffect, useRef, useState } from "react";
  2. import * as Popover from "@radix-ui/react-popover";
  3. import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
  4. import { trackEvent } from "../../packages/excalidraw/analytics";
  5. import { getFrame } from "../../packages/excalidraw/utils";
  6. import { useI18n } from "../../packages/excalidraw/i18n";
  7. import { KEYS } from "../../packages/excalidraw/keys";
  8. import { Dialog } from "../../packages/excalidraw/components/Dialog";
  9. import {
  10. copyIcon,
  11. LinkIcon,
  12. playerPlayIcon,
  13. playerStopFilledIcon,
  14. share,
  15. shareIOS,
  16. shareWindows,
  17. tablerCheckIcon,
  18. } from "../../packages/excalidraw/components/icons";
  19. import { TextField } from "../../packages/excalidraw/components/TextField";
  20. import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
  21. import type { CollabAPI } from "../collab/Collab";
  22. import { activeRoomLinkAtom } from "../collab/Collab";
  23. import { atom, useAtom, useAtomValue } from "jotai";
  24. import "./ShareDialog.scss";
  25. import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
  26. type OnExportToBackend = () => void;
  27. type ShareDialogType = "share" | "collaborationOnly";
  28. export const shareDialogStateAtom = atom<
  29. { isOpen: false } | { isOpen: true; type: ShareDialogType }
  30. >({ isOpen: false });
  31. const getShareIcon = () => {
  32. const navigator = window.navigator as any;
  33. const isAppleBrowser = /Apple/.test(navigator.vendor);
  34. const isWindowsBrowser = navigator.appVersion.indexOf("Win") !== -1;
  35. if (isAppleBrowser) {
  36. return shareIOS;
  37. } else if (isWindowsBrowser) {
  38. return shareWindows;
  39. }
  40. return share;
  41. };
  42. export type ShareDialogProps = {
  43. collabAPI: CollabAPI | null;
  44. handleClose: () => void;
  45. onExportToBackend: OnExportToBackend;
  46. type: ShareDialogType;
  47. };
  48. const ActiveRoomDialog = ({
  49. collabAPI,
  50. activeRoomLink,
  51. handleClose,
  52. }: {
  53. collabAPI: CollabAPI;
  54. activeRoomLink: string;
  55. handleClose: () => void;
  56. }) => {
  57. const { t } = useI18n();
  58. const [justCopied, setJustCopied] = useState(false);
  59. const timerRef = useRef<number>(0);
  60. const ref = useRef<HTMLInputElement>(null);
  61. const isShareSupported = "share" in navigator;
  62. const copyRoomLink = async () => {
  63. try {
  64. await copyTextToSystemClipboard(activeRoomLink);
  65. } catch (e) {
  66. collabAPI.setCollabError(t("errors.copyToSystemClipboardFailed"));
  67. }
  68. setJustCopied(true);
  69. if (timerRef.current) {
  70. window.clearTimeout(timerRef.current);
  71. }
  72. timerRef.current = window.setTimeout(() => {
  73. setJustCopied(false);
  74. }, 3000);
  75. ref.current?.select();
  76. };
  77. const shareRoomLink = async () => {
  78. try {
  79. await navigator.share({
  80. title: t("roomDialog.shareTitle"),
  81. text: t("roomDialog.shareTitle"),
  82. url: activeRoomLink,
  83. });
  84. } catch (error: any) {
  85. // Just ignore.
  86. }
  87. };
  88. return (
  89. <>
  90. <h3 className="ShareDialog__active__header">
  91. {t("labels.liveCollaboration").replace(/\./g, "")}
  92. </h3>
  93. <TextField
  94. defaultValue={collabAPI.getUsername()}
  95. placeholder="Your name"
  96. label="Your name"
  97. onChange={collabAPI.setUsername}
  98. onKeyDown={(event) => event.key === KEYS.ENTER && handleClose()}
  99. />
  100. <div className="ShareDialog__active__linkRow">
  101. <TextField
  102. ref={ref}
  103. label="Link"
  104. readonly
  105. fullWidth
  106. value={activeRoomLink}
  107. />
  108. {isShareSupported && (
  109. <FilledButton
  110. size="large"
  111. variant="icon"
  112. label="Share"
  113. icon={getShareIcon()}
  114. className="ShareDialog__active__share"
  115. onClick={shareRoomLink}
  116. />
  117. )}
  118. <Popover.Root open={justCopied}>
  119. <Popover.Trigger asChild>
  120. <FilledButton
  121. size="large"
  122. label="Copy link"
  123. icon={copyIcon}
  124. onClick={copyRoomLink}
  125. />
  126. </Popover.Trigger>
  127. <Popover.Content
  128. onOpenAutoFocus={(event) => event.preventDefault()}
  129. onCloseAutoFocus={(event) => event.preventDefault()}
  130. className="ShareDialog__popover"
  131. side="top"
  132. align="end"
  133. sideOffset={5.5}
  134. >
  135. {tablerCheckIcon} copied
  136. </Popover.Content>
  137. </Popover.Root>
  138. </div>
  139. <div className="ShareDialog__active__description">
  140. <p>
  141. <span
  142. role="img"
  143. aria-hidden="true"
  144. className="ShareDialog__active__description__emoji"
  145. >
  146. 🔒{" "}
  147. </span>
  148. {t("roomDialog.desc_privacy")}
  149. </p>
  150. <p>{t("roomDialog.desc_exitSession")}</p>
  151. </div>
  152. <div className="ShareDialog__active__actions">
  153. <FilledButton
  154. size="large"
  155. variant="outlined"
  156. color="danger"
  157. label={t("roomDialog.button_stopSession")}
  158. icon={playerStopFilledIcon}
  159. onClick={() => {
  160. trackEvent("share", "room closed");
  161. collabAPI.stopCollaboration();
  162. if (!collabAPI.isCollaborating()) {
  163. handleClose();
  164. }
  165. }}
  166. />
  167. </div>
  168. </>
  169. );
  170. };
  171. const ShareDialogPicker = (props: ShareDialogProps) => {
  172. const { t } = useI18n();
  173. const { collabAPI } = props;
  174. const startCollabJSX = collabAPI ? (
  175. <>
  176. <div className="ShareDialog__picker__header">
  177. {t("labels.liveCollaboration").replace(/\./g, "")}
  178. </div>
  179. <div className="ShareDialog__picker__description">
  180. <div style={{ marginBottom: "1em" }}>{t("roomDialog.desc_intro")}</div>
  181. {t("roomDialog.desc_privacy")}
  182. </div>
  183. <div className="ShareDialog__picker__button">
  184. <FilledButton
  185. size="large"
  186. label={t("roomDialog.button_startSession")}
  187. icon={playerPlayIcon}
  188. onClick={() => {
  189. trackEvent("share", "room creation", `ui (${getFrame()})`);
  190. collabAPI.startCollaboration(null);
  191. }}
  192. />
  193. </div>
  194. {props.type === "share" && (
  195. <div className="ShareDialog__separator">
  196. <span>{t("shareDialog.or")}</span>
  197. </div>
  198. )}
  199. </>
  200. ) : null;
  201. return (
  202. <>
  203. {startCollabJSX}
  204. {props.type === "share" && (
  205. <>
  206. <div className="ShareDialog__picker__header">
  207. {t("exportDialog.link_title")}
  208. </div>
  209. <div className="ShareDialog__picker__description">
  210. {t("exportDialog.link_details")}
  211. </div>
  212. <div className="ShareDialog__picker__button">
  213. <FilledButton
  214. size="large"
  215. label={t("exportDialog.link_button")}
  216. icon={LinkIcon}
  217. onClick={async () => {
  218. await props.onExportToBackend();
  219. props.handleClose();
  220. }}
  221. />
  222. </div>
  223. </>
  224. )}
  225. </>
  226. );
  227. };
  228. const ShareDialogInner = (props: ShareDialogProps) => {
  229. const activeRoomLink = useAtomValue(activeRoomLinkAtom);
  230. return (
  231. <Dialog size="small" onCloseRequest={props.handleClose} title={false}>
  232. <div className="ShareDialog">
  233. {props.collabAPI && activeRoomLink ? (
  234. <ActiveRoomDialog
  235. collabAPI={props.collabAPI}
  236. activeRoomLink={activeRoomLink}
  237. handleClose={props.handleClose}
  238. />
  239. ) : (
  240. <ShareDialogPicker {...props} />
  241. )}
  242. </div>
  243. </Dialog>
  244. );
  245. };
  246. export const ShareDialog = (props: {
  247. collabAPI: CollabAPI | null;
  248. onExportToBackend: OnExportToBackend;
  249. }) => {
  250. const [shareDialogState, setShareDialogState] = useAtom(shareDialogStateAtom);
  251. const { openDialog } = useUIAppState();
  252. useEffect(() => {
  253. if (openDialog) {
  254. setShareDialogState({ isOpen: false });
  255. }
  256. }, [openDialog, setShareDialogState]);
  257. if (!shareDialogState.isOpen) {
  258. return null;
  259. }
  260. return (
  261. <ShareDialogInner
  262. handleClose={() => setShareDialogState({ isOpen: false })}
  263. collabAPI={props.collabAPI}
  264. onExportToBackend={props.onExportToBackend}
  265. type={shareDialogState.type}
  266. />
  267. );
  268. };