App.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  1. import { useCallback, useEffect, useRef, useState } from "react";
  2. import {
  3. Box,
  4. Button,
  5. Container,
  6. Flex,
  7. Heading,
  8. HStack,
  9. Icon,
  10. Input,
  11. InputGroup,
  12. ButtonGroup,
  13. InputRightElement,
  14. Link,
  15. Select,
  16. Stack,
  17. Switch,
  18. Text,
  19. useToast,
  20. } from "@chakra-ui/react";
  21. import {
  22. VscChevronRight,
  23. VscCloudDownload,
  24. VscCloudUpload,
  25. VscFolderOpened,
  26. VscGist,
  27. VscRepoPull,
  28. } from "react-icons/vsc";
  29. import useStorage from "use-local-storage-state";
  30. import Editor from "@monaco-editor/react";
  31. import { editor } from "monaco-editor/esm/vs/editor/editor.api";
  32. import rustpadRaw from "../rustpad-server/src/rustpad.rs?raw";
  33. import {
  34. getFileExtension,
  35. getLanguage,
  36. Language,
  37. languages,
  38. } from "./languages";
  39. import animals from "./animals.json";
  40. import Rustpad, { UserInfo } from "./rustpad";
  41. import useHash from "./useHash";
  42. import ConnectionStatus from "./ConnectionStatus";
  43. import Footer from "./Footer";
  44. import User from "./User";
  45. function getWsUri(id: string) {
  46. return (
  47. (window.location.origin.startsWith("https") ? "wss://" : "ws://") +
  48. window.location.host +
  49. `/api/socket/${id}`
  50. );
  51. }
  52. function useKeyboardCtrlIntercept(
  53. key: string,
  54. reaction: (event: KeyboardEvent) => unknown
  55. ) {
  56. useEffect(() => {
  57. const wrappedReaction: typeof reaction = (event) => {
  58. if (!(event.metaKey || event.ctrlKey)) return;
  59. if (event.key.toLowerCase() !== key.toLowerCase()) return;
  60. event.preventDefault();
  61. reaction(event);
  62. };
  63. const controller = new AbortController();
  64. window.addEventListener("keydown", wrappedReaction, {signal: controller.signal});
  65. return () => controller.abort();
  66. }, [key, reaction]);
  67. }
  68. function generateName() {
  69. return "Anonymous " + animals[Math.floor(Math.random() * animals.length)];
  70. }
  71. function generateHue() {
  72. return Math.floor(Math.random() * 360);
  73. }
  74. /**
  75. * This appears to still be the best way to download a file while suggesting a filename.
  76. *
  77. * According to [mdn](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-download)
  78. * the download attribute gets ignored on URIs that are not either `same-origin` or use the `blob` or `data` schemes.
  79. */
  80. function downloadUri(uri: string, filename: string) {
  81. const downloadAnchor = document.createElement("a");
  82. downloadAnchor.download = filename;
  83. downloadAnchor.href = uri;
  84. downloadAnchor.click();
  85. }
  86. function downloadText(text: string, fileName: string) {
  87. const file = new File([text], fileName);
  88. const url = URL.createObjectURL(file);
  89. downloadUri(url, fileName);
  90. URL.revokeObjectURL(url)
  91. }
  92. async function getFileUploadWithDialog() {
  93. const uploadInput = document.createElement("input");
  94. uploadInput.type = "file";
  95. uploadInput.click();
  96. const controller = new AbortController();
  97. // await the user-input (selecting the file)
  98. await new Promise((resolve) => uploadInput.addEventListener("change", resolve, {signal: controller.signal}));
  99. controller.abort();
  100. console.log("reached", uploadInput.files?.length);
  101. const files = uploadInput.files;
  102. if (files?.length !== 1) return;
  103. return files[0];
  104. }
  105. function App() {
  106. const toast = useToast();
  107. const [language, setLanguage] = useState<Language>("plaintext");
  108. const [connection, setConnection] = useState<
  109. "connected" | "disconnected" | "desynchronized"
  110. >("disconnected");
  111. const [users, setUsers] = useState<Record<number, UserInfo>>({});
  112. const [name, setName] = useStorage("name", generateName);
  113. const [hue, setHue] = useStorage("hue", generateHue);
  114. const [editor, setEditor] = useState<editor.IStandaloneCodeEditor>();
  115. const [darkMode, setDarkMode] = useStorage("darkMode", () => false);
  116. const rustpad = useRef<Rustpad>();
  117. const id = useHash();
  118. useEffect(() => {
  119. if (editor?.getModel()) {
  120. const model = editor.getModel()!;
  121. model.setValue("");
  122. model.setEOL(0); // LF
  123. rustpad.current = new Rustpad({
  124. uri: getWsUri(id),
  125. editor,
  126. onConnected: () => setConnection("connected"),
  127. onDisconnected: () => setConnection("disconnected"),
  128. onDesynchronized: () => {
  129. setConnection("desynchronized");
  130. toast({
  131. title: "Desynchronized with server",
  132. description: "Please save your work and refresh the page.",
  133. status: "error",
  134. duration: null,
  135. });
  136. },
  137. onChangeLanguage: (language) => {
  138. if (languages.includes(language)) {
  139. setLanguage(language);
  140. }
  141. },
  142. onChangeUsers: setUsers,
  143. });
  144. return () => {
  145. rustpad.current?.dispose();
  146. rustpad.current = undefined;
  147. };
  148. }
  149. }, [id, editor, toast, setUsers]);
  150. useEffect(() => {
  151. if (connection === "connected") {
  152. rustpad.current?.setInfo({ name, hue });
  153. }
  154. }, [connection, name, hue]);
  155. function handleChangeLanguage(language: Language) {
  156. setLanguage(language);
  157. if (rustpad.current?.setLanguage(language)) {
  158. toast({
  159. title: "Language updated",
  160. description: (
  161. <>
  162. All users are now editing in{" "}
  163. <Text as="span" fontWeight="semibold">
  164. {language}
  165. </Text>
  166. .
  167. </>
  168. ),
  169. status: "info",
  170. duration: 2000,
  171. isClosable: true,
  172. });
  173. }
  174. }
  175. async function handleCopy() {
  176. await navigator.clipboard.writeText(`${window.location.origin}/#${id}`);
  177. toast({
  178. title: "Copied!",
  179. description: "Link copied to clipboard",
  180. status: "success",
  181. duration: 2000,
  182. isClosable: true,
  183. });
  184. }
  185. function setText(newText: string) {
  186. const model = editor?.getModel();
  187. if (!model || !editor) return;
  188. model.pushEditOperations(
  189. editor.getSelections(),
  190. [
  191. {
  192. range: model.getFullModelRange(),
  193. text: newText,
  194. },
  195. ],
  196. () => null
  197. );
  198. editor.setPosition({ column: 0, lineNumber: 0 });
  199. }
  200. function handleLoadSample() {
  201. setText(rustpadRaw);
  202. if (language !== "rust") {
  203. handleChangeLanguage("rust");
  204. }
  205. }
  206. async function handleUploadFile(file: File) {
  207. const text = await file.text();
  208. setText(text);
  209. const newLanguage = getLanguage(file.name);
  210. if (newLanguage !== language) handleChangeLanguage(newLanguage);
  211. }
  212. function handleDownloadFile() {
  213. const model = editor?.getModel();
  214. if (!model || !editor) return;
  215. downloadText(
  216. model.getValue(),
  217. `rustpad.${getFileExtension(language)}`,
  218. )
  219. }
  220. useKeyboardCtrlIntercept("s", handleDownloadFile);
  221. function handleDarkMode() {
  222. setDarkMode(!darkMode);
  223. }
  224. return (
  225. <Flex
  226. direction="column"
  227. h="100vh"
  228. overflow="hidden"
  229. bgColor={darkMode ? "#1e1e1e" : "white"}
  230. color={darkMode ? "#cbcaca" : "inherit"}
  231. onDragOver={(event) => event.preventDefault()}
  232. onDrop={(event) => {
  233. event.preventDefault();
  234. const dragItems = event.dataTransfer.items;
  235. if (dragItems.length !== 1) return;
  236. const file = dragItems[0].getAsFile();
  237. if (file === null) return;
  238. handleUploadFile(file);
  239. }}
  240. >
  241. <Box
  242. flexShrink={0}
  243. bgColor={darkMode ? "#333333" : "#e8e8e8"}
  244. color={darkMode ? "#cccccc" : "#383838"}
  245. textAlign="center"
  246. fontSize="sm"
  247. py={0.5}
  248. >
  249. Rustpad
  250. </Box>
  251. <Flex flex="1 0" minH={0}>
  252. <Container
  253. w="xs"
  254. bgColor={darkMode ? "#252526" : "#f3f3f3"}
  255. overflowY="auto"
  256. maxW="full"
  257. lineHeight={1.4}
  258. py={4}
  259. >
  260. <ConnectionStatus darkMode={darkMode} connection={connection} />
  261. <Flex justifyContent="space-between" mt={4} mb={1.5} w="full">
  262. <Heading size="sm">Dark Mode</Heading>
  263. <Switch isChecked={darkMode} onChange={handleDarkMode} />
  264. </Flex>
  265. <Heading mt={4} mb={1.5} size="sm">
  266. Language
  267. </Heading>
  268. <Select
  269. size="sm"
  270. bgColor={darkMode ? "#3c3c3c" : "white"}
  271. borderColor={darkMode ? "#3c3c3c" : "white"}
  272. value={language}
  273. onChange={(event) =>
  274. handleChangeLanguage(event.target.value as Language)
  275. }
  276. >
  277. {languages.map((lang) => (
  278. <option key={lang} value={lang} style={{ color: "black" }}>
  279. {lang}
  280. </option>
  281. ))}
  282. </Select>
  283. <Heading mt={4} mb={1.5} size="sm">
  284. Share Link
  285. </Heading>
  286. <InputGroup size="sm">
  287. <Input
  288. readOnly
  289. pr="3.5rem"
  290. variant="outline"
  291. bgColor={darkMode ? "#3c3c3c" : "white"}
  292. borderColor={darkMode ? "#3c3c3c" : "white"}
  293. value={`${window.location.origin}/#${id}`}
  294. />
  295. <InputRightElement width="3.5rem">
  296. <Button
  297. h="1.4rem"
  298. size="xs"
  299. onClick={handleCopy}
  300. _hover={{ bg: darkMode ? "#575759" : "gray.200" }}
  301. bgColor={darkMode ? "#575759" : "gray.200"}
  302. >
  303. Copy
  304. </Button>
  305. </InputRightElement>
  306. </InputGroup>
  307. <Heading mt={4} mb={1.5} size="sm">
  308. Upload & Download
  309. </Heading>
  310. <Text>
  311. You can also upload with drag&drop and downlod with Ctrl + S
  312. </Text>
  313. <ButtonGroup size="sm" display="flex">
  314. <Button
  315. size="sm"
  316. colorScheme={darkMode ? "whiteAlpha" : "blackAlpha"}
  317. borderColor={darkMode ? "purple.400" : "purple.600"}
  318. color={darkMode ? "purple.400" : "purple.600"}
  319. variant="outline"
  320. leftIcon={<VscCloudUpload />}
  321. mt={1}
  322. flex="auto"
  323. onClick={async () => {
  324. const file = await getFileUploadWithDialog();
  325. if (!file) return;
  326. handleUploadFile(file);
  327. }}
  328. >
  329. Upload
  330. </Button>
  331. <Button
  332. size="sm"
  333. colorScheme={darkMode ? "whiteAlpha" : "blackAlpha"}
  334. borderColor={darkMode ? "purple.400" : "purple.600"}
  335. color={darkMode ? "purple.400" : "purple.600"}
  336. variant="outline"
  337. leftIcon={<VscCloudDownload />}
  338. mt={1}
  339. flex="auto"
  340. onClick={(event) => {
  341. event.preventDefault();
  342. handleDownloadFile();
  343. }}
  344. >
  345. Download
  346. </Button>
  347. </ButtonGroup>
  348. <Heading mt={4} mb={1.5} size="sm">
  349. Active Users
  350. </Heading>
  351. <Stack spacing={0} mb={1.5} fontSize="sm">
  352. <User
  353. info={{ name, hue }}
  354. isMe
  355. onChangeName={(name) => name.length > 0 && setName(name)}
  356. onChangeColor={() => setHue(generateHue())}
  357. darkMode={darkMode}
  358. />
  359. {Object.entries(users).map(([id, info]) => (
  360. <User key={id} info={info} darkMode={darkMode} />
  361. ))}
  362. </Stack>
  363. <Heading mt={4} mb={1.5} size="sm">
  364. About
  365. </Heading>
  366. <Text fontSize="sm" mb={1.5}>
  367. <strong>Rustpad</strong> is an open-source collaborative text editor
  368. based on the <em>operational transformation</em> algorithm.
  369. </Text>
  370. <Text fontSize="sm" mb={1.5}>
  371. Share a link to this pad with others, and they can edit from their
  372. browser while seeing your changes in real time.
  373. </Text>
  374. <Text fontSize="sm" mb={1.5}>
  375. Built using Rust and TypeScript. See the{" "}
  376. <Link
  377. color="blue.600"
  378. fontWeight="semibold"
  379. href="https://github.com/ekzhang/rustpad"
  380. isExternal
  381. >
  382. GitHub repository
  383. </Link>{" "}
  384. for details.
  385. </Text>
  386. <Button
  387. size="sm"
  388. colorScheme={darkMode ? "whiteAlpha" : "blackAlpha"}
  389. borderColor={darkMode ? "purple.400" : "purple.600"}
  390. color={darkMode ? "purple.400" : "purple.600"}
  391. variant="outline"
  392. leftIcon={<VscRepoPull />}
  393. mt={1}
  394. onClick={handleLoadSample}
  395. >
  396. Read the code
  397. </Button>
  398. </Container>
  399. <Flex flex={1} minW={0} h="100%" direction="column" overflow="hidden">
  400. <HStack
  401. h={6}
  402. spacing={1}
  403. color="#888888"
  404. fontWeight="medium"
  405. fontSize="13px"
  406. px={3.5}
  407. flexShrink={0}
  408. >
  409. <Icon as={VscFolderOpened} fontSize="md" color="blue.500" />
  410. <Text>documents</Text>
  411. <Icon as={VscChevronRight} fontSize="md" />
  412. <Icon as={VscGist} fontSize="md" color="purple.500" />
  413. <Text>{id}</Text>
  414. </HStack>
  415. <Box flex={1} minH={0}>
  416. <Editor
  417. theme={darkMode ? "vs-dark" : "vs"}
  418. language={language}
  419. options={{
  420. automaticLayout: true,
  421. fontSize: 13,
  422. }}
  423. onMount={(editor) => setEditor(editor)}
  424. />
  425. </Box>
  426. </Flex>
  427. </Flex>
  428. <Footer />
  429. </Flex>
  430. );
  431. }
  432. export default App;