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
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"@tanstack/react-query-devtools": "^5.100.9",
"@tiptap/core": "^3.22.4",
"@tiptap/extension-collaboration": "^3.20.1",
"@tiptap/extension-image": "^3.20.1",
"@tiptap/extensions": "^3.20.4",
"@tiptap/react": "^3.20.1",
"@tiptap/starter-kit": "^3.20.1",
Expand Down
12 changes: 12 additions & 0 deletions frontend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

59 changes: 59 additions & 0 deletions frontend/src/app/api/upload-image/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
export async function POST(request: Request) {
const authHeader = request.headers.get('Authorization');
if (!authHeader) {
return Response.json({ error: '인증 정보가 없습니다.' }, { status: 401 });
}

const formData = await request.formData();
const file = formData.get('file');
if (!(file instanceof File)) {
return Response.json({ error: '파일이 없습니다.' }, { status: 400 });
}

const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL;
const extension = file.name.split('.').pop() ?? '';

// 1. Presigned URL 발급 (서버→백엔드, CORS 없음)
const presignRes = await fetch(`${apiBase}/v1/files/presigned-url`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
body: JSON.stringify({ fileName: file.name, fileSize: file.size, contentType: file.type }),
});

const presignJson = await presignRes.json();
const { fileKey, presignedUrl, uploadUrl } = presignJson.data ?? {};

if (!fileKey || !presignedUrl || !uploadUrl) {
return Response.json({ error: 'Presigned URL 발급 실패' }, { status: 500 });
}

// 2. S3 직접 PUT (서버→S3, CORS 없음)
const s3Res = await fetch(presignedUrl, {
method: 'PUT',
body: await file.arrayBuffer(),
headers: { 'Content-Type': file.type },
});

if (!s3Res.ok) {
return Response.json({ error: 'S3 업로드 실패' }, { status: 500 });
}

// 3. 업로드 완료 확인
await fetch(`${apiBase}/v1/files`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
body: JSON.stringify({
fileKey,
fileName: file.name,
fileSize: file.size,
extension,
contentType: file.type,
}),
});

// 프로토콜 보정 + 공백 등 인코딩 (마크다운 파서가 공백 있는 URL을 이미지로 인식 못함)
const normalizedUrl = /^https?:\/\//i.test(uploadUrl) ? uploadUrl : `https://${uploadUrl}`;
const encodedUrl = encodeURI(normalizedUrl);

return Response.json({ url: encodedUrl });
}
200 changes: 200 additions & 0 deletions frontend/src/components/commons/editor/EditorToolbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
'use client';

import type { Editor } from '@tiptap/react';
import {
IconBold,
IconCode,
IconImage,
IconLineHorizontal,
IconList,
IconListOrdered,
IconQuote,
IconStrikethrough,
} from '@wanteddev/wds-icon';
import { useRef, type ReactNode } from 'react';

import { uploadImage } from './uploadImage';

interface ToolbarButtonProps {
onClick: () => void;
active?: boolean;
title: string;
children: ReactNode;
}

function ToolbarButton({ onClick, active, title, children }: ToolbarButtonProps) {
return (
<button
type="button"
onMouseDown={(e) => {
e.preventDefault();
onClick();
}}
title={title}
aria-label={title}
aria-pressed={active}
className={`flex h-7 w-7 shrink-0 items-center justify-center bg-transparent text-sm transition-colors ${
active ? 'text-primary-40' : 'text-label-alternative'
}`}
>
{children}
</button>
);
}

function Group({ children }: { children: ReactNode }) {
return <div className="flex items-center gap-0.5">{children}</div>;
}

function Divider() {
return <div className="bg-line-normal-neutral mx-1.5 h-4 w-px shrink-0" />;
}

interface EditorToolbarProps {
editor: Editor | null;
}

export function EditorToolbar({ editor }: EditorToolbarProps) {
const fileInputRef = useRef<HTMLInputElement>(null);

if (!editor) return null;

const handleImageFile = (file: File) => {
uploadImage(file).then((url) => {
editor.chain().focus().setImage({ src: url }).run();
});
};

return (
<div className="border-line-normal-neutral bg-static-white sticky top-0 z-10 flex w-full flex-wrap items-center gap-y-1 border-b px-3 py-2">
<Group>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
active={editor.isActive('heading', { level: 1 })}
title="제목 1"
>
<span className="text-[11px] font-bold tracking-tight">H1</span>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
active={editor.isActive('heading', { level: 2 })}
title="제목 2"
>
<span className="text-[11px] font-bold tracking-tight">H2</span>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
active={editor.isActive('heading', { level: 3 })}
title="제목 3"
>
<span className="text-[11px] font-bold tracking-tight">H3</span>
</ToolbarButton>
</Group>

<Divider />

<Group>
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive('bold')}
title="굵게"
>
<IconBold width={15} height={15} />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive('italic')}
title="기울임"
>
<span className="text-sm" style={{ fontStyle: 'italic', fontFamily: 'Georgia, serif' }}>
I
</span>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleStrike().run()}
active={editor.isActive('strike')}
title="취소선"
>
<IconStrikethrough width={15} height={15} />
</ToolbarButton>
</Group>

<Divider />

<Group>
<ToolbarButton
onClick={() => editor.chain().focus().toggleCode().run()}
active={editor.isActive('code')}
title="인라인 코드"
>
<IconCode width={15} height={15} />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
active={editor.isActive('codeBlock')}
title="코드 블록"
>
<span className="font-mono text-[11px] font-medium">{'{}'}</span>
</ToolbarButton>
</Group>

<Divider />

<Group>
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
active={editor.isActive('bulletList')}
title="글머리 기호 목록"
>
<IconList width={15} height={15} />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
active={editor.isActive('orderedList')}
title="번호 매기기 목록"
>
<IconListOrdered width={15} height={15} />
</ToolbarButton>
</Group>

<Divider />

<Group>
<ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
active={editor.isActive('blockquote')}
title="인용구"
>
<IconQuote width={15} height={15} />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().setHorizontalRule().run()}
active={false}
title="구분선"
>
<IconLineHorizontal width={15} height={15} />
</ToolbarButton>
</Group>

<Divider />

<Group>
<ToolbarButton onClick={() => fileInputRef.current?.click()} active={false} title="이미지">
<IconImage width={15} height={15} />
</ToolbarButton>
</Group>

<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleImageFile(file);
e.target.value = '';
}}
/>
</div>
);
}
57 changes: 49 additions & 8 deletions frontend/src/components/commons/editor/editor.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { Collaboration, isChangeOrigin } from '@tiptap/extension-collaboration';
import { Image } from '@tiptap/extension-image';
import { Placeholder } from '@tiptap/extensions';
import { EditorContent, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
Expand All @@ -10,7 +11,9 @@ import type { XmlFragment } from 'yjs';

import { useYjsContext } from '@/contexts/YjsContext';
import { useYjsFragmentInit } from '@/hooks/useYjsFragmentInit';
import { EditorToolbar } from './EditorToolbar';
import { TypingProfilePresence } from './TypingProfilePresence';
import { uploadImage } from './uploadImage';

const SAVE_DEBOUNCE_MS = 1000;

Expand Down Expand Up @@ -48,6 +51,7 @@ export default function Editor({
StarterKit.configure({ undoRedo: false }),
Markdown,
Placeholder.configure({ placeholder: '내용을 입력하세요.' }),
Image.configure({ inline: false, allowBase64: false }),
...(fragment ? [Collaboration.configure({ fragment })] : []),
],
editable,
Expand All @@ -64,6 +68,43 @@ export default function Editor({
},
editorProps: {
attributes: { class: 'prose focus:outline-none m-5 pb-40 pl-5' },
handlePaste(view, event) {
const items = Array.from(event.clipboardData?.items ?? []);
const imageItem = items.find((item) => item.type.startsWith('image/'));
if (!imageItem) return false;

event.preventDefault();
const file = imageItem.getAsFile();
if (!file) return false;

uploadImage(file).then((url) => {
view.dispatch(
view.state.tr.replaceSelectionWith(
view.state.schema.nodes.image.create({ src: url }),
),
);
});
return true;
},
handleDrop(view, event) {
const files = Array.from(event.dataTransfer?.files ?? []).filter((f) =>
f.type.startsWith('image/'),
);
if (!files.length) return false;

event.preventDefault();
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos;
if (pos === undefined) return false;

files.forEach((file) => {
uploadImage(file).then((url) => {
view.dispatch(
view.state.tr.insert(pos, view.state.schema.nodes.image.create({ src: url })),
);
});
});
return true;
},
},
immediatelyRender: false,
},
Expand All @@ -78,14 +119,14 @@ export default function Editor({
}, [content, editor, fragment]);

return (
<div
className="prose relative [&_.ProseMirror]:leading-[1.4] [&_.ProseMirror_p]:my-0"
data-typing-profile-container
>
<EditorContent editor={editor} />
{fragment && collaborationField && editable && (
<TypingProfilePresence editor={editor} yjsCtx={yjsCtx} field={collaborationField} />
)}
<div className="relative w-full" data-typing-profile-container>
{editable && <EditorToolbar editor={editor} />}
<div className="prose relative overflow-x-hidden [&_.ProseMirror]:leading-[1.4] [&_.ProseMirror_p]:my-0">
<EditorContent editor={editor} />
{fragment && collaborationField && editable && (
<TypingProfilePresence editor={editor} yjsCtx={yjsCtx} field={collaborationField} />
)}
</div>
</div>
);
}
Loading
Loading