Skip to content

Commit 3028dfe

Browse files
committed
feat: MDXEditor 추가
1 parent 137f001 commit 3028dfe

File tree

4 files changed

+905
-0
lines changed

4 files changed

+905
-0
lines changed

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@
2929
"@mui/material": "^7.1.0",
3030
"@suspensive/react": "^3.2.0",
3131
"@tanstack/react-query": "^5.76.1",
32+
"@uiw/react-md-editor": "^4.0.7",
3233
"astring": "^1.9.0",
3334
"axios": "^1.9.0",
35+
"crypto-js": "^4.2.0",
3436
"globals": "^15.15.0",
3537
"mui-mdx-components": "^0.5.0",
3638
"notistack": "^3.0.2",
@@ -46,6 +48,7 @@
4648
"@rollup/plugin-node-resolve": "^16.0.1",
4749
"@rollup/plugin-replace": "^6.0.2",
4850
"@tanstack/react-query-devtools": "^5.76.1",
51+
"@types/crypto-js": "^4.2.2",
4952
"@types/mdx": "^2.0.13",
5053
"@types/node": "^22.15.18",
5154
"@types/react": "^19.1.4",

packages/common/src/components/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import {
66
} from './dynamic_route';
77
import { ErrorFallback as ErrorFallbackComponent } from './error_handler';
88
import { MDXRenderer as MDXRendererComponent } from "./mdx";
9+
import { MDXEditor as MDXEditorComponent } from './mdx_editor';
910
import { PythonKorea as PythonKoreaComponent } from './pythonkorea';
1011

1112
namespace Components {
1213
export const CommonContextProvider = CommonContextProviderComponent;
1314
export const RouteRenderer = RouteRendererComponent;
1415
export const PageRenderer = PageRendererComponent;
1516
export const PageIdParamRenderer = PageIdParamRendererComponent;
17+
export const MDXEditor = MDXEditorComponent;
1618
export const MDXRenderer = MDXRendererComponent;
1719
export const PythonKorea = PythonKoreaComponent;
1820
export const ErrorFallback = ErrorFallbackComponent;
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
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

Comments
 (0)