|
| 1 | +import * as React from "react"; |
| 2 | + |
| 3 | +import { Apps, Save } from "@mui/icons-material"; |
| 4 | +import { Button, ButtonProps, MenuItem, Select, Stack, Typography } from "@mui/material"; |
| 5 | +import MDEditor, { GroupOptions, RefMDEditor, commands } from '@uiw/react-md-editor'; |
| 6 | +import * as CryptoJS from "crypto-js"; |
| 7 | +import type { MDXComponents } from "mdx/types"; |
| 8 | + |
| 9 | +import Hooks from "../hooks"; |
| 10 | + |
| 11 | +const LOCAL_STORAGE_KEY = "mdx_editor_input_"; |
| 12 | + |
| 13 | +type CustomComponentInfoType = { |
| 14 | + k: string; // key |
| 15 | + n: string; // name |
| 16 | + v?: MDXComponents[string]; // value |
| 17 | +} |
| 18 | + |
| 19 | +type MDXEditorProps = { |
| 20 | + sectionId?: string; |
| 21 | + defaultValue?: string; |
| 22 | + inputRef?: React.RefObject<HTMLTextAreaElement | null>; |
| 23 | + onLoad?: (value: string) => void; |
| 24 | + onSave?: (value: string) => void; |
| 25 | + ctrlSMode?: "ignore" | "save"; |
| 26 | + submitActions?: ButtonProps[]; |
| 27 | +}; |
| 28 | + |
| 29 | +const TextEditorStyle: React.CSSProperties = { |
| 30 | + flexGrow: 1, |
| 31 | + width: '100%', |
| 32 | + maxWidth: '100%', |
| 33 | + |
| 34 | + wordBreak: 'break-word', |
| 35 | + whiteSpace: 'pre-wrap', |
| 36 | + overflowWrap: 'break-word', |
| 37 | + |
| 38 | + fieldSizing: 'content', |
| 39 | +} as React.CSSProperties; |
| 40 | + |
| 41 | +const getDefaultValueFromLocalStorage = (sectionId?: string): string => localStorage.getItem(LOCAL_STORAGE_KEY + (sectionId || "unknown")) ?? ""; |
| 42 | + |
| 43 | +const calculateMD5FromFileBase64 = (fileBase64: string): string => CryptoJS.MD5(CryptoJS.enc.Base64.parse(fileBase64)).toString(); |
| 44 | + |
| 45 | +const onFileInEvent: React.DragEventHandler<HTMLDivElement> = (event) => { |
| 46 | + event.preventDefault(); |
| 47 | + event.stopPropagation(); |
| 48 | + |
| 49 | + if (!event.dataTransfer) { // Might be a drag event |
| 50 | + alert('이 브라우저는 해당 동작을 지원하지 않습니다.'); |
| 51 | + return; |
| 52 | + } |
| 53 | + |
| 54 | + const images = Array.from(event.dataTransfer.files).filter(f => f.type.startsWith("image/")) |
| 55 | + if (images.length === 0) { |
| 56 | + alert('이미지 파일만 첨부할 수 있어요.'); |
| 57 | + return; |
| 58 | + } |
| 59 | + |
| 60 | + images.forEach( |
| 61 | + (item) => { |
| 62 | + let reader = new FileReader(); |
| 63 | + reader.onload = (e) => { |
| 64 | + if (!e.target || typeof e.target.result !== "string") return; |
| 65 | + console.log(`이미지 MD5 해시: ${calculateMD5FromFileBase64(e.target.result.split(',')[1])}`); |
| 66 | + } |
| 67 | + reader.onerror = (e) => { |
| 68 | + console.error('Error reading file:', e); |
| 69 | + alert('파일을 읽는 중 오류가 발생했습니다.'); |
| 70 | + }; |
| 71 | + reader.readAsDataURL(item); |
| 72 | + } |
| 73 | + ); |
| 74 | +} |
| 75 | + |
| 76 | +const getCustomComponentSelector: (registeredComponentList: CustomComponentInfoType[]) => GroupOptions["children"] = (registeredComponentList) => ({ close, getState, textApi }) => { |
| 77 | + const componentSelectorRef = React.useRef<HTMLSelectElement>(null); |
| 78 | + |
| 79 | + const onInsertBtnClick = () => { |
| 80 | + if (!textApi || !getState || !registeredComponentList?.length || !componentSelectorRef.current) return undefined; |
| 81 | + |
| 82 | + const state = getState(); |
| 83 | + if (!state) return undefined; |
| 84 | + |
| 85 | + const selectedComponentData = registeredComponentList.find(({ k }) => k === componentSelectorRef?.current?.value); |
| 86 | + if (!selectedComponentData) return undefined; |
| 87 | + |
| 88 | + let newText = `<${selectedComponentData.k} />`; |
| 89 | + if (state.selectedText) { |
| 90 | + newText += `\n${state.selectedText}`; |
| 91 | + if (state.selection.start - 1 !== -1 && state.text[state.selection.start - 1] !== "\n") newText = `\n${newText}`; |
| 92 | + } else { |
| 93 | + if (state.selection.start - 1 !== -1 && state.text[state.selection.start - 1] !== "\n") newText = `\n${newText}`; |
| 94 | + if (state.selection.end !== state.text.length && state.text[state.selection.end] !== "\n") newText += "\n"; |
| 95 | + } |
| 96 | + |
| 97 | + textApi.replaceSelection(newText); |
| 98 | + close(); |
| 99 | + } |
| 100 | + |
| 101 | + return <Stack spacing={1} sx={{ p: 1, flexGrow: 1, minWidth: 200 }}> |
| 102 | + <Typography variant="subtitle1" color="text.secondary">컴포넌트 삽입</Typography> |
| 103 | + <Select inputRef={componentSelectorRef} defaultValue="" size="small" fullWidth> |
| 104 | + {registeredComponentList.map(({ k, n }) => <MenuItem key={k} value={k}>{n}</MenuItem>)} |
| 105 | + </Select> |
| 106 | + <Button size="small" variant="contained" onClick={onInsertBtnClick}>삽입</Button> |
| 107 | + <Button size="small" variant="outlined" sx={{ flexGrow: 1 }} onClick={close}>닫기</Button> |
| 108 | + </Stack>; |
| 109 | +} |
| 110 | + |
| 111 | +export const MDXEditor: React.FC<MDXEditorProps> = ({ sectionId, defaultValue, inputRef, onLoad, onSave, ctrlSMode, submitActions }) => { |
| 112 | + const [value, setValue] = React.useState<string>(defaultValue || getDefaultValueFromLocalStorage(sectionId)); |
| 113 | + const { mdxComponents } = Hooks.Common.useCommonContext(); |
| 114 | + |
| 115 | + if (!inputRef) inputRef = React.useRef<HTMLTextAreaElement | null>(null); |
| 116 | + const setRef: React.RefAttributes<RefMDEditor>["ref"] = (n) => { |
| 117 | + if (n?.textarea) { |
| 118 | + !inputRef.current && onLoad?.(n.textarea.value); |
| 119 | + inputRef.current = n.textarea |
| 120 | + } |
| 121 | + } |
| 122 | + |
| 123 | + const registeredComponentList: CustomComponentInfoType[] = [ |
| 124 | + { k: "", n: "", v: undefined }, |
| 125 | + ...Object.entries(mdxComponents ?? {}).map(([k, v]) => { |
| 126 | + const splicedKey = k.replace(/__/g, '.').split('.'); |
| 127 | + const n = [...splicedKey.slice(0, -1).map((word) => word.toLowerCase()), splicedKey[splicedKey.length - 1]].join('.'); |
| 128 | + return { k, n, v }; |
| 129 | + }) |
| 130 | + ]; |
| 131 | + |
| 132 | + const onSaveAction = () => { |
| 133 | + if (!inputRef.current) return; |
| 134 | + |
| 135 | + setValue(inputRef.current.value); |
| 136 | + onSave?.(inputRef.current.value); |
| 137 | + localStorage.setItem(LOCAL_STORAGE_KEY + (sectionId || "unknown"), inputRef.current.value); |
| 138 | + alert("저장했습니다."); |
| 139 | + } |
| 140 | + |
| 141 | + const handleCtrlSAction: (this: GlobalEventHandlers, ev: KeyboardEvent) => any = (event) => { |
| 142 | + if (event.key === "s" && (event.ctrlKey || event.metaKey)) { |
| 143 | + event.preventDefault(); |
| 144 | + event.stopPropagation(); |
| 145 | + console.log(`Ctrl+S pressed, executing ${ctrlSMode} action`); |
| 146 | + ctrlSMode === "save" && onSaveAction(); |
| 147 | + } |
| 148 | + } |
| 149 | + |
| 150 | + React.useEffect( |
| 151 | + ctrlSMode ? () => { |
| 152 | + document.addEventListener("keydown", handleCtrlSAction); |
| 153 | + return () => { |
| 154 | + console.log("Removing event listener for Ctrl+S action"); |
| 155 | + document.removeEventListener("keydown", handleCtrlSAction); |
| 156 | + } |
| 157 | + } : () => { }, [inputRef.current] |
| 158 | + ) |
| 159 | + |
| 160 | + return <Stack direction="column" spacing={2} sx={{ width: "100%", height: "100%", maxWidth: "100%" }}> |
| 161 | + <MDEditor |
| 162 | + preview="edit" |
| 163 | + highlightEnable={true} |
| 164 | + ref={setRef} |
| 165 | + value={value} |
| 166 | + onChange={(v, e, s) => setValue(v || "")} |
| 167 | + commands={[ |
| 168 | + commands.group( |
| 169 | + [ |
| 170 | + commands.title1, |
| 171 | + commands.title2, |
| 172 | + commands.title3, |
| 173 | + commands.title4, |
| 174 | + commands.title5, |
| 175 | + commands.title6, |
| 176 | + ], |
| 177 | + { |
| 178 | + name: 'title', |
| 179 | + groupName: 'title', |
| 180 | + buttonProps: { 'aria-label': 'Insert title' } |
| 181 | + } |
| 182 | + ), |
| 183 | + commands.bold, |
| 184 | + commands.italic, |
| 185 | + commands.code, |
| 186 | + commands.link, |
| 187 | + commands.divider, |
| 188 | + commands.quote, |
| 189 | + commands.codeBlock, |
| 190 | + commands.hr, |
| 191 | + commands.image, |
| 192 | + commands.divider, |
| 193 | + commands.unorderedListCommand, |
| 194 | + commands.orderedListCommand, |
| 195 | + commands.divider, |
| 196 | + commands.group([], { |
| 197 | + name: 'custom components', |
| 198 | + groupName: 'custom components', |
| 199 | + icon: <Apps style={{ fontSize: 12 }} />, |
| 200 | + children: getCustomComponentSelector(registeredComponentList), |
| 201 | + buttonProps: { 'aria-label': 'Insert custom component' } |
| 202 | + }), |
| 203 | + ]} |
| 204 | + extraCommands={[ |
| 205 | + commands.group([], { |
| 206 | + name: 'save', |
| 207 | + groupName: 'save', |
| 208 | + icon: <Save style={{ fontSize: 12 }} />, |
| 209 | + execute: onSaveAction, |
| 210 | + buttonProps: { 'aria-label': 'Save' } |
| 211 | + }) |
| 212 | + ]} |
| 213 | + style={TextEditorStyle} |
| 214 | + /> |
| 215 | + <Stack direction="row" spacing={2} sx={{ mt: 2 }}> |
| 216 | + {submitActions && submitActions.map((buttonProps, index) => <Button key={index} {...buttonProps} />)} |
| 217 | + </Stack> |
| 218 | + </Stack>; |
| 219 | +}; |
0 commit comments