Skip to content

Commit

Permalink
Add shortcut for text formatting and FormatBar component (#263)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
beberiche authored and minai621 committed Nov 5, 2024
1 parent 5ddcea2 commit 5761126
Show file tree
Hide file tree
Showing 4 changed files with 337 additions and 3 deletions.
29 changes: 29 additions & 0 deletions frontend/src/components/common/TooltipToggleButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Tooltip title={title} placement="top">
<ToggleButton sx={style.toggleButton} {...props}>
{children}
</ToggleButton>
</Tooltip>
);
}

export default TooltipToggleButton;
84 changes: 81 additions & 3 deletions frontend/src/components/editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -25,11 +27,73 @@ function Editor() {
const workspaceStore = useSelector(selectWorkspace);
const { mutateAsync: createUploadUrl } = useCreateUploadUrlMutation();
const { mutateAsync: uploadFile } = useUploadFileMutation();

const [formatBarState, setFormatBarState] = useState<FormatBarState>({
show: false,
position: { top: 0, left: 0 },
selectedFormats: new Set<FormatType>(),
});

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<FormatType>();

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;

Expand Down Expand Up @@ -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),
Expand All @@ -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);
}
}),
],
});

Expand All @@ -95,6 +164,9 @@ function Editor() {
createUploadUrl,
uploadFile,
settingStore.fileUpload?.enable,
applyFormat,
updateFormatBar,
setKeymapConfig,
]);

return (
Expand All @@ -113,6 +185,12 @@ function Editor() {
minHeight: "100%",
}}
/>
{Boolean(formatBarState.show && editorStore.cmView) && (
<FormatBar
formatBarState={formatBarState}
onChangeFormatBarState={setFormatBarState}
/>
)}
</div>
</ScrollSyncPane>
);
Expand Down
75 changes: 75 additions & 0 deletions frontend/src/components/editor/FormatBar.tsx
Original file line number Diff line number Diff line change
@@ -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<React.SetStateAction<FormatBarState>>;
}

function FormatBar({
formatBarState: { show: showFormatBar, position: formatBarPosition, selectedFormats },
onChangeFormatBarState,
}: FormatBarProps) {
const { toggleButtonChangeHandler } = useFormatUtils();

return (
<Popover
open={showFormatBar}
anchorReference="anchorPosition"
anchorPosition={{
top: formatBarPosition.top,
left: formatBarPosition.left,
}}
onClose={() => onChangeFormatBarState((prev) => ({ ...prev, show: false }))}
anchorOrigin={{
vertical: "top",
horizontal: "left",
}}
transformOrigin={{
vertical: "bottom",
horizontal: "left",
}}
disableAutoFocus
>
<ToggleButtonGroup
sx={{ padding: "3px 5px" }}
value={Array.from(selectedFormats)}
onChange={toggleButtonChangeHandler(selectedFormats, onChangeFormatBarState)}
exclusive
aria-label="text formatting"
>
<TooltipToggleButton
color={selectedFormats.has(FormatType.ITALIC) ? "primary" : "secondary"}
title={"Cmd+I / Ctrl+I"}
value={FormatType.ITALIC}
>
<i>i</i>
</TooltipToggleButton>
<TooltipToggleButton
color={selectedFormats.has(FormatType.BOLD) ? "primary" : "secondary"}
title={"Cmd+B / Ctrl+B"}
value={FormatType.BOLD}
>
<strong>B</strong>
</TooltipToggleButton>
<TooltipToggleButton
color={selectedFormats.has(FormatType.STRIKETHROUGH) ? "primary" : "secondary"}
title={"Cmd+Shift+X / Ctrl+Shfit+X"}
value={FormatType.STRIKETHROUGH}
>
~
</TooltipToggleButton>
<TooltipToggleButton
color={selectedFormats.has(FormatType.CODE) ? "primary" : "secondary"}
title={"Cmd+E / Ctrl+E"}
value={FormatType.CODE}
>
{"</>"}
</TooltipToggleButton>
</ToggleButtonGroup>
</Popover>
);
}

export default FormatBar;
152 changes: 152 additions & 0 deletions frontend/src/hooks/useFormatUtils.ts
Original file line number Diff line number Diff line change
@@ -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<FormatType>;
}

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<FormatType>,
onChangeFormatBarState: Dispatch<SetStateAction<FormatBarState>>
) => {
return (_event: MouseEvent<HTMLElement>, 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,
};
};

0 comments on commit 5761126

Please sign in to comment.