diff --git a/src/app/business/components/WriteForm.tsx b/src/app/business/components/WriteForm.tsx
index 4586f47..f92785c 100644
--- a/src/app/business/components/WriteForm.tsx
+++ b/src/app/business/components/WriteForm.tsx
@@ -1,38 +1,27 @@
'use client';
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { useEditor } from '@tiptap/react';
-import { useBusinessStore } from '@/store/business.store';
-import { uploadImage } from '@/lib/imageUpload';
-import {
- getImageDimensions,
- clampImageDimensions,
-} from '@/lib/getImageDimensions';
-import { getSelectionAvailableWidth } from '@/lib/business/editor/getSelectionAvailableWidth';
-import StarterKit from '@tiptap/starter-kit';
-import Highlight from '@tiptap/extension-highlight';
-import TextStyle from '@tiptap/extension-text-style';
-import Color from '@tiptap/extension-color';
-import Placeholder from '@tiptap/extension-placeholder';
-import Table from '@tiptap/extension-table';
-import TableRow from '@tiptap/extension-table-row';
-import TableHeader from '@tiptap/extension-table-header';
-import TableCell from '@tiptap/extension-table-cell';
import { Editor, JSONContent } from '@tiptap/core';
+import {
+ createEditorFeaturesConfig,
+ createEditorSkillsConfig,
+ createEditorGoalsConfig,
+ createEditorItemNameConfig,
+ createEditorOneLineIntroConfig,
+ createEditorGeneralConfig,
+} from '@/lib/business/editor/editorConstants';
+import { useBusinessStore } from '@/store/business.store';
+import { useSpellCheckStore } from '@/store/spellcheck.store';
+import { useEditorStore } from '@/store/editor.store';
import { useSpellCheck } from '@/hooks/mutation/useSpellCheck';
import { SpellPayload } from '@/lib/business/postSpellCheck';
-import { useSpellCheckStore } from '@/store/spellcheck.store';
import { applySpellHighlights, clearSpellErrors } from '@/util/spellMark';
-import SpellError from '@/util/spellError';
+import { clearFixedCorrections } from '@/util/spellReplace';
import { mapSpellResponse } from '@/types/business/business.type';
-import { useEditorStore } from '@/store/editor.store';
-import { DeleteTableOnDelete, ResizableImage, SelectTableOnBorderClick, EnsureTrailingParagraph } from '../../../lib/business/editor/extensions';
-import { createPasteHandler } from '../../../lib/business/editor/useEditorConfig';
-import { ImageCommandAttributes } from '@/lib/business/editor/types';
import WriteFormHeader from './editor/WriteFormHeader';
import WriteFormToolbar from './editor/WriteFormToolbar';
import OverviewSection from './editor/OverviewSection';
import GeneralSection from './editor/GeneralSection';
-import { clearFixedCorrections } from '@/util/spellReplace';
const WriteForm = ({
number = '0',
@@ -43,185 +32,54 @@ const WriteForm = ({
title?: string;
subtitle?: string;
}) => {
- const editorFeatures = useEditor({
- extensions: [
- StarterKit,
- SpellError,
- DeleteTableOnDelete,
- Highlight.configure({ multicolor: true }),
- TextStyle,
- Color,
- ResizableImage.configure({ inline: false }),
- Table.configure({ resizable: true }),
- TableRow,
- TableHeader,
- TableCell,
- SelectTableOnBorderClick,
- EnsureTrailingParagraph,
- Placeholder.configure({
- placeholder:
- '아이템의 핵심기능은 무엇이며, 어떤 기능을 구현·작동 하는지 설명해주세요.',
- includeChildren: false,
- showOnlyWhenEditable: true,
- }),
- ],
- content: '
',
- editorProps: {
- handlePaste: createPasteHandler(),
- },
- immediatelyRender: false,
- });
-
- const editorSkills = useEditor({
- extensions: [
- StarterKit,
- SpellError,
- DeleteTableOnDelete,
- Highlight.configure({ multicolor: true }),
- TextStyle,
- Color,
- ResizableImage.configure({ inline: false }),
- Table.configure({ resizable: true }),
- TableRow,
- TableHeader,
- TableCell,
- SelectTableOnBorderClick,
- EnsureTrailingParagraph,
- Placeholder.configure({
- placeholder:
- '보유한 기술 및 지식재산권이 별도로 없을 경우, 아이템에 필요한 핵심기술을 어떻게 개발해 나갈것인지 계획에 대해 작성해주세요. \n ※ 지식재산권: 특허, 상표권, 디자인, 실용신안권 등.',
- includeChildren: false,
- showOnlyWhenEditable: true,
- }),
- ],
- content: '',
- editorProps: {
- handlePaste: createPasteHandler(),
- },
- immediatelyRender: false,
- });
-
- const editorGoals = useEditor({
- extensions: [
- StarterKit,
- SpellError,
- DeleteTableOnDelete,
- Highlight.configure({ multicolor: true }),
- TextStyle,
- Color,
- ResizableImage.configure({ inline: false }),
- Table.configure({ resizable: true }),
- TableRow,
- TableHeader,
- TableCell,
- SelectTableOnBorderClick,
- EnsureTrailingParagraph,
- Placeholder.configure({
- placeholder: '본 사업을 통해 달성하고 싶은 궁극적인 목표에 대해 설명',
- includeChildren: false,
- showOnlyWhenEditable: true,
- }),
- ],
- content: '',
- editorProps: {
- handlePaste: createPasteHandler(),
- },
- immediatelyRender: false,
- });
-
- // 아이템명 에디터 (하이라이트, 볼드, 색상만 가능, 헤딩/표/이미지 비활성화)
- const editorItemName = useEditor({
- extensions: [
- StarterKit.configure({
- heading: false,
- blockquote: false,
- codeBlock: false,
- horizontalRule: false,
- hardBreak: false,
- }),
- SpellError,
- Highlight.configure({ multicolor: true }),
- TextStyle,
- Color,
- Placeholder.configure({
- placeholder: '답변을 입력하세요.',
- includeChildren: false,
- showOnlyWhenEditable: true,
- }),
- ],
- content: '',
- immediatelyRender: false,
- });
+ const isOverview = number === '0';
- // 아이템 한줄소개 에디터 (하이라이트, 볼드, 색상만 가능, 헤딩/표/이미지 비활성화)
- const editorOneLineIntro = useEditor({
- extensions: [
- StarterKit.configure({
- heading: false,
- blockquote: false,
- codeBlock: false,
- horizontalRule: false,
- hardBreak: false,
- }),
- SpellError,
- Highlight.configure({ multicolor: true }),
- TextStyle,
- Color,
- Placeholder.configure({
- placeholder: '답변을 입력하세요.',
- includeChildren: false,
- showOnlyWhenEditable: true,
- }),
- ],
- content: '',
- immediatelyRender: false,
- });
+ // 에디터 인스턴스 생성
+ const editorFeatures = useEditor(createEditorFeaturesConfig());
+ const editorSkills = useEditor(createEditorSkillsConfig());
+ const editorGoals = useEditor(createEditorGoalsConfig());
+ const editorItemName = useEditor(createEditorItemNameConfig());
+ const editorOneLineIntro = useEditor(createEditorOneLineIntroConfig());
+ const editorGeneral = useEditor(createEditorGeneralConfig());
+
+ const overviewEditors = useMemo(
+ () => ({
+ itemName: editorItemName,
+ oneLineIntro: editorOneLineIntro,
+ features: editorFeatures,
+ skills: editorSkills,
+ goals: editorGoals,
+ }),
+ [editorItemName, editorOneLineIntro, editorFeatures, editorSkills, editorGoals]
+ );
- const editorGeneral = useEditor({
- extensions: [
- StarterKit,
- SpellError,
- DeleteTableOnDelete,
- Highlight.configure({ multicolor: true }),
- TextStyle,
- Color,
- ResizableImage.configure({ inline: false }),
- Table.configure({ resizable: true }),
- TableRow,
- TableHeader,
- TableCell,
- SelectTableOnBorderClick,
- EnsureTrailingParagraph,
- Placeholder.configure({
- placeholder:
- '세부 항목별 체크리스트를 참고하며 작성해주시면, 리포트 점수가 올라갑니다.',
- includeChildren: false,
- showOnlyWhenEditable: true,
- }),
- ],
- content: '',
- editorProps: {
- handlePaste: createPasteHandler(),
- },
- immediatelyRender: false,
- });
+ const defaultEditor = useMemo(
+ () => (isOverview ? editorFeatures : editorGeneral),
+ [isOverview, editorFeatures, editorGeneral]
+ );
const {
updateItemContent,
getItemContent,
lastSavedTime,
isSaving,
- saveAllItems,
+ saveSingleItem,
planId,
} = useBusinessStore();
- // 현재 섹션의 contents만 구독하여 변경 감지
const currentContent = useBusinessStore((state) => state.contents[number]);
const [activeEditor, setActiveEditor] = useState(null);
const [grammarActive, setGrammarActive] = useState(false);
- const fileInputRef = useRef(null);
- const isOverview = number === '0';
- // 에디터 내용 복원 헬퍼 함수
+ const convertStringToJSONContent = useCallback((text: string): JSONContent => ({
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text }],
+ },
+ ],
+ }), []);
+
const restoreEditorContent = useCallback(
(editor: Editor | null, content: JSONContent | null | undefined) => {
if (!editor || editor.isDestroyed) return;
@@ -229,23 +87,20 @@ const WriteForm = ({
if (content) {
const currentJSON = editor.getJSON();
const nextJSON = JSON.parse(JSON.stringify(content));
- if (JSON.stringify(currentJSON) === JSON.stringify(nextJSON)) {
- return;
- }
+ if (JSON.stringify(currentJSON) === JSON.stringify(nextJSON)) return;
editor.commands.setContent(nextJSON, false);
} else {
const currentJSON = editor.getJSON();
- const isAlreadyEmpty =
+ const isEmpty =
!currentJSON ||
!Array.isArray(currentJSON.content) ||
currentJSON.content.length === 0 ||
(currentJSON.content.length === 1 &&
currentJSON.content[0]?.type === 'paragraph' &&
- (!currentJSON.content[0]?.content ||
- currentJSON.content[0]?.content?.length === 0));
- if (isAlreadyEmpty) return;
- // content가 없으면 에디터를 빈 상태로 초기화
- editor.commands.clearContent(false);
+ (!currentJSON.content[0]?.content || currentJSON.content[0]?.content?.length === 0));
+ if (!isEmpty) {
+ editor.commands.clearContent(false);
+ }
}
} catch (e) {
console.error('에디터 내용 복원 실패:', e);
@@ -254,50 +109,21 @@ const WriteForm = ({
[]
);
- // number가 변경되거나 contents가 업데이트될 때 store에서 내용 불러오기
useEffect(() => {
const content = getItemContent(number);
- // 에디터 내용 복원
if (isOverview) {
- // 개요 섹션: editorFeatures, editorSkills, editorGoals 모두 복원
- // itemName과 oneLineIntro는 JSONContent로 저장되어 있을 수 있음
restoreEditorContent(
editorItemName,
- content.itemName
- ? typeof content.itemName === 'string'
- ? {
- type: 'doc',
- content: [
- {
- type: 'paragraph',
- content: [{ type: 'text', text: content.itemName }],
- },
- ],
- }
- : content.itemName
- : null
+ content.itemName ? (typeof content.itemName === 'string' ? convertStringToJSONContent(content.itemName) : content.itemName) : null
);
restoreEditorContent(
editorOneLineIntro,
- content.oneLineIntro
- ? typeof content.oneLineIntro === 'string'
- ? {
- type: 'doc',
- content: [
- {
- type: 'paragraph',
- content: [{ type: 'text', text: content.oneLineIntro }],
- },
- ],
- }
- : content.oneLineIntro
- : null
+ content.oneLineIntro ? (typeof content.oneLineIntro === 'string' ? convertStringToJSONContent(content.oneLineIntro) : content.oneLineIntro) : null
);
restoreEditorContent(editorFeatures, content.editorFeatures);
restoreEditorContent(editorSkills, content.editorSkills);
restoreEditorContent(editorGoals, content.editorGoals);
} else {
- // 일반 섹션: editorGeneral만 복원
restoreEditorContent(editorGeneral, content.editorContent);
}
}, [
@@ -312,267 +138,164 @@ const WriteForm = ({
currentContent,
getItemContent,
restoreEditorContent,
+ convertStringToJSONContent,
]);
- // 공통 저장 함수 (디바운스 적용)
const debouncedSave = useCallback(async () => {
if (!planId) return;
try {
- await saveAllItems(planId);
+ await saveSingleItem(planId, number);
} catch (error) {
console.error('자동 저장 실패:', error);
}
- }, [planId, saveAllItems]);
+ }, [planId, number, saveSingleItem]);
+
+ const saveSelection = useCallback((editor: Editor | null) => {
+ if (!editor || editor.isDestroyed) return null;
+ return { from: editor.state.selection.from, to: editor.state.selection.to };
+ }, []);
+
+ const restoreSelection = useCallback(
+ (editor: Editor, selection: { from: number; to: number }) => {
+ try {
+ editor.chain().focus().setTextSelection(selection).run();
+ } catch (e) {
+ console.error('커서 위치 복원 실패:', e);
+ }
+ },
+ []
+ );
+
+ const saveEditorContentToStore = useCallback(() => {
+ const primaryEditor = isOverview ? editorFeatures : editorGeneral;
+ if (isOverview) {
+ updateItemContent(number, {
+ itemName: editorItemName?.getJSON() || null,
+ oneLineIntro: editorOneLineIntro?.getJSON() || null,
+ editorFeatures: primaryEditor?.getJSON() || null,
+ editorSkills: editorSkills?.getJSON() || null,
+ editorGoals: editorGoals?.getJSON() || null,
+ });
+ } else {
+ updateItemContent(number, {
+ editorContent: primaryEditor?.getJSON() || null,
+ });
+ }
+ }, [
+ isOverview,
+ number,
+ editorItemName,
+ editorOneLineIntro,
+ editorFeatures,
+ editorGeneral,
+ editorSkills,
+ editorGoals,
+ updateItemContent,
+ ]);
- // 에디터 업데이트 핸들러 생성
const createUpdateHandler = useCallback(
(timeoutRef: React.MutableRefObject) => {
return () => {
- // 저장 전 커서 위치 저장
- const saveSelection = (editor: Editor | null) => {
- if (!editor || editor.isDestroyed) return null;
- return {
- from: editor.state.selection.from,
- to: editor.state.selection.to,
- };
+ const primaryEditor = isOverview ? editorFeatures : editorGeneral;
+ const selections = {
+ main: saveSelection(primaryEditor),
+ skills: saveSelection(editorSkills),
+ goals: saveSelection(editorGoals),
+ itemName: saveSelection(editorItemName),
+ oneLineIntro: saveSelection(editorOneLineIntro),
};
- const primaryEditor = isOverview ? editorFeatures : editorGeneral;
- const mainSelection = saveSelection(primaryEditor);
- const skillsSelection = saveSelection(editorSkills);
- const goalsSelection = saveSelection(editorGoals);
- const itemNameSelection = saveSelection(editorItemName);
- const oneLineIntroSelection = saveSelection(editorOneLineIntro);
-
- // store에 즉시 저장 (메모리 작업이므로 디바운스 불필요)
- if (isOverview) {
- updateItemContent(number, {
- itemName: editorItemName?.getJSON() || null,
- oneLineIntro: editorOneLineIntro?.getJSON() || null,
- editorFeatures: primaryEditor?.getJSON() || null,
- editorSkills: editorSkills?.getJSON() || null,
- editorGoals: editorGoals?.getJSON() || null,
- });
- } else {
- updateItemContent(number, {
- editorContent: primaryEditor?.getJSON() || null,
- });
- }
+ saveEditorContentToStore();
- // 커서 위치 복원 로직
requestAnimationFrame(() => {
- const fallbackEditor = primaryEditor;
- const currentActiveEditor = activeEditor || fallbackEditor;
- if (currentActiveEditor && !currentActiveEditor.isDestroyed) {
- let selectionToRestore = null;
- if (currentActiveEditor === fallbackEditor && mainSelection) {
- selectionToRestore = mainSelection;
- } else if (
- currentActiveEditor === editorSkills &&
- skillsSelection
- ) {
- selectionToRestore = skillsSelection;
- } else if (currentActiveEditor === editorGoals && goalsSelection) {
- selectionToRestore = goalsSelection;
- } else if (
- currentActiveEditor === editorItemName &&
- itemNameSelection
- ) {
- selectionToRestore = itemNameSelection;
- } else if (
- currentActiveEditor === editorOneLineIntro &&
- oneLineIntroSelection
- ) {
- selectionToRestore = oneLineIntroSelection;
- }
- if (selectionToRestore) {
- try {
- currentActiveEditor
- .chain()
- .focus()
- .setTextSelection({
- from: selectionToRestore.from,
- to: selectionToRestore.to,
- })
- .run();
- } catch (e) {
- // 커서 위치 복원 실패 시 무시
- }
- }
+ const currentEditor = activeEditor || primaryEditor;
+ if (!currentEditor || currentEditor.isDestroyed) return;
+
+ let selection: typeof selections.main = null;
+ if (currentEditor === primaryEditor) {
+ selection = selections.main;
+ } else if (currentEditor === editorSkills) {
+ selection = selections.skills;
+ } else if (currentEditor === editorGoals) {
+ selection = selections.goals;
+ } else if (currentEditor === editorItemName) {
+ selection = selections.itemName;
+ } else if (currentEditor === editorOneLineIntro) {
+ selection = selections.oneLineIntro;
}
+
+ if (selection) restoreSelection(currentEditor, selection);
});
- if (timeoutRef.current) {
- clearTimeout(timeoutRef.current);
- }
- timeoutRef.current = setTimeout(() => {
- debouncedSave();
- }, 300);
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ timeoutRef.current = setTimeout(debouncedSave, 300);
};
},
[
isOverview,
- number,
- editorItemName,
- editorOneLineIntro,
editorFeatures,
editorGeneral,
editorSkills,
editorGoals,
- updateItemContent,
- debouncedSave,
+ editorItemName,
+ editorOneLineIntro,
+ saveEditorContentToStore,
activeEditor,
+ saveSelection,
+ restoreSelection,
+ debouncedSave,
]
);
- // 에디터에 onChange 이벤트 리스너 추가 (store는 즉시 저장, API만 디바운스)
- const mainTimeoutRef = useRef(null);
- const skillsTimeoutRef = useRef(null);
- const goalsTimeoutRef = useRef(null);
- const itemNameTimeoutRef = useRef(null);
- const oneLineIntroTimeoutRef = useRef(null);
-
- useEffect(() => {
- const mainEditorInstance = isOverview ? editorFeatures : editorGeneral;
- if (!mainEditorInstance) return;
-
- const handleMainUpdate = createUpdateHandler(mainTimeoutRef);
- mainEditorInstance.on('update', handleMainUpdate);
-
- const cleanup: (() => void)[] = [
- () => {
- if (mainTimeoutRef.current) clearTimeout(mainTimeoutRef.current);
- mainEditorInstance.off('update', handleMainUpdate);
- },
- ];
-
- if (isOverview) {
- // 아이템명 에디터
- if (editorItemName) {
- const handleItemNameUpdate = createUpdateHandler(itemNameTimeoutRef);
- editorItemName.on('update', handleItemNameUpdate);
- cleanup.push(() => {
- if (itemNameTimeoutRef.current)
- clearTimeout(itemNameTimeoutRef.current);
- editorItemName.off('update', handleItemNameUpdate);
- });
- }
-
- // 한줄소개 에디터
- if (editorOneLineIntro) {
- const handleOneLineIntroUpdate = createUpdateHandler(
- oneLineIntroTimeoutRef
- );
- editorOneLineIntro.on('update', handleOneLineIntroUpdate);
- cleanup.push(() => {
- if (oneLineIntroTimeoutRef.current)
- clearTimeout(oneLineIntroTimeoutRef.current);
- editorOneLineIntro.off('update', handleOneLineIntroUpdate);
- });
- }
-
- if (editorSkills) {
- const handleSkillsUpdate = createUpdateHandler(skillsTimeoutRef);
- editorSkills.on('update', handleSkillsUpdate);
- cleanup.push(() => {
- if (skillsTimeoutRef.current) clearTimeout(skillsTimeoutRef.current);
- editorSkills.off('update', handleSkillsUpdate);
- });
- }
+ const timeoutRefs = useRef({
+ main: null as NodeJS.Timeout | null,
+ skills: null as NodeJS.Timeout | null,
+ goals: null as NodeJS.Timeout | null,
+ itemName: null as NodeJS.Timeout | null,
+ oneLineIntro: null as NodeJS.Timeout | null,
+ });
- if (editorGoals) {
- const handleGoalsUpdate = createUpdateHandler(goalsTimeoutRef);
- editorGoals.on('update', handleGoalsUpdate);
- cleanup.push(() => {
- if (goalsTimeoutRef.current) clearTimeout(goalsTimeoutRef.current);
- editorGoals.off('update', handleGoalsUpdate);
- });
- }
- }
+ const registerEditorListener = useCallback(
+ (
+ editor: Editor | null,
+ timeoutKey: keyof typeof timeoutRefs.current
+ ): (() => void) | null => {
+ if (!editor) return null;
+ const timeoutRef = { current: timeoutRefs.current[timeoutKey] };
+ const handler = createUpdateHandler(timeoutRef);
+ editor.on('update', handler);
+ timeoutRefs.current[timeoutKey] = timeoutRef.current;
+ return () => {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ editor.off('update', handler);
+ };
+ },
+ [createUpdateHandler]
+ );
- return () => {
- cleanup.forEach((fn) => fn());
- };
+ useEffect(() => {
+ const mainEditor = isOverview ? editorFeatures : editorGeneral;
+ if (!mainEditor) return;
+
+ const cleanups: (() => void)[] = [
+ registerEditorListener(mainEditor, 'main'),
+ isOverview ? registerEditorListener(overviewEditors.itemName, 'itemName') : null,
+ isOverview ? registerEditorListener(overviewEditors.oneLineIntro, 'oneLineIntro') : null,
+ isOverview ? registerEditorListener(overviewEditors.skills, 'skills') : null,
+ isOverview ? registerEditorListener(overviewEditors.goals, 'goals') : null,
+ ].filter((cleanup): cleanup is () => void => cleanup !== null);
+
+ return () => cleanups.forEach((cleanup) => cleanup());
}, [
+ isOverview,
editorFeatures,
editorGeneral,
- editorSkills,
- editorGoals,
- editorItemName,
- editorOneLineIntro,
- planId,
- isOverview,
- createUpdateHandler,
+ overviewEditors,
+ registerEditorListener,
]);
- // 이미지 파일 선택 핸들러
- const handleImageUpload = async (
- event: React.ChangeEvent
- ) => {
- const file = event.target.files?.[0];
- if (!file || !activeEditor) return;
-
- // 이미지 파일만 허용
- if (!file.type.startsWith('image/')) {
- alert('이미지 파일만 업로드 가능합니다.');
- return;
- }
-
- try {
- // 서버에 이미지 업로드 및 공개 URL 받기
- const imageUrl = await uploadImage(file);
-
- if (imageUrl && activeEditor) {
- const { width, height } = await getImageDimensions(imageUrl);
- const selectionWidth = getSelectionAvailableWidth(activeEditor);
- const editorDom = activeEditor.view.dom as HTMLElement | null;
- const fallbackWidth = editorDom
- ? editorDom.clientWidth - 48
- : undefined;
- const maxWidth = selectionWidth ?? fallbackWidth;
- const { width: clampedWidth, height: clampedHeight } =
- clampImageDimensions(width, height, maxWidth ?? undefined);
- const imageAttributes: ImageCommandAttributes = {
- src: imageUrl,
- width: clampedWidth ?? undefined,
- height: clampedHeight ?? undefined,
- };
-
- activeEditor.chain().focus().setImage(imageAttributes).run();
- }
- } catch (error) {
- console.error('이미지 업로드 실패:', error);
- alert('이미지 업로드에 실패했습니다. 다시 시도해주세요.');
- }
-
- // 같은 파일을 다시 선택할 수 있도록 input 값 초기화
- if (fileInputRef.current) {
- fileInputRef.current.value = '';
- }
- };
-
- // 이미지 버튼 클릭 핸들러
- const handleImageButtonClick = () => {
- if (!activeEditor) {
- // activeEditor가 없으면 기본 에디터에 포커스
- const defaultEditor = isOverview ? editorFeatures : editorGeneral;
- if (defaultEditor && !defaultEditor.isDestroyed) {
- defaultEditor.commands.focus();
- setActiveEditor(defaultEditor);
- }
- }
- fileInputRef.current?.click();
- };
-
- //-----------------------------------------------------------------------------------------
- //맞춤법검사
-
- const {
- openPanel,
- setLoading,
- setItems,
- reset: resetSpell,
- } = useSpellCheckStore();
+ // 맞춤법 검사
+ const { openPanel, setLoading, setItems, reset: resetSpell } = useSpellCheckStore();
const { mutate: spellcheck } = useSpellCheck();
const spellChecking = useSpellCheckStore((s) => s.loading);
const items = useSpellCheckStore((s) => s.items);
@@ -580,17 +303,15 @@ const WriteForm = ({
const editors = useMemo(
() =>
- (isOverview
- ? [editorFeatures, editorSkills, editorGoals]
- : [editorGeneral]
- ).filter((e): e is Editor => !!e && !e.isDestroyed),
+ (isOverview ? [editorFeatures, editorSkills, editorGoals] : [editorGeneral]).filter(
+ (e): e is Editor => !!e && !e.isDestroyed
+ ),
[isOverview, editorFeatures, editorSkills, editorGoals, editorGeneral]
);
const resetSpellVisuals = useCallback((edit: Editor[]) => {
const id = requestAnimationFrame(() => {
clearFixedCorrections(edit);
-
edit.forEach((ed) => clearSpellErrors(ed));
});
return () => cancelAnimationFrame(id);
@@ -611,15 +332,7 @@ const WriteForm = ({
skills: isOverview ? (editorSkills ?? null) : null,
goals: isOverview ? (editorGoals ?? null) : null,
});
- }, [
- number,
- isOverview,
- editorFeatures,
- editorGeneral,
- editorSkills,
- editorGoals,
- register,
- ]);
+ }, [number, isOverview, editorFeatures, editorGeneral, editorSkills, editorGoals, register]);
useEffect(() => {
resetSpell();
@@ -630,13 +343,9 @@ const WriteForm = ({
const handleSpellCheckClick = () => {
setGrammarActive((v) => !v);
openPanel();
-
- if (editors.length) {
- resetSpellVisuals(editors);
- }
+ if (editors.length) resetSpellVisuals(editors);
setLoading(true);
-
const payload = SpellPayload({
number,
title,
@@ -671,20 +380,14 @@ const WriteForm = ({
activeEditor={activeEditor}
editorItemName={isOverview ? editorItemName : undefined}
editorOneLineIntro={isOverview ? editorOneLineIntro : undefined}
- onImageClick={handleImageButtonClick}
+ defaultEditor={defaultEditor}
+ onActiveEditorChange={setActiveEditor}
onSpellCheckClick={handleSpellCheckClick}
grammarActive={grammarActive}
spellChecking={spellChecking}
isSaving={isSaving}
lastSavedTime={lastSavedTime}
/>
-
{/* 스크롤 가능한 콘텐츠 영역 */}
diff --git a/src/app/business/components/editor/WriteFormToolbar.tsx b/src/app/business/components/editor/WriteFormToolbar.tsx
index e50c546..c4d9191 100644
--- a/src/app/business/components/editor/WriteFormToolbar.tsx
+++ b/src/app/business/components/editor/WriteFormToolbar.tsx
@@ -14,12 +14,17 @@ import GrammerIcon from '@/assets/icons/write-icons/grammer.svg';
import GrammerActiveIcon from '@/assets/icons/write-icons/grammer-active.svg';
import TableGridSelector from './TableGridSelector';
import { useAuthStore } from '@/store/auth.store';
+import { uploadImage } from '@/lib/imageUpload';
+import { getImageDimensions, clampImageDimensions } from '@/lib/getImageDimensions';
+import { getSelectionAvailableWidth } from '@/lib/business/editor/getSelectionAvailableWidth';
+import { ImageCommandAttributes } from '@/types/business/business.type';
interface WriteFormToolbarProps {
activeEditor: Editor | null;
editorItemName?: Editor | null;
editorOneLineIntro?: Editor | null;
- onImageClick: () => void;
+ defaultEditor: Editor | null;
+ onActiveEditorChange: (editor: Editor | null) => void;
onSpellCheckClick: () => void;
grammarActive: boolean;
spellChecking: boolean;
@@ -31,7 +36,8 @@ const WriteFormToolbar = ({
activeEditor,
editorItemName,
editorOneLineIntro,
- onImageClick,
+ defaultEditor,
+ onActiveEditorChange,
onSpellCheckClick,
grammarActive,
spellChecking,
@@ -44,6 +50,54 @@ const WriteFormToolbar = ({
const [showTableGrid, setShowTableGrid] = useState(false);
const spellButtonDisabled = spellChecking || !isAuthenticated;
const tableButtonRef = useRef(null);
+ const fileInputRef = useRef(null);
+
+ // 이미지 업로드
+ const handleImageUpload = async (event: React.ChangeEvent) => {
+ const file = event.target.files?.[0];
+ if (!file || !activeEditor) return;
+
+ if (!file.type.startsWith('image/')) {
+ alert('이미지 파일만 업로드 가능합니다.');
+ return;
+ }
+
+ try {
+ const imageUrl = await uploadImage(file);
+ if (!imageUrl || !activeEditor) return;
+
+ const { width, height } = await getImageDimensions(imageUrl);
+ const selectionWidth = getSelectionAvailableWidth(activeEditor);
+ const editorDom = activeEditor.view.dom as HTMLElement | null;
+ const maxWidth = selectionWidth ?? (editorDom ? editorDom.clientWidth - 48 : undefined);
+ const { width: clampedWidth, height: clampedHeight } = clampImageDimensions(
+ width,
+ height,
+ maxWidth
+ );
+
+ activeEditor.chain().focus().setImage({
+ src: imageUrl,
+ width: clampedWidth ?? undefined,
+ height: clampedHeight ?? undefined,
+ } as ImageCommandAttributes).run();
+ } catch (error) {
+ console.error('이미지 업로드 실패:', error);
+ alert('이미지 업로드에 실패했습니다. 다시 시도해주세요.');
+ } finally {
+ if (fileInputRef.current) fileInputRef.current.value = '';
+ }
+ };
+
+ const handleImageButtonClick = () => {
+ if (!activeEditor) {
+ if (defaultEditor && !defaultEditor.isDestroyed) {
+ defaultEditor.commands.focus();
+ onActiveEditorChange(defaultEditor);
+ }
+ }
+ fileInputRef.current?.click();
+ };
const handleTableClick = () => {
setShowTableGrid(!showTableGrid);
@@ -284,9 +338,16 @@ const WriteFormToolbar = ({
}
- onClick={onImageClick}
+ onClick={handleImageButtonClick}
disabled={isSimpleEditor || !isAuthenticated}
/>
+