Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
659 changes: 181 additions & 478 deletions src/app/business/components/WriteForm.tsx

Large diffs are not rendered by default.

67 changes: 64 additions & 3 deletions src/app/business/components/editor/WriteFormToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,7 +36,8 @@ const WriteFormToolbar = ({
activeEditor,
editorItemName,
editorOneLineIntro,
onImageClick,
defaultEditor,
onActiveEditorChange,
onSpellCheckClick,
grammarActive,
spellChecking,
Expand All @@ -44,6 +50,54 @@ const WriteFormToolbar = ({
const [showTableGrid, setShowTableGrid] = useState(false);
const spellButtonDisabled = spellChecking || !isAuthenticated;
const tableButtonRef = useRef<HTMLButtonElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);

// 이미지 업로드
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
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);
Expand Down Expand Up @@ -284,9 +338,16 @@ const WriteFormToolbar = ({
</div>
<ToolButton
label={<ImageIcon />}
onClick={onImageClick}
onClick={handleImageButtonClick}
disabled={isSimpleEditor || !isAuthenticated}
/>
<input
type="file"
ref={fileInputRef}
accept="image/*"
onChange={handleImageUpload}
className="hidden"
/>
<button
type="button"
onClick={onSpellCheckClick}
Expand Down
190 changes: 190 additions & 0 deletions src/lib/business/editor/editorConstants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
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 type { Editor } from '@tiptap/core';
import type { EditorView } from '@tiptap/pm/view';
import SpellError from '@/util/spellError';
import {
DeleteTableOnDelete,
ResizableImage,
SelectTableOnBorderClick,
EnsureTrailingParagraph,
} from './extensions';
import { uploadImage } from '@/lib/imageUpload';
import { getImageDimensions, clampImageDimensions } from '@/lib/getImageDimensions';
import { ImageCommandAttributes } from '@/types/business/business.type';
import { getSelectionAvailableWidth } from './getSelectionAvailableWidth';

// 공통 에디터 확장 (표, 이미지, 하이라이트 등 모든 기능 포함)
export const COMMON_EXTENSIONS = [
StarterKit,
SpellError,
DeleteTableOnDelete,
Highlight.configure({ multicolor: true }),
TextStyle,
Color,
ResizableImage.configure({ inline: false }),
Table.configure({ resizable: true }),
TableRow,
TableHeader,
TableCell,
SelectTableOnBorderClick,
EnsureTrailingParagraph,
];

// 간단한 에디터 확장 (하이라이트, 볼드, 색상만 가능, 헤딩/표/이미지 비활성화)
export const SIMPLE_EXTENSIONS = [
StarterKit.configure({
heading: false,
blockquote: false,
codeBlock: false,
horizontalRule: false,
hardBreak: false,
}),
SpellError,
Highlight.configure({ multicolor: true }),
TextStyle,
Color,
];

// 이미지 붙여넣기 핸들러 생성
const createPasteHandler = () => {
return (view: EditorView, event: ClipboardEvent) => {
const items = Array.from(event.clipboardData?.items || []);
const imageItem = items.find(
(item) => item.type.indexOf('image') !== -1
);

if (imageItem) {
event.preventDefault();
const file = imageItem.getAsFile();
if (file) {
// 파일 크기 제한 (5MB)
// const maxSize = 5 * 1024 * 1024;
// if (file.size > maxSize) {
// alert('이미지 크기는 5MB 이하여야 합니다.');
// return true;
// }

// 비동기로 업로드 처리
uploadImage(file)
.then(async (imageUrl) => {
if (imageUrl) {
const editor = (view as EditorView & { editor?: Editor }).editor;
const { width, height } = await getImageDimensions(imageUrl);
const selectionWidth = getSelectionAvailableWidth(editor ?? null);
const fallbackWidth = view.dom?.clientWidth ? view.dom.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,
};
editor
?.chain()
.focus()
.setImage(imageAttributes)
.run();
}
Comment on lines +77 to +95
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the actual file and surrounding context
wc -l src/lib/business/editor/editorConstants.ts

Repository: StartUpLight/STARLIGHT_FE

Length of output: 115


🏁 Script executed:

# Get the specific section mentioned in the review
sed -n '70,100p' src/lib/business/editor/editorConstants.ts

Repository: StartUpLight/STARLIGHT_FE

Length of output: 1736


🏁 Script executed:

# Search for how editor is being passed/accessed elsewhere
rg "handlePaste|EditorView" src/lib/business/editor/ -A 5 -B 5

Repository: StartUpLight/STARLIGHT_FE

Length of output: 8153


🌐 Web query:

TipTap EditorView handlePaste access editor instance recommended approach

💡 Result:

Short answer — don’t try to reach into Tiptap’s Editor from a raw ProseMirror handlePaste; use Tiptap’s APIs instead (paste rules, extension plugins or editor events). Recommended options:

  • For content transforms (text/HTML → nodes/marks): implement addPasteRules() in a Node/Mark/Extension. It’s the high‑level Tiptap way to intercept and transform pasted content. [1]

  • For more complex/custom paste logic (need access to view/state/dispatch): add a ProseMirror plugin from an extension via addProseMirrorPlugins() and implement handlePaste(view, event, slice) there. You receive the ProseMirror EditorView and Slice (ProseMirror API) to dispatch transactions. Use this only when pasteRules aren’t enough. (ProseMirror handlePaste signature). [2][3]

  • For file/image pastes: use the official @tiptap/extension-file-handler (onPaste / onDrop) which supplies the Tiptap editor instance and pasted files — convenient and recommended for uploads. [4]

  • Alternatively, for global behavior: register an editor-level "paste" event when creating the Editor (editor.on('paste', ...)) or use the EditorContext/hooks in React integrations to access the editor instance. [1][5]

References

  1. Tiptap — Paste rules (addPasteRules) and extension API. [1]
  2. ProseMirror — handlePaste(view, event, slice) (signature & behavior). [2]
  3. Tiptap — ProseMirror integration / core concepts (how to add plugins). [3]
  4. @tiptap/extension-file-handler — onPaste/onDrop that pass the Tiptap editor and files. [4]
  5. Tiptap — Editor events (paste) and React EditorContext usage. [5]

If you want, tell me which exact paste behavior you need and I’ll show the minimal addPasteRules or addProseMirrorPlugins example.


🏁 Script executed:

# Check if @tiptap/extension-file-handler is available
rg "extension-file-handler|file-handler" src/

Repository: StartUpLight/STARLIGHT_FE

Length of output: 51


🏁 Script executed:

# Check package.json for available TipTap extensions
cat package.json | grep -A 30 "dependencies"

Repository: StartUpLight/STARLIGHT_FE

Length of output: 1081


ProseMirror 플러그인을 통해 에디터 인스턴스에 안전하게 접근하세요.

(view as EditorView & { editor?: Editor }).editor는 TipTap의 공식 API가 아닌 내부 구현에 의존합니다. TipTap 버전 업그레이드 시 동작이 변경될 수 있습니다.

권장 방안:

  • addProseMirrorPlugins(): Extension에서 ProseMirror 플러그인으로 handlePaste를 구현하면 view/state/dispatch에 접근 가능합니다.
  • 클로저 패턴: handlePaste를 생성할 때 에디터 인스턴스를 매개변수로 전달받아 클로저로 캡슐화합니다.
  • 에디터 레벨 이벤트: editor.on('paste', ...) 또는 React 통합 시 EditorContext를 통해 에디터에 접근합니다.

현재 패턴을 위의 공식 방식으로 리팩토링하는 것을 권장합니다.

🤖 Prompt for AI Agents
In src/lib/business/editor/editorConstants.ts around lines 77 to 95, the code
directly casts view to access a non-public editor instance ((view as EditorView
& { editor?: Editor }).editor) which relies on TipTap internals and is brittle
across upgrades; replace this pattern by exposing the editor through a supported
mechanism: implement the paste handler inside addProseMirrorPlugins so you
receive view/state/dispatch directly, or create the handler as a closure that
receives and captures the editor instance when the extension is initialized, or
register a proper editor-level paste event (editor.on('paste', ...) or via
EditorContext in React). Move the image-insert logic out of direct view casting
and instead call editor.chain().focus().setImage(...) using the
injected/captured editor reference obtained via one of those supported
approaches.

})
.catch((error) => {
console.error('이미지 업로드 실패:', error);
alert('이미지 업로드에 실패했습니다. 다시 시도해주세요.');
});
return true;
}
}
return false;
};
};

// 에디터 설정 생성 함수들
export const createEditorFeaturesConfig = () => ({
extensions: [
...COMMON_EXTENSIONS,
Placeholder.configure({
placeholder: '아이템의 핵심기능은 무엇이며, 어떤 기능을 구현·작동 하는지 설명해주세요.',
includeChildren: false,
showOnlyWhenEditable: true,
}),
],
content: '<p></p>',
editorProps: { handlePaste: createPasteHandler() },
immediatelyRender: false,
});

export const createEditorSkillsConfig = () => ({
extensions: [
...COMMON_EXTENSIONS,
Placeholder.configure({
placeholder: '보유한 기술 및 지식재산권이 별도로 없을 경우, 아이템에 필요한 핵심기술을 어떻게 개발해 나갈것인지 계획에 대해 작성해주세요. \n ※ 지식재산권: 특허, 상표권, 디자인, 실용신안권 등.',
includeChildren: false,
showOnlyWhenEditable: true,
}),
],
content: '<p></p>',
editorProps: { handlePaste: createPasteHandler() },
immediatelyRender: false,
});

export const createEditorGoalsConfig = () => ({
extensions: [
...COMMON_EXTENSIONS,
Placeholder.configure({
placeholder: '본 사업을 통해 달성하고 싶은 궁극적인 목표에 대해 설명',
includeChildren: false,
showOnlyWhenEditable: true,
}),
],
content: '<p></p>',
editorProps: { handlePaste: createPasteHandler() },
immediatelyRender: false,
});

export const createEditorItemNameConfig = () => ({
extensions: [
...SIMPLE_EXTENSIONS,
Placeholder.configure({
placeholder: '답변을 입력하세요.',
includeChildren: false,
showOnlyWhenEditable: true,
}),
],
content: '<p></p>',
immediatelyRender: false,
});

export const createEditorOneLineIntroConfig = () => ({
extensions: [
...SIMPLE_EXTENSIONS,
Placeholder.configure({
placeholder: '답변을 입력하세요.',
includeChildren: false,
showOnlyWhenEditable: true,
}),
],
content: '<p></p>',
immediatelyRender: false,
});

export const createEditorGeneralConfig = () => ({
extensions: [
...COMMON_EXTENSIONS,
Placeholder.configure({
placeholder: '세부 항목별 체크리스트를 참고하며 작성해주시면, 리포트 점수가 올라갑니다.',
includeChildren: false,
showOnlyWhenEditable: true,
}),
],
content: '<p></p>',
editorProps: { handlePaste: createPasteHandler() },
immediatelyRender: false,
});

7 changes: 0 additions & 7 deletions src/lib/business/editor/types.ts

This file was deleted.

60 changes: 0 additions & 60 deletions src/lib/business/editor/useEditorConfig.ts

This file was deleted.

Loading