123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460 |
- import { useCallback, useEffect, useRef, useState } from "react";
- import {
- Box,
- Button,
- Container,
- Flex,
- Heading,
- HStack,
- Icon,
- Input,
- InputGroup,
- ButtonGroup,
- InputRightElement,
- Link,
- Select,
- Stack,
- Switch,
- Text,
- useToast,
- } from "@chakra-ui/react";
- import {
- VscChevronRight,
- VscCloudDownload,
- VscCloudUpload,
- VscFolderOpened,
- VscGist,
- VscRepoPull,
- } from "react-icons/vsc";
- import useStorage from "use-local-storage-state";
- import Editor from "@monaco-editor/react";
- import { editor } from "monaco-editor/esm/vs/editor/editor.api";
- import rustpadRaw from "../rustpad-server/src/rustpad.rs?raw";
- import {
- getFileExtension,
- getLanguage,
- Language,
- languages,
- } from "./languages";
- import animals from "./animals.json";
- import Rustpad, { UserInfo } from "./rustpad";
- import useHash from "./useHash";
- import ConnectionStatus from "./ConnectionStatus";
- import Footer from "./Footer";
- import User from "./User";
- function getWsUri(id: string) {
- return (
- (window.location.origin.startsWith("https") ? "wss://" : "ws://") +
- window.location.host +
- `/api/socket/${id}`
- );
- }
- function useKeyboardCtrlIntercept(
- key: string,
- reaction: (event: KeyboardEvent) => unknown
- ) {
- useEffect(() => {
- const wrappedReaction: typeof reaction = (event) => {
- if (!(event.metaKey || event.ctrlKey)) return;
- if (event.key.toLowerCase() !== key.toLowerCase()) return;
- event.preventDefault();
- reaction(event);
- };
- const controller = new AbortController();
- window.addEventListener("keydown", wrappedReaction, {signal: controller.signal});
- return () => controller.abort();
- }, [key, reaction]);
- }
- function generateName() {
- return "Anonymous " + animals[Math.floor(Math.random() * animals.length)];
- }
- function generateHue() {
- return Math.floor(Math.random() * 360);
- }
- /**
- * This appears to still be the best way to download a file while suggesting a filename.
- *
- * According to [mdn](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-download)
- * the download attribute gets ignored on URIs that are not either `same-origin` or use the `blob` or `data` schemes.
- */
- function downloadUri(uri: string, filename: string) {
- const downloadAnchor = document.createElement("a");
- downloadAnchor.download = filename;
- downloadAnchor.href = uri;
- downloadAnchor.click();
- }
- function downloadText(text: string, fileName: string) {
- const file = new File([text], fileName);
- const url = URL.createObjectURL(file);
- downloadUri(url, fileName);
- URL.revokeObjectURL(url)
- }
- async function getFileUploadWithDialog() {
- const uploadInput = document.createElement("input");
- uploadInput.type = "file";
- uploadInput.click();
- const controller = new AbortController();
- // await the user-input (selecting the file)
- await new Promise((resolve) => uploadInput.addEventListener("change", resolve, {signal: controller.signal}));
- controller.abort();
- console.log("reached", uploadInput.files?.length);
- const files = uploadInput.files;
- if (files?.length !== 1) return;
- return files[0];
- }
- function App() {
- const toast = useToast();
- const [language, setLanguage] = useState<Language>("plaintext");
- const [connection, setConnection] = useState<
- "connected" | "disconnected" | "desynchronized"
- >("disconnected");
- const [users, setUsers] = useState<Record<number, UserInfo>>({});
- const [name, setName] = useStorage("name", generateName);
- const [hue, setHue] = useStorage("hue", generateHue);
- const [editor, setEditor] = useState<editor.IStandaloneCodeEditor>();
- const [darkMode, setDarkMode] = useStorage("darkMode", () => false);
- const rustpad = useRef<Rustpad>();
- const id = useHash();
- useEffect(() => {
- if (editor?.getModel()) {
- const model = editor.getModel()!;
- model.setValue("");
- model.setEOL(0); // LF
- rustpad.current = new Rustpad({
- uri: getWsUri(id),
- editor,
- onConnected: () => setConnection("connected"),
- onDisconnected: () => setConnection("disconnected"),
- onDesynchronized: () => {
- setConnection("desynchronized");
- toast({
- title: "Desynchronized with server",
- description: "Please save your work and refresh the page.",
- status: "error",
- duration: null,
- });
- },
- onChangeLanguage: (language) => {
- if (languages.includes(language)) {
- setLanguage(language);
- }
- },
- onChangeUsers: setUsers,
- });
- return () => {
- rustpad.current?.dispose();
- rustpad.current = undefined;
- };
- }
- }, [id, editor, toast, setUsers]);
- useEffect(() => {
- if (connection === "connected") {
- rustpad.current?.setInfo({ name, hue });
- }
- }, [connection, name, hue]);
- function handleChangeLanguage(language: Language) {
- setLanguage(language);
- if (rustpad.current?.setLanguage(language)) {
- toast({
- title: "Language updated",
- description: (
- <>
- All users are now editing in{" "}
- <Text as="span" fontWeight="semibold">
- {language}
- </Text>
- .
- </>
- ),
- status: "info",
- duration: 2000,
- isClosable: true,
- });
- }
- }
- async function handleCopy() {
- await navigator.clipboard.writeText(`${window.location.origin}/#${id}`);
- toast({
- title: "Copied!",
- description: "Link copied to clipboard",
- status: "success",
- duration: 2000,
- isClosable: true,
- });
- }
- function setText(newText: string) {
- const model = editor?.getModel();
- if (!model || !editor) return;
- model.pushEditOperations(
- editor.getSelections(),
- [
- {
- range: model.getFullModelRange(),
- text: newText,
- },
- ],
- () => null
- );
- editor.setPosition({ column: 0, lineNumber: 0 });
- }
- function handleLoadSample() {
- setText(rustpadRaw);
- if (language !== "rust") {
- handleChangeLanguage("rust");
- }
- }
- async function handleUploadFile(file: File) {
- const text = await file.text();
- setText(text);
- const newLanguage = getLanguage(file.name);
- if (newLanguage !== language) handleChangeLanguage(newLanguage);
- }
- function handleDownloadFile() {
- const model = editor?.getModel();
- if (!model || !editor) return;
- downloadText(
- model.getValue(),
- `rustpad.${getFileExtension(language)}`,
- )
- }
- useKeyboardCtrlIntercept("s", handleDownloadFile);
- function handleDarkMode() {
- setDarkMode(!darkMode);
- }
- return (
- <Flex
- direction="column"
- h="100vh"
- overflow="hidden"
- bgColor={darkMode ? "#1e1e1e" : "white"}
- color={darkMode ? "#cbcaca" : "inherit"}
- onDragOver={(event) => event.preventDefault()}
- onDrop={(event) => {
- event.preventDefault();
- const dragItems = event.dataTransfer.items;
- if (dragItems.length !== 1) return;
- const file = dragItems[0].getAsFile();
- if (file === null) return;
- handleUploadFile(file);
- }}
- >
- <Box
- flexShrink={0}
- bgColor={darkMode ? "#333333" : "#e8e8e8"}
- color={darkMode ? "#cccccc" : "#383838"}
- textAlign="center"
- fontSize="sm"
- py={0.5}
- >
- Rustpad
- </Box>
- <Flex flex="1 0" minH={0}>
- <Container
- w="xs"
- bgColor={darkMode ? "#252526" : "#f3f3f3"}
- overflowY="auto"
- maxW="full"
- lineHeight={1.4}
- py={4}
- >
- <ConnectionStatus darkMode={darkMode} connection={connection} />
- <Flex justifyContent="space-between" mt={4} mb={1.5} w="full">
- <Heading size="sm">Dark Mode</Heading>
- <Switch isChecked={darkMode} onChange={handleDarkMode} />
- </Flex>
- <Heading mt={4} mb={1.5} size="sm">
- Language
- </Heading>
- <Select
- size="sm"
- bgColor={darkMode ? "#3c3c3c" : "white"}
- borderColor={darkMode ? "#3c3c3c" : "white"}
- value={language}
- onChange={(event) =>
- handleChangeLanguage(event.target.value as Language)
- }
- >
- {languages.map((lang) => (
- <option key={lang} value={lang} style={{ color: "black" }}>
- {lang}
- </option>
- ))}
- </Select>
- <Heading mt={4} mb={1.5} size="sm">
- Share Link
- </Heading>
- <InputGroup size="sm">
- <Input
- readOnly
- pr="3.5rem"
- variant="outline"
- bgColor={darkMode ? "#3c3c3c" : "white"}
- borderColor={darkMode ? "#3c3c3c" : "white"}
- value={`${window.location.origin}/#${id}`}
- />
- <InputRightElement width="3.5rem">
- <Button
- h="1.4rem"
- size="xs"
- onClick={handleCopy}
- _hover={{ bg: darkMode ? "#575759" : "gray.200" }}
- bgColor={darkMode ? "#575759" : "gray.200"}
- >
- Copy
- </Button>
- </InputRightElement>
- </InputGroup>
- <Heading mt={4} mb={1.5} size="sm">
- Upload & Download
- </Heading>
- <Text>
- You can also upload with drag&drop and downlod with Ctrl + S
- </Text>
- <ButtonGroup size="sm" display="flex">
- <Button
- size="sm"
- colorScheme={darkMode ? "whiteAlpha" : "blackAlpha"}
- borderColor={darkMode ? "purple.400" : "purple.600"}
- color={darkMode ? "purple.400" : "purple.600"}
- variant="outline"
- leftIcon={<VscCloudUpload />}
- mt={1}
- flex="auto"
- onClick={async () => {
- const file = await getFileUploadWithDialog();
- if (!file) return;
- handleUploadFile(file);
- }}
- >
- Upload
- </Button>
- <Button
- size="sm"
- colorScheme={darkMode ? "whiteAlpha" : "blackAlpha"}
- borderColor={darkMode ? "purple.400" : "purple.600"}
- color={darkMode ? "purple.400" : "purple.600"}
- variant="outline"
- leftIcon={<VscCloudDownload />}
- mt={1}
- flex="auto"
- onClick={(event) => {
- event.preventDefault();
- handleDownloadFile();
- }}
- >
- Download
- </Button>
- </ButtonGroup>
- <Heading mt={4} mb={1.5} size="sm">
- Active Users
- </Heading>
- <Stack spacing={0} mb={1.5} fontSize="sm">
- <User
- info={{ name, hue }}
- isMe
- onChangeName={(name) => name.length > 0 && setName(name)}
- onChangeColor={() => setHue(generateHue())}
- darkMode={darkMode}
- />
- {Object.entries(users).map(([id, info]) => (
- <User key={id} info={info} darkMode={darkMode} />
- ))}
- </Stack>
- <Heading mt={4} mb={1.5} size="sm">
- About
- </Heading>
- <Text fontSize="sm" mb={1.5}>
- <strong>Rustpad</strong> is an open-source collaborative text editor
- based on the <em>operational transformation</em> algorithm.
- </Text>
- <Text fontSize="sm" mb={1.5}>
- Share a link to this pad with others, and they can edit from their
- browser while seeing your changes in real time.
- </Text>
- <Text fontSize="sm" mb={1.5}>
- Built using Rust and TypeScript. See the{" "}
- <Link
- color="blue.600"
- fontWeight="semibold"
- href="https://github.com/ekzhang/rustpad"
- isExternal
- >
- GitHub repository
- </Link>{" "}
- for details.
- </Text>
- <Button
- size="sm"
- colorScheme={darkMode ? "whiteAlpha" : "blackAlpha"}
- borderColor={darkMode ? "purple.400" : "purple.600"}
- color={darkMode ? "purple.400" : "purple.600"}
- variant="outline"
- leftIcon={<VscRepoPull />}
- mt={1}
- onClick={handleLoadSample}
- >
- Read the code
- </Button>
- </Container>
- <Flex flex={1} minW={0} h="100%" direction="column" overflow="hidden">
- <HStack
- h={6}
- spacing={1}
- color="#888888"
- fontWeight="medium"
- fontSize="13px"
- px={3.5}
- flexShrink={0}
- >
- <Icon as={VscFolderOpened} fontSize="md" color="blue.500" />
- <Text>documents</Text>
- <Icon as={VscChevronRight} fontSize="md" />
- <Icon as={VscGist} fontSize="md" color="purple.500" />
- <Text>{id}</Text>
- </HStack>
- <Box flex={1} minH={0}>
- <Editor
- theme={darkMode ? "vs-dark" : "vs"}
- language={language}
- options={{
- automaticLayout: true,
- fontSize: 13,
- }}
- onMount={(editor) => setEditor(editor)}
- />
- </Box>
- </Flex>
- </Flex>
- <Footer />
- </Flex>
- );
- }
- export default App;
|