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
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@
"@tiptap/extension-image": "^3.20.0",
"@tiptap/extension-link": "^3.20.0",
"@tiptap/extension-placeholder": "^3.20.0",
"@tiptap/extension-table": "^3.20.0",
"@tiptap/extension-table-cell": "^3.20.0",
"@tiptap/extension-table-header": "^3.20.0",
"@tiptap/extension-table-row": "^3.20.0",
"@tiptap/extension-underline": "^3.20.0",
"@tiptap/react": "^3.20.0",
"@tiptap/starter-kit": "^3.20.0",
Expand Down
32 changes: 32 additions & 0 deletions src/app/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -1384,6 +1384,38 @@ https://velog.io/@oneook/tailwindcss-4.0-%EB%AC%B4%EC%97%87%EC%9D%B4-%EB%8B%AC%E
margin: 16px 0;
}

.tiptap-editor .tiptap table {
width: 100%;
border-collapse: collapse;
margin-bottom: 12px;
border: 1px solid var(--color-border-subtle);
border-radius: 8px;
overflow: hidden;
table-layout: fixed;
}

.tiptap-editor .tiptap table th,
.tiptap-editor .tiptap table td {
border: 1px solid var(--color-border-subtle);
padding: 6px 8px;
vertical-align: top;
text-align: left;
}

.tiptap-editor .tiptap table th {
background-color: var(--color-background-alternative);
font-weight: 700;
}

.tiptap-editor .tiptap table th p,
.tiptap-editor .tiptap table td p {
margin-bottom: 0;
}

.tiptap-editor .tiptap table .selectedCell {
background-color: var(--color-background-brand-subtle);
}

.tiptap-editor .tiptap p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
Expand Down
18 changes: 18 additions & 0 deletions src/components/common/ui/editor/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ import {
} from '@tiptap/core';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import ImageExtension from '@tiptap/extension-image';
import {
Table as TableExtension,
TableCell,
TableHeader,
TableRow,
} from '@tiptap/extension-table';
import type { Mark, Node as ProseMirrorNode } from '@tiptap/pm/model';
import c from 'highlight.js/lib/languages/c';
import cpp from 'highlight.js/lib/languages/cpp';
Expand Down Expand Up @@ -49,6 +55,18 @@ LOWLIGHT_LANGUAGES.forEach(([name, language]) => {
lowlight.register(name, language);
});

/**
* WYSIWYG 표 편집을 위한 TipTap 표 확장 묶음입니다.
* 컬럼 리사이즈는 비활성화해 저장 HTML에 colwidth/style 같은 추가 속성이 끼지 않도록 합니다
* (정화기 통과 및 미리보기/상세 렌더 일관성 유지).
*/
export const MarkdownTableExtensions = [
TableExtension.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
];

const INSTANT_CODE_BLOCK_INPUT_REGEX = /^```$/;

const RESIZABLE_IMAGE_CLASS_NAME = 'tiptap-resizable-image';
Expand Down
62 changes: 9 additions & 53 deletions src/components/common/ui/editor/markdown-content.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,12 @@
'use client';

import 'highlight.js/styles/github.css';
import DOMPurify from 'dompurify';
import { marked } from 'marked';
import { memo, useEffect, useMemo, useRef } from 'react';
import { cn } from '@/components/common/ui/(shadcn)/lib/utils';
import { normalizeMarkdownContent } from '@/utils/markdown-content-normalize';
import { isHtmlContent } from '@/utils/markdown-content-shared';
import { replaceEmoticonShortcodes } from './emoticon-shortcode';
import hljs from './hljs-setup';
import {
applyPostSanitizeAttributes,
SANITIZE_OPTIONS,
} from './markdown-sanitizer';
import { renderMarkdownTablesInHtml } from './markdown-table-utils';
import { isMermaidCodeBlock, renderMermaidBlocks } from './mermaid-renderer';
import { replaceStandaloneYouTubeLinksWithEmbeds } from './youtube-utils';
enhanceRenderedMarkdown,
renderMarkdownToSafeHtml,
} from './markdown-render-pipeline';

const MARKDOWN_CONTENT_BASE_STYLES = [
'wrap-break-word',
Expand Down Expand Up @@ -58,39 +49,12 @@ function MarkdownContent({
className,
emptyMessage = '아직 작성된 소개가 없습니다.',
}: MarkdownContentProps) {
const normalizedContent = normalizeMarkdownContent(content);
const hasContent = normalizedContent.length > 0;
const containerRef = useRef<HTMLDivElement>(null);

const sanitizedHtml = useMemo(() => {
if (!hasContent) {
return '';
}

const isOriginalHtml = isHtmlContent(normalizedContent);
const normalizedContentWithEmbeds = replaceEmoticonShortcodes(
replaceStandaloneYouTubeLinksWithEmbeds(normalizedContent),
);

let html: string;

if (isOriginalHtml) {
html = renderMarkdownTablesInHtml(normalizedContentWithEmbeds);
} else {
const rendered = marked.parse(normalizedContentWithEmbeds, {
breaks: true,
gfm: true,
});
html = typeof rendered === 'string' ? rendered : '';
}

const sanitized = String(DOMPurify.sanitize(html, SANITIZE_OPTIONS));

return applyPostSanitizeAttributes({
originalHtml: html,
sanitizedHtml: sanitized,
});
}, [hasContent, normalizedContent]);
const sanitizedHtml = useMemo(
() => renderMarkdownToSafeHtml(content),
[content],
);
const hasContent = sanitizedHtml.length > 0;

useEffect(() => {
const container = containerRef.current;
Expand All @@ -99,15 +63,7 @@ function MarkdownContent({
}

container.innerHTML = sanitizedHtml;
container.querySelectorAll('pre code').forEach((block) => {
if (isMermaidCodeBlock(block)) {
return;
}

hljs.highlightElement(block as HTMLElement);
});

renderMermaidBlocks(container).catch((): undefined => undefined);
enhanceRenderedMarkdown(container);
}, [sanitizedHtml]);

if (!hasContent) {
Expand Down
46 changes: 16 additions & 30 deletions src/components/common/ui/editor/markdown-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
LinkExitOnSpaceExtension,
lowlight,
MarkdownHistoryShortcutsExtension,
MarkdownTableExtensions,
ResizableImageExtension,
YouTubeEmbedExtension,
} from './extensions';
Expand All @@ -63,8 +64,7 @@ import {
toImageInputAccept,
} from './image-utils';
import {
convertHtmlTableToMarkdownTable,
convertTabularTextToMarkdownTable,
convertTabularTextToHtmlTable,
isHtmlTableOnlyPaste,
} from './markdown-table-utils';
import { normalizeRichClipboardHtml } from './rich-clipboard-normalizer';
Expand Down Expand Up @@ -159,35 +159,17 @@ function MarkdownEditor({
.run();
};

const insertMarkdownTable = (
editorInstance: Editor,
markdownTable: string,
) => {
const tableRows = markdownTable.split('\n').map((line) => ({
type: 'paragraph',
content: [
{
type: 'text',
text: line,
},
],
}));

return editorInstance.chain().focus().insertContent(tableRows).run();
};

const handleInsertTable = () => {
const editorInstance = getValidEditorInstance();
if (!editorInstance) {
return;
}

insertMarkdownTable(
editorInstance,
['| 항목 | 설명 |', '| --- | --- |', '| 예시 | 내용을 입력하세요 |'].join(
'\n',
),
);
editorInstance
.chain()
.focus()
.insertTable({ rows: 3, cols: 2, withHeaderRow: true })
.run();
};

const resolvedImageConfig = useMemo(() => {
Expand Down Expand Up @@ -332,6 +314,7 @@ function MarkdownEditor({
MarkdownHistoryShortcutsExtension,
LinkExitOnSpaceExtension,
YouTubeEmbedExtension,
...MarkdownTableExtensions,
UnderlineExtension,
LinkExtension.configure({
openOnClick: false,
Expand Down Expand Up @@ -401,15 +384,18 @@ function MarkdownEditor({

const pastedHtml = clipboardData.getData('text/html');
const pastedText = clipboardData.getData('text/plain');
const markdownTable = isHtmlTableOnlyPaste(pastedHtml)
? convertHtmlTableToMarkdownTable(pastedHtml)
// 표만 붙여넣는 경우 실제 표(WYSIWYG) 노드로 삽입한다.
// - HTML 표(엑셀/시트/Notion 등): 클립보드 HTML을 그대로 넣으면 TipTap이 표 노드로 파싱한다.
// - HTML 없는 탭 구분 텍스트: 실제 <table> HTML로 변환해 삽입한다.
const htmlTableToInsert = isHtmlTableOnlyPaste(pastedHtml)
? pastedHtml
: !pastedHtml.trim()
? convertTabularTextToMarkdownTable(pastedText)
? convertTabularTextToHtmlTable(pastedText)
: undefined;

if (markdownTable) {
if (htmlTableToInsert) {
event.preventDefault();
insertMarkdownTable(editorInstance, markdownTable);
editorInstance.chain().focus().insertContent(htmlTableToInsert).run();
return true;
}

Expand Down
41 changes: 41 additions & 0 deletions src/components/common/ui/editor/markdown-render-pipeline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { restoreEmptyParagraphsAsLineBreaks } from './markdown-render-pipeline';

describe('restoreEmptyParagraphsAsLineBreaks', () => {
it('내용이 없는 빈 단락을 줄바꿈이 보이는 <p><br></p>로 복원한다', () => {
expect(restoreEmptyParagraphsAsLineBreaks('<p></p>')).toBe('<p><br></p>');
});

it('속성이 있는 빈 단락도 복원한다 (TipTap 직렬화 형태)', () => {
expect(restoreEmptyParagraphsAsLineBreaks('<p dir="ltr"></p>')).toBe(
'<p><br></p>',
);
});

it('공백/&nbsp;만 있는 단락도 빈 단락으로 간주해 복원한다', () => {
expect(restoreEmptyParagraphsAsLineBreaks('<p> </p>')).toBe('<p><br></p>');
expect(restoreEmptyParagraphsAsLineBreaks('<p>&nbsp;</p>')).toBe(
'<p><br></p>',
);
expect(restoreEmptyParagraphsAsLineBreaks('<p>&#160;</p>')).toBe(
'<p><br></p>',
);
});

it('연속된 빈 단락을 각각 줄바꿈으로 복원한다', () => {
expect(restoreEmptyParagraphsAsLineBreaks('<p></p><p></p>')).toBe(
'<p><br></p><p><br></p>',
);
});

it('내용이 있는 단락은 변경하지 않는다', () => {
const html = '<p>본문</p>';
expect(restoreEmptyParagraphsAsLineBreaks(html)).toBe(html);
});

it('빈 단락과 내용 단락이 섞여 있어도 빈 단락만 복원한다', () => {
expect(
restoreEmptyParagraphsAsLineBreaks('<p>첫째 줄</p><p></p><p>셋째 줄</p>'),
).toBe('<p>첫째 줄</p><p><br></p><p>셋째 줄</p>');
});
});
83 changes: 83 additions & 0 deletions src/components/common/ui/editor/markdown-render-pipeline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import DOMPurify from 'dompurify';
import { marked } from 'marked';
import { normalizeMarkdownContent } from '@/utils/markdown-content-normalize';
import { isHtmlContent } from '@/utils/markdown-content-shared';
import { replaceEmoticonShortcodes } from './emoticon-shortcode';
import hljs from './hljs-setup';
import {
applyPostSanitizeAttributes,
SANITIZE_OPTIONS,
} from './markdown-sanitizer';
import { renderMarkdownTablesInHtml } from './markdown-table-utils';
import { isMermaidCodeBlock, renderMermaidBlocks } from './mermaid-renderer';
import { replaceStandaloneYouTubeLinksWithEmbeds } from './youtube-utils';

/**
* 내용이 없는 빈 단락(`<p></p>`)을 줄바꿈이 보이는 `<p><br></p>`로 복원합니다.
* TipTap 에디터는 빈 줄을 빈 단락으로 직렬화하는데, 렌더 단계에서 빈 단락은
* margin collapse로 사라져 에디터에서 보이던 줄바꿈이 미리보기/상세에서 누락됩니다.
* 에디터-미리보기-상세의 줄바꿈을 동일하게 맞추기 위해 사용합니다.
*/
const EMPTY_PARAGRAPH_PATTERN = /<p\b[^>]*>(?:\s|&nbsp;|&#160;|&#xa0;)*<\/p>/gi;

export const restoreEmptyParagraphsAsLineBreaks = (html: string): string =>
html.replace(EMPTY_PARAGRAPH_PATTERN, '<p><br></p>');

/**
* 마크다운/HTML 콘텐츠를 정화된 안전한 HTML로 변환하는 단일 파이프라인입니다.
* admin 미리보기(MarkdownContent)와 레슨 상세·피드·커뮤니티(MarkdownContentCore)가
* 모두 이 함수를 사용해 동일한 결과를 보장합니다(에디터-미리보기-상세 동기화).
*
* 처리 순서: 정규화 → 이모티콘/유튜브 임베드 치환 →
* (HTML이면 파이프 단락→표 변환, 마크다운이면 marked 파싱) →
* 빈 단락→줄바꿈 복원 → DOMPurify 정화 → 정화 후 속성 복원(이미지 URL/폭, 링크 target 등).
*/
export const renderMarkdownToSafeHtml = (content: unknown): string => {
const normalizedContent = normalizeMarkdownContent(content);
if (normalizedContent.length === 0) {
return '';
}

const isOriginalHtml = isHtmlContent(normalizedContent);
const contentWithEmbeds = replaceEmoticonShortcodes(
replaceStandaloneYouTubeLinksWithEmbeds(normalizedContent),
);

let html: string;

if (isOriginalHtml) {
html = renderMarkdownTablesInHtml(contentWithEmbeds);
} else {
const rendered = marked.parse(contentWithEmbeds, {
breaks: true,
gfm: true,
});
html = typeof rendered === 'string' ? rendered : '';
}

html = restoreEmptyParagraphsAsLineBreaks(html);

const sanitized = String(DOMPurify.sanitize(html, SANITIZE_OPTIONS));

return applyPostSanitizeAttributes({
originalHtml: html,
sanitizedHtml: sanitized,
});
};

/**
* 렌더된 마크다운 컨테이너에 클라이언트 사이드 후처리를 적용합니다.
* 코드 블록 신택스 하이라이팅(mermaid 코드 블록은 제외)과 mermaid 다이어그램 렌더링을 수행합니다.
* 렌더 전략(dangerouslySetInnerHTML vs innerHTML)과 무관하게 동일한 결과를 보장합니다.
*/
export const enhanceRenderedMarkdown = (container: HTMLElement): void => {
container.querySelectorAll('pre code').forEach((block) => {
if (isMermaidCodeBlock(block)) {
return;
}

hljs.highlightElement(block as HTMLElement);
});

renderMermaidBlocks(container).catch((): undefined => undefined);
};
2 changes: 2 additions & 0 deletions src/components/common/ui/editor/markdown-sanitizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,13 @@ export const SANITIZE_OPTIONS: DOMPurifyConfig = {
'allowfullscreen',
'alt',
'class',
'colspan',
'frameborder',
'height',
'href',
'loading',
'referrerpolicy',
'rowspan',
'src',
'title',
'width',
Expand Down
Loading
Loading