diff --git a/frontend/package.json b/frontend/package.json index dcd0689547baf2f356f505b388e53a51c99f60c4..ff501b7b9bf8f3976cfb8bf4e1120e611e89a71f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,33 +4,34 @@ "private": true, "proxy": "http://localhost:8080", "dependencies": { + "@matejmazur/react-katex": "^3.0.2", "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^7.1.2", "@types/jest": "^24.0.0", + "@types/lodash": "^4.14.106", "@types/node": "^12.0.0", + "@types/pdfjs-dist": "^2.0.0", "@types/react": "^16.9.0", "@types/react-dom": "^16.9.0", - "react": "^16.12.0", - "react-dom": "^16.12.0", - "react-scripts": "3.4.0", - "typescript": "~3.7.2", - "@matejmazur/react-katex": "^3.0.2", - "@types/lodash": "^4.14.106", - "@types/pdfjs-dist": "^2.0.0", "@types/react-router-dom": "^5.1.3", "@types/react-syntax-highlighter": "^10.1.0", + "emotion": "^10.0.27", "glamor": "^2.20.40", "katex": "^0.10.0", "lodash": "^4.17.5", "moment": "^2.22.2", "pdfjs-dist": "^2.0.489", + "prettier": "^1.18.2", + "react": "^16.12.0", + "react-dom": "^16.12.0", "react-feather": "^2.0.3", "react-markdown": "^4.0.3", "react-router-dom": "^5.1.2", + "react-scripts": "3.4.0", "react-syntax-highlighter": "^10.2.1", "remark-math": "^1.0.5", - "prettier": "^1.18.2", + "typescript": "~3.7.2", "worker-loader": "^2.0.0" }, "scripts": { diff --git a/frontend/src/components/Editor/BasicEditor.tsx b/frontend/src/components/Editor/BasicEditor.tsx new file mode 100644 index 0000000000000000000000000000000000000000..014aec0e528e52f61bcaa87387f4db0586eae754 --- /dev/null +++ b/frontend/src/components/Editor/BasicEditor.tsx @@ -0,0 +1,121 @@ +import * as React from "react"; +import { useRef, useCallback, useEffect } from "react"; +import { css, cx } from "emotion"; +import { Range } from "./utils/types"; + +const wrapperStyle = css` + position: relative; +`; +const commonStyle = css` + font-family: "Fira Code", monospace; + font-size: 14px; + white-space: pre-wrap; + word-wrap: break-word; + width: 100%; + box-sizing: border-box; + padding: 0; + margin: 0; +`; +const textareaStyle = css` + position: absolute; + top: 0; + color: black; + caret-color: inherit; + background: transparent; + resize: none; + border: none; + &:focus { + outline: none; + } +`; +const preStyle = css` + user-select: none; + color: transparent; +`; + +interface Props { + value: string; + onChange: (newValue: string) => void; + + getSelectionRangeRef: React.RefObject<() => Range | undefined>; + setSelectionRangeRef: React.RefObject<(newSelection: Range) => void>; + + onMetaKey: (str: string, shift: boolean) => boolean; +} +const BasicEditor: React.FC<Props> = ({ + value, + onChange, + getSelectionRangeRef, + setSelectionRangeRef, + onMetaKey, +}) => { + const textareaElRef = useRef<HTMLTextAreaElement>(null); + const preElRef = useRef<HTMLPreElement>(null); + + // tslint:disable-next-line: no-any + (getSelectionRangeRef as any).current = () => { + const textarea = textareaElRef.current; + if (textarea === null) return; + return { + start: textarea.selectionStart, + end: textarea.selectionEnd, + }; + }; + + // tslint:disable-next-line: no-any + (setSelectionRangeRef as any).current = (newSelection: Range) => { + const textarea = textareaElRef.current; + if (textarea === null) return; + setTimeout(() => { + textarea.selectionStart = newSelection.start; + textarea.selectionEnd = newSelection.end; + }, 0); + }; + + const onTextareaChange = useCallback( + e => { + const newContent = e.currentTarget.value; + onChange(newContent); + }, + [onChange], + ); + + const onTextareaKeyDown = useCallback( + (e: React.KeyboardEvent<HTMLTextAreaElement>) => { + if (e.ctrlKey || e.metaKey) { + if (onMetaKey(e.key.toLowerCase(), e.shiftKey)) { + e.preventDefault(); + } + } + }, + [onMetaKey], + ); + + const onResize = useCallback(() => { + const textareaEl = textareaElRef.current; + if (textareaEl === null) return; + const preEl = preElRef.current; + if (preEl === null) return; + textareaEl.style.height = `${preEl.clientHeight}px`; + }, []); + + useEffect(() => { + onResize(); + }, [value, onResize]); + + return ( + <div className={wrapperStyle}> + <pre ref={preElRef} className={cx(commonStyle, preStyle)}> + {value + "\n"} + </pre> + <textarea + value={value} + onChange={onTextareaChange} + onKeyDown={onTextareaKeyDown} + ref={textareaElRef} + className={cx(commonStyle, textareaStyle)} + /> + </div> + ); +}; +export default BasicEditor; diff --git a/frontend/src/components/Editor/Container.tsx b/frontend/src/components/Editor/Container.tsx new file mode 100644 index 0000000000000000000000000000000000000000..759b5aa6fe2f90dd8adf87aa8ec6c24c15eeea40 --- /dev/null +++ b/frontend/src/components/Editor/Container.tsx @@ -0,0 +1,14 @@ +import * as React from "react"; +import { css } from "emotion"; +const containerStyle = css` + width: 100%; + max-width: 600px; + margin: auto; + padding: 1em; + box-sizing: border-box; +`; + +const Container: React.FC<{}> = ({ children }) => { + return <div className={containerStyle}>{children}</div>; +}; +export default Container; diff --git a/frontend/src/components/Editor/Dropzone.tsx b/frontend/src/components/Editor/Dropzone.tsx new file mode 100644 index 0000000000000000000000000000000000000000..be3e6d3c5bf52e90e25a65cd0014828c6669196c --- /dev/null +++ b/frontend/src/components/Editor/Dropzone.tsx @@ -0,0 +1,64 @@ +import * as React from "react"; +import { css } from "emotion"; +import { useCallback } from "react"; + +const dropZoneStyle = css` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +`; + +interface Props { + onDragLeave: () => void; + onDrop: (files: File[]) => void; +} + +const DropZone: React.FC<Props> = ({ onDragLeave, onDrop }) => { + const onDragLeaveHandler = useCallback( + (e: React.DragEvent<HTMLDivElement>) => { + e.preventDefault(); + e.stopPropagation(); + onDragLeave(); + }, + [onDragLeave], + ); + const onDropHandler = useCallback( + (e: React.DragEvent<HTMLDivElement>) => { + e.preventDefault(); + e.stopPropagation(); + onDragLeave(); + const items = e.dataTransfer.items; + if (items === undefined) return; + const files: File[] = []; + for (let i = 0; i < items.length; i++) { + const item = items.item ? items.item(i) : items[i]; + if (item.kind !== "file") continue; + const file = item.getAsFile(); + if (file === null) continue; + files.push(file); + } + if (files.length > 0) { + onDrop(files); + } + }, + [onDrop, onDragLeave], + ); + const onDragOverHandler = useCallback( + (e: React.DragEvent<HTMLDivElement>) => { + e.preventDefault(); + e.stopPropagation(); + }, + [], + ); + return ( + <div + className={dropZoneStyle} + onDragLeave={onDragLeaveHandler} + onDrop={onDropHandler} + onDragOver={onDragOverHandler} + /> + ); +}; +export default DropZone; diff --git a/frontend/src/components/Editor/EditorFooter.tsx b/frontend/src/components/Editor/EditorFooter.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e953f119407ac49fa2d873288f0262552ac51c8a --- /dev/null +++ b/frontend/src/components/Editor/EditorFooter.tsx @@ -0,0 +1,111 @@ +import * as React from "react"; +import { css } from "emotion"; +import { useRef, useCallback } from "react"; +import { Image as ImageIcon, Plus } from "react-feather"; +import { ImageHandle } from "./utils/types"; + +const addImageIconStyle = css` + margin: 0; + border: none; + cursor: pointer; + background-color: transparent; + padding: 6px; +`; +const footerStyle = css` + border-top: 1px solid rgba(0, 0, 0, 0.1); +`; +const rowStyle = css` + display: flex; + flex-direction: row; + align-items: center; +`; +const spacer = css` + flex-grow: 1; +`; +const fileInputStyle = css` + visibility: hidden; + display: none; +`; +const addImageButtonStyle = css` + border: none; + background-color: transparent; + cursor: pointer; + display: flex; + align-items: center; + color: rgba(0, 0, 0, 0.4); + transition: color 0.1s; + &:hover { + color: rgba(0, 0, 0, 0.8); + } +`; +const addImageTextStyle = css` + font-size: 12px; +`; +interface Props { + onFiles: (files: File[]) => void; + onOpenOverlay: () => void; + attachments: ImageHandle[]; + onDelete: (handle: ImageHandle) => void; +} +const EditorFooter: React.FC<Props> = ({ + onFiles, + attachments, + onDelete, + onOpenOverlay, +}) => { + const iconSize = 15; + const fileInputRef = useRef<HTMLInputElement>(null); + + const onFile = useCallback(() => { + const fileInput = fileInputRef.current; + if (fileInput === null) return; + fileInput.click(); + }, []); + const onChangeHandler = useCallback( + (e: React.ChangeEvent<HTMLInputElement>) => { + const fileInput = fileInputRef.current; + if (fileInput === null) return; + const fileList = fileInput.files; + if (fileList === null) return; + const files: File[] = []; + for (let i = 0; i < fileList.length; i++) { + const file = fileList.item(i); + if (file === null) continue; + files.push(file); + } + onFiles(files); + fileInput.value = ""; + }, + [onFiles], + ); + return ( + <div className={footerStyle}> + <div className={rowStyle}> + <div className={spacer} /> + <button onClick={onOpenOverlay} className={addImageButtonStyle}> + <div className={addImageIconStyle}> + <ImageIcon size={iconSize} /> + </div> + <div className={addImageTextStyle}>Browse Images</div> + </button> + <button onClick={onFile} className={addImageButtonStyle}> + <div className={addImageIconStyle}> + <Plus size={iconSize} /> + </div> + <div className={addImageTextStyle}>Add Image</div> + </button> + <input + type="file" + className={fileInputStyle} + ref={fileInputRef} + onChange={onChangeHandler} + /> + </div> + <small> + You can use Markdown. Use ``` code ``` for code. Use $ math $ or $$ \n + math \n $$ for latex math. + </small> + </div> + ); +}; +export default EditorFooter; diff --git a/frontend/src/components/Editor/EditorHeader.tsx b/frontend/src/components/Editor/EditorHeader.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6805c201ed1b5e7dd0f1846d46e4f530c7f4298f --- /dev/null +++ b/frontend/src/components/Editor/EditorHeader.tsx @@ -0,0 +1,84 @@ +import { EditorMode } from "./utils/types"; +import * as React from "react"; +import TabBar from "./TabBar"; +import { css } from "emotion"; +import { Bold, Italic, Link, Code, DollarSign } from "react-feather"; + +const iconButtonStyle = css` + margin: 0; + border: none; + cursor: pointer; + background-color: transparent; + padding: 6px; + color: rgba(0, 0, 0, 0.4); + transition: color 0.1s; + &:hover { + color: rgba(0, 0, 0, 0.8); + } +`; +const headerStyle = css` + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: row; + align-items: flex-end; +`; +const spacer = css` + flex-grow: 1; +`; + +interface Props { + activeMode: EditorMode; + onActiveModeChange: (newMode: EditorMode) => void; + + onMathClick: () => void; + onCodeClick: () => void; + onLinkClick: () => void; + onItalicClick: () => void; + onBoldClick: () => void; +} +const EditorHeader: React.FC<Props> = ({ + activeMode, + onActiveModeChange, + ...handlers +}) => { + const iconSize = 15; + return ( + <div className={headerStyle}> + <TabBar + items={[ + { + title: "Write", + active: activeMode === "write", + onClick: () => onActiveModeChange("write"), + }, + { + title: "Preview", + active: activeMode === "preview", + onClick: () => onActiveModeChange("preview"), + }, + ]} + /> + <div className={spacer} /> + {activeMode === "write" && ( + <> + <button className={iconButtonStyle} onClick={handlers.onMathClick}> + <DollarSign size={iconSize} /> + </button> + <button className={iconButtonStyle} onClick={handlers.onCodeClick}> + <Code size={iconSize} /> + </button> + <button className={iconButtonStyle} onClick={handlers.onLinkClick}> + <Link size={iconSize} /> + </button> + <button className={iconButtonStyle} onClick={handlers.onItalicClick}> + <Italic size={iconSize} /> + </button> + <button className={iconButtonStyle} onClick={handlers.onBoldClick}> + <Bold size={iconSize} /> + </button> + </> + )} + </div> + ); +}; +export default EditorHeader; diff --git a/frontend/src/components/Editor/TabBar.tsx b/frontend/src/components/Editor/TabBar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d6b4acb1779555707ae40ec6fac3f1a713e33113 --- /dev/null +++ b/frontend/src/components/Editor/TabBar.tsx @@ -0,0 +1,57 @@ +import * as React from "react"; +import { css, cx } from "emotion"; + +const buttonStyle = css` + border: none; + background-color: transparent; + cursor: pointer; + padding: 0.4em; + font-weight: 400; + font-size: 14px; + margin: 0; + color: rgba(0, 0, 0, 0.5); + transition: color 0.1s, border-bottom 0.1s; + border-radius: 0; + &:hover { + background-color: transparent; + } +`; +const inactiveButtonStyle = css` + border-bottom: 1px solid transparent; + &:hover { + color: rgba(0, 0, 0, 0.9); + border-bottom: 1px solid rgba(0, 0, 0, 0.3); + } +`; +const activeButtonStyle = css` + color: rgba(0, 0, 0, 1); + border-bottom: 1px solid #3867d6; +`; + +interface Item { + title: string; + active: boolean; + onClick: () => void; +} +interface Props { + items: Item[]; +} +const TabBar: React.FC<Props> = ({ items }) => { + return ( + <div> + {items.map(item => ( + <button + key={item.title} + onClick={item.onClick} + className={cx( + buttonStyle, + item.active ? activeButtonStyle : inactiveButtonStyle, + )} + > + {item.title} + </button> + ))} + </div> + ); +}; +export default TabBar; diff --git a/frontend/src/components/Editor/index.tsx b/frontend/src/components/Editor/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..01dd6e28e60395595235a215172ca64995af4672 --- /dev/null +++ b/frontend/src/components/Editor/index.tsx @@ -0,0 +1,275 @@ +import * as React from "react"; +import { useCallback, useState, useRef } from "react"; +import { css, cx } from "emotion"; +import { Range, EditorMode, ImageHandle } from "./utils/types"; +import BasicEditor from "./BasicEditor"; +import EditorHeader from "./EditorHeader"; +import DropZone from "./Dropzone"; +import EditorFooter from "./EditorFooter"; +import ImageOverlay from "../image-overlay"; +import { UndoStack, push, undo, redo } from "./utils/undo-stack"; + +const editorWrapperStyle = css` + padding: 1.2em; +`; + +const wrapperStyle = css` + position: relative; + border: 1px solid transparent; +`; +const dragHoveredWrapperStyle = css` + border: 1px solid #fed330; + border-radius: 3px; +`; +interface Props { + value: string; + onChange: (newValue: string) => void; + imageHandler: (file: File) => Promise<ImageHandle>; + preview: (str: string) => React.ReactNode; + + undoStack: UndoStack; + setUndoStack: (newStack: UndoStack) => void; +} +const Editor: React.FC<Props> = ({ + value, + onChange, + imageHandler, + preview, + undoStack, + setUndoStack, +}) => { + const [mode, setMode] = useState<EditorMode>("write"); + const [isDragHovered, setIsDragHovered] = useState(false); + const [attachments, setAttachments] = useState<ImageHandle[]>([]); + const [overlayOpen, setOverlayOpen] = useState(false); + + const setCurrent = useCallback( + (newValue: string, newSelection?: Range) => { + if (newSelection) setSelectionRangeRef.current(newSelection); + onChange(newValue); + const selection = getSelectionRangeRef.current(); + if (selection === undefined) return; + const newStack = push(undoStack, value, selection); + setUndoStack(newStack); + }, + [undoStack, setUndoStack, onChange, value], + ); + + const setSelectionRangeRef = useRef<(newSelection: Range) => void>( + (a: Range) => undefined, + ); + const getSelectionRangeRef = useRef<() => Range | undefined>(() => ({ + start: 0, + end: 0, + })); + + const insertImage = useCallback( + (handle: ImageHandle) => { + const selection = getSelectionRangeRef.current(); + if (selection === undefined) return; + const before = value.substring(0, selection.start); + const content = value.substring(selection.start, selection.end); + const after = value.substring(selection.end); + const newContent = "`; + const newSelection = { + start: selection.start + 2, + end: selection.start + content.length + 2, + }; + setCurrent(before + newContent + after, newSelection); + }, + [setCurrent, value], + ); + + const insertLink = useCallback(() => { + const selection = getSelectionRangeRef.current(); + if (selection === undefined) return; + const before = value.substring(0, selection.start); + const content = value.substring(selection.start, selection.end); + const after = value.substring(selection.end); + const newContent = "[" + content + "](https://www.example.com)"; + const newSelection = { + start: selection.start + content.length + 3, + end: selection.start + newContent.length - 1, + }; + setCurrent(before + newContent + after, newSelection); + }, [setCurrent, value]); + + const wrapSelection = useCallback( + (str: string) => { + const selection = getSelectionRangeRef.current(); + if (selection === undefined) return; + const before = value.substring(0, selection.start); + const content = value.substring(selection.start, selection.end); + const after = value.substring(selection.end); + const newContent = str + content + str; + + if (content.length === 0) { + const newSelection = { + start: selection.start + str.length, + end: selection.end + str.length, + }; + setCurrent(before + newContent + after, newSelection); + } else { + const newSelection = { + start: selection.start, + end: selection.end + newContent.length - content.length, + }; + setCurrent(before + newContent + after, newSelection); + } + }, + [setCurrent, value], + ); + + const onMathClick = useCallback(() => { + wrapSelection("$"); + }, [wrapSelection]); + + const onCodeClick = useCallback(() => { + wrapSelection("`"); + }, [wrapSelection]); + + const onLinkClick = useCallback(() => { + insertLink(); + }, [insertLink]); + + const onItalicClick = useCallback(() => { + wrapSelection("*"); + }, [wrapSelection]); + + const onBoldClick = useCallback(() => { + wrapSelection("**"); + }, [wrapSelection]); + + const onMetaKey = useCallback( + (key: string, shift: boolean) => { + if (key.toLowerCase() === "b") { + onBoldClick(); + return true; + } else if (key.toLowerCase() === "i") { + onItalicClick(); + return true; + } else if (key === "z" && !shift) { + if (undoStack.prev.length > 0) { + const selection = getSelectionRangeRef.current(); + if (selection === undefined) return true; + const [newState, newStack] = undo(undoStack, { + value: value, + selection, + time: new Date(), + }); + setUndoStack(newStack); + onChange(newState.value); + setSelectionRangeRef.current(newState.selection); + } + return true; + } else if (key === "z" && shift) { + if (undoStack.next.length > 0) { + const selection = getSelectionRangeRef.current(); + if (selection === undefined) return true; + const [newState, newStack] = redo(undoStack, { + value: value, + selection, + time: new Date(), + }); + setUndoStack(newStack); + onChange(newState.value); + setSelectionRangeRef.current(newState.selection); + } + return true; + } + return false; + }, + [onBoldClick, onItalicClick, onChange, setUndoStack, undoStack, value], + ); + + const onDragEnter = useCallback(() => { + setIsDragHovered(true); + }, []); + + const onDragLeave = useCallback(() => { + setIsDragHovered(false); + }, []); + + const onFile = useCallback( + async (file: File) => { + const handle = await imageHandler(file); + setAttachments(a => [...a, handle]); + insertImage(handle); + }, + [imageHandler, insertImage], + ); + + const onFiles = useCallback( + (files: File[]) => { + for (const file of files) { + onFile(file); + } + }, + [onFile], + ); + + const onDeleteAttachment = useCallback(async (handle: ImageHandle) => { + await handle.remove(); + setAttachments(a => a.filter(h => h !== handle)); + }, []); + + const onImageDialogClose = useCallback( + (image: string) => { + setOverlayOpen(false); + if (image.length === 0) return; + insertImage({ + name: image, + src: image, + remove: () => Promise.resolve(), + }); + }, + [insertImage], + ); + + const onOpenOverlay = useCallback(() => { + setOverlayOpen(true); + }, []); + + return ( + <> + <div + className={cx(wrapperStyle, isDragHovered && dragHoveredWrapperStyle)} + onDragEnter={onDragEnter} + > + <EditorHeader + activeMode={mode} + onActiveModeChange={setMode} + onMathClick={onMathClick} + onCodeClick={onCodeClick} + onLinkClick={onLinkClick} + onItalicClick={onItalicClick} + onBoldClick={onBoldClick} + /> + <div className={editorWrapperStyle}> + {mode === "write" ? ( + <BasicEditor + value={value} + onChange={newValue => setCurrent(newValue)} + setSelectionRangeRef={setSelectionRangeRef} + getSelectionRangeRef={getSelectionRangeRef} + onMetaKey={onMetaKey} + /> + ) : ( + preview(value) + )} + </div> + <EditorFooter + onFiles={onFiles} + attachments={attachments} + onDelete={onDeleteAttachment} + onOpenOverlay={onOpenOverlay} + /> + {isDragHovered && ( + <DropZone onDragLeave={onDragLeave} onDrop={onFiles} /> + )} + </div> + {overlayOpen && <ImageOverlay onClose={onImageDialogClose} />} + </> + ); +}; +export default Editor; diff --git a/frontend/src/components/Editor/utils/types.ts b/frontend/src/components/Editor/utils/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..0db2f012bf2ae4125cf9d1dde97bf7bb4595d7e1 --- /dev/null +++ b/frontend/src/components/Editor/utils/types.ts @@ -0,0 +1,10 @@ +export interface Range { + start: number; + end: number; +} +export interface ImageHandle { + name: string; + src: string; + remove: () => Promise<void>; +} +export type EditorMode = "write" | "preview"; diff --git a/frontend/src/components/Editor/utils/undo-stack.ts b/frontend/src/components/Editor/utils/undo-stack.ts new file mode 100644 index 0000000000000000000000000000000000000000..e07b400cb740f0dd5e7898f8943d4f9bb10ece77 --- /dev/null +++ b/frontend/src/components/Editor/utils/undo-stack.ts @@ -0,0 +1,73 @@ +import { Range } from "./types"; + +export interface UndoState { + value: string; + selection: Range; + time: Date; +} +export interface UndoStack { + prev: UndoState[]; + next: UndoState[]; +} +/** + * Determines if the two `UndoState` instances `a` and `b` can be merged. + * Currently only states can be merged where at most a single line was + * changed and in that line only a single word was appended. + * @param a + * @param b + */ +const canBeMerged = (a: UndoState, b: UndoState) => { + const timeDiff = Math.abs(a.time.getTime() - b.time.getTime()); + if (timeDiff > 10000) return false; + const aLines = a.value.split(/[\r\n]+/); + const bLines = b.value.split(/[\r\n]+/); + if (aLines.length !== bLines.length) return false; + let changeLine = -1; + for (let i = 0; i < aLines.length; i++) { + if (aLines[i] === bLines[i]) continue; + if (changeLine !== -1) return false; + changeLine = i; + } + if (changeLine === -1) return true; + const aLine = aLines[changeLine]; + const bLine = bLines[changeLine]; + const [baseContent, newContent] = + aLine.length < bLine.length ? [aLine, bLine] : [bLine, aLine]; + if (newContent.indexOf(baseContent) !== 0) return false; + const diff = newContent.substring(baseContent.length); + const words = diff.split(/\b/); + const res = words.length <= 1; + return res; +}; +export const push = (prevStack: UndoStack, value: string, selection: Range) => + prevStack.prev.length > 0 && + canBeMerged(prevStack.prev[prevStack.prev.length - 1], { + value, + selection, + time: new Date(), + }) + ? { + prev: prevStack.prev, + next: [], + } + : { + prev: [...prevStack.prev, { value, selection, time: new Date() }], + next: [], + }; +export const undo = (prevStack: UndoStack, currentState: UndoState) => + [ + prevStack.prev[prevStack.prev.length - 1], + { + prev: prevStack.prev.slice(0, -1), + next: [...prevStack.next, currentState].slice(-100), + }, + ] as [UndoState, UndoStack]; + +export const redo = (prevStack: UndoStack, currentState: UndoState) => + [ + prevStack.next[prevStack.next.length - 1], + { + prev: [...prevStack.prev, currentState].slice(-100), + next: prevStack.next.slice(0, -1), + }, + ] as [UndoState, UndoStack]; diff --git a/frontend/src/components/answer.tsx b/frontend/src/components/answer.tsx index 3dc42971f9e7d7a803bfc4f94c80e4a9a7f15edf..925248cff73108066faac2694f4b5c1fa39a673e 100644 --- a/frontend/src/components/answer.tsx +++ b/frontend/src/components/answer.tsx @@ -4,14 +4,14 @@ import moment from "moment"; import Comment from "./comment"; import { css } from "glamor"; import MarkdownText from "./markdown-text"; -import { fetchpost } from "../fetch-utils"; -import ImageOverlay from "./image-overlay"; +import { fetchpost, imageHandler } from "../fetch-utils"; import Colors from "../colors"; import { Link } from "react-router-dom"; import globalcss from "../globalcss"; import GlobalConsts from "../globalconsts"; import colors from "../colors"; -import { listenEnter } from "../input-utils"; +import Editor from "./Editor"; +import { UndoStack } from "./Editor/utils/undo-stack"; interface Props { isReadonly: boolean; @@ -27,8 +27,8 @@ interface Props { interface State { editing: boolean; imageDialog: boolean; - imageCursorPosition: number; text: string; + undoStack: UndoStack; savedText: string; addingComment: boolean; allCommentsVisible: boolean; @@ -129,6 +129,7 @@ const styles = { boxSizing: "border-box", }), actionButtons: css({ + width: "100%", display: "flex", justifyContent: "flex-end", marginRight: "25px", @@ -161,11 +162,11 @@ export default class AnswerComponent extends React.Component<Props, State> { state: State = { editing: this.props.answer.canEdit && this.props.answer.text.length === 0, imageDialog: false, - imageCursorPosition: -1, savedText: this.props.answer.text, text: this.props.answer.text, allCommentsVisible: false, addingComment: false, + undoStack: { prev: [], next: [] }, }; componentDidUpdate( @@ -222,7 +223,6 @@ export default class AnswerComponent extends React.Component<Props, State> { startEdit = () => { this.setState({ editing: true, - imageCursorPosition: -1, }); }; @@ -232,29 +232,9 @@ export default class AnswerComponent extends React.Component<Props, State> { })); }; - startImageDialog = () => { - this.setState({ imageDialog: true }); - }; - - endImageDialog = (image: string) => { - if (image.length > 0) { - const imageTag = ``; - this.setState(prevState => ({ - imageDialog: false, - text: - prevState.text.slice(0, prevState.imageCursorPosition) + - imageTag + - prevState.text.slice(prevState.imageCursorPosition), - })); - } else { - this.setState({ imageDialog: false }); - } - }; - - answerTextareaChange = (event: React.FormEvent<HTMLTextAreaElement>) => { + answerTextareaChange = (newValue: string) => { this.setState({ - text: event.currentTarget.value, - imageCursorPosition: event.currentTarget.selectionStart, + text: newValue, }); }; @@ -407,38 +387,25 @@ export default class AnswerComponent extends React.Component<Props, State> { )} </div> </div> - <div {...styles.answer}> - <MarkdownText value={this.state.text} /> - </div> + {!this.state.editing && ( + <div {...styles.answer}> + <MarkdownText value={this.state.text} /> + </div> + )} {this.state.editing && ( <div> <div {...styles.answerInput}> - <textarea - {...styles.textareaInput} - onKeyUp={this.answerTextareaChange} - onChange={this.answerTextareaChange} - cols={120} - rows={20} + <Editor value={this.state.text} - onKeyPress={listenEnter(this.saveAnswer, true)} + onChange={this.answerTextareaChange} + imageHandler={imageHandler} + preview={str => <MarkdownText value={str} />} + undoStack={this.state.undoStack} + setUndoStack={undoStack => this.setState({ undoStack })} /> </div> <div {...styles.answerTexHint}> - <div> - <small> - You can use Markdown. Use ``` code ``` for code. Use $ math $ - or $$ \n math \n $$ for latex math. - </small> - </div> <div {...styles.actionButtons}> - <div {...styles.actionButton} onClick={this.startImageDialog}> - <img - {...styles.actionImg} - src="/static/images.svg" - title="Images" - alt="Images" - /> - </div> <div {...styles.actionButton} onClick={this.saveAnswer}> <img {...styles.actionImg} @@ -522,9 +489,6 @@ export default class AnswerComponent extends React.Component<Props, State> { )} </div> )} - {this.state.imageDialog && ( - <ImageOverlay onClose={this.endImageDialog} /> - )} {(answer.comments.length > 0 || this.state.addingComment) && ( <div {...styles.comments}> diff --git a/frontend/src/components/comment.tsx b/frontend/src/components/comment.tsx index 24874c6694db43b604511dea8b325039dd709982..ab5db565674856441196eec96fa81c260f1ce124 100644 --- a/frontend/src/components/comment.tsx +++ b/frontend/src/components/comment.tsx @@ -3,13 +3,13 @@ import { AnswerSection, Comment } from "../interfaces"; import moment from "moment"; import { css } from "glamor"; import MarkdownText from "./markdown-text"; -import { fetchpost } from "../fetch-utils"; -import ImageOverlay from "./image-overlay"; +import { fetchpost, imageHandler } from "../fetch-utils"; import { Link } from "react-router-dom"; import globalcss from "../globalcss"; import GlobalConsts from "../globalconsts"; -import { listenEnter } from "../input-utils"; import Colors from "../colors"; +import Editor from "./Editor"; +import { UndoStack } from "./Editor/utils/undo-stack"; interface Props { isReadonly: boolean; @@ -26,8 +26,7 @@ interface State { editing: boolean; text: string; savedText: string; - imageDialog: boolean; - imageCursorPosition: number; + undoStack: UndoStack; } const styles = { @@ -72,8 +71,7 @@ export default class CommentComponent extends React.Component<Props, State> { editing: !!this.props.isNewComment, savedText: this.props.comment.text, text: this.props.comment.text, - imageDialog: false, - imageCursorPosition: -1, + undoStack: { prev: [], next: [] }, }; removeComment = () => { @@ -127,32 +125,12 @@ export default class CommentComponent extends React.Component<Props, State> { } }; - commentTextareaChange = (event: React.FormEvent<HTMLTextAreaElement>) => { + commentTextareaChange = (newValue: string) => { this.setState({ - text: event.currentTarget.value, - imageCursorPosition: event.currentTarget.selectionStart, + text: newValue, }); }; - startImageDialog = () => { - this.setState({ imageDialog: true }); - }; - - endImageDialog = (image: string) => { - if (image.length > 0) { - const imageTag = ``; - this.setState(prevState => ({ - imageDialog: false, - text: - prevState.text.slice(0, prevState.imageCursorPosition) + - imageTag + - prevState.text.slice(prevState.imageCursorPosition), - })); - } else { - this.setState({ imageDialog: false }); - } - }; - render() { const { comment } = this.props; return ( @@ -199,33 +177,26 @@ export default class CommentComponent extends React.Component<Props, State> { )} </div> </div> - <div {...styles.comment}> - <MarkdownText - value={this.state.editing ? this.state.text : comment.text} - /> - </div> + {!this.state.editing && ( + <div {...styles.comment}> + <MarkdownText + value={this.state.editing ? this.state.text : comment.text} + /> + </div> + )} {this.state.editing && ( <div> <div> - <textarea - {...styles.textareaInput} - onKeyUp={this.commentTextareaChange} - onChange={this.commentTextareaChange} - cols={80} - rows={5} + <Editor value={this.state.text} - onKeyPress={listenEnter(this.saveComment, true)} + onChange={this.commentTextareaChange} + imageHandler={imageHandler} + preview={str => <MarkdownText value={str} />} + undoStack={this.state.undoStack} + setUndoStack={undoStack => this.setState({ undoStack })} /> </div> <div {...styles.actionButtons}> - <div {...styles.actionButton} onClick={this.startImageDialog}> - <img - {...styles.actionImg} - src="/static/images.svg" - title="Images" - alt="Images" - /> - </div> <div {...styles.actionButton} onClick={this.saveComment}> <img {...styles.actionImg} @@ -247,9 +218,6 @@ export default class CommentComponent extends React.Component<Props, State> { </div> </div> )} - {this.state.imageDialog && ( - <ImageOverlay onClose={this.endImageDialog} /> - )} </div> ); } diff --git a/frontend/src/components/image-overlay.tsx b/frontend/src/components/image-overlay.tsx index a0ddb9a222988c1908ef51f4aa4d007103d2bf27..4d08fc5ee94015665ad187db276a337504fc75a8 100644 --- a/frontend/src/components/image-overlay.tsx +++ b/frontend/src/components/image-overlay.tsx @@ -14,6 +14,7 @@ const styles = { bottom: "0", paddingTop: "200px", paddingBottom: "200px", + zIndex: 100, "@media (max-height: 799px)": { paddingTop: "50px", paddingBottom: "50px", diff --git a/frontend/src/fetch-utils.tsx b/frontend/src/fetch-utils.tsx index 00415b2582d48f42df097a26de84c763d9e2387e..bda8bca52d259de0d01125f01695e6df1cd4e30f 100644 --- a/frontend/src/fetch-utils.tsx +++ b/frontend/src/fetch-utils.tsx @@ -1,3 +1,5 @@ +import { ImageHandle } from "./components/Editor/utils/types"; + export async function fetchpost(url: string, data: { [key: string]: any }) { const formData = new FormData(); // Convert the `data` object into a `formData` object by iterating @@ -47,6 +49,23 @@ export async function fetchapi(url: string) { } } +export function imageHandler(file: File): Promise<ImageHandle> { + return new Promise((resolve, reject) => { + fetchpost("/api/uploadimg", { + file: file, + }) + .then(res => { + resolve({ + name: file.name, + src: res.filename, + remove: async () => { + await fetchpost(`/api/image/${res.filename}/remove`, {}); + }, + }); + }) + .catch(e => reject(e)); + }); +} export function getCookie(name: string): string | null { let cookieValue = null; if (document.cookie && document.cookie !== "") { diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 49a83966d376fb538b16f79d08c5dc765dfa2916..704d66a25f900e988e1c5dedd276087e5d5dce09 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -142,7 +142,7 @@ dependencies: "@babel/types" "^7.8.3" -"@babel/helper-module-imports@^7.8.3": +"@babel/helper-module-imports@^7.0.0", "@babel/helper-module-imports@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.8.3.tgz#7fe39589b39c016331b6b8c3f441e8f0b1419498" dependencies: @@ -796,6 +796,62 @@ version "10.1.0" resolved "https://registry.yarnpkg.com/@csstools/normalize.css/-/normalize.css-10.1.0.tgz#f0950bba18819512d42f7197e56c518aa491cf18" +"@emotion/cache@^10.0.27": + version "10.0.29" + resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-10.0.29.tgz#87e7e64f412c060102d589fe7c6dc042e6f9d1e0" + integrity sha512-fU2VtSVlHiF27empSbxi1O2JFdNWZO+2NFHfwO0pxgTep6Xa3uGb+3pVKfLww2l/IBGLNEZl5Xf/++A4wAYDYQ== + dependencies: + "@emotion/sheet" "0.9.4" + "@emotion/stylis" "0.8.5" + "@emotion/utils" "0.11.3" + "@emotion/weak-memoize" "0.2.5" + +"@emotion/hash@0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.8.0.tgz#bbbff68978fefdbe68ccb533bc8cbe1d1afb5413" + integrity sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow== + +"@emotion/memoize@0.7.4": + version "0.7.4" + resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.7.4.tgz#19bf0f5af19149111c40d98bb0cf82119f5d9eeb" + integrity sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw== + +"@emotion/serialize@^0.11.15", "@emotion/serialize@^0.11.16": + version "0.11.16" + resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-0.11.16.tgz#dee05f9e96ad2fb25a5206b6d759b2d1ed3379ad" + integrity sha512-G3J4o8by0VRrO+PFeSc3js2myYNOXVJ3Ya+RGVxnshRYgsvErfAOglKAiy1Eo1vhzxqtUvjCyS5gtewzkmvSSg== + dependencies: + "@emotion/hash" "0.8.0" + "@emotion/memoize" "0.7.4" + "@emotion/unitless" "0.7.5" + "@emotion/utils" "0.11.3" + csstype "^2.5.7" + +"@emotion/sheet@0.9.4": + version "0.9.4" + resolved "https://registry.yarnpkg.com/@emotion/sheet/-/sheet-0.9.4.tgz#894374bea39ec30f489bbfc3438192b9774d32e5" + integrity sha512-zM9PFmgVSqBw4zL101Q0HrBVTGmpAxFZH/pYx/cjJT5advXguvcgjHFTCaIO3enL/xr89vK2bh0Mfyj9aa0ANA== + +"@emotion/stylis@0.8.5": + version "0.8.5" + resolved "https://registry.yarnpkg.com/@emotion/stylis/-/stylis-0.8.5.tgz#deacb389bd6ee77d1e7fcaccce9e16c5c7e78e04" + integrity sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ== + +"@emotion/unitless@0.7.5": + version "0.7.5" + resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.7.5.tgz#77211291c1900a700b8a78cfafda3160d76949ed" + integrity sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg== + +"@emotion/utils@0.11.3": + version "0.11.3" + resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-0.11.3.tgz#a759863867befa7e583400d322652a3f44820924" + integrity sha512-0o4l6pZC+hI88+bzuaX/6BgOvQVhbt2PfmxauVaYOGgbsAw14wdKyvMCZXnsnsHys94iadcF+RG/wZyx6+ZZBw== + +"@emotion/weak-memoize@0.2.5": + version "0.2.5" + resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz#8eed982e2ee6f7f4e44c253e12962980791efd46" + integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA== + "@hapi/address@2.x.x": version "2.1.4" resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.4.tgz#5d67ed43f3fd41a69d4b9ff7b56e7c0d1d0a81e5" @@ -1815,6 +1871,22 @@ babel-plugin-dynamic-import-node@^2.3.0: dependencies: object.assign "^4.1.0" +babel-plugin-emotion@^10.0.27: + version "10.0.29" + resolved "https://registry.yarnpkg.com/babel-plugin-emotion/-/babel-plugin-emotion-10.0.29.tgz#89d8e497091fcd3d10331f097f1471e4cc3f35b4" + integrity sha512-7Jpi1OCxjyz0k163lKtqP+LHMg5z3S6A7vMBfHnF06l2unmtsOmFDzZBpGf0CWo1G4m8UACfVcDJiSiRuu/cSw== + dependencies: + "@babel/helper-module-imports" "^7.0.0" + "@emotion/hash" "0.8.0" + "@emotion/memoize" "0.7.4" + "@emotion/serialize" "^0.11.16" + babel-plugin-macros "^2.0.0" + babel-plugin-syntax-jsx "^6.18.0" + convert-source-map "^1.5.0" + escape-string-regexp "^1.0.5" + find-root "^1.1.0" + source-map "^0.5.7" + babel-plugin-istanbul@^5.1.0: version "5.2.0" resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-5.2.0.tgz#df4ade83d897a92df069c4d9a25cf2671293c854" @@ -1830,7 +1902,7 @@ babel-plugin-jest-hoist@^24.9.0: dependencies: "@types/babel__traverse" "^7.0.6" -babel-plugin-macros@2.8.0: +babel-plugin-macros@2.8.0, babel-plugin-macros@^2.0.0: version "2.8.0" resolved "https://registry.yarnpkg.com/babel-plugin-macros/-/babel-plugin-macros-2.8.0.tgz#0f958a7cc6556b1e65344465d99111a1e5e10138" dependencies: @@ -1842,6 +1914,11 @@ babel-plugin-named-asset-import@^0.3.6: version "0.3.6" resolved "https://registry.yarnpkg.com/babel-plugin-named-asset-import/-/babel-plugin-named-asset-import-0.3.6.tgz#c9750a1b38d85112c9e166bf3ef7c5dbc605f4be" +babel-plugin-syntax-jsx@^6.18.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz#0af32a9a6e13ca7a3fd5069e62d7b0f58d0d8946" + integrity sha1-CvMqmm4Tyno/1QaeYtew9Y0NiUY= + babel-plugin-syntax-object-rest-spread@^6.8.0: version "6.13.0" resolved "https://registry.yarnpkg.com/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz#fd6536f2bce13836ffa3a5458c4903a597bb3bf5" @@ -2570,7 +2647,7 @@ content-type@~1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" -convert-source-map@1.7.0, convert-source-map@^1.4.0, convert-source-map@^1.7.0: +convert-source-map@1.7.0, convert-source-map@^1.4.0, convert-source-map@^1.5.0, convert-source-map@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442" dependencies: @@ -2652,6 +2729,16 @@ create-ecdh@^4.0.0: bn.js "^4.1.0" elliptic "^6.0.0" +create-emotion@^10.0.27: + version "10.0.27" + resolved "https://registry.yarnpkg.com/create-emotion/-/create-emotion-10.0.27.tgz#cb4fa2db750f6ca6f9a001a33fbf1f6c46789503" + integrity sha512-fIK73w82HPPn/RsAij7+Zt8eCE8SptcJ3WoRMfxMtjteYxud8GDTKKld7MYwAX2TVhrw29uR1N/bVGxeStHILg== + dependencies: + "@emotion/cache" "^10.0.27" + "@emotion/serialize" "^0.11.15" + "@emotion/sheet" "0.9.4" + "@emotion/utils" "0.11.3" + create-hash@^1.1.0, create-hash@^1.1.2: version "1.2.0" resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" @@ -2901,7 +2988,7 @@ cssstyle@^1.0.0, cssstyle@^1.1.1: dependencies: cssom "0.3.x" -csstype@^2.2.0: +csstype@^2.2.0, csstype@^2.5.7: version "2.6.9" resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.9.tgz#05141d0cd557a56b8891394c1911c40c8a98d098" @@ -3256,6 +3343,14 @@ emojis-list@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" +emotion@^10.0.27: + version "10.0.27" + resolved "https://registry.yarnpkg.com/emotion/-/emotion-10.0.27.tgz#f9ca5df98630980a23c819a56262560562e5d75e" + integrity sha512-2xdDzdWWzue8R8lu4G76uWX5WhyQuzATon9LmNeCy/2BHVC6dsEpfhN1a0qhELgtDVdjyEA6J8Y/VlI5ZnaH0g== + dependencies: + babel-plugin-emotion "^10.0.27" + create-emotion "^10.0.27" + encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" @@ -3849,6 +3944,11 @@ find-cache-dir@^3.2.0: make-dir "^3.0.2" pkg-dir "^4.1.0" +find-root@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4" + integrity sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng== + find-up@4.1.0, find-up@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" @@ -8447,7 +8547,7 @@ source-map@0.6.1, source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, sourc version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" -source-map@^0.5.0, source-map@^0.5.6: +source-map@^0.5.0, source-map@^0.5.6, source-map@^0.5.7: version "0.5.7" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"