Skip to content

Commit

Permalink
Add hyperlink creation feature (#332)
Browse files Browse the repository at this point in the history
* Add useToolBar hook

* Add hyperlink creation feature

* Add text selection check before inserting hyperlink

* Refactor `useToolBar` & extract `checkAndAddFormat` functon to a separate utility file

* Change valid URL check regular expression to `validator` library

* Fix missing validator package installation
  • Loading branch information
choidabom authored Sep 5, 2024
1 parent 0485293 commit ba3d54d
Show file tree
Hide file tree
Showing 6 changed files with 156 additions and 81 deletions.
16 changes: 16 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"rehype-rewrite": "^4.0.2",
"rehype-sanitize": "^6.0.0",
"remark-math": "^6.0.0",
"validator": "^13.12.0",
"vite-plugin-package-version": "^1.1.0",
"yorkie-js-sdk": "0.4.31"
},
Expand All @@ -87,6 +88,7 @@
"@types/react-dom": "^18.2.17",
"@types/react-infinite-scroller": "^1.2.5",
"@types/react-scroll-sync": "^0.9.0",
"@types/validator": "^13.12.1",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
Expand Down
98 changes: 17 additions & 81 deletions frontend/src/components/editor/Editor.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import { markdown } from "@codemirror/lang-markdown";
import { EditorState } from "@codemirror/state";
import { keymap, ViewUpdate } from "@codemirror/view";
import { keymap } from "@codemirror/view";
import { xcodeDark, xcodeLight } from "@uiw/codemirror-theme-xcode";
import { basicSetup, EditorView } from "codemirror";
import { useCallback, useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { ScrollSyncPane } from "react-scroll-sync";
import { useCreateUploadUrlMutation, useUploadFileMutation } from "../../hooks/api/file";
import { useCurrentTheme } from "../../hooks/useCurrentTheme";
import { FormatType, ToolBarState, useFormatUtils } from "../../hooks/useFormatUtils";
import { useFormatUtils } from "../../hooks/useFormatUtils";
import { useToolBar } from "../../hooks/useToolBar";
import { selectEditor, setCmView } from "../../store/editorSlice";
import { selectSetting } from "../../store/settingSlice";
import { selectWorkspace } from "../../store/workspaceSlice";
import { imageUploader } from "../../utils/imageUploader";
import { intelligencePivot } from "../../utils/intelligence/intelligencePivot";
import { urlHyperlinkInserter } from "../../utils/urlHyperlinkInserter";
import { yorkieCodeMirror } from "../../utils/yorkie";
import ToolBar from "./ToolBar";

Expand All @@ -26,77 +28,15 @@ function Editor() {
const workspaceStore = useSelector(selectWorkspace);
const { mutateAsync: createUploadUrl } = useCreateUploadUrlMutation();
const { mutateAsync: uploadFile } = useUploadFileMutation();

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

const { getFormatMarkerLength, applyFormat, setKeymapConfig } = useFormatUtils();
const { applyFormat, setKeymapConfig } = useFormatUtils();
const { toolBarState, setToolBarState, updateFormatBar } = useToolBar();

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

// TODO: Modify the rendering method so that it is not affected by the size of the Toolbar
setToolBarState((prev) => ({
...prev,
show: true,
position: {
top: coords.top - 5,
left: coords.left,
},
selectedFormats: formats,
}));
}
} else {
setToolBarState((prev) => ({
...prev,
show: false,
selectedFormats: new Set(),
}));
}
},
[getFormatMarkerLength]
);

useEffect(() => {
let view: EditorView | undefined = undefined;

if (
!element ||
!editorStore.doc ||
Expand Down Expand Up @@ -126,44 +66,40 @@ function Editor() {
keymap.of(setKeymapConfig()),
basicSetup,
markdown(),
yorkieCodeMirror(editorStore.doc, editorStore.client),
themeMode == "light" ? xcodeLight : xcodeDark,
EditorView.theme({
"&": { width: "100%" },
}),
EditorView.theme({ "&": { width: "100%" } }),
EditorView.lineWrapping,
intelligencePivot,
...(settingStore.fileUpload.enable
? [imageUploader(handleUploadImage, editorStore.doc)]
: []),
EditorView.updateListener.of((update) => {
if (update.selectionSet) {
updateFormatBar(update);
}
}),
yorkieCodeMirror(editorStore.doc, editorStore.client),
intelligencePivot,
...(settingStore.fileUpload.enable
? [imageUploader(handleUploadImage, editorStore.doc)]
: []),
urlHyperlinkInserter(editorStore.doc),
],
});

view = new EditorView({
state,
parent: element,
});
const view = new EditorView({ state, parent: element });

dispatch(setCmView(view));

return () => {
view?.destroy();
};
}, [
dispatch,
element,
editorStore.client,
editorStore.doc,
element,
themeMode,
workspaceStore.data,
settingStore.fileUpload?.enable,
dispatch,
createUploadUrl,
uploadFile,
settingStore.fileUpload?.enable,
applyFormat,
updateFormatBar,
setKeymapConfig,
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/hooks/useFormatUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,26 @@ export const useFormatUtils = () => {
[cmView, applyFormat]
);

const checkAndAddFormat = useCallback(
(
selectedTextStart: string,
selectedTextEnd: string,
marker: string,
format: FormatType,
formats: Set<FormatType>
) => {
if (selectedTextStart.includes(marker) && selectedTextEnd.includes(marker)) {
formats.add(format);
}
},
[]
);

return {
getFormatMarkerLength,
applyFormat,
setKeymapConfig,
toggleButtonChangeHandler,
checkAndAddFormat,
};
};
60 changes: 60 additions & 0 deletions frontend/src/hooks/useToolBar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useCallback, useState } from "react";
import { FormatType, ToolBarState, useFormatUtils } from "./useFormatUtils";
import { ViewUpdate } from "@codemirror/view";

export const useToolBar = () => {
const [toolBarState, setToolBarState] = useState<ToolBarState>({
show: false,
position: { top: 0, left: 0 },
selectedFormats: new Set<FormatType>(),
});
const { getFormatMarkerLength, checkAndAddFormat } = useFormatUtils();

const updateFormatBar = useCallback(
(update: ViewUpdate) => {
const { state, view } = update;
const selection = state.selection.main;

if (selection.empty) {
setToolBarState((prev) => ({
...prev,
show: false,
selectedFormats: new Set(),
}));
return;
}

const coords = view.coordsAtPos(selection.from);
if (!coords) return;

const maxLength = getFormatMarkerLength(view.state, selection.from);
const selectedTextStart = state.sliceDoc(selection.from - maxLength, selection.from);
const selectedTextEnd = state.sliceDoc(selection.to, selection.to + maxLength);
const formats = new Set<FormatType>();
const formatChecks = [
{ marker: "**", format: FormatType.BOLD },
{ marker: "_", format: FormatType.ITALIC },
{ marker: "`", format: FormatType.CODE },
{ marker: "~~", format: FormatType.STRIKETHROUGH },
];

formatChecks.forEach(({ marker, format }) => {
checkAndAddFormat(selectedTextStart, selectedTextEnd, marker, format, formats);
});

// TODO: Modify the rendering method so that it is not affected by the size of the Toolbar
setToolBarState((prev) => ({
...prev,
show: true,
position: {
top: coords.top - 5,
left: coords.left,
},
selectedFormats: formats,
}));
},
[getFormatMarkerLength, checkAndAddFormat]
);

return { toolBarState, setToolBarState, updateFormatBar };
};
45 changes: 45 additions & 0 deletions frontend/src/utils/urlHyperlinkInserter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { EditorView } from "codemirror";
import validator from "validator";
import { CodePairDocType } from "../store/editorSlice";

const isValidUrl = (url: string) => {
return validator.isURL(url);
};

const insertLinkToEditor = (url: string, view: EditorView, doc: CodePairDocType) => {
const { from, to } = view.state.selection.main;
const selectedText = view.state.sliceDoc(from, to);
const insert = `[${selectedText}](${url})`;

doc.update((root, presence) => {
root.content.edit(from, to, insert);
presence.set({
selection: root.content.indexRangeToPosRange([
from + insert.length,
from + insert.length,
]),
});
});

view.dispatch({
changes: { from, to, insert },
selection: {
anchor: from + insert.length,
},
});
};

export const urlHyperlinkInserter = (doc: CodePairDocType) => {
return EditorView.domEventHandlers({
paste(event, view) {
const url = event.clipboardData?.getData("text/plain");
if (!url || !isValidUrl(url)) return;

const { from, to } = view.state.selection.main;
if (from === to) return;

insertLinkToEditor(url, view, doc);
event.preventDefault();
},
});
};

0 comments on commit ba3d54d

Please sign in to comment.