diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index 96f0c8e7..534aa081 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -47,6 +47,12 @@ } } ], + "jsx-a11y/label-has-associated-control": [ + "error", + { + "labelAttributes": ["htmlFor"] + } + ], "prettier/prettier": ["error", { "endOfLine": "auto" }], "react/react-in-jsx-scope": "off", "react/jsx-filename-extension": ["error", { "extensions": [".ts", ".tsx"] }], diff --git a/frontend/components/common/Content/styled.ts b/frontend/components/common/Content/styled.ts index 5f4a2a29..14e8e97d 100644 --- a/frontend/components/common/Content/styled.ts +++ b/frontend/components/common/Content/styled.ts @@ -8,11 +8,12 @@ export const ContentTitle = styled.h1` margin-bottom: 16px; font-size: 24px; font-weight: 700; + display: flex; + align-items: center; + height: 35px; `; export const ContentBody = styled.div` - padding-top: 10px; - > * { line-height: 2; font-weight: 400; @@ -68,6 +69,10 @@ export const ContentBody = styled.div` } } + em { + font-style: italic; + } + blockquote { margin: 24px 0; padding: 24px 16px; @@ -86,7 +91,6 @@ export const ContentBody = styled.div` border-radius: 4px; font-family: 'consolas'; font-size: 16px; - font-weight: 700; line-height: 1.4; code { diff --git a/frontend/components/edit/Editor/core/theme.ts b/frontend/components/edit/Editor/core/theme.ts index 78182252..f6e50d48 100644 --- a/frontend/components/edit/Editor/core/theme.ts +++ b/frontend/components/edit/Editor/core/theme.ts @@ -1,25 +1,34 @@ import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; -import { tags } from '@lezer/highlight'; +import { tags as t } from '@lezer/highlight'; export default function theme() { const highlightStyle = HighlightStyle.define([ - { tag: tags.heading1, fontSize: '24px', fontWeight: '700' }, - { tag: tags.heading2, fontSize: '20px', fontWeight: '700' }, - { tag: tags.heading3, fontSize: '18px', fontWeight: '700' }, - { tag: tags.link, textDecoration: 'underline' }, - { tag: tags.strikethrough, textDecoration: 'line-through' }, - { tag: tags.invalid, color: '#cb2431' }, - { tag: [tags.string, tags.meta, tags.regexp], color: '#222222', fontWeight: 700 }, - { tag: [tags.heading, tags.strong], color: '#222222', fontWeight: '700' }, - { tag: [tags.emphasis], color: '#24292e', fontStyle: 'italic' }, - { tag: [tags.comment, tags.bracket], color: '#6a737d' }, - { tag: [tags.className, tags.propertyName], color: '#6f42c1' }, - { tag: [tags.variableName, tags.attributeName, tags.number, tags.operator], color: '#005cc5' }, - { tag: [tags.keyword, tags.typeName, tags.typeOperator, tags.typeName], color: '#d73a49' }, - { tag: [tags.name, tags.quote], color: '#22863a' }, - { tag: [tags.deleted], color: '#b31d28', backgroundColor: 'ffeef0' }, - { tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: '#e36209' }, - { tag: [tags.url, tags.escape, tags.regexp, tags.link], color: '#222222' }, + { tag: t.heading1, fontSize: '24px', fontWeight: '700' }, + { tag: t.heading2, fontSize: '20px', fontWeight: '700' }, + { tag: t.heading3, fontSize: '18px', fontWeight: '700' }, + { tag: t.link, textDecoration: 'underline' }, + { tag: t.strikethrough, textDecoration: 'line-through' }, + { tag: t.invalid, color: '#cb2431' }, + { tag: t.name, color: '#22863a' }, + { tag: t.emphasis, color: '#24292e', fontStyle: 'italic' }, + { tag: t.deleted, color: '#b31d28', backgroundColor: '#ffeef0' }, + { tag: [t.heading, t.strong, t.meta], color: '#222222', fontWeight: '700' }, + { tag: [t.url, t.escape, t.regexp, t.link, t.quote], color: '#222222' }, + { tag: t.comment, color: '#6a737d', fontFamily: 'consolas' }, + { + tag: [t.attributeName, t.className, t.propertyName, t.function(t.definition(t.variableName))], + color: '#6f42c1', + fontFamily: 'consolas', + }, + { tag: [t.operator, t.variableName, t.bracket], color: '#222222', fontFamily: 'consolas' }, + { tag: [t.string], color: '#032f62', fontFamily: 'consolas' }, + { tag: [t.number], color: '#005cc5', fontFamily: 'consolas' }, + { + tag: [t.keyword, t.typeName, t.typeOperator, t.typeName, t.atom, t.moduleKeyword], + color: '#d73a49', + fontFamily: 'consolas', + }, + { tag: [t.bool, t.special(t.variableName)], color: '#005cc5', fontFamily: 'consolas' }, ]); return [syntaxHighlighting(highlightStyle)]; diff --git a/frontend/components/edit/Editor/core/useCodeMirror.ts b/frontend/components/edit/Editor/core/useCodeMirror.ts index b2ab2cb4..a7a517e5 100644 --- a/frontend/components/edit/Editor/core/useCodeMirror.ts +++ b/frontend/components/edit/Editor/core/useCodeMirror.ts @@ -1,9 +1,10 @@ import { useCallback, useEffect, useState } from 'react'; +import { indentWithTab } from '@codemirror/commands'; import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; import { languages } from '@codemirror/language-data'; import { EditorState } from '@codemirror/state'; -import { placeholder } from '@codemirror/view'; +import { placeholder, keymap } from '@codemirror/view'; import { EditorView } from 'codemirror'; import { createImageApi } from '@apis/imageApi'; @@ -37,13 +38,113 @@ export default function useCodeMirror() { }); }; + const handleImage = (imageFile: File) => { + if (!/image\/[png,jpg,jpeg,gif]/.test(imageFile.type)) return; + + const formData = new FormData(); + + formData.append('image', imageFile); + + createImage(formData); + }; + const onChange = () => { return EditorView.updateListener.of(({ view, docChanged }) => { if (docChanged) setDocument(view.state.doc.toString()); }); }; - const onPaste = () => { + const insertStartToggle = (symbol: string) => { + if (!editorView) return; + + editorView.focus(); + + const { head } = editorView.state.selection.main; + const { from, to, text } = editorView.state.doc.lineAt(head); + + const hasExist = text.startsWith(symbol); + + if (!hasExist) { + editorView.dispatch({ + changes: { + from, + to, + insert: `${symbol}${text}`, + }, + selection: { + anchor: from + text.length + symbol.length, + }, + }); + + return; + } + + editorView.dispatch({ + changes: { + from, + to, + insert: `${text.slice(symbol.length, text.length)}`, + }, + }); + }; + + const insertBetweenToggle = (symbol: string, defaultText = '텍스트') => { + if (!editorView) return; + + editorView.focus(); + + const { from, to } = editorView.state.selection.ranges[0]; + + const text = editorView.state.sliceDoc(from, to); + + const prefixText = editorView.state.sliceDoc(from - symbol.length, from); + const affixText = editorView.state.sliceDoc(to, to + symbol.length); + + const hasExist = symbol === prefixText && symbol === affixText; + + if (!hasExist) { + editorView.dispatch({ + changes: { + from, + to, + insert: `${symbol}${text || defaultText}${symbol}`, + }, + selection: { + head: from + symbol.length, + anchor: text ? to + symbol.length : to + symbol.length + defaultText.length, + }, + }); + + return; + } + + editorView.dispatch({ + changes: { + from: from - symbol.length, + to: to + symbol.length, + insert: text, + }, + selection: { + head: from - symbol.length, + anchor: to - symbol.length, + }, + }); + }; + + const insertCursor = (text: string) => { + if (!editorView) return; + + editorView.focus(); + + const cursor = editorView.state.selection.main.head; + + editorView.dispatch({ + changes: { from: cursor, to: cursor, insert: text }, + selection: { anchor: cursor + text.length }, + }); + }; + + const eventHandler = () => { return EditorView.domEventHandlers({ paste(event) { if (!event.clipboardData) return; @@ -52,16 +153,20 @@ export default function useCodeMirror() { // eslint-disable-next-line no-restricted-syntax for (const item of items) { - if (item.kind === 'file' && /image\/[png,jpg,jpeg,gif]/.test(item.type)) { - const blob = item.getAsFile() as Blob; + if (!(item.kind === 'file')) return; + handleImage(item.getAsFile() as File); + } + }, + drop(event, view) { + if (!event.dataTransfer) return; - const formData = new FormData(); + const cursorPos = view.posAtCoords({ x: event.clientX, y: event.clientY }); + if (cursorPos) view.dispatch({ selection: { anchor: cursorPos, head: cursorPos } }); - formData.append('image', blob); + const { files } = event.dataTransfer; - createImage(formData); - } - } + // eslint-disable-next-line no-restricted-syntax + for (const file of files) handleImage(file); }, }); }; @@ -69,20 +174,10 @@ export default function useCodeMirror() { useEffect(() => { if (!editorView) return; - const cursor = editorView.state.selection.main.head; - - const markdownImage = (path: string) => `![image](${path})\n`; - - const insert = markdownImage(image?.imagePath); + const markdownImage = (path: string) => `![image](${path})`; + const text = markdownImage(image?.imagePath); - editorView.dispatch({ - changes: { - from: cursor, - to: cursor, - insert, - }, - selection: { anchor: cursor + insert.length }, - }); + insertCursor(text); }, [image]); useEffect(() => { @@ -97,8 +192,19 @@ export default function useCodeMirror() { placeholder('내용을 입력해주세요.'), theme(), onChange(), - onPaste(), + eventHandler(), EditorView.lineWrapping, + EditorView.theme({ + '.cm-content': { + padding: 0, + lineHeight: 2, + fontFamily: 'Noto Sans KR', + }, + '.cm-line': { + padding: 0, + }, + }), + keymap.of([indentWithTab]), ], }); @@ -113,5 +219,13 @@ export default function useCodeMirror() { return () => view?.destroy(); }, [element]); - return { ref, document, replaceDocument }; + return { + ref, + document, + replaceDocument, + insertStartToggle, + insertBetweenToggle, + insertCursor, + handleImage, + }; } diff --git a/frontend/components/edit/Editor/index.tsx b/frontend/components/edit/Editor/index.tsx index 357b92e5..90b3d8cd 100644 --- a/frontend/components/edit/Editor/index.tsx +++ b/frontend/components/edit/Editor/index.tsx @@ -1,7 +1,18 @@ +import Image from 'next/image'; + import { useEffect, useState } from 'react'; import { useRecoilState } from 'recoil'; +import BoldIcon from '@assets/ico_bold.svg'; +import CodeIcon from '@assets/ico_code.svg'; +import H1Icon from '@assets/ico_h1.svg'; +import H2Icon from '@assets/ico_h2.svg'; +import H3Icon from '@assets/ico_h3.svg'; +import ImageIcon from '@assets/ico_image.svg'; +import ItalicIcon from '@assets/ico_italic.svg'; +import LinkIcon from '@assets/ico_link.svg'; +import QuoteIcon from '@assets/ico_quote.svg'; import articleState from '@atoms/article'; import articleBuffer from '@atoms/articleBuffer'; import Content from '@components/common/Content'; @@ -11,7 +22,16 @@ import useInput from '@hooks/useInput'; import { IArticle } from '@interfaces'; import { html2markdown, markdown2html } from '@utils/parser'; -import { CodeMirrorWrapper, EditorInner, EditorWrapper, TitleInput } from './styled'; +import { + EditorButtonWrapper, + CodeMirrorWrapper, + EditorInner, + EditorWrapper, + TitleInput, + EditorButton, + EditorButtonSplit, + EditorImageInput, +} from './styled'; interface EditorProps { handleModalOpen: () => void; @@ -19,7 +39,15 @@ interface EditorProps { } export default function Editor({ handleModalOpen, originalArticle }: EditorProps) { - const { ref, document, replaceDocument } = useCodeMirror(); + const { + ref, + document, + replaceDocument, + insertStartToggle, + insertBetweenToggle, + insertCursor, + handleImage, + } = useCodeMirror(); const [buffer, setBuffer] = useRecoilState(articleBuffer); const [isModifyMode, setIsModifyMode] = useState(false); @@ -57,6 +85,48 @@ export default function Editor({ handleModalOpen, originalArticle }: EditorProps + + insertStartToggle('# ')}> + Heading1 Icon + + insertStartToggle('## ')}> + Heading2 Icon + + insertStartToggle('### ')}> + Heading3 Icon + + + insertBetweenToggle('**')}> + Bold Icon + + insertBetweenToggle('_')}> + Italic Icon + + + insertStartToggle('> ')}> + Quote Icon + + insertBetweenToggle('\n```\n', '코드')}> + Code Icon + + + insertCursor('[텍스트](주소)')}> + Link Icon + + + + { + if (event.target.files) handleImage(event.target.files[0]); + }} + /> + +
diff --git a/frontend/components/edit/Editor/styled.ts b/frontend/components/edit/Editor/styled.ts index b6395662..a1780044 100644 --- a/frontend/components/edit/Editor/styled.ts +++ b/frontend/components/edit/Editor/styled.ts @@ -1,5 +1,7 @@ import styled from 'styled-components'; +import { Flex } from '@styles/layout'; + export const EditorWrapper = styled.div` width: 100%; height: calc(var(--window-inner-height)); @@ -23,22 +25,70 @@ export const EditorInner = styled.div` } `; -export const CodeMirrorWrapper = styled.div` - font-size: 16px; - height: calc(var(--window-inner-height) - 160px); - overflow: auto; +export const EditorButtonWrapper = styled(Flex)` + margin-bottom: 16px; + padding-bottom: 8px; + border-bottom: 1px solid var(--grey-02-color); + align-items: center; + gap: 8px; + overflow-x: auto; + -ms-overflow-style: none; + &::-webkit-scrollbar { + display: none; + } +`; - .cm-editor.cm-focused { - outline: none; +export const EditorButton = styled.button` + padding: 4px; + width: 36px; + height: 36px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + box-sizing: border-box; + + &:hover { + background-color: var(--light-orange-color); + } + + img, + label { + width: 24px; + height: 24px; + } + + > div { + color: var(--title-active-color); } `; +export const EditorImageInput = styled.input` + display: none; +`; + +export const EditorButtonSplit = styled.div` + border-left: 2px solid var(--grey-02-color); + height: 16px; +`; + export const TitleInput = styled.input` - padding-left: 6px; + padding: 0; width: 100%; border: none; outline: none; + font-family: 'Noto Sans KR'; font-size: 24px; font-weight: 700; margin-bottom: 16px; `; + +export const CodeMirrorWrapper = styled.div` + font-size: 16px; + height: calc(var(--window-inner-height) - 220px); + overflow: auto; + + .cm-editor.cm-focused { + outline: none; + } +`; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 92adead7..08e0cf5b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "knoticle-client", "version": "0.1.0", "dependencies": { + "@codemirror/commands": "^6.1.2", "@codemirror/lang-markdown": "^6.0.5", "@codemirror/language": "^6.3.1", "@codemirror/language-data": "^6.1.0", @@ -38,6 +39,7 @@ "rehype-parse": "^8.0.4", "rehype-remark": "^9.1.2", "rehype-stringify": "^9.0.3", + "remark-breaks": "^3.0.2", "remark-parse": "^10.0.1", "remark-rehype": "^10.1.0", "remark-stringify": "^10.0.2", @@ -5394,6 +5396,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-breaks": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-3.0.2.tgz", + "integrity": "sha512-x96YDJ9X+Ry0/JNZFKfr1hpcAKvGYWfUTszxY9RbxKEqq6uzPPoLCuHdZsLPZZUdAv3nCROyc7FPrQLWr2rxyw==", + "dependencies": { + "@types/mdast": "^3.0.0", + "unified": "^10.0.0", + "unist-util-visit": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-parse": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-10.0.1.tgz", @@ -10283,6 +10299,16 @@ "unified": "^10.0.0" } }, + "remark-breaks": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-3.0.2.tgz", + "integrity": "sha512-x96YDJ9X+Ry0/JNZFKfr1hpcAKvGYWfUTszxY9RbxKEqq6uzPPoLCuHdZsLPZZUdAv3nCROyc7FPrQLWr2rxyw==", + "requires": { + "@types/mdast": "^3.0.0", + "unified": "^10.0.0", + "unist-util-visit": "^4.0.0" + } + }, "remark-parse": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-10.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0b7c13c2..7622954b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "lint": "next lint" }, "dependencies": { + "@codemirror/commands": "^6.1.2", "@codemirror/lang-markdown": "^6.0.5", "@codemirror/language": "^6.3.1", "@codemirror/language-data": "^6.1.0", @@ -39,6 +40,7 @@ "rehype-parse": "^8.0.4", "rehype-remark": "^9.1.2", "rehype-stringify": "^9.0.3", + "remark-breaks": "^3.0.2", "remark-parse": "^10.0.1", "remark-rehype": "^10.1.0", "remark-stringify": "^10.0.2", diff --git a/frontend/pages/editor.tsx b/frontend/pages/editor.tsx index a26c6595..e90f5a09 100644 --- a/frontend/pages/editor.tsx +++ b/frontend/pages/editor.tsx @@ -20,6 +20,8 @@ export default function EditorPage() { const PublishModal = dynamic(() => import('@components/edit/PublishModal')); const ModifyModal = dynamic(() => import('@components/edit/ModifyModal')); + const router = useRouter(); + const [isModalShown, setModalShown] = useState(false); const [originalArticle, setOriginalArticle] = useState(undefined); @@ -31,7 +33,17 @@ export default function EditorPage() { const user = useRecoilValue(signInStatusState); - const router = useRouter(); + const syncHeight = () => { + document.documentElement.style.setProperty('--window-inner-height', `${window.innerHeight}px`); + }; + + useEffect(() => { + syncHeight(); + + window.addEventListener('resize', syncHeight); + + return () => window.removeEventListener('resize', syncHeight); + }, []); useEffect(() => { getUserKnottedBooks(user.nickname); @@ -52,16 +64,6 @@ export default function EditorPage() { setOriginalArticle(article); }, [article]); - const syncHeight = () => { - document.documentElement.style.setProperty('--window-inner-height', `${window.innerHeight}px`); - }; - - useEffect(() => { - syncHeight(); - window.addEventListener('resize', syncHeight); - return () => window.removeEventListener('resize', syncHeight); - }, []); - return ( diff --git a/frontend/public/assets/ico_bold.svg b/frontend/public/assets/ico_bold.svg new file mode 100644 index 00000000..d9ac0a88 --- /dev/null +++ b/frontend/public/assets/ico_bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/ico_code.svg b/frontend/public/assets/ico_code.svg new file mode 100644 index 00000000..3a27fb19 --- /dev/null +++ b/frontend/public/assets/ico_code.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/ico_h1.svg b/frontend/public/assets/ico_h1.svg new file mode 100644 index 00000000..11eec217 --- /dev/null +++ b/frontend/public/assets/ico_h1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/ico_h2.svg b/frontend/public/assets/ico_h2.svg new file mode 100644 index 00000000..ba8b5b5b --- /dev/null +++ b/frontend/public/assets/ico_h2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/ico_h3.svg b/frontend/public/assets/ico_h3.svg new file mode 100644 index 00000000..85d26a48 --- /dev/null +++ b/frontend/public/assets/ico_h3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/ico_image.svg b/frontend/public/assets/ico_image.svg new file mode 100644 index 00000000..f6b7f4e8 --- /dev/null +++ b/frontend/public/assets/ico_image.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/ico_italic.svg b/frontend/public/assets/ico_italic.svg new file mode 100644 index 00000000..85ec7506 --- /dev/null +++ b/frontend/public/assets/ico_italic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/ico_link.svg b/frontend/public/assets/ico_link.svg new file mode 100644 index 00000000..1a97c9a2 --- /dev/null +++ b/frontend/public/assets/ico_link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/ico_quote.svg b/frontend/public/assets/ico_quote.svg new file mode 100644 index 00000000..fa4d4a8f --- /dev/null +++ b/frontend/public/assets/ico_quote.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/ico_strikethrough.svg b/frontend/public/assets/ico_strikethrough.svg new file mode 100644 index 00000000..7738a0ef --- /dev/null +++ b/frontend/public/assets/ico_strikethrough.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/utils/parser.ts b/frontend/utils/parser.ts index 93de0109..42fea91b 100644 --- a/frontend/utils/parser.ts +++ b/frontend/utils/parser.ts @@ -2,6 +2,7 @@ import rehypeHighlight from 'rehype-highlight'; import rehypeParse from 'rehype-parse'; import rehypeRemark from 'rehype-remark'; import rehypeStringify from 'rehype-stringify'; +import remarkBreaks from 'remark-breaks'; import remarkParse from 'remark-parse'; import remarkRehype from 'remark-rehype'; import remarkStringify from 'remark-stringify'; @@ -10,6 +11,7 @@ import { unified } from 'unified'; export const markdown2html = (markdown: string) => { const html = unified() .use(remarkParse) + .use(remarkBreaks) .use(remarkRehype) .use(rehypeStringify) .processSync(markdown)