Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
ed7a783
chore: tiptap v2로 패키지 설치
nabbang6 Feb 19, 2026
d2a8b6a
style: 에디터 전역 스타일 추가
nabbang6 Feb 19, 2026
a814925
feat: 게시글 작성 전역 상태 스토어 추가
nabbang6 Feb 19, 2026
f4180b1
fix: 충돌 해결
nabbang6 Feb 19, 2026
17b93bd
feat: 에디터 타입 정의
nabbang6 Feb 19, 2026
cd8f84c
feat: 슬래시 메뉴 커맨드 상수 정의 추가
nabbang6 Feb 19, 2026
18bcfbc
feat: 슬래시 메뉴 컴포넌트 추가
nabbang6 Feb 19, 2026
d25acd4
feat: Tiptap 에디터 컴포넌트 추가
nabbang6 Feb 19, 2026
99d012e
style: ProseMirror 체크리스트 여백 스타일 수정
nabbang6 Feb 19, 2026
b0b0931
Merge branch 'main' of https://github.com/Team-Weeth/weeth-client int…
nabbang6 Feb 20, 2026
9cc3f11
style: ProseMirror 하드코딩 색상 디자인 토큰으로 교체
nabbang6 Feb 20, 2026
c3f21e5
feat: SlashMenu 키보드 탐색 자동 스크롤 추가 및 디자인 토큰 적용
nabbang6 Feb 20, 2026
08e7b18
feat: Editor 슬래시 메뉴 stale closure 수정 및 커서 이탈 감지 추가
nabbang6 Feb 20, 2026
a658d6d
style: board/page.tsx 메인 패딩 p-8 → 디자인 토큰 p-700으로 교체
nabbang6 Feb 21, 2026
40e7598
style: ProseMirror 하드코딩 spacing/radius 값 디자인 토큰으로 교체
nabbang6 Feb 21, 2026
4ce4254
refactor: BubbleMenu 버튼 스타일 cva/cn으로 추출 및 비토큰 클래스 토큰으로 교체
nabbang6 Feb 21, 2026
83d5d23
style: EditorContent 비토큰 타이포/컬러/간격 디자인 토큰으로 교체
nabbang6 Feb 21, 2026
28dfed1
refactor: SlashMenu 아이콘 문자열에서 Lucide React 컴포넌트로 교체
nabbang6 Feb 21, 2026
9538078
style: prose 제거 후 ProseMirror 타이포/컬러/리스트 스타일 직접 관리
nabbang6 Feb 21, 2026
9b0ff79
refactor: usePostStore에 combine + devtools 적용 및 cohort > cardinalNumb…
nabbang6 Feb 23, 2026
2afcf55
refactor: Editor 컴포넌트를 usePostEditor + extensions + EditorBubbleMenu로 분리
nabbang6 Feb 23, 2026
edf3053
style: 에디터 내부 리스트 스타일 제거 (직접 정의 x, tiptap 기본 스타일 적용)
nabbang6 Feb 23, 2026
4f23d30
feat: 들여쓰기(tab) 지원 기능 추가
nabbang6 Feb 23, 2026
689ffc4
chore: react compiler 설치 및 설정
nabbang6 Feb 24, 2026
9bb31f7
refactor: React Compiler 도입으로 인해 불필요한 useMemo/useCallback 제거
nabbang6 Feb 24, 2026
f758a9a
fix: 코드 래빗 리뷰 반영
nabbang6 Feb 25, 2026
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
29 changes: 29 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,35 @@
"format:check": "prettier --check ."
},
"dependencies": {
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.62.11",
"@tiptap/core": "2.4.0",
"@tiptap/extension-blockquote": "2.4.0",
"@tiptap/extension-bold": "2.4.0",
"@tiptap/extension-bubble-menu": "2.4.0",
"@tiptap/extension-bullet-list": "2.4.0",
"@tiptap/extension-code": "2.4.0",
"@tiptap/extension-code-block": "2.4.0",
"@tiptap/extension-document": "2.4.0",
"@tiptap/extension-dropcursor": "2.4.0",
"@tiptap/extension-floating-menu": "2.4.0",
"@tiptap/extension-gapcursor": "2.4.0",
"@tiptap/extension-hard-break": "2.4.0",
"@tiptap/extension-heading": "2.4.0",
"@tiptap/extension-history": "2.4.0",
"@tiptap/extension-horizontal-rule": "2.4.0",
"@tiptap/extension-italic": "2.4.0",
"@tiptap/extension-list-item": "2.4.0",
"@tiptap/extension-ordered-list": "2.4.0",
"@tiptap/extension-paragraph": "2.4.0",
"@tiptap/extension-placeholder": "2.4.0",
"@tiptap/extension-strike": "2.4.0",
"@tiptap/extension-task-item": "2.4.0",
"@tiptap/extension-task-list": "2.4.0",
"@tiptap/extension-text": "2.4.0",
"@tiptap/extension-typography": "2.4.0",
"@tiptap/pm": "2.4.0",
"@tiptap/react": "2.4.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.468.0",
Expand All @@ -22,6 +50,7 @@
"react-dom": "19.2.3",
"tailwind-merge": "^2.6.0",
"tw-animate-css": "^1.4.0",
"tippy.js": "^6.3.7",
"zustand": "^5.0.2"
},
"devDependencies": {
Expand Down
857 changes: 770 additions & 87 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion src/app/(private)/(main)/board/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
'use client';

import dynamic from 'next/dynamic';

const Editor = dynamic(() => import('@/components/board/Editor'), { ssr: false });

export default function BoardPage() {
return <div>BoardPage</div>;
return (
<main className="mx-auto max-w-3xl p-8">
<Editor />
Comment on lines 8 to 10
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

메인 패딩은 토큰 간격 클래스로 맞춰주세요.

p-8 대신 토큰 간격(p-100~500)으로 치환하거나 필요한 경우 토큰을 확장해서 사용해주세요.
As per coding guidelines, Use spacing design token classes: p-100~500, gap-100~400.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(private)/(main)/board/page.tsx around lines 8 - 10, The main
element in page.tsx is using a hardcoded utility class p-8; replace it with the
spacing design token class (e.g., p-200 or the appropriate p-100~500 value) to
comply with the spacing tokens, e.g., change the <main className="mx-auto
max-w-3xl p-8"> usage to use a token like p-200 (or add/extend token styles if
none match the required spacing), keeping other classes (mx-auto, max-w-3xl) and
leaving the <Editor /> component unchanged.

</main>
);
}
60 changes: 57 additions & 3 deletions src/app/globals.css
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
@import 'tailwindcss';
@import "tw-animate-css";

@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
@plugin "@tailwindcss/typography";
Comment on lines +2 to +4
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

Stylelint 에러(@custom-variant/@plugin) 처리 필요.

현재 stylelint가 unknown at-rule로 에러를 내고 있어 린트 실패 가능성이 큽니다. 파일 단위 예외 처리 또는 stylelint 설정에서 해당 at-rule을 허용해주세요.

🔧 예시 수정
 `@import` 'tailwindcss';
 `@import` 'tw-animate-css';
+/* stylelint-disable-next-line scss/at-rule-no-unknown */
 `@custom-variant` dark (&:is(.dark *));
+/* stylelint-disable-next-line scss/at-rule-no-unknown */
 `@plugin` "@tailwindcss/typography";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
@plugin "@tailwindcss/typography";
`@import` 'tw-animate-css';
/* stylelint-disable-next-line scss/at-rule-no-unknown */
`@custom-variant` dark (&:is(.dark *));
/* stylelint-disable-next-line scss/at-rule-no-unknown */
`@plugin` "@tailwindcss/typography";
🧰 Tools
🪛 Stylelint (17.3.0)

[error] 3-3: Unexpected unknown at-rule "@custom-variant" (scss/at-rule-no-unknown)

(scss/at-rule-no-unknown)


[error] 4-4: Unexpected unknown at-rule "@plugin" (scss/at-rule-no-unknown)

(scss/at-rule-no-unknown)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/globals.css` around lines 2 - 4, Stylelint is flagging unknown
at-rules (`@custom-variant`, `@plugin`, `@import` 'tw-animate-css') in globals.css;
fix by either adding a per-file disable comment for at-rule-no-unknown or
updating the stylelint config to allow these at-rules (e.g., register
customAtRules or plugins) so `@custom-variant`, `@plugin` and the tw-animate-css
import are accepted; locate the occurrences of "@custom-variant", "@plugin" and
the "@import 'tw-animate-css'" line and apply the chosen fix consistently.


@font-face {
font-family: 'Pretendard Variable';
Expand Down Expand Up @@ -487,11 +487,65 @@ select:focus {
background-clip: content-box;
}

.ProseMirror {
color: var(--text-normal);
}

/* 인용 */
.ProseMirror blockquote {
font-style: italic;
border-left: 3px solid var(--line);
padding-left: 1rem;
margin: 1rem 0;
font-weight: normal;
}

.ProseMirror blockquote p::before,
.ProseMirror blockquote p::after {
content: none !important;
}

/* 인라인 코드 */
.ProseMirror code:not(pre code) {
background-color: var(--container-neutral-alternative);
color: var(--text-normal);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-weight: normal;
}

.ProseMirror code::before,
.ProseMirror code::after {
content: none;
}

/* 코드 블록 */
.ProseMirror pre {
background-color: var(--container-neutral-alternative);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
}

.ProseMirror pre code {
background-color: transparent;
color: var(--text-normal);
padding: 0;
}

.ProseMirror ul[data-type='taskList'] {
padding-left: 0;
}

.ProseMirror ul[data-type='taskList'] li p {
margin-top: 8px !important;
margin-bottom: 8px !important;
}

@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
}
153 changes: 153 additions & 0 deletions src/components/board/Editor/SlashMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
'use client';

import { Editor as TiptapEditor } from '@tiptap/core';
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
import { STYLE_ITEMS, INSERT_ITEMS } from '@/constants/editor';
import { MenuItem } from '@/types/editor';

interface SlashMenuContentProps {
editor: TiptapEditor;
onClose: () => void;
}

/**
* Slash Command 메뉴 UI
*
* 역할:
* - '/' 입력 후 나타나는 커맨드 목록 렌더링
* - 키보드 탐색 (↑ ↓ Enter Escape)
* - 선택 시 slash 문자 제거 후 해당 command 실행
*/

export function SlashMenuContent({ editor, onClose }: SlashMenuContentProps) {
const [selectedIndex, setSelectedIndex] = useState(0);
const scrollContainerRef = useRef<HTMLDivElement>(null);

/**
* 그룹 구조 정의
*/
const GROUPS = useMemo(
() => [
{ title: 'Style', items: STYLE_ITEMS },
{ title: 'Insert', items: INSERT_ITEMS },
],
[],
);

const flatItems = useMemo(() => GROUPS.flatMap((group) => group.items), [GROUPS]);
Copy link
Collaborator

Choose a reason for hiding this comment

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

useMemo 사용된 부분 보면서 든 생각인데, React 19부터 자동으로 최적화해주는 ReactCompiler가 등장해서 수동 메모이제이션을 할 필요가 없어졌다고 합니다! useMemo, useCallback 이런거 안써도 된대요.. 완전히 필요 없어지는 건 아니지만 대부분은 ReactCompiler 가 잘 최적화해주는 것 같습니다
그래서 ReactCompiler를 사용해보는 게 어떤지 논의해보면 좋을 것 같습니당

babel-plugin-react-compiler 설치 후 next.config.ts파일에 설정 추가해주면 되는 것 같아요!

React 공식문서
https://ko.react.dev/learn/react-compiler/introduction

Next.js 한글번역 문서
https://nextjs-ko.org/docs/app/api-reference/next-config-js/reactCompiler


useEffect(() => {
if (flatItems.length === 0) {
setSelectedIndex(0);
return;
}

if (selectedIndex >= flatItems.length) {
setSelectedIndex(0);
}
}, [flatItems.length, selectedIndex]);

// 선택된 아이템이 스크롤 영역 밖에 있을 때 자동 스크롤
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;

const selectedEl = container.querySelector<HTMLElement>(`[data-index="${selectedIndex}"]`);
selectedEl?.scrollIntoView({ block: 'nearest' });
}, [selectedIndex]);
Comment on lines +44 to +51
Copy link
Member

Choose a reason for hiding this comment

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

selectedIndex 변경 시 scrollIntoView 처리하는 로직은 추후 멘션 리스트나 커맨드 팔레트 등에서도 동일하게 사용할 가능성이 있어 보여서 custom hook으로 분리해도 좋을 것 같습니당!!! 예를 들면 useAutoScrollIntoView훅으로 분리하는건 어떨까욤??


// 메뉴 선택 시 실행
const handleSelect = useCallback(
(item: MenuItem) => {
const { $anchor } = editor.state.selection;

const from = $anchor.pos - 1;
const to = $anchor.pos;

editor.chain().focus().deleteRange({ from, to }).run();
item.command(editor);
onClose();
},
[editor, onClose],
);

// 키보드 이벤트 핸들링
useEffect(() => {
if (flatItems.length === 0) return;

const dom = editor.view.dom;

const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % flatItems.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => (prev - 1 + flatItems.length) % flatItems.length);
} else if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
const item = flatItems[selectedIndex];
if (item) handleSelect(item);
} else if (e.key === 'Escape') {
onClose();
}
};

dom.addEventListener('keydown', handleKeyDown);
return () => dom.removeEventListener('keydown', handleKeyDown);
}, [editor, flatItems, selectedIndex, handleSelect, onClose]);

if (flatItems.length === 0) return null;

// runningIndex는 그룹을 넘어서도 연속된 flat index를 부여하기 위해 사용
let runningIndex = 0;

return (
<div className="border-line bg-container-neutral w-64 overflow-hidden rounded-lg border shadow-xl">
<div ref={scrollContainerRef} className="max-h-80 overflow-x-hidden overflow-y-auto">
{GROUPS.map((group, groupIdx) => (
<div key={group.title}>
<div className={`px-3 pt-2 pb-1 ${groupIdx !== 0 ? 'border-line border-t' : ''}`}>
<p className="text-text-disabled text-xs font-semibold tracking-wider uppercase">
{group.title}
</p>
</div>

{group.items.map((item) => {
const currentIndex = runningIndex++;
const isSelected = currentIndex === selectedIndex;

return (
<button
key={item.label}
type="button"
data-index={currentIndex}
onMouseDown={(e) => {
e.preventDefault();
handleSelect(item);
}}
className={`flex w-full items-center gap-3 px-3 py-2 text-left transition-colors ${
isSelected
? 'bg-container-neutral-interaction'
: 'hover:bg-container-neutral-alternative'
}`}
>
<span className="border-line bg-container-neutral-alternative text-text-alternative flex h-8 w-8 shrink-0 items-center justify-center rounded border text-sm font-medium">
{item.icon}
</span>
<div>
<p className="text-text-strong text-sm font-medium">{item.label}</p>
<p className="text-text-disabled text-xs">{item.description}</p>
Comment on lines 100 to 136
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

className 결합 및 간격/타이포 토큰 적용이 필요합니다.

템플릿 리터럴로 클래스 결합 중이며, px-3, py-2, gap-3, text-sm/xs 등 비토큰 간격/타이포가 섞여 있습니다. cn()으로 결합을 통일하고, 간격·타이포는 p-100~500, gap-100~400, typo-* 토큰 클래스로 치환해주세요.

🔧 예시 수정
+import { cn } from '@/lib/cn';
...
-<div className={`px-3 pt-2 pb-1 ${groupIdx !== 0 ? 'border-line border-t' : ''}`}>
+<div className={cn('px-3 pt-2 pb-1', groupIdx !== 0 && 'border-line border-t')}>
...
-  className={`flex w-full items-center gap-3 px-3 py-2 text-left transition-colors ${
-    isSelected ? 'bg-container-neutral-interaction' : 'hover:bg-container-neutral-alternative'
-  }`}
+  className={cn(
+    'flex w-full items-center gap-3 px-3 py-2 text-left transition-colors',
+    isSelected ? 'bg-container-neutral-interaction' : 'hover:bg-container-neutral-alternative',
+  )}
As per coding guidelines, Use `cn()` from `@/lib/cn` for className merging instead of manual concatenation; Use typography design token classes: `typo-h1~h3`, `typo-sub1~2`, `typo-body1~2`, `typo-caption1~2`, `typo-button1~2`; Use spacing design token classes: `p-100~500`, `gap-100~400`.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/board/Editor/SlashMenu.tsx` around lines 106 - 141, In
SlashMenu (rendering GROUPS.map and each item button) replace the
template-literal className concatenation on the item <button> and the inner
<span> and <p> tags with the cn() helper imported from '@/lib/cn', and swap raw
spacing/typography classes (px-3, py-2, gap-3, text-sm, text-xs, h-8, w-8, etc.)
for your design token classes (spacing p-100~500, gap-100~400 and typography
typo-* such as typo-body1/2 or typo-caption1/2) so e.g. the conditional selected
vs hover classes remain wrapped by cn(...) while using tokens for padding, gap
and text sizes; ensure handleSelect, selectedIndex and runningIndex logic is
unchanged and keep the data-index and onMouseDown behavior intact.

</div>
</button>
);
})}
</div>
))}

<div className="pb-1" />
</div>
</div>
);
}
Loading
Loading