-
Notifications
You must be signed in to change notification settings - Fork 0
[Feat] WTH-136 : 마크다운 기반 게시글 작성 기능 구현 #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
The head ref may contain hidden characters: "WTH-136-\uB9C8\uD06C\uB2E4\uC6B4-\uAE30\uBC18-\uAC8C\uC2DC\uAE00-\uC791\uC131-\uAE30\uB2A5-\uAD6C\uD604"
Changes from 13 commits
ed7a783
d2a8b6a
a814925
f4180b1
17b93bd
cd8f84c
18bcfbc
d25acd4
99d012e
b0b0931
9cc3f11
c3f21e5
08e7b18
a658d6d
40e7598
4ce4254
83d5d23
28dfed1
9538078
9b0ff79
2afcf55
edf3053
4f23d30
689ffc4
9bb31f7
f758a9a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| 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 /> | ||
| </main> | ||
| ); | ||
| } | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Stylelint 에러( 현재 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
Suggested change
🧰 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 |
||||||||||||||||||
|
|
||||||||||||||||||
| @font-face { | ||||||||||||||||||
| font-family: 'Pretendard Variable'; | ||||||||||||||||||
|
|
@@ -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; | ||||||||||||||||||
| } | ||||||||||||||||||
coderabbitai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||||
|
|
||||||||||||||||||
| @layer base { | ||||||||||||||||||
| * { | ||||||||||||||||||
| @apply border-border outline-ring/50; | ||||||||||||||||||
| } | ||||||||||||||||||
| body { | ||||||||||||||||||
| @apply bg-background text-foreground; | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| 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]); | ||
|
||
|
|
||
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| // 메뉴 선택 시 실행 | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. className 결합 및 간격/타이포 토큰 적용이 필요합니다. 템플릿 리터럴로 클래스 결합 중이며, 🔧 예시 수정+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',
+ )}🤖 Prompt for AI Agents |
||
| </div> | ||
| </button> | ||
| ); | ||
| })} | ||
| </div> | ||
| ))} | ||
|
|
||
| <div className="pb-1" /> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
메인 패딩은 토큰 간격 클래스로 맞춰주세요.
p-8대신 토큰 간격(p-100~500)으로 치환하거나 필요한 경우 토큰을 확장해서 사용해주세요.As per coding guidelines, Use spacing design token classes:
p-100~500,gap-100~400.🤖 Prompt for AI Agents