A lot of refactors, mostly moving code out of App

* Create a SideBarGroup that makes groups in the sidebar easier to spot
* Create a seperate component for every sidebar group
* move MOST of the handling functions for these sidebar groups into that
  component to make clearer what theyre for
* move a few other Components and helper functions to where they make more sense or their
  own file
This commit is contained in:
Samuel Maier 2022-03-08 13:24:30 +01:00
parent 3e86be8348
commit a4d8500ccc
12 changed files with 463 additions and 299 deletions

View file

@ -1,47 +1,33 @@
import { useEffect, useRef, useState } from "react";
import React, { 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 { VscChevronRight, VscFolderOpened, VscGist } 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 { getFileExtension, getLanguage, Language } from "./languages";
import animals from "./animals.json";
import Rustpad, { UserInfo } from "./rustpad";
import useHash from "./useHash";
import ConnectionStatus from "./ConnectionStatus";
import ConnectionStatus, { ConnectionStatusState } from "./ConnectionStatus";
import Footer from "./Footer";
import User from "./User";
import { generateHue, DisplayUsers } from "./sidebarComponents/DisplayUsers";
import { ShareLink } from "./sidebarComponents/ShareLink";
import { LanguageSelection } from "./sidebarComponents/LanguageSelection";
import { About } from "./sidebarComponents/About";
import { DownloadUpload } from "./sidebarComponents/DownloadUpload";
import { useCustomToasts } from "./useCustomToasts";
import { useKeyboardCtrlIntercept } from "./useKeyboardCtrlIntercept";
import { downloadText } from "./downloadUploadWrappers";
function getWsUri(id: string) {
return (
@ -51,72 +37,15 @@ function getWsUri(id: string) {
);
}
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 toasts = useCustomToasts();
const [language, setLanguage] = useState<Language>("plaintext");
const [connection, setConnection] = useState<
"connected" | "disconnected" | "desynchronized"
>("disconnected");
const [connection, setConnection] =
useState<ConnectionStatusState>("disconnected");
const [users, setUsers] = useState<Record<number, UserInfo>>({});
const [name, setName] = useStorage("name", generateName);
const [hue, setHue] = useStorage("hue", generateHue);
@ -137,18 +66,9 @@ function App() {
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);
}
toasts.desynchronized();
},
onChangeLanguage: setLanguage,
onChangeUsers: setUsers,
});
return () => {
@ -156,7 +76,7 @@ function App() {
rustpad.current = undefined;
};
}
}, [id, editor, toast, setUsers]);
}, [id, editor, toasts, setUsers]);
useEffect(() => {
if (connection === "connected") {
@ -164,38 +84,6 @@ function App() {
}
}, [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;
@ -212,33 +100,29 @@ function App() {
editor.setPosition({ column: 0, lineNumber: 0 });
}
function handleLoadSample() {
setText(rustpadRaw);
if (language !== "rust") {
handleChangeLanguage("rust");
}
}
async function handleUploadFile(file: File) {
async function uploadFile(file: File) {
const text = await file.text();
setText(text);
const newLanguage = getLanguage(file.name);
if (newLanguage !== language) handleChangeLanguage(newLanguage);
setLanguage(newLanguage);
toasts.fileUpload(name);
}
function handleDownloadFile() {
function downloadFile() {
const model = editor?.getModel();
if (!model || !editor) return;
downloadText(
model.getValue(),
`rustpad.${getFileExtension(language)}`,
)
if (!model) return;
downloadText(model.getValue(), `rustpad.${getFileExtension(language)}`);
}
useKeyboardCtrlIntercept("s", handleDownloadFile);
useKeyboardCtrlIntercept("s", downloadFile);
function handleDarkMode() {
setDarkMode(!darkMode);
function toggleDarkMode() {
setDarkMode((darkMode) => !darkMode);
}
function loadRustpadSourceSample() {
setText(rustpadRaw);
setLanguage("rust");
}
return (
@ -255,7 +139,7 @@ function App() {
if (dragItems.length !== 1) return;
const file = dragItems[0].getAsFile();
if (file === null) return;
handleUploadFile(file);
uploadFile(file);
}}
>
<Box
@ -281,147 +165,59 @@ function App() {
<Flex justifyContent="space-between" mt={4} mb={1.5} w="full">
<Heading size="sm">Dark Mode</Heading>
<Switch isChecked={darkMode} onChange={handleDarkMode} />
<Switch isChecked={darkMode} onChange={toggleDarkMode} />
</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);
<SideBarGroup title="Language">
<LanguageSelection
{...{
language,
setLanguage,
darkMode,
}}
>
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>
</SideBarGroup>
<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>
<SideBarGroup title="Share Link">
<ShareLink
{...{
id,
darkMode,
}}
/>
</SideBarGroup>
<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>
<SideBarGroup title="Upload and Download">
<DownloadUpload
{...{
uploadFile,
downloadFile,
darkMode,
}}
/>
</SideBarGroup>
<SideBarGroup title="Active Users">
<DisplayUsers
{...{
users,
name,
setName,
hue,
setHue,
darkMode,
}}
/>
</SideBarGroup>
<SideBarGroup title="About">
<About
{...{
loadRustpadSourceSample,
darkMode,
}}
/>
</SideBarGroup>
</Container>
<Flex flex={1} minW={0} h="100%" direction="column" overflow="hidden">
<HStack
@ -457,4 +253,18 @@ function App() {
);
}
function SideBarGroup({
title,
children,
}: React.PropsWithChildren<{ title: string }>) {
return (
<>
<Heading mt={4} mb={1.5} size="sm">
{title}
</Heading>
{children}
</>
);
}
export default App;

View file

@ -1,8 +1,13 @@
import { HStack, Icon, Text } from "@chakra-ui/react";
import { VscCircleFilled } from "react-icons/vsc";
export type ConnectionStatusState =
| "connected"
| "disconnected"
| "desynchronized";
type ConnectionStatusProps = {
connection: "connected" | "disconnected" | "desynchronized";
connection: ConnectionStatusState;
darkMode: boolean;
};

View file

@ -0,0 +1,37 @@
/**
* 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.
*/
export function downloadUri(uri: string, filename: string) {
const downloadAnchor = document.createElement("a");
downloadAnchor.download = filename;
downloadAnchor.href = uri;
downloadAnchor.click();
}
export function downloadText(text: string, fileName: string) {
const file = new File([text], fileName);
const url = URL.createObjectURL(file);
downloadUri(url, fileName);
URL.revokeObjectURL(url);
}
export 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];
}

View file

@ -85,19 +85,19 @@ export type Language = typeof languages[number];
type LanguageExtensionDecls = {
/** the default extension (for download and to recognize uploads)*/
extension: string,
extension: string;
/** Other known extensions for that language (to detect it in uploads etc) */
aliasExtensions: string[],
aliasExtensions: string[];
};
const languagesAndExtensions: {[key in Language]?: LanguageExtensionDecls} = {
const languagesAndExtensions: { [key in Language]?: LanguageExtensionDecls } = {
typescript: {
extension: "ts",
aliasExtensions: ["tsx"],
},
rust: {
extension: "rs",
aliasExtensions: []
aliasExtensions: [],
},
cpp: {
extension: "cpp",
@ -105,7 +105,7 @@ const languagesAndExtensions: {[key in Language]?: LanguageExtensionDecls} = {
},
yaml: {
extension: "yaml",
aliasExtensions:["yml"],
aliasExtensions: ["yml"],
},
html: {
extension: "html",
@ -117,19 +117,24 @@ type LanguageExtensions = { [key in string]: Language };
/** Used on download to determine file extension. */
export function getFileExtension(language: Language) {
return languagesAndExtensions[language as keyof typeof languagesAndExtensions]?.extension ?? "txt";
return (
languagesAndExtensions[language as keyof typeof languagesAndExtensions]
?.extension ?? "txt"
);
}
const defaultExtensionsToLanguage = {} as LanguageExtensions;
const additionalExtensionsToLanguage = {} as LanguageExtensions;
Object.entries(languagesAndExtensions).forEach(([language, {extension, aliasExtensions}]) => {
defaultExtensionsToLanguage[extension] = language as Language;
aliasExtensions.forEach((aliasExtension) => {
additionalExtensionsToLanguage[aliasExtension] = language as Language;
})
})
Object.entries(languagesAndExtensions).forEach(
([language, { extension, aliasExtensions }]) => {
defaultExtensionsToLanguage[extension] = language as Language;
aliasExtensions.forEach((aliasExtension) => {
additionalExtensionsToLanguage[aliasExtension] = language as Language;
});
}
);
const extensionsToLanguage: LanguageExtensions = {
...additionalExtensionsToLanguage,

View file

@ -0,0 +1,44 @@
import { Link, Text } from "@chakra-ui/layout";
import "react";
import { VscRepoPull } from "react-icons/vsc";
import { DecoratedButton } from "./DecoratedButton";
type AboutProps = {
loadRustpadSourceSample: () => unknown;
darkMode: boolean;
};
export function About({ loadRustpadSourceSample, darkMode }: AboutProps) {
return (
<>
<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>
<DecoratedButton
onClick={loadRustpadSourceSample}
icon={<VscRepoPull />}
darkMode={darkMode}
>
Read the code
</DecoratedButton>
</>
);
}

View file

@ -0,0 +1,28 @@
import { Button, ButtonOptions } from "@chakra-ui/button";
export function DecoratedButton({
onClick,
darkMode,
icon,
children,
}: React.PropsWithChildren<{
onClick: React.MouseEventHandler<HTMLButtonElement>;
icon: ButtonOptions["leftIcon"];
darkMode?: boolean;
}>) {
return (
<Button
size="sm"
colorScheme={darkMode ? "whiteAlpha" : "blackAlpha"}
borderColor={darkMode ? "purple.400" : "purple.600"}
color={darkMode ? "purple.400" : "purple.600"}
variant="outline"
leftIcon={icon}
mt={1}
flex="auto"
onClick={onClick}
>
{children}
</Button>
);
}

View file

@ -1,3 +1,6 @@
import { Stack } from "@chakra-ui/layout";
import "react";
import { UserInfo } from "../rustpad";
import {
Button,
ButtonGroup,
@ -18,7 +21,6 @@ import {
import { useRef } from "react";
import { FaPalette } from "react-icons/fa";
import { VscAccount } from "react-icons/vsc";
import { UserInfo } from "./rustpad";
type UserProps = {
info: UserInfo;
@ -109,4 +111,37 @@ function User({
);
}
export default User;
type DisplayUserProps = UserInfo & {
darkMode: boolean;
setName: (newName: string) => unknown;
setHue: (newHue: number) => unknown;
users: Record<number, UserInfo>;
};
export function generateHue() {
return Math.floor(Math.random() * 360);
}
export function DisplayUsers({
name,
hue,
setName,
setHue,
users,
darkMode,
}: DisplayUserProps) {
return (
<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>
);
}

View file

@ -0,0 +1,48 @@
import { ButtonGroup } from "@chakra-ui/button";
import { Text } from "@chakra-ui/layout";
import "react";
import { VscCloudDownload, VscCloudUpload } from "react-icons/vsc";
import { getFileUploadWithDialog } from "../downloadUploadWrappers";
import { DecoratedButton } from "./DecoratedButton";
type DownloadUploadProps = {
downloadFile: () => unknown;
uploadFile: (file: File) => unknown;
darkMode: boolean;
};
export function DownloadUpload({
downloadFile,
uploadFile,
darkMode,
}: DownloadUploadProps) {
return (
<>
<Text>You can also upload with drag and drop and download with Ctrl + S</Text>
<ButtonGroup size="sm" display="flex">
<DecoratedButton
onClick={async (event) => {
event.preventDefault();
const file = await getFileUploadWithDialog();
if (!file) return;
uploadFile(file);
}}
icon={<VscCloudUpload />}
darkMode={darkMode}
>
Upload
</DecoratedButton>
<DecoratedButton
onClick={(event) => {
event.preventDefault();
downloadFile();
}}
icon={<VscCloudDownload />}
darkMode={darkMode}
>
Download
</DecoratedButton>
</ButtonGroup>
</>
);
}

View file

@ -0,0 +1,39 @@
import { Select } from "@chakra-ui/select";
import "react";
import { useCustomToasts } from "../useCustomToasts";
import { Language, languages } from "../languages";
type LanguageSelectionProps = {
language: Language;
setLanguage: (newLanguage: Language) => unknown;
darkMode: boolean;
};
export function LanguageSelection({
language,
setLanguage,
darkMode,
}: LanguageSelectionProps) {
const toasts = useCustomToasts();
function handleChangeLanguage(language: Language) {
setLanguage(language);
// if (rustpad.current?.setLanguage(language)) {
toasts.languageChange(language);
// }
}
return (
<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>
);
}

View file

@ -0,0 +1,39 @@
import { Button } from "@chakra-ui/button";
import { Input, InputGroup, InputRightElement } from "@chakra-ui/input";
import "react";
import { useCustomToasts } from "../useCustomToasts";
type ShareLinkProps = { darkMode: boolean; id: string };
export function ShareLink({ darkMode, id }: ShareLinkProps) {
const toasts = useCustomToasts();
async function handleCopy() {
await navigator.clipboard.writeText(`${window.location.origin}/#${id}`);
toasts.copyToClipBoard();
}
return (
<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>
);
}

53
src/useCustomToasts.tsx Normal file
View file

@ -0,0 +1,53 @@
import { Language } from "./languages";
import "react";
import { useToast, Text } from "@chakra-ui/react";
import { useMemo } from "react";
export function useCustomToasts() {
const toast = useToast();
const messages = useMemo(
() => ({
copyToClipBoard: () =>
toast({
title: "Copied!",
description: "Link copied to clipboard",
status: "success",
duration: 2000,
isClosable: true,
}),
languageChange: (newLanguage: Language) =>
toast({
title: "Language updated",
description: (
<>
All users are now editing in{" "}
<Text as="span" fontWeight="semibold">
{newLanguage}
</Text>
.
</>
),
status: "info",
duration: 2000,
isClosable: true,
}),
desynchronized: () =>
toast({
title: "Desynchronized with server",
description: "Please save your work and refresh the page.",
status: "error",
duration: null,
}),
fileUpload: (user: string) =>
toast({
title: "File uploaded",
description: `A file has been uploaded by ${user}, the edited text has been changed and the language updated accordingly`,
status: "info",
duration: 5000,
isClosable: true,
}),
}),
[toast]
);
return messages;
}

View file

@ -0,0 +1,21 @@
import { useEffect } from "react";
export 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]);
}