- {sectionNumber}
+
+
+
+ {sectionNumber}
+
-
+
{sectionTitle}
@@ -300,6 +437,17 @@ const Preview = () => {
const ITEM_MARGIN = 16; // mb-4 = 16px
currentPageHeight += SECTION_BOTTOM_MARGIN - ITEM_MARGIN; // ๋ง์ง๋ง ์์ดํ
์ mb-4๋ฅผ ์ ์ธํ๊ณ ์น์
์ฌ๋ฐฑ ์ถ๊ฐ
}
+
+ // ๊ฐ์ ์น์
(1๋ฒ)์ ๋ค์ ์น์
๊ณผ ํ์ด์ง๋ฅผ ๊ณต์ ํ์ง ์๋๋ก ๊ฐ์ ๋ถ๋ฆฌ
+ if (sectionNumber === 1 && currentPageContent.length > 0) {
+ newPages.push({
+ content: [...currentPageContent],
+ showHeader: isFirstPage,
+ });
+ currentPageContent = [];
+ currentPageHeight = 0;
+ isFirstPage = false;
+ }
});
// ๋ง์ง๋ง ํ์ด์ง ์ถ๊ฐ
@@ -333,8 +481,9 @@ const Preview = () => {
position: 'fixed',
top: '-9999px',
left: '-9999px',
- width: `${A4_WIDTH - 96}px`, // padding ์ ์ธ
- padding: '24px',
+ width: `${A4_WIDTH}px`,
+ padding: '24px 48px',
+ boxSizing: 'border-box',
visibility: 'hidden',
pointerEvents: 'none',
}}
diff --git a/src/app/business/components/WriteForm.tsx b/src/app/business/components/WriteForm.tsx
index d089a31..f92785c 100644
--- a/src/app/business/components/WriteForm.tsx
+++ b/src/app/business/components/WriteForm.tsx
@@ -1,34 +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 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, ImageCutPaste, ResizableImage, SelectTableOnBorderClick } 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',
@@ -39,385 +32,270 @@ const WriteForm = ({
title?: string;
subtitle?: string;
}) => {
- const editorFeatures = useEditor({
- extensions: [
- StarterKit,
- SpellError,
- DeleteTableOnDelete,
- ImageCutPaste,
- Highlight.configure({ multicolor: true }),
- TextStyle,
- Color,
- ResizableImage.configure({ inline: false }),
- Table.configure({ resizable: true }),
- TableRow,
- TableHeader,
- TableCell,
- SelectTableOnBorderClick,
- Placeholder.configure({
- placeholder:
- '์์ดํ
์ ํต์ฌ๊ธฐ๋ฅ์ ๋ฌด์์ด๋ฉฐ, ์ด๋ค ๊ธฐ๋ฅ์ ๊ตฌํยท์๋ ํ๋์ง ์ค๋ช
ํด์ฃผ์ธ์.',
- includeChildren: false,
- showOnlyWhenEditable: true,
- }),
- ],
- content: '
',
- editorProps: {
- handlePaste: createPasteHandler(),
- },
- });
+ const isOverview = number === '0';
- const editorSkills = useEditor({
- extensions: [
- StarterKit,
- SpellError,
- DeleteTableOnDelete,
- ImageCutPaste,
- Highlight.configure({ multicolor: true }),
- TextStyle,
- Color,
- ResizableImage.configure({ inline: false }),
- Table.configure({ resizable: true }),
- TableRow,
- TableHeader,
- TableCell,
- SelectTableOnBorderClick,
- Placeholder.configure({
- placeholder:
- '๋ณด์ ํ ๊ธฐ์ ๋ฐ ์ง์์ฌ์ฐ๊ถ์ด ๋ณ๋๋ก ์์ ๊ฒฝ์ฐ, ์์ดํ
์ ํ์ํ ํต์ฌ๊ธฐ์ ์ ์ด๋ป๊ฒ ๊ฐ๋ฐํด ๋๊ฐ๊ฒ์ธ์ง ๊ณํ์ ๋ํด ์์ฑํด์ฃผ์ธ์. \n โป ์ง์์ฌ์ฐ๊ถ: ํนํ, ์ํ๊ถ, ๋์์ธ, ์ค์ฉ์ ์๊ถ ๋ฑ.',
- includeChildren: false,
- showOnlyWhenEditable: true,
- }),
- ],
- content: '
',
- editorProps: {
- handlePaste: createPasteHandler(),
- },
- });
+ // ์๋ํฐ ์ธ์คํด์ค ์์ฑ
+ 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 editorGoals = useEditor({
- extensions: [
- StarterKit,
- SpellError,
- DeleteTableOnDelete,
- ImageCutPaste,
- Highlight.configure({ multicolor: true }),
- TextStyle,
- Color,
- ResizableImage.configure({ inline: false }),
- Table.configure({ resizable: true }),
- TableRow,
- TableHeader,
- TableCell,
- SelectTableOnBorderClick,
- Placeholder.configure({
- placeholder: '๋ณธ ์ฌ์
์ ํตํด ๋ฌ์ฑํ๊ณ ์ถ์ ๊ถ๊ทน์ ์ธ ๋ชฉํ์ ๋ํด ์ค๋ช
',
- includeChildren: false,
- showOnlyWhenEditable: true,
- }),
- ],
- content: '
',
- editorProps: {
- handlePaste: createPasteHandler(),
- },
- });
- const { updateItemContent, getItemContent, lastSavedTime, isSaving, saveAllItems, planId } = useBusinessStore();
- // ํ์ฌ ์น์
์ contents๋ง ๊ตฌ๋
ํ์ฌ ๋ณ๊ฒฝ ๊ฐ์ง
+ const defaultEditor = useMemo(
+ () => (isOverview ? editorFeatures : editorGeneral),
+ [isOverview, editorFeatures, editorGeneral]
+ );
+
+ const {
+ updateItemContent,
+ getItemContent,
+ lastSavedTime,
+ isSaving,
+ saveSingleItem,
+ planId,
+ } = useBusinessStore();
const currentContent = useBusinessStore((state) => state.contents[number]);
- const [activeEditor, setActiveEditor] = useState<
- typeof editorFeatures | null
- >(null);
+ const [activeEditor, setActiveEditor] = useState
(null);
const [grammarActive, setGrammarActive] = useState(false);
- const fileInputRef = useRef(null);
- const isOverview = number === '0';
-
- // store์์ ์ ์ฅ๋ ๋ด์ฉ ๋ถ๋ฌ์ค๊ธฐ
- const savedContent = getItemContent(number);
- const [itemName, setItemName] = useState(savedContent.itemName || '');
- const [oneLineIntro, setOneLineIntro] = useState(
- savedContent.oneLineIntro || ''
- );
-
- // ์๋ํฐ ๋ด์ฉ ๋ณต์ ํฌํผ ํจ์
- const restoreEditorContent = useCallback((editor: Editor | null, content: JSONContent | null | undefined) => {
- if (!editor || editor.isDestroyed) return;
- try {
- if (content) {
- editor.commands.setContent(content);
- } else {
- // content๊ฐ ์์ผ๋ฉด ์๋ํฐ๋ฅผ ๋น ์ํ๋ก ์ด๊ธฐํ
- editor.commands.clearContent();
+ 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;
+ try {
+ if (content) {
+ const currentJSON = editor.getJSON();
+ const nextJSON = JSON.parse(JSON.stringify(content));
+ if (JSON.stringify(currentJSON) === JSON.stringify(nextJSON)) return;
+ editor.commands.setContent(nextJSON, false);
+ } else {
+ const currentJSON = editor.getJSON();
+ 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 (!isEmpty) {
+ editor.commands.clearContent(false);
+ }
+ }
+ } catch (e) {
+ console.error('์๋ํฐ ๋ด์ฉ ๋ณต์ ์คํจ:', e);
}
+ },
+ []
+ );
- // setTimeout(() => {
- // if (!editor || editor.isDestroyed) return;
-
- // const { doc } = editor.state;
- // if (doc.content.size === 0) return;
-
- // // ๋ง์ง๋ง ๋
ธ๋ ํ์ธ
- // const lastNode = doc.lastChild;
- // if (lastNode && lastNode.type.name === 'table') {
- // // ํ๊ฐ ๋ง์ง๋ง์ด๋ฉด ํ ์๋์ ๋น ๋ฌธ๋จ ์ถ๊ฐ
- // const endPos = doc.content.size;
- // editor.commands.insertContentAt(
- // endPos,
- // { type: 'paragraph' },
- // { updateSelection: false }
- // );
- // }
- // }, 0);
- } catch (e) {
- console.error('์๋ํฐ ๋ด์ฉ ๋ณต์ ์คํจ:', e);
- }
- }, []);
-
- // number๊ฐ ๋ณ๊ฒฝ๋๊ฑฐ๋ contents๊ฐ ์
๋ฐ์ดํธ๋ ๋ store์์ ๋ด์ฉ ๋ถ๋ฌ์ค๊ธฐ
useEffect(() => {
- if (!editorFeatures) return;
-
const content = getItemContent(number);
- setItemName(content.itemName || '');
- setOneLineIntro(content.oneLineIntro || '');
-
- // ์๋ํฐ ๋ด์ฉ ๋ณต์
if (isOverview) {
+ restoreEditorContent(
+ editorItemName,
+ content.itemName ? (typeof content.itemName === 'string' ? convertStringToJSONContent(content.itemName) : content.itemName) : null
+ );
+ restoreEditorContent(
+ editorOneLineIntro,
+ 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 {
- restoreEditorContent(editorFeatures, content.editorContent);
+ restoreEditorContent(editorGeneral, content.editorContent);
}
- }, [number, editorFeatures, editorSkills, editorGoals, currentContent, getItemContent, isOverview, restoreEditorContent]);
+ }, [
+ number,
+ isOverview,
+ editorFeatures,
+ editorGeneral,
+ editorSkills,
+ editorGoals,
+ editorItemName,
+ editorOneLineIntro,
+ 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]);
-
- // ์๋ํฐ ์
๋ฐ์ดํธ ํธ๋ค๋ฌ ์์ฑ
- 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,
- };
- };
+ }, [planId, number, saveSingleItem]);
- const mainSelection = saveSelection(editorFeatures);
- const skillsSelection = saveSelection(editorSkills);
- const goalsSelection = saveSelection(editorGoals);
-
- // store์ ์ฆ์ ์ ์ฅ (๋ฉ๋ชจ๋ฆฌ ์์
์ด๋ฏ๋ก ๋๋ฐ์ด์ค ๋ถํ์)
- if (isOverview) {
- updateItemContent(number, {
- itemName,
- oneLineIntro,
- editorFeatures: editorFeatures?.getJSON() || null,
- editorSkills: editorSkills?.getJSON() || null,
- editorGoals: editorGoals?.getJSON() || null,
- });
- } else {
- updateItemContent(number, {
- editorContent: editorFeatures?.getJSON() || null,
- });
- }
-
- // ์ปค์ ์์น ๋ณต์ ๋ก์ง
- requestAnimationFrame(() => {
- const currentActiveEditor = activeEditor || editorFeatures;
- if (currentActiveEditor && !currentActiveEditor.isDestroyed) {
- let selectionToRestore = null;
- if (currentActiveEditor === editorFeatures && mainSelection) {
- selectionToRestore = mainSelection;
- } else if (currentActiveEditor === editorSkills && skillsSelection) {
- selectionToRestore = skillsSelection;
- } else if (currentActiveEditor === editorGoals && goalsSelection) {
- selectionToRestore = goalsSelection;
- }
- if (selectionToRestore) {
- try {
- currentActiveEditor
- .chain()
- .focus()
- .setTextSelection({ from: selectionToRestore.from, to: selectionToRestore.to })
- .run();
- } catch (e) {
- // ์ปค์ ์์น ๋ณต์ ์คํจ ์ ๋ฌด์
- }
- }
- }
- });
+ const saveSelection = useCallback((editor: Editor | null) => {
+ if (!editor || editor.isDestroyed) return null;
+ return { from: editor.state.selection.from, to: editor.state.selection.to };
+ }, []);
- if (timeoutRef.current) {
- clearTimeout(timeoutRef.current);
+ const restoreSelection = useCallback(
+ (editor: Editor, selection: { from: number; to: number }) => {
+ try {
+ editor.chain().focus().setTextSelection(selection).run();
+ } catch (e) {
+ console.error('์ปค์ ์์น ๋ณต์ ์คํจ:', e);
}
- timeoutRef.current = setTimeout(() => {
- debouncedSave();
- }, 300);
- };
- }, [isOverview, number, itemName, oneLineIntro, editorFeatures, editorSkills, editorGoals, updateItemContent, debouncedSave, activeEditor]);
-
- // ์๋ํฐ์ onChange ์ด๋ฒคํธ ๋ฆฌ์ค๋ ์ถ๊ฐ (store๋ ์ฆ์ ์ ์ฅ, API๋ง ๋๋ฐ์ด์ค)
- const mainTimeoutRef = useRef(null);
- const skillsTimeoutRef = useRef(null);
- const goalsTimeoutRef = useRef(null);
-
- useEffect(() => {
- if (!editorFeatures) return;
-
- const handleMainUpdate = createUpdateHandler(mainTimeoutRef);
- editorFeatures.on('update', handleMainUpdate);
-
- const cleanup: (() => void)[] = [() => {
- if (mainTimeoutRef.current) clearTimeout(mainTimeoutRef.current);
- editorFeatures.off('update', handleMainUpdate);
- }];
+ },
+ []
+ );
+ const saveEditorContentToStore = useCallback(() => {
+ const primaryEditor = isOverview ? editorFeatures : editorGeneral;
if (isOverview) {
- if (editorSkills) {
- const handleSkillsUpdate = createUpdateHandler(skillsTimeoutRef);
- editorSkills.on('update', handleSkillsUpdate);
- cleanup.push(() => {
- if (skillsTimeoutRef.current) clearTimeout(skillsTimeoutRef.current);
- editorSkills.off('update', handleSkillsUpdate);
- });
- }
-
- if (editorGoals) {
- const handleGoalsUpdate = createUpdateHandler(goalsTimeoutRef);
- editorGoals.on('update', handleGoalsUpdate);
- cleanup.push(() => {
- if (goalsTimeoutRef.current) clearTimeout(goalsTimeoutRef.current);
- editorGoals.off('update', handleGoalsUpdate);
- });
- }
+ 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,
+ });
}
-
- return () => {
- cleanup.forEach(fn => fn());
- };
}, [
+ isOverview,
+ number,
+ editorItemName,
+ editorOneLineIntro,
editorFeatures,
+ editorGeneral,
editorSkills,
editorGoals,
- planId,
- isOverview,
- createUpdateHandler,
+ updateItemContent,
]);
- // TextInput ๊ฐ ๋ณ๊ฒฝ ์ store์ ์ฆ์ ์ ์ฅ, API ์ ์ฅ๋ง ๋๋ฐ์ด์ค ์ ์ฉ
- const textInputTimeoutRef = useRef(null);
-
- const handleTextInputChange = useCallback((field: 'itemName' | 'oneLineIntro', value: string) => {
- // store์ ์ฆ์ ์ ์ฅ (๋ฉ๋ชจ๋ฆฌ ์์
์ด๋ฏ๋ก ๋๋ฐ์ด์ค ๋ถํ์)
- if (field === 'itemName') {
- setItemName(value);
- updateItemContent(number, { itemName: value });
- } else {
- setOneLineIntro(value);
- updateItemContent(number, { oneLineIntro: value });
- }
-
- // API ์ ์ฅ๋ง ๋๋ฐ์ด์ค ์ ์ฉ (๋คํธ์ํฌ ์์ฒญ์ด๋ฏ๋ก)
- if (textInputTimeoutRef.current) {
- clearTimeout(textInputTimeoutRef.current);
- }
- textInputTimeoutRef.current = setTimeout(() => {
- debouncedSave();
- }, 500);
- }, [number, updateItemContent, debouncedSave]);
-
- const handleItemNameChange = useCallback((value: string) => {
- handleTextInputChange('itemName', value);
- }, [handleTextInputChange]);
-
- const handleOneLineIntroChange = useCallback((value: string) => {
- handleTextInputChange('oneLineIntro', value);
- }, [handleTextInputChange]);
-
- // ์ด๋ฏธ์ง ํ์ผ ์ ํ ํธ๋ค๋ฌ
- const handleImageUpload = async (
- event: React.ChangeEvent
- ) => {
- const file = event.target.files?.[0];
- if (!file || !activeEditor) return;
-
- // ์ด๋ฏธ์ง ํ์ผ๋ง ํ์ฉ
- if (!file.type.startsWith('image/')) {
- alert('์ด๋ฏธ์ง ํ์ผ๋ง ์
๋ก๋ ๊ฐ๋ฅํฉ๋๋ค.');
- return;
- }
+ const createUpdateHandler = useCallback(
+ (timeoutRef: React.MutableRefObject) => {
+ return () => {
+ const primaryEditor = isOverview ? editorFeatures : editorGeneral;
+ const selections = {
+ main: saveSelection(primaryEditor),
+ skills: saveSelection(editorSkills),
+ goals: saveSelection(editorGoals),
+ itemName: saveSelection(editorItemName),
+ oneLineIntro: saveSelection(editorOneLineIntro),
+ };
- // ํ์ผ ํฌ๊ธฐ ์ ํ (์: 5MB)
- // const maxSize = 5 * 1024 * 1024; // 5MB
- // if (file.size > maxSize) {
- // alert('์ด๋ฏธ์ง ํฌ๊ธฐ๋ 5MB ์ดํ์ฌ์ผ ํฉ๋๋ค.');
- // return;
- // }
+ saveEditorContentToStore();
+
+ requestAnimationFrame(() => {
+ 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;
+ }
- try {
- // ์๋ฒ์ ์ด๋ฏธ์ง ์
๋ก๋ ๋ฐ ๊ณต๊ฐ URL ๋ฐ๊ธฐ
- const imageUrl = await uploadImage(file);
-
- if (imageUrl && activeEditor) {
- const { width, height } = await getImageDimensions(imageUrl);
- const editorDom = activeEditor.view.dom as HTMLElement | null;
- const maxWidth = editorDom ? editorDom.clientWidth - 48 : undefined;
- const { width: clampedWidth, height: clampedHeight } = clampImageDimensions(width, height, maxWidth);
- const imageAttributes: ImageCommandAttributes = {
- src: imageUrl,
- width: clampedWidth ?? undefined,
- height: clampedHeight ?? undefined,
- };
+ if (selection) restoreSelection(currentEditor, selection);
+ });
- activeEditor
- .chain()
- .focus()
- .setImage(imageAttributes)
- .run();
- }
- } catch (error) {
- console.error('์ด๋ฏธ์ง ์
๋ก๋ ์คํจ:', error);
- alert('์ด๋ฏธ์ง ์
๋ก๋์ ์คํจํ์ต๋๋ค. ๋ค์ ์๋ํด์ฃผ์ธ์.');
- }
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ timeoutRef.current = setTimeout(debouncedSave, 300);
+ };
+ },
+ [
+ isOverview,
+ editorFeatures,
+ editorGeneral,
+ editorSkills,
+ editorGoals,
+ editorItemName,
+ editorOneLineIntro,
+ saveEditorContentToStore,
+ activeEditor,
+ saveSelection,
+ restoreSelection,
+ debouncedSave,
+ ]
+ );
- // ๊ฐ์ ํ์ผ์ ๋ค์ ์ ํํ ์ ์๋๋ก input ๊ฐ ์ด๊ธฐํ
- if (fileInputRef.current) {
- fileInputRef.current.value = '';
- }
- };
+ 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,
+ });
- // ์ด๋ฏธ์ง ๋ฒํผ ํด๋ฆญ ํธ๋ค๋ฌ
- const handleImageButtonClick = () => {
- if (!activeEditor) {
- // activeEditor๊ฐ ์์ผ๋ฉด ๊ธฐ๋ณธ ์๋ํฐ์ ํฌ์ปค์ค
- if (editorFeatures && !editorFeatures.isDestroyed) {
- editorFeatures.commands.focus();
- setActiveEditor(editorFeatures);
- }
- }
- fileInputRef.current?.click();
- };
+ 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]
+ );
- //-----------------------------------------------------------------------------------------
- //๋ง์ถค๋ฒ๊ฒ์ฌ
+ 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,
+ overviewEditors,
+ registerEditorListener,
+ ]);
- 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);
@@ -425,17 +303,15 @@ const WriteForm = ({
const editors = useMemo(
() =>
- (isOverview
- ? [editorFeatures, editorSkills, editorGoals]
- : [editorFeatures]
- ).filter((e): e is Editor => !!e && !e.isDestroyed),
- [isOverview, editorFeatures, editorSkills, editorGoals]
+ (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);
@@ -452,11 +328,11 @@ const WriteForm = ({
useEffect(() => {
register({
sectionNumber: number,
- features: editorFeatures ?? null,
+ features: (isOverview ? editorFeatures : editorGeneral) ?? null,
skills: isOverview ? (editorSkills ?? null) : null,
goals: isOverview ? (editorGoals ?? null) : null,
});
- }, [number, isOverview, editorFeatures, editorSkills, editorGoals, register]);
+ }, [number, isOverview, editorFeatures, editorGeneral, editorSkills, editorGoals, register]);
useEffect(() => {
resetSpell();
@@ -467,21 +343,17 @@ 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,
- itemName,
- oneLineIntro,
- editorFeatures,
- editorSkills,
- editorGoals,
+ itemName: editorItemName?.getText() || '',
+ oneLineIntro: editorOneLineIntro?.getText() || '',
+ editorFeatures: (isOverview ? editorFeatures : editorGeneral) ?? null,
+ editorSkills: isOverview ? editorSkills : null,
+ editorGoals: isOverview ? editorGoals : null,
});
spellcheck(payload, {
@@ -499,41 +371,38 @@ const WriteForm = ({
};
return (
-
+
-
{/* ์คํฌ๋กค ๊ฐ๋ฅํ ์ฝํ
์ธ ์์ญ */}
{isOverview ? (
) : (
)}
diff --git a/src/app/business/components/editor/GeneralSection.tsx b/src/app/business/components/editor/GeneralSection.tsx
index 181d559..613f101 100644
--- a/src/app/business/components/editor/GeneralSection.tsx
+++ b/src/app/business/components/editor/GeneralSection.tsx
@@ -10,7 +10,7 @@ interface GeneralSectionProps {
const GeneralSection = ({ editor, onEditorFocus }: GeneralSectionProps) => {
return (
{
if (editor && !editor.isDestroyed) {
editor.commands.focus();
diff --git a/src/app/business/components/editor/OverviewSection.tsx b/src/app/business/components/editor/OverviewSection.tsx
index 8b29a6a..b82d029 100644
--- a/src/app/business/components/editor/OverviewSection.tsx
+++ b/src/app/business/components/editor/OverviewSection.tsx
@@ -1,27 +1,22 @@
import { Editor } from '@tiptap/core';
import { EditorContent } from '@tiptap/react';
-import TextInput from './TextInput';
import TableToolbar from './TableToolbar';
interface OverviewSectionProps {
- itemName: string;
- oneLineIntro: string;
+ editorItemName: Editor | null;
+ editorOneLineIntro: Editor | null;
editorFeatures: Editor | null;
editorSkills: Editor | null;
editorGoals: Editor | null;
- onItemNameChange: (value: string) => void;
- onOneLineIntroChange: (value: string) => void;
onEditorFocus: (editor: Editor) => void;
}
const OverviewSection = ({
- itemName,
- oneLineIntro,
+ editorItemName,
+ editorOneLineIntro,
editorFeatures,
editorSkills,
editorGoals,
- onItemNameChange,
- onOneLineIntroChange,
onEditorFocus,
}: OverviewSectionProps) => {
return (
@@ -30,22 +25,48 @@ const OverviewSection = ({
-
+
{
+ if (editorItemName && !editorItemName.isDestroyed) {
+ editorItemName.commands.focus();
+ onEditorFocus(editorItemName);
+ }
+ }}
+ >
+ {editorItemName && (
+ onEditorFocus(editorItemName)}
+ className="prose max-w-none cursor-text placeholder:text-gray-400 focus:outline-none"
+ placeholder="๋ต๋ณ์ ์
๋ ฅํ์ธ์."
+ />
+ )}
+
-
+
{
+ if (editorOneLineIntro && !editorOneLineIntro.isDestroyed) {
+ editorOneLineIntro.commands.focus();
+ onEditorFocus(editorOneLineIntro);
+ }
+ }}
+ >
+ {editorOneLineIntro && (
+ onEditorFocus(editorOneLineIntro)}
+ className="prose max-w-none cursor-text placeholder:text-gray-400 focus:outline-none"
+ placeholder="๋ต๋ณ์ ์
๋ ฅํ์ธ์."
+ />
+ )}
+
diff --git a/src/app/business/components/editor/ToolButton.tsx b/src/app/business/components/editor/ToolButton.tsx
index 3c2cbf2..2a1acd3 100644
--- a/src/app/business/components/editor/ToolButton.tsx
+++ b/src/app/business/components/editor/ToolButton.tsx
@@ -1,21 +1,28 @@
import { forwardRef } from 'react';
-const ToolButton = forwardRef
void;
active?: boolean;
label: React.ReactNode;
-}>(({ onClick, active, label }, ref) => {
+ disabled?: boolean;
+}
+
+const ToolButton = forwardRef(({ onClick, active, label, disabled }, ref) => {
return (
diff --git a/src/app/business/components/editor/WriteFormToolbar.tsx b/src/app/business/components/editor/WriteFormToolbar.tsx
index cac5230..c4d9191 100644
--- a/src/app/business/components/editor/WriteFormToolbar.tsx
+++ b/src/app/business/components/editor/WriteFormToolbar.tsx
@@ -1,4 +1,4 @@
-import { Editor } from '@tiptap/core';
+import { Editor, JSONContent } from '@tiptap/core';
import { useState, useRef } from 'react';
import ToolButton from './ToolButton';
import BoldIcon from '@/assets/icons/write-icons/bold.svg';
@@ -13,10 +13,18 @@ import Heading3Icon from '@/assets/icons/write-icons/heading3.svg';
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;
- onImageClick: () => void;
+ editorItemName?: Editor | null;
+ editorOneLineIntro?: Editor | null;
+ defaultEditor: Editor | null;
+ onActiveEditorChange: (editor: Editor | null) => void;
onSpellCheckClick: () => void;
grammarActive: boolean;
spellChecking: boolean;
@@ -26,20 +34,178 @@ interface WriteFormToolbarProps {
const WriteFormToolbar = ({
activeEditor,
- onImageClick,
+ editorItemName,
+ editorOneLineIntro,
+ defaultEditor,
+ onActiveEditorChange,
onSpellCheckClick,
grammarActive,
spellChecking,
isSaving,
lastSavedTime,
}: WriteFormToolbarProps) => {
+ // ์์ดํ
๋ช
๋๋ ํ์ค์๊ฐ ์๋ํฐ์ธ์ง ํ์ธ
+ const isSimpleEditor = activeEditor === editorItemName || activeEditor === editorOneLineIntro;
+ const { isAuthenticated } = useAuthStore();
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);
};
+ const handleHeading = (level: 1 | 2 | 3) => {
+ if (!activeEditor) return;
+ const { state } = activeEditor;
+ const { from, to, empty } = state.selection;
+
+ // ํ
์คํธ๊ฐ ์ ํ๋์ด ์์ผ๋ฉด ์ ํ ๋ถ๋ถ๋ง ํค๋ฉ์ผ๋ก ๋ณํ
+ if (!empty && from !== to) {
+ const $from = state.selection.$from;
+
+ // ์ ํํ ํ
์คํธ ์ถ์ถ
+ const selectedText = state.doc.textBetween(from, to);
+ if (!selectedText.trim()) return;
+
+ // ๋ฌธ๋จ ์์น ์ฐพ๊ธฐ
+ let paragraphStart = from;
+ let paragraphBeforePos = -1;
+
+ for (let d = $from.depth; d > 0; d--) {
+ const node = $from.node(d);
+ if (node.type.name === 'paragraph') {
+ paragraphBeforePos = $from.before(d);
+ paragraphStart = $from.start(d);
+ break;
+ }
+ }
+
+ if (paragraphBeforePos === -1) {
+ activeEditor.chain().focus().toggleHeading({ level }).run();
+ return;
+ }
+
+ // ์ ํํ ๋ฒ์์ ์ฝํ
์ธ ์ถ์ถ (๋งํฌ ํฌํจ)
+ const selectedSlice = state.doc.slice(from, to);
+ const selectedContentJSON = selectedSlice.content.toJSON();
+
+ // ๋ฌธ๋จ์ ๋ ์์น ์ฐพ๊ธฐ
+ const $to = state.selection.$to;
+ let paragraphEnd = to;
+ for (let d = $to.depth; d > 0; d--) {
+ const node = $to.node(d);
+ if (node.type.name === 'paragraph') {
+ paragraphEnd = $to.start(d) + node.nodeSize - 2;
+ break;
+ }
+ }
+
+ // ์๋ค ์ฝํ
์ธ ์ถ์ถ (๋งํฌ ํฌํจ)
+ const beforeSlice = state.doc.slice(paragraphStart, from);
+ const afterSlice = state.doc.slice(to, paragraphEnd);
+ const beforeContentJSON = beforeSlice.content.toJSON();
+ const afterContentJSON = afterSlice.content.toJSON();
+
+ // ์๋ค ํ
์คํธ ํ์ธ
+ const beforeText = state.doc.textBetween(paragraphStart, from);
+ const afterText = state.doc.textBetween(to, paragraphEnd);
+
+ // ์ ์ฝํ
์ธ ๊ตฌ์ฑ
+ const newContent: JSONContent[] = [];
+
+ // ์๋ถ๋ถ ๋ฌธ๋จ (ํ
์คํธ๊ฐ ์์ ๋๋ง)
+ if (beforeText.trim()) {
+ newContent.push({
+ type: 'paragraph',
+ content: Array.isArray(beforeContentJSON) && beforeContentJSON.length > 0
+ ? beforeContentJSON
+ : [{ type: 'text', text: beforeText }],
+ });
+ }
+
+ // ํค๋ฉ
+ newContent.push({
+ type: 'heading',
+ attrs: { level },
+ content: Array.isArray(selectedContentJSON) && selectedContentJSON.length > 0
+ ? selectedContentJSON
+ : [{ type: 'text', text: selectedText }],
+ });
+
+ // ๋ท๋ถ๋ถ ๋ฌธ๋จ (ํ
์คํธ๊ฐ ์์ ๋๋ง)
+ if (afterText.trim()) {
+ newContent.push({
+ type: 'paragraph',
+ content: Array.isArray(afterContentJSON) && afterContentJSON.length > 0
+ ? afterContentJSON
+ : [{ type: 'text', text: afterText }],
+ });
+ }
+
+ // ์ ์ฒด ๋ฌธ๋จ ์ญ์ ํ ์ ์ฝํ
์ธ ๋ฅผ ํ ๋ฒ์ ์ฝ์
+ if (newContent.length > 0) {
+ activeEditor
+ .chain()
+ .focus()
+ .deleteRange({ from: paragraphStart, to: paragraphEnd })
+ .insertContentAt(paragraphBeforePos, newContent)
+ .run();
+ }
+ } else {
+ // ํ
์คํธ๊ฐ ์ ํ๋์ง ์์์ผ๋ฉด ๊ธฐ๋ณธ ๋์
+ activeEditor.chain().focus().toggleHeading({ level }).run();
+ }
+ };
+
const handleTableSelect = (rows: number, cols: number) => {
if (!activeEditor) return;
const { state } = activeEditor;
@@ -139,17 +305,20 @@ const WriteFormToolbar = ({
}
active={!!activeEditor?.isActive('heading', { level: 1 })}
- onClick={() => activeEditor?.chain().focus().toggleHeading({ level: 1 }).run()}
+ onClick={() => handleHeading(1)}
+ disabled={isSimpleEditor}
/>
}
active={!!activeEditor?.isActive('heading', { level: 2 })}
- onClick={() => activeEditor?.chain().focus().toggleHeading({ level: 2 }).run()}
+ onClick={() => handleHeading(2)}
+ disabled={isSimpleEditor}
/>
}
active={!!activeEditor?.isActive('heading', { level: 3 })}
- onClick={() => activeEditor?.chain().focus().toggleHeading({ level: 3 }).run()}
+ onClick={() => handleHeading(3)}
+ disabled={isSimpleEditor}
/>
@@ -157,8 +326,9 @@ const WriteFormToolbar = ({
ref={tableButtonRef}
label={
}
onClick={handleTableClick}
+ disabled={isSimpleEditor}
/>
- {showTableGrid && (
+ {showTableGrid && !isSimpleEditor && (
setShowTableGrid(false)}
@@ -166,13 +336,25 @@ const WriteFormToolbar = ({
/>
)}
- } onClick={onImageClick} />
+ }
+ onClick={handleImageButtonClick}
+ disabled={isSimpleEditor || !isAuthenticated}
+ />
+