Skip to content

Commit 4670928

Browse files
committed
Merge remote-tracking branch 'origin/feature/add-image-inserter-on-mdx-editor'
2 parents ea6c09e + 0762e3c commit 4670928

File tree

1 file changed

+142
-18
lines changed

1 file changed

+142
-18
lines changed

packages/common/src/components/mdx_editor.tsx

Lines changed: 142 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
import { Apps } from "@mui/icons-material";
2-
import { Button, MenuItem, Select, Stack, Typography } from "@mui/material";
1+
import { AddPhotoAlternate, Apps } from "@mui/icons-material";
2+
import { Button, CircularProgress, MenuItem, Select, Stack, Tab, Tabs, TextField, Typography } from "@mui/material";
3+
import { Grid } from "@mui/system";
4+
import { Suspense } from "@suspensive/react";
35
import MDEditor, { GroupOptions, ICommand, commands } from "@uiw/react-md-editor";
46
import type { MDXComponents } from "mdx/types";
57
import * as React from "react";
8+
import * as R from "remeda";
69
// import * as CryptoJS from "crypto-js";
710

811
import Hooks from "../hooks";
@@ -65,6 +68,135 @@ const TextEditorStyle: React.CSSProperties = {
6568
// );
6669
// }
6770

71+
const insertText = (newText: string, getState: () => false | commands.TextState, textApi: commands.TextAreaTextApi) => {
72+
const state = getState();
73+
if (!state) return undefined;
74+
75+
if (state.selectedText) {
76+
newText += `\n${state.selectedText}`;
77+
if (state.selection.start - 1 !== -1 && state.text[state.selection.start - 1] !== "\n") newText = `\n${newText}`;
78+
} else {
79+
if (state.selection.start - 1 !== -1 && state.text[state.selection.start - 1] !== "\n") newText = `\n${newText}`;
80+
if (state.selection.end !== state.text.length && state.text[state.selection.end] !== "\n") newText += "\n";
81+
}
82+
83+
textApi.replaceSelection(newText);
84+
};
85+
86+
type ImageSelectorWidgetStateType = {
87+
tab: number;
88+
selectedImageUrl?: string;
89+
};
90+
91+
type PublicFileType = {
92+
id: string;
93+
file: string;
94+
mimetype: string;
95+
};
96+
97+
const ImageSelector: GroupOptions["children"] = Suspense.with(
98+
{ fallback: <CircularProgress /> },
99+
({ close, getState, textApi }) => {
100+
const urlInputRef = React.useRef<HTMLInputElement>(null);
101+
const backendAdminAPIClient = Hooks.BackendAdminAPI.useBackendAdminClient();
102+
const { data } = Hooks.BackendAdminAPI.useListQuery<PublicFileType>(backendAdminAPIClient, "file", "publicfile");
103+
const [widgetState, setWidgetState] = React.useState<ImageSelectorWidgetStateType>({ tab: 0 });
104+
const setTab = (_: React.SyntheticEvent, tab: number) => setWidgetState((ps) => ({ ...ps, tab }));
105+
const setImageUrl = (selectedImageUrl?: string) => setWidgetState((ps) => ({ ...ps, selectedImageUrl }));
106+
107+
const insertImage = (inputStr: string) => {
108+
console.log(textApi, getState);
109+
if (!textApi || !getState) return undefined;
110+
insertText(inputStr, getState, textApi);
111+
setImageUrl();
112+
close();
113+
};
114+
const getSelectedUrl = (): string | undefined => {
115+
if (
116+
widgetState.tab === 0 &&
117+
R.isString(widgetState.selectedImageUrl) &&
118+
widgetState.selectedImageUrl.trim() !== ""
119+
) {
120+
return widgetState.selectedImageUrl.trim();
121+
} else if (
122+
widgetState.tab === 1 &&
123+
urlInputRef.current &&
124+
urlInputRef.current.checkValidity() &&
125+
urlInputRef.current.value.trim() !== ""
126+
) {
127+
return urlInputRef.current.value.trim();
128+
}
129+
130+
if (widgetState.tab === 0) alert("사진을 선택해주세요.");
131+
if (widgetState.tab === 1) urlInputRef.current?.reportValidity();
132+
133+
return undefined;
134+
};
135+
const onHTMLInsertBtnClick = () => {
136+
const url = getSelectedUrl();
137+
if (R.isString(url)) insertImage(`<img src="${url}" alt="이미지 설명" />`);
138+
};
139+
const onMarkdownInsertBtnClick = () => {
140+
const url = getSelectedUrl();
141+
if (R.isString(url)) insertImage(`![이미지 설명](${url})`);
142+
};
143+
144+
return (
145+
<Stack spacing={1} sx={{ p: 1, flexGrow: 1, minWidth: 200, maxHeight: "50rem" }}>
146+
<Tabs value={widgetState.tab} onChange={setTab} scrollButtons={false}>
147+
<Tab wrapped label="업로드 된 사진 중 선택" />
148+
<Tab wrapped label="사진 URL 직접 입력" />
149+
</Tabs>
150+
{widgetState.tab === 0 && (
151+
<>
152+
<Typography variant="subtitle1" color="text.secondary">
153+
업로드 된 사진 중 선택
154+
</Typography>
155+
<Grid>
156+
{data
157+
.filter((item) => item.mimetype.startsWith("image/"))
158+
.map((item) => ({ ...item, file: item.file.split("?")[0] })) // Remove query parameters if any
159+
.map((item) => {
160+
const selected = widgetState.selectedImageUrl === item.file;
161+
return (
162+
<Button
163+
variant="outlined"
164+
size="small"
165+
onClick={() => setImageUrl(item.file)}
166+
sx={{
167+
border: `1px solid ${selected ? "primary.main" : "grey.400"}`,
168+
backgroundColor: selected ? "primary.main" : "transparent",
169+
}}
170+
>
171+
<img src={item.file} alt="이미지 미리보기" style={{ maxWidth: 100, maxHeight: 100 }} />
172+
</Button>
173+
);
174+
})}
175+
</Grid>
176+
</>
177+
)}
178+
{widgetState.tab === 1 && (
179+
<>
180+
<Typography variant="subtitle1" color="text.secondary">
181+
사진 URL 직접 입력
182+
</Typography>
183+
<TextField label="사진 URL" size="small" type="url" fullWidth required inputRef={urlInputRef} />
184+
</>
185+
)}
186+
<Button size="small" variant="contained" onClick={onHTMLInsertBtnClick}>
187+
HTML로 삽입
188+
</Button>
189+
<Button size="small" variant="contained" onClick={onMarkdownInsertBtnClick}>
190+
마크다운으로 삽입
191+
</Button>
192+
<Button size="small" variant="outlined" sx={{ flexGrow: 1 }} onClick={close}>
193+
닫기
194+
</Button>
195+
</Stack>
196+
);
197+
}
198+
);
199+
68200
const getCustomComponentSelector: (registeredComponentList: CustomComponentInfoType[]) => GroupOptions["children"] =
69201
(registeredComponentList) =>
70202
({ close, getState, textApi }) => {
@@ -73,24 +205,10 @@ const getCustomComponentSelector: (registeredComponentList: CustomComponentInfoT
73205
const onInsertBtnClick = () => {
74206
if (!textApi || !getState || !registeredComponentList?.length || !componentSelectorRef.current) return undefined;
75207

76-
const state = getState();
77-
if (!state) return undefined;
78-
79208
const selectedComponentData = registeredComponentList.find(({ k }) => k === componentSelectorRef?.current?.value);
80209
if (!selectedComponentData) return undefined;
81210

82-
let newText = `<${selectedComponentData.k} />`;
83-
if (state.selectedText) {
84-
newText += `\n${state.selectedText}`;
85-
if (state.selection.start - 1 !== -1 && state.text[state.selection.start - 1] !== "\n")
86-
newText = `\n${newText}`;
87-
} else {
88-
if (state.selection.start - 1 !== -1 && state.text[state.selection.start - 1] !== "\n")
89-
newText = `\n${newText}`;
90-
if (state.selection.end !== state.text.length && state.text[state.selection.end] !== "\n") newText += "\n";
91-
}
92-
93-
textApi.replaceSelection(newText);
211+
insertText(`<${selectedComponentData.k} />`, getState, textApi);
94212
close();
95213
};
96214

@@ -158,7 +276,6 @@ export const MDXEditor: React.FC<MDXEditorProps> = ({ disabled, defaultValue, on
158276
commands.quote,
159277
commands.codeBlock,
160278
commands.hr,
161-
commands.image,
162279
commands.divider,
163280
commands.unorderedListCommand,
164281
commands.orderedListCommand,
@@ -170,6 +287,13 @@ export const MDXEditor: React.FC<MDXEditorProps> = ({ disabled, defaultValue, on
170287
children: getCustomComponentSelector(registeredComponentList),
171288
buttonProps: { "aria-label": "Insert custom component" },
172289
}),
290+
commands.group([], {
291+
name: "image selector",
292+
groupName: "image selector",
293+
icon: <AddPhotoAlternate style={{ fontSize: 12 }} />,
294+
children: (props) => <ImageSelector {...props} />,
295+
buttonProps: { "aria-label": "Insert image" },
296+
}),
173297
]}
174298
extraCommands={extraCommands}
175299
style={TextEditorStyle}

0 commit comments

Comments
 (0)