diff --git a/package.json b/package.json index a98be7ffc..146cf7452 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/global.css b/src/app/global.css index 1c11c9a84..f8fc12c1f 100644 --- a/src/app/global.css +++ b/src/app/global.css @@ -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; diff --git a/src/components/common/ui/editor/extensions.ts b/src/components/common/ui/editor/extensions.ts index 46dc62c5b..5912dbcec 100644 --- a/src/components/common/ui/editor/extensions.ts +++ b/src/components/common/ui/editor/extensions.ts @@ -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'; @@ -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'; diff --git a/src/components/common/ui/editor/markdown-content.tsx b/src/components/common/ui/editor/markdown-content.tsx index 167651af8..9548a4093 100644 --- a/src/components/common/ui/editor/markdown-content.tsx +++ b/src/components/common/ui/editor/markdown-content.tsx @@ -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', @@ -58,39 +49,12 @@ function MarkdownContent({ className, emptyMessage = '아직 작성된 소개가 없습니다.', }: MarkdownContentProps) { - const normalizedContent = normalizeMarkdownContent(content); - const hasContent = normalizedContent.length > 0; const containerRef = useRef(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; @@ -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) { diff --git a/src/components/common/ui/editor/markdown-editor.tsx b/src/components/common/ui/editor/markdown-editor.tsx index 8428573c7..edfd7d0da 100644 --- a/src/components/common/ui/editor/markdown-editor.tsx +++ b/src/components/common/ui/editor/markdown-editor.tsx @@ -50,6 +50,7 @@ import { LinkExitOnSpaceExtension, lowlight, MarkdownHistoryShortcutsExtension, + MarkdownTableExtensions, ResizableImageExtension, YouTubeEmbedExtension, } from './extensions'; @@ -63,8 +64,7 @@ import { toImageInputAccept, } from './image-utils'; import { - convertHtmlTableToMarkdownTable, - convertTabularTextToMarkdownTable, + convertTabularTextToHtmlTable, isHtmlTableOnlyPaste, } from './markdown-table-utils'; import { normalizeRichClipboardHtml } from './rich-clipboard-normalizer'; @@ -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(() => { @@ -332,6 +314,7 @@ function MarkdownEditor({ MarkdownHistoryShortcutsExtension, LinkExitOnSpaceExtension, YouTubeEmbedExtension, + ...MarkdownTableExtensions, UnderlineExtension, LinkExtension.configure({ openOnClick: false, @@ -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 없는 탭 구분 텍스트: 실제 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; } diff --git a/src/components/common/ui/editor/markdown-render-pipeline.test.ts b/src/components/common/ui/editor/markdown-render-pipeline.test.ts new file mode 100644 index 000000000..16b562429 --- /dev/null +++ b/src/components/common/ui/editor/markdown-render-pipeline.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { restoreEmptyParagraphsAsLineBreaks } from './markdown-render-pipeline'; + +describe('restoreEmptyParagraphsAsLineBreaks', () => { + it('내용이 없는 빈 단락을 줄바꿈이 보이는


로 복원한다', () => { + expect(restoreEmptyParagraphsAsLineBreaks('

')).toBe('


'); + }); + + it('속성이 있는 빈 단락도 복원한다 (TipTap 직렬화 형태)', () => { + expect(restoreEmptyParagraphsAsLineBreaks('

')).toBe( + '


', + ); + }); + + it('공백/ 만 있는 단락도 빈 단락으로 간주해 복원한다', () => { + expect(restoreEmptyParagraphsAsLineBreaks('

')).toBe('


'); + expect(restoreEmptyParagraphsAsLineBreaks('

 

')).toBe( + '


', + ); + expect(restoreEmptyParagraphsAsLineBreaks('

 

')).toBe( + '


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

')).toBe( + '



', + ); + }); + + it('내용이 있는 단락은 변경하지 않는다', () => { + const html = '

본문

'; + expect(restoreEmptyParagraphsAsLineBreaks(html)).toBe(html); + }); + + it('빈 단락과 내용 단락이 섞여 있어도 빈 단락만 복원한다', () => { + expect( + restoreEmptyParagraphsAsLineBreaks('

첫째 줄

셋째 줄

'), + ).toBe('

첫째 줄


셋째 줄

'); + }); +}); diff --git a/src/components/common/ui/editor/markdown-render-pipeline.ts b/src/components/common/ui/editor/markdown-render-pipeline.ts new file mode 100644 index 000000000..52d6b28c8 --- /dev/null +++ b/src/components/common/ui/editor/markdown-render-pipeline.ts @@ -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'; + +/** + * 내용이 없는 빈 단락(`

`)을 줄바꿈이 보이는 `


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


'); + +/** + * 마크다운/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); +}; diff --git a/src/components/common/ui/editor/markdown-sanitizer.ts b/src/components/common/ui/editor/markdown-sanitizer.ts index 768615ba9..46a155b3a 100644 --- a/src/components/common/ui/editor/markdown-sanitizer.ts +++ b/src/components/common/ui/editor/markdown-sanitizer.ts @@ -38,11 +38,13 @@ export const SANITIZE_OPTIONS: DOMPurifyConfig = { 'allowfullscreen', 'alt', 'class', + 'colspan', 'frameborder', 'height', 'href', 'loading', 'referrerpolicy', + 'rowspan', 'src', 'title', 'width', diff --git a/src/components/common/ui/editor/markdown-table-utils.ts b/src/components/common/ui/editor/markdown-table-utils.ts index b085df6f7..416af9577 100644 --- a/src/components/common/ui/editor/markdown-table-utils.ts +++ b/src/components/common/ui/editor/markdown-table-utils.ts @@ -110,6 +110,28 @@ export const convertTabularTextToMarkdownTable = (text: string) => { ].join('\n'); }; +/** + * 탭 구분 텍스트(스프레드시트 등에서 복사)를 실제 `
` HTML 문자열로 변환합니다. + * TipTap 에디터에 insertContent로 넣으면 실제 표 노드(WYSIWYG)로 파싱됩니다. + */ +export const convertTabularTextToHtmlTable = ( + text: string, +): string | undefined => { + if (typeof window === 'undefined') { + return undefined; + } + + const markdown = convertTabularTextToMarkdownTable(text); + if (!markdown) { + return undefined; + } + + const document = window.document.implementation.createHTMLDocument(''); + const table = createTableElement(document, markdown.split('\n')); + + return table.outerHTML; +}; + export const convertHtmlTableElementToMarkdownTable = (table: Element) => { const rows = Array.from(table.querySelectorAll('tr')) .map((row) => diff --git a/src/components/common/ui/rich-text/markdown-content-core.tsx b/src/components/common/ui/rich-text/markdown-content-core.tsx index 6ae02a457..088aa4d68 100644 --- a/src/components/common/ui/rich-text/markdown-content-core.tsx +++ b/src/components/common/ui/rich-text/markdown-content-core.tsx @@ -1,57 +1,12 @@ 'use client'; import 'highlight.js/styles/github.css'; -import DOMPurify, { type Config as DOMPurifyConfig } from 'dompurify'; -import hljs from 'highlight.js/lib/core'; -import bash from 'highlight.js/lib/languages/bash'; -import c from 'highlight.js/lib/languages/c'; -import cpp from 'highlight.js/lib/languages/cpp'; -import css from 'highlight.js/lib/languages/css'; -import dart from 'highlight.js/lib/languages/dart'; -import go from 'highlight.js/lib/languages/go'; -import java from 'highlight.js/lib/languages/java'; -import javascript from 'highlight.js/lib/languages/javascript'; -import json from 'highlight.js/lib/languages/json'; -import kotlin from 'highlight.js/lib/languages/kotlin'; -import plaintext from 'highlight.js/lib/languages/plaintext'; -import python from 'highlight.js/lib/languages/python'; -import rust from 'highlight.js/lib/languages/rust'; -import sql from 'highlight.js/lib/languages/sql'; -import swift from 'highlight.js/lib/languages/swift'; -import typescript from 'highlight.js/lib/languages/typescript'; -import xml from 'highlight.js/lib/languages/xml'; -import { marked } from 'marked'; import { useEffect, useMemo, useRef } from 'react'; import { cn } from '@/components/common/ui/(shadcn)/lib/utils'; -import { replaceEmoticonShortcodes } from '@/components/common/ui/editor/emoticon-shortcode'; import { - applyYouTubeIframeAttributes, - replaceStandaloneYouTubeLinksWithEmbeds, -} from '@/components/common/ui/editor/youtube-utils'; -import { isHtmlContent } from '@/utils/markdown-content-shared'; -import { normalizeMarkdownForRichRendering } from '@/utils/markdown-rendering-utils'; - -hljs.registerLanguage('kotlin', kotlin); -hljs.registerLanguage('sql', sql); -hljs.registerLanguage('typescript', typescript); -hljs.registerLanguage('ts', typescript); -hljs.registerLanguage('javascript', javascript); -hljs.registerLanguage('js', javascript); -hljs.registerLanguage('java', java); -hljs.registerLanguage('python', python); -hljs.registerLanguage('css', css); -hljs.registerLanguage('html', xml); -hljs.registerLanguage('xml', xml); -hljs.registerLanguage('json', json); -hljs.registerLanguage('bash', bash); -hljs.registerLanguage('sh', bash); -hljs.registerLanguage('plaintext', plaintext); -hljs.registerLanguage('cpp', cpp); -hljs.registerLanguage('c', c); -hljs.registerLanguage('go', go); -hljs.registerLanguage('rust', rust); -hljs.registerLanguage('swift', swift); -hljs.registerLanguage('dart', dart); + enhanceRenderedMarkdown, + renderMarkdownToSafeHtml, +} from '@/components/common/ui/editor/markdown-render-pipeline'; export interface MarkdownContentCoreProps { content: string; @@ -59,189 +14,24 @@ export interface MarkdownContentCoreProps { emptyMessage?: string; } -const SANITIZE_OPTIONS: DOMPurifyConfig = { - ALLOWED_TAGS: [ - 'a', - 'blockquote', - 'br', - 'code', - 'del', - 'em', - 'h1', - 'h2', - 'h3', - 'hr', - 'iframe', - 'img', - 'li', - 'ol', - 'p', - 'pre', - 'span', - 'strong', - 'u', - 'ul', - ], - ALLOWED_ATTR: [ - 'allow', - 'allowfullscreen', - 'alt', - 'class', - 'frameborder', - 'height', - 'href', - 'loading', - 'referrerpolicy', - 'src', - 'title', - 'width', - ], - ALLOW_DATA_ATTR: false, - ALLOWED_URI_REGEXP: - /^(?:https?:\/\/|mailto:|tel:|\/images\/|\/emoticon\/|#)/i, -}; - -const IMAGE_WIDTH_MIN = 80; -const IMAGE_WIDTH_MAX = 400; - -const parseSanitizedImageWidth = ( - value: string | undefined, -): number | undefined => { - if (!value) { - return undefined; - } - - const parsed = Number(value); - if (!Number.isFinite(parsed)) { - return undefined; - } - - const clamped = Math.min( - IMAGE_WIDTH_MAX, - Math.max(IMAGE_WIDTH_MIN, Math.round(parsed)), - ); - - return clamped; -}; - -const applyPostSanitizeAttributes = ({ - originalHtml, - sanitizedHtml, -}: { - originalHtml: string; - sanitizedHtml: string; -}) => { - if (typeof window === 'undefined') { - return sanitizedHtml; - } - - const originalDocument = new window.DOMParser().parseFromString( - originalHtml, - 'text/html', - ); - const widthBucketsBySrc = new Map(); - - originalDocument.querySelectorAll('img[src]').forEach((imageElement) => { - const src = imageElement.getAttribute('src')?.trim(); - if (!src) { - return; - } - - const width = parseSanitizedImageWidth( - imageElement.getAttribute('width') ?? undefined, - ); - if (width === undefined) { - return; - } - - const bucket = widthBucketsBySrc.get(src) ?? []; - bucket.push(width); - widthBucketsBySrc.set(src, bucket); - }); - - const document = new window.DOMParser().parseFromString( - sanitizedHtml, - 'text/html', - ); - const anchors = document.querySelectorAll('a[href]'); - - anchors.forEach((anchor) => { - anchor.setAttribute('target', '_blank'); - anchor.setAttribute('rel', 'noreferrer'); - }); - - document.querySelectorAll('img[src]').forEach((imageElement) => { - const src = imageElement.getAttribute('src')?.trim(); - if (!src) { - return; - } - - imageElement.setAttribute('loading', 'lazy'); - imageElement.setAttribute('decoding', 'async'); - - const bucket = widthBucketsBySrc.get(src); - if (!bucket || bucket.length === 0) { - return; - } - - const width = bucket.shift(); - if (width === undefined) { - return; - } - - imageElement.setAttribute('width', String(width)); - }); - - applyYouTubeIframeAttributes(document); - - return document.body.innerHTML; -}; - export default function MarkdownContentCore({ content, className, emptyMessage = '아직 작성된 내용이 없습니다.', }: MarkdownContentCoreProps) { - const hasContent = content.trim().length > 0; const containerRef = useRef(null); - - const sanitizedHtml = useMemo(() => { - if (!hasContent) { - return ''; - } - - const contentWithEmbeds = replaceEmoticonShortcodes( - replaceStandaloneYouTubeLinksWithEmbeds(content), - ); - - const html = isHtmlContent(contentWithEmbeds) - ? contentWithEmbeds - : (() => { - const rendered = marked.parse(contentWithEmbeds, { - breaks: true, - gfm: true, - }); - return typeof rendered === 'string' ? rendered : ''; - })(); - - const sanitized = DOMPurify.sanitize(html, SANITIZE_OPTIONS); - - return applyPostSanitizeAttributes({ - originalHtml: html, - sanitizedHtml: sanitized, - }); - }, [content, hasContent]); + const sanitizedHtml = useMemo( + () => renderMarkdownToSafeHtml(content), + [content], + ); + const hasContent = sanitizedHtml.length > 0; useEffect(() => { if (!containerRef.current || !sanitizedHtml) { return; } - const codeBlocks = containerRef.current.querySelectorAll('pre code'); - - codeBlocks.forEach((block) => { - hljs.highlightElement(block as HTMLElement); - }); + enhanceRenderedMarkdown(containerRef.current); }, [sanitizedHtml]); if (!hasContent) { @@ -267,6 +57,10 @@ export default function MarkdownContentCore({ '[&_blockquote]:rounded-100 [&_blockquote]:bg-background-alternative [&_blockquote]:border-border-subtle [&_blockquote]:mb-150 [&_blockquote]:border-l-4 [&_blockquote]:px-150 [&_blockquote]:py-125', '[&_blockquote_p]:font-designer-16r [&_blockquote_p]:text-text-subtle [&_blockquote_p]:leading-relaxed', '[&_a]:text-text-brand [&_a]:underline', + '[&_s]:line-through [&_del]:line-through', + '[&_table]:border-border-subtle [&_table]:mb-150 [&_table]:w-full [&_table]:border-collapse [&_table]:overflow-hidden [&_table]:rounded-100 [&_table]:border', + '[&_th]:bg-background-alternative [&_th]:font-designer-13b [&_th]:text-text-default [&_th]:border-border-subtle [&_th]:border [&_th]:px-100 [&_th]:py-75 [&_th]:text-left', + '[&_td]:font-designer-13r [&_td]:text-text-default [&_td]:border-border-subtle [&_td]:border [&_td]:px-100 [&_td]:py-75', '[&_iframe.youtube-embed]:mb-150 [&_iframe.youtube-embed]:block [&_iframe.youtube-embed]:aspect-video [&_iframe.youtube-embed]:w-full [&_iframe.youtube-embed]:max-w-full [&_iframe.youtube-embed]:rounded-100 [&_iframe.youtube-embed]:border [&_iframe.youtube-embed]:border-border-subtle', '[&_img]:rounded-100 [&_img]:border-border-subtle [&_img]:mb-150 [&_img]:block [&_img]:h-auto [&_img]:max-h-rich-text-image [&_img]:max-w-rich-text-image [&_img]:border [&_img]:object-contain', '[&_img.emoticon-inline]:!inline-block [&_img.emoticon-inline]:!h-[24px] [&_img.emoticon-inline]:!w-auto [&_img.emoticon-inline]:!max-h-[24px] [&_img.emoticon-inline]:!max-w-none [&_img.emoticon-inline]:!border-0 [&_img.emoticon-inline]:!rounded-none [&_img.emoticon-inline]:!my-0 [&_img.emoticon-inline]:!mb-0 [&_img.emoticon-inline]:!align-middle', @@ -276,6 +70,8 @@ export default function MarkdownContentCore({ '[&_code]:rounded-50 [&_code]:bg-background-alternative [&_code]:font-designer-13r [&_code]:px-75 [&_code]:py-25', '[&_pre]:rounded-100 [&_pre]:bg-background-alternative [&_pre]:mb-150 [&_pre]:overflow-x-auto [&_pre]:px-125 [&_pre]:py-100', '[&_pre_code]:bg-transparent [&_pre_code]:px-0 [&_pre_code]:py-0', + '[&_.mermaid-rendered-diagram]:border-border-subtle [&_.mermaid-rendered-diagram]:bg-background-default [&_.mermaid-rendered-diagram]:mb-150 [&_.mermaid-rendered-diagram]:overflow-auto [&_.mermaid-rendered-diagram]:rounded-100 [&_.mermaid-rendered-diagram]:border [&_.mermaid-rendered-diagram]:p-125', + '[&_.mermaid-render-error]:border-border-error [&_.mermaid-render-error]:bg-background-error-subtle [&_.mermaid-render-error]:text-text-error [&_.mermaid-render-error]:font-designer-13r [&_.mermaid-render-error]:mb-150 [&_.mermaid-render-error]:rounded-100 [&_.mermaid-render-error]:border [&_.mermaid-render-error]:p-125', '[&_hr]:border-border-subtle [&_hr]:my-200', className, )} diff --git a/yarn.lock b/yarn.lock index cdc468170..3e1cb46aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3666,6 +3666,26 @@ resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-3.23.6.tgz#b68e8df11c8c24c82d8c292ff94bc962f3cec871" integrity sha512-oF7FEZ37f15aCe5kPgzGDYf/m+hr7VdQ/Ko/Hds/UM9pX7AG1fdtmRrl6wqkRqDM/incZaC/AQR2/Dpo2VCNGQ== +"@tiptap/extension-table-cell@^3.20.0": + version "3.26.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-table-cell/-/extension-table-cell-3.26.1.tgz#f028bf4744e33876ad5c2521d7c10fc737a65545" + integrity sha512-eCGgHrzIUPHZpz/z3F4O8yk+SM/HBcLVvAWTHl8P+4/GC2+6oVFH+9ixBDIMKiJugSOnuOY8uLm30+Ld/MtyTw== + +"@tiptap/extension-table-header@^3.20.0": + version "3.26.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-table-header/-/extension-table-header-3.26.1.tgz#7c00b6280020abd07281196ca27d80b351c27549" + integrity sha512-idVDYdhVpTL4hnzuf/MbE74HHjqqqIRCVwzfbTy/d5JnTnJ1LXpJZKz2oFWNOk5NaAq0kPhkwkz5lSBUgd2DbQ== + +"@tiptap/extension-table-row@^3.20.0": + version "3.26.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-table-row/-/extension-table-row-3.26.1.tgz#e8de4ab494316ac7882ae9d4d8a7f4a4a80e3fb4" + integrity sha512-zAr7bQcUHoBpeysvbzxW8JchMduUn0wGwA2UeEgoE1K+gep74wRHs9LE8NRd70hARbZLzgUMRXcpT+W1pdoMMw== + +"@tiptap/extension-table@^3.20.0": + version "3.26.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-table/-/extension-table-3.26.1.tgz#7012985aa2385eef231c36a3faec21e843421ac9" + integrity sha512-epxUhc5ecxsH39lzNejc2WxFPXAXWGs9g2ofKDrIaoSlZlfFHf89/sEGSz048a46E5Sb+fYCtzUvRUUx+aG4xw== + "@tiptap/extension-text@^3.23.6": version "3.23.6" resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-3.23.6.tgz#e6154568e0869c6fcd81c4df84dc41134311b483"