From 576112650a1163c81ed1930db48055437c2c50a9 Mon Sep 17 00:00:00 2001 From: "JongHyeon Kim (Bell)" Date: Sat, 3 Aug 2024 23:37:12 +0900 Subject: [PATCH] Add shortcut for text formatting and FormatBar component (#263) * Add text formatting (bold, italic, code) * Add prevention of duplicate text formatting and toggle handling for multiple text formats * Add formatBar for bold, italic, and cod in Editor component * Styling component using MUI * Add shortcut to support for strikethrough text formatting * refactor FormatBar, TooltipButton component into seperate file * refactor functions within the Editor component * Change formatBar position (for YorkieIntelligence) & Add debouncing for selected text * refactor tooltipButton sx props * refactor combine formatBar states into a single state object * refactor change component TooltipButton -> TooltipToggleButton - TooltipToggleButton move to folder src/components/editor -> src/components/common - Remove custom interface and import ToogleButtonProps in MUI * refactor extract formatBar functions (useFormatUtils) - create customHook useFormatUtils.ts - extract Format&FormatBar functions to useFormatUtils.ts * Refactor useFormatUtils & TooltipToggleButton --- .../components/common/TooltipToggleButton.tsx | 29 ++++ frontend/src/components/editor/Editor.tsx | 84 +++++++++- frontend/src/components/editor/FormatBar.tsx | 75 +++++++++ frontend/src/hooks/useFormatUtils.ts | 152 ++++++++++++++++++ 4 files changed, 337 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/common/TooltipToggleButton.tsx create mode 100644 frontend/src/components/editor/FormatBar.tsx create mode 100644 frontend/src/hooks/useFormatUtils.ts diff --git a/frontend/src/components/common/TooltipToggleButton.tsx b/frontend/src/components/common/TooltipToggleButton.tsx new file mode 100644 index 00000000..85247ce8 --- /dev/null +++ b/frontend/src/components/common/TooltipToggleButton.tsx @@ -0,0 +1,29 @@ +import { ToggleButton, Tooltip, ToggleButtonProps } from "@mui/material"; + +const style = { + toggleButton: { + width: "25px", + height: "25px", + minWidth: "25px", + padding: "0", + margin: "2px", + border: "none", + fontWeight: "bold", + }, +}; + +function TooltipToggleButton({ + title, + children, + ...props +}: ToggleButtonProps & { title?: string }) { + return ( + + + {children} + + + ); +} + +export default TooltipToggleButton; diff --git a/frontend/src/components/editor/Editor.tsx b/frontend/src/components/editor/Editor.tsx index 825b0515..12b6e92c 100644 --- a/frontend/src/components/editor/Editor.tsx +++ b/frontend/src/components/editor/Editor.tsx @@ -7,14 +7,16 @@ import { selectEditor, setCmView } from "../../store/editorSlice"; import { yorkieCodeMirror } from "../../utils/yorkie"; import { xcodeLight, xcodeDark } from "@uiw/codemirror-theme-xcode"; import { useCurrentTheme } from "../../hooks/useCurrentTheme"; -import { keymap } from "@codemirror/view"; -import { indentWithTab } from "@codemirror/commands"; +import { keymap, ViewUpdate } from "@codemirror/view"; import { intelligencePivot } from "../../utils/intelligence/intelligencePivot"; import { imageUploader } from "../../utils/imageUploader"; import { useCreateUploadUrlMutation, useUploadFileMutation } from "../../hooks/api/file"; import { selectWorkspace } from "../../store/workspaceSlice"; import { ScrollSyncPane } from "react-scroll-sync"; import { selectSetting } from "../../store/settingSlice"; +import { FormatBarState, useFormatUtils, FormatType } from "../../hooks/useFormatUtils"; + +import FormatBar from "./FormatBar"; function Editor() { const dispatch = useDispatch(); @@ -25,11 +27,73 @@ function Editor() { const workspaceStore = useSelector(selectWorkspace); const { mutateAsync: createUploadUrl } = useCreateUploadUrlMutation(); const { mutateAsync: uploadFile } = useUploadFileMutation(); + + const [formatBarState, setFormatBarState] = useState({ + show: false, + position: { top: 0, left: 0 }, + selectedFormats: new Set(), + }); + + const { getFormatMarkerLength, applyFormat, setKeymapConfig } = useFormatUtils(); + const ref = useCallback((node: HTMLElement | null) => { if (!node) return; setElement(node); }, []); + const updateFormatBar = useCallback( + (update: ViewUpdate) => { + const selection = update.state.selection.main; + if (!selection.empty) { + const coords = update.view.coordsAtPos(selection.from); + if (coords) { + const maxLength = getFormatMarkerLength(update.view.state, selection.from); + + const selectedTextStart = update.state.sliceDoc( + selection.from - maxLength, + selection.from + ); + const selectedTextEnd = update.state.sliceDoc( + selection.to, + selection.to + maxLength + ); + const formats = new Set(); + + const checkAndAddFormat = (marker: string, format: FormatType) => { + if ( + selectedTextStart.includes(marker) && + selectedTextEnd.includes(marker) + ) { + formats.add(format); + } + }; + + checkAndAddFormat("**", FormatType.BOLD); + checkAndAddFormat("_", FormatType.ITALIC); + checkAndAddFormat("`", FormatType.CODE); + checkAndAddFormat("~~", FormatType.STRIKETHROUGH); + + setFormatBarState((prev) => ({ + ...prev, + show: true, + position: { + top: coords.top - 8, + left: coords.left + 190, + }, + selectedFormats: formats, + })); + } + } else { + setFormatBarState((prev) => ({ + ...prev, + show: false, + selectedFormats: new Set(), + })); + } + }, + [getFormatMarkerLength] + ); + useEffect(() => { let view: EditorView | undefined = undefined; @@ -59,6 +123,7 @@ function Editor() { const state = EditorState.create({ doc: editorStore.doc.getRoot().content?.toString() ?? "", extensions: [ + keymap.of(setKeymapConfig()), basicSetup, markdown(), yorkieCodeMirror(editorStore.doc, editorStore.client), @@ -67,11 +132,15 @@ function Editor() { "&": { width: "100%" }, }), EditorView.lineWrapping, - keymap.of([indentWithTab]), intelligencePivot, ...(settingStore.fileUpload.enable ? [imageUploader(handleUploadImage, editorStore.doc)] : []), + EditorView.updateListener.of((update) => { + if (update.selectionSet) { + updateFormatBar(update); + } + }), ], }); @@ -95,6 +164,9 @@ function Editor() { createUploadUrl, uploadFile, settingStore.fileUpload?.enable, + applyFormat, + updateFormatBar, + setKeymapConfig, ]); return ( @@ -113,6 +185,12 @@ function Editor() { minHeight: "100%", }} /> + {Boolean(formatBarState.show && editorStore.cmView) && ( + + )} ); diff --git a/frontend/src/components/editor/FormatBar.tsx b/frontend/src/components/editor/FormatBar.tsx new file mode 100644 index 00000000..e58516f2 --- /dev/null +++ b/frontend/src/components/editor/FormatBar.tsx @@ -0,0 +1,75 @@ +import { Popover, ToggleButtonGroup } from "@mui/material"; +import TooltipToggleButton from "../common/TooltipToggleButton"; +import { FormatBarState, useFormatUtils, FormatType } from "../../hooks/useFormatUtils"; + +interface FormatBarProps { + formatBarState: FormatBarState; + onChangeFormatBarState: React.Dispatch>; +} + +function FormatBar({ + formatBarState: { show: showFormatBar, position: formatBarPosition, selectedFormats }, + onChangeFormatBarState, +}: FormatBarProps) { + const { toggleButtonChangeHandler } = useFormatUtils(); + + return ( + onChangeFormatBarState((prev) => ({ ...prev, show: false }))} + anchorOrigin={{ + vertical: "top", + horizontal: "left", + }} + transformOrigin={{ + vertical: "bottom", + horizontal: "left", + }} + disableAutoFocus + > + + + i + + + B + + + ~ + + + {""} + + + + ); +} + +export default FormatBar; diff --git a/frontend/src/hooks/useFormatUtils.ts b/frontend/src/hooks/useFormatUtils.ts new file mode 100644 index 00000000..9443350d --- /dev/null +++ b/frontend/src/hooks/useFormatUtils.ts @@ -0,0 +1,152 @@ +import { MouseEvent, useCallback } from "react"; +import { EditorState, Text, EditorSelection, Transaction } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { indentWithTab } from "@codemirror/commands"; +import { Dispatch, SetStateAction } from "react"; +import { useSelector } from "react-redux"; +import { selectEditor } from "../store/editorSlice"; + +export interface FormatBarState { + show: boolean; + position: { top: number; left: number }; + selectedFormats: Set; +} + +export enum FormatType { + BOLD = "bold", + ITALIC = "italic", + CODE = "code", + STRIKETHROUGH = "strikeThrough", +} + +export const useFormatUtils = () => { + const { cmView } = useSelector(selectEditor); + + const getFormatMarker = useCallback((formatType: FormatType) => { + switch (formatType) { + case FormatType.BOLD: + return "**"; + case FormatType.ITALIC: + return "_"; + case FormatType.CODE: + return "`"; + case FormatType.STRIKETHROUGH: + return "~~"; + } + }, []); + + const getFormatMarkerLength = useCallback((state: EditorState, from: number) => { + const maxCheckLength = 10; + + const markerSet = new Set(["*", "_", "`", "~"]); + const docSlice = state.sliceDoc(Math.max(0, from - maxCheckLength), from); + return [...docSlice].reduce((acc, c) => (markerSet.has(c) ? acc + 1 : acc), 0); + }, []); + + const applyFormat = useCallback( + (formatType: FormatType) => { + const marker = getFormatMarker(formatType); + const markerLength = marker.length; + + return ({ state, dispatch }: EditorView) => { + const changes = state.changeByRange((range) => { + const maxLength = getFormatMarkerLength(state, range.from); + const beforeIdx = state + .sliceDoc( + range.from - maxLength < 0 ? 0 : range.from - maxLength, + range.from + ) + .indexOf(marker); + const afterIdx = state.sliceDoc(range.to, range.to + maxLength).indexOf(marker); + + const changes = [ + beforeIdx === -1 + ? { + from: range.from, + insert: Text.of([marker]), + } + : { + from: range.from - maxLength + beforeIdx, + to: range.from - maxLength + beforeIdx + markerLength, + insert: Text.of([""]), + }, + + afterIdx === -1 + ? { + from: range.to, + insert: Text.of([marker]), + } + : { + from: range.to + afterIdx, + to: range.to + afterIdx + markerLength, + insert: Text.of([""]), + }, + ]; + + const extendBefore = beforeIdx === -1 ? markerLength : -markerLength; + const extendAfter = afterIdx === -1 ? markerLength : -markerLength; + + return { + changes, + range: EditorSelection.range( + range.from + extendBefore, + range.to + extendAfter + ), + }; + }); + + dispatch( + state.update(changes, { + scrollIntoView: true, + annotations: Transaction.userEvent.of("input"), + }) + ); + + return true; + }; + }, + [getFormatMarker, getFormatMarkerLength] + ); + + const setKeymapConfig = useCallback( + () => [ + indentWithTab, + { key: "Mod-b", run: applyFormat(FormatType.BOLD) }, + { key: "Mod-i", run: applyFormat(FormatType.ITALIC) }, + { key: "Mod-e", run: applyFormat(FormatType.CODE) }, + { key: "Mod-Shift-x", run: applyFormat(FormatType.STRIKETHROUGH) }, + ], + [applyFormat] + ); + + const toggleButtonChangeHandler = useCallback( + ( + selectedFormats: Set, + onChangeFormatBarState: Dispatch> + ) => { + return (_event: MouseEvent, format: FormatType) => { + if (!cmView) return; + + const newSelectedFormats = new Set(selectedFormats); + if (newSelectedFormats.has(format)) { + newSelectedFormats.delete(format); + } else { + newSelectedFormats.add(format); + } + onChangeFormatBarState((prev) => ({ + ...prev, + selectedFormats: newSelectedFormats, + })); + applyFormat(format)(cmView); + }; + }, + [cmView, applyFormat] + ); + + return { + getFormatMarkerLength, + applyFormat, + setKeymapConfig, + toggleButtonChangeHandler, + }; +};