diff --git a/.changeset/few-oranges-tickle.md b/.changeset/few-oranges-tickle.md new file mode 100644 index 0000000..b355fcc --- /dev/null +++ b/.changeset/few-oranges-tickle.md @@ -0,0 +1,5 @@ +--- +"notion-to-jsx": patch +--- + +refactor: components to use TypeScript types and remove React imports diff --git a/packages/notion-to-jsx/src/components/Cover/index.tsx b/packages/notion-to-jsx/src/components/Cover/index.tsx index 01dcc17..b144245 100644 --- a/packages/notion-to-jsx/src/components/Cover/index.tsx +++ b/packages/notion-to-jsx/src/components/Cover/index.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { coverContainer, skeletonWrapper, imageStyle } from './styles.css'; import Skeleton from '../Skeleton'; -interface CoverProps { +interface Props { src: string; alt: string; } @@ -11,7 +11,7 @@ interface CoverProps { * 노션 페이지 상단에 표시되는 커버 이미지 컴포넌트 * 이미지 로딩 중에는 스켈레톤 UI를 표시하고, 로딩 완료 시 자연스럽게 이미지로 전환됩니다. */ -const Cover = ({ src, alt }: CoverProps) => { +const Cover = ({ src, alt }: Props) => { const [isLoaded, setIsLoaded] = useState(false); return ( diff --git a/packages/notion-to-jsx/src/components/Renderer/components/Block/BlockRenderer.tsx b/packages/notion-to-jsx/src/components/Renderer/components/Block/BlockRenderer.tsx index 8d2d06c..3ab0ae5 100644 --- a/packages/notion-to-jsx/src/components/Renderer/components/Block/BlockRenderer.tsx +++ b/packages/notion-to-jsx/src/components/Renderer/components/Block/BlockRenderer.tsx @@ -1,5 +1,3 @@ -import React from 'react'; - import { MemoizedRichText, MemoizedImage, @@ -12,76 +10,63 @@ import { ColumnList } from '../Column'; import { Quote } from '../Quote'; import Table from '../Table'; import { Toggle } from '../Toggle'; +import { NotionBlock } from '../../../../types'; export interface Props { - block: any; - onFocus?: () => void; - index: number; + block: NotionBlock; isColumn?: boolean; } -const BlockRenderer: React.FC = ({ - block, - onFocus, - index, - isColumn = false, -}) => { +const BlockRenderer = ({ block, isColumn = false }: Props) => { if (!block) return null; - const blockProps = { - tabIndex: 0, - onFocus, - }; - switch (block.type) { case 'link_preview': - return ( - - ); + return ; case 'paragraph': return ( - + ); case 'heading_1': return ( - + ); case 'heading_2': return ( - + ); case 'heading_3': return ( - + ); case 'code': return ( -
+
); case 'image': return ( -
+
= ({ ); case 'column_list': - return ; + return ; case 'column': // 개별 column은 ColumnList에서 처리됩니다 return null; case 'quote': - return ; + return ; case 'table': - return ; + return
; case 'toggle': - return ( - - ); + return ; default: return null; diff --git a/packages/notion-to-jsx/src/components/Renderer/components/Bookmark/Bookmark.tsx b/packages/notion-to-jsx/src/components/Renderer/components/Bookmark/Bookmark.tsx index 33779f9..7d5022b 100644 --- a/packages/notion-to-jsx/src/components/Renderer/components/Bookmark/Bookmark.tsx +++ b/packages/notion-to-jsx/src/components/Renderer/components/Bookmark/Bookmark.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { link, card, @@ -11,22 +10,14 @@ import { favicon, urlText, } from './styles.css'; +import { OpenGraphData } from './type'; -interface OpenGraphData { - title: string; - description: string; - image: string; - siteName: string; - url: string; - favicon?: string; -} - -export interface BookmarkProps { +export interface Props { url: string; metadata?: OpenGraphData; } -const Bookmark: React.FC = ({ url, metadata }) => { +const Bookmark = ({ url, metadata }: Props) => { return (
diff --git a/packages/notion-to-jsx/src/components/Renderer/components/Bookmark/type.ts b/packages/notion-to-jsx/src/components/Renderer/components/Bookmark/type.ts new file mode 100644 index 0000000..f06f00f --- /dev/null +++ b/packages/notion-to-jsx/src/components/Renderer/components/Bookmark/type.ts @@ -0,0 +1,8 @@ +export interface OpenGraphData { + title: string; + description: string; + image: string; + siteName: string; + url: string; + favicon?: string; +} diff --git a/packages/notion-to-jsx/src/components/Renderer/components/Code/CodeBlock.tsx b/packages/notion-to-jsx/src/components/Renderer/components/Code/CodeBlock.tsx index 459a048..4acf201 100644 --- a/packages/notion-to-jsx/src/components/Renderer/components/Code/CodeBlock.tsx +++ b/packages/notion-to-jsx/src/components/Renderer/components/Code/CodeBlock.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import { ReactNode, useMemo } from 'react'; import { codeBlock } from './styles.css'; import Prism, { Grammar, Token } from 'prismjs'; import { MemoizedRichText } from '../MemoizedComponents'; @@ -12,19 +12,13 @@ if (typeof window !== 'undefined') { window.Prism = Prism; } -export interface Props { - code: string; - language: string; - caption?: RichTextItem[]; -} - -const renderToken = (token: string | Token, i: number): React.ReactNode => { +const renderToken = (token: string | Token, i: number): ReactNode => { if (typeof token === 'string') { return {token}; } const content = token.content; - let tokenContent: React.ReactNode; + let tokenContent: ReactNode; if (Array.isArray(content)) { tokenContent = content.map((subToken, j) => renderToken(subToken, j)); @@ -41,7 +35,13 @@ const renderToken = (token: string | Token, i: number): React.ReactNode => { ); }; -const CodeBlock: React.FC = ({ code, language, caption }) => { +export interface Props { + code: string; + language: string; + caption?: RichTextItem[]; +} + +const CodeBlock = ({ code, language, caption }: Props) => { const tokens = useMemo(() => { const prismLanguage = Prism.languages[language] || Prism.languages.plaintext; diff --git a/packages/notion-to-jsx/src/components/Renderer/components/Column/Column.tsx b/packages/notion-to-jsx/src/components/Renderer/components/Column/Column.tsx index 5017b5a..d285875 100644 --- a/packages/notion-to-jsx/src/components/Renderer/components/Column/Column.tsx +++ b/packages/notion-to-jsx/src/components/Renderer/components/Column/Column.tsx @@ -1,25 +1,18 @@ -import React from 'react'; +import { ColumnBlock } from '../../../../types'; import BlockRenderer from '../Block/BlockRenderer'; import { columnContainer } from './styles.css'; export interface ColumnProps { - block: any; - onFocus?: () => void; + block: ColumnBlock; } -const Column: React.FC = ({ block, onFocus }) => { +const Column = ({ block }: ColumnProps) => { if (!block || !block.children) return null; return (
- {block.children.map((childBlock: any, index: number) => ( - + {block.children.map((childBlock) => ( + ))}
); diff --git a/packages/notion-to-jsx/src/components/Renderer/components/Column/ColumnList.tsx b/packages/notion-to-jsx/src/components/Renderer/components/Column/ColumnList.tsx index 5e273bf..eab92c9 100644 --- a/packages/notion-to-jsx/src/components/Renderer/components/Column/ColumnList.tsx +++ b/packages/notion-to-jsx/src/components/Renderer/components/Column/ColumnList.tsx @@ -1,19 +1,18 @@ -import React from 'react'; import Column from './Column'; import { columnListContainer } from './styles.css'; +import { ColumnListBlock } from '../../../../types'; export interface ColumnListProps { - block: any; - onFocus?: () => void; + block: ColumnListBlock; } -const ColumnList: React.FC = ({ block, onFocus }) => { +const ColumnList = ({ block }: ColumnListProps) => { if (!block || !block.children) return null; return (
- {block.children.map((column: any) => ( - + {block.children.map((column) => ( + ))}
); diff --git a/packages/notion-to-jsx/src/components/Renderer/components/Image/Image.tsx b/packages/notion-to-jsx/src/components/Renderer/components/Image/Image.tsx index 44727db..6b1c4b8 100644 --- a/packages/notion-to-jsx/src/components/Renderer/components/Image/Image.tsx +++ b/packages/notion-to-jsx/src/components/Renderer/components/Image/Image.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; import { MemoizedRichText } from '../MemoizedComponents'; import { imageContainer, @@ -16,15 +16,6 @@ export interface ImageFormat { block_aspect_ratio?: number; } -export interface ImageProps { - src: string; - alt: string; - caption?: RichTextItem[]; - priority?: boolean; - format?: ImageFormat; - isColumn?: boolean; -} - const MAX_WIDTH = 720; // 이미지 태그에 사용되는 aspectRatio 스타일 @@ -34,13 +25,22 @@ const getImageTagStyle = (format?: ImageFormat) => { : undefined; }; -const Image: React.FC = ({ +export interface Props { + src: string; + alt: string; + caption?: RichTextItem[]; + priority?: boolean; + format?: ImageFormat; + isColumn?: boolean; +} + +const Image = ({ src, alt, caption: imageCaption, format, isColumn = false, -}) => { +}: Props) => { const [isLoaded, setIsLoaded] = useState(false); return ( diff --git a/packages/notion-to-jsx/src/components/Renderer/components/Image/index.ts b/packages/notion-to-jsx/src/components/Renderer/components/Image/index.ts index ef76c9d..4b8b9bb 100644 --- a/packages/notion-to-jsx/src/components/Renderer/components/Image/index.ts +++ b/packages/notion-to-jsx/src/components/Renderer/components/Image/index.ts @@ -1,2 +1,2 @@ export { default as Image } from './Image'; -export type { ImageProps } from './Image'; +export type { Props as ImageProps } from './Image'; diff --git a/packages/notion-to-jsx/src/components/Renderer/components/LinkPreview/LinkPreview.tsx b/packages/notion-to-jsx/src/components/Renderer/components/LinkPreview/LinkPreview.tsx index ef4f4f7..2645a87 100644 --- a/packages/notion-to-jsx/src/components/Renderer/components/LinkPreview/LinkPreview.tsx +++ b/packages/notion-to-jsx/src/components/Renderer/components/LinkPreview/LinkPreview.tsx @@ -1,10 +1,6 @@ -import React, { useState, useEffect } from 'react'; +import { useState, useEffect } from 'react'; import * as styles from './styles.css'; -export interface LinkPreviewProps { - url: string; -} - interface RepoData { name: string; full_name: string; @@ -172,7 +168,10 @@ const formatUpdatedTime = (dateString: string): string => { } }; -const LinkPreview: React.FC = ({ url }) => { +export interface LinkPreviewProps { + url: string; +} +const LinkPreview = ({ url }: LinkPreviewProps) => { const [repoData, setRepoData] = useState(null); const [figmaData, setFigmaData] = useState(null); const [loading, setLoading] = useState(true); diff --git a/packages/notion-to-jsx/src/components/Renderer/components/List/List.tsx b/packages/notion-to-jsx/src/components/Renderer/components/List/List.tsx index 56f59ad..0527bc5 100644 --- a/packages/notion-to-jsx/src/components/Renderer/components/List/List.tsx +++ b/packages/notion-to-jsx/src/components/Renderer/components/List/List.tsx @@ -1,20 +1,19 @@ -import React from 'react'; +import { PropsWithChildren, HTMLAttributes } from 'react'; import { list, listItem } from './styles.css'; interface ListProps - extends React.HTMLAttributes { + extends HTMLAttributes { as?: 'ul' | 'ol'; - type: 'bulleted' | 'numbered'; - children: React.ReactNode; + type: 'bulleted_list_item' | 'numbered_list_item'; } -export const List: React.FC = ({ +export const List = ({ as: Component = 'ul', type, className, children, ...props -}) => { +}: PropsWithChildren) => { return ( {children} @@ -22,15 +21,11 @@ export const List: React.FC = ({ ); }; -interface ListItemProps extends React.HTMLAttributes { - children: React.ReactNode; -} - -export const ListItem: React.FC = ({ +export const ListItem = ({ className, children, ...props -}) => { +}: PropsWithChildren>) => { return (
  • {children} diff --git a/packages/notion-to-jsx/src/components/Renderer/components/List/ListBlocksRenderer.tsx b/packages/notion-to-jsx/src/components/Renderer/components/List/ListBlocksRenderer.tsx index 4a258b6..3a0c6fa 100644 --- a/packages/notion-to-jsx/src/components/Renderer/components/List/ListBlocksRenderer.tsx +++ b/packages/notion-to-jsx/src/components/Renderer/components/List/ListBlocksRenderer.tsx @@ -1,72 +1,80 @@ -import React from 'react'; import { List, ListItem } from './List'; import { MemoizedRichText } from '../MemoizedComponents'; +import { + BulletedListItemBlock, + NumberedListItemBlock, +} from '../../../../types'; -export interface Props { - blocks: any[]; - startIndex: number; - type: 'bulleted' | 'numbered'; +interface RecursiveListItemProps { + block: BulletedListItemBlock | NumberedListItemBlock; } - // 리스트 아이템을 렌더링하는 컴포넌트 (중첩 리스트 지원) -const RecursiveListItem: React.FC<{ - block: any; - index: number; -}> = ({ block, index }) => { - const blockProps = { - tabIndex: 0, - }; - +const RecursiveListItem = ({ block }: RecursiveListItemProps) => { const blockType = block.type; - const richTexts = block[blockType]?.rich_text; + let content; + + if (blockType === 'bulleted_list_item') { + content = block.bulleted_list_item; + } else { + content = block.numbered_list_item; + } + + const richTexts = content.rich_text; + + // 자식 블록들을 필터링하여 현재 리스트 타입(bulleted 또는 numbered)과 일치하는 블록만 선택 + const filteredChildren = block.children?.filter( + (child): child is BulletedListItemBlock | NumberedListItemBlock => + child.type === blockType + ); return ( - + - {block.children && block.children.length > 0 && ( - + {filteredChildren && filteredChildren.length > 0 && ( + )} ); }; +interface RecursiveListGroupProps { + blocks: (BulletedListItemBlock | NumberedListItemBlock)[]; + type: 'bulleted_list_item' | 'numbered_list_item'; +} // 중첩 리스트 그룹을 렌더링하는 컴포넌트 -const RecursiveListGroup: React.FC<{ - blocks: any[]; - type: 'bulleted' | 'numbered'; -}> = ({ blocks, type }) => { +const RecursiveListGroup = ({ blocks, type }: RecursiveListGroupProps) => { if (!blocks || blocks.length === 0) return null; - // 리스트 타입에 맞는 아이템만 필터링 - const listItems = blocks.filter( - (block) => block.type === `${type}_list_item` - ); - - if (listItems.length === 0) return null; - return ( - {listItems.map((block, index) => ( - + {blocks.map((block) => ( + ))} ); }; -const ListBlocksRenderer: React.FC = ({ blocks, startIndex, type }) => { +export interface ListBlocksRendererProps { + blocks: (BulletedListItemBlock | NumberedListItemBlock)[]; + startIndex: number; + type: (BulletedListItemBlock | NumberedListItemBlock)['type']; +} + +const ListBlocksRenderer = ({ + blocks, + startIndex, + type, +}: ListBlocksRendererProps) => { let consecutiveItems = 0; for (let i = startIndex; i < blocks.length; i++) { const block = blocks[i]; if (!block) break; - if (block.type === `${type}_list_item`) { + if (block.type === type) { consecutiveItems++; } else { break; @@ -77,17 +85,13 @@ const ListBlocksRenderer: React.FC = ({ blocks, startIndex, type }) => { return ( - {listItems.map((block, index) => ( - + {listItems.map((block) => ( + ))} ); diff --git a/packages/notion-to-jsx/src/components/Renderer/components/List/styles.css.ts b/packages/notion-to-jsx/src/components/Renderer/components/List/styles.css.ts index 59bf973..c29dd02 100644 --- a/packages/notion-to-jsx/src/components/Renderer/components/List/styles.css.ts +++ b/packages/notion-to-jsx/src/components/Renderer/components/List/styles.css.ts @@ -10,10 +10,10 @@ export const list = recipe({ }, variants: { type: { - bulleted: { + bulleted_list_item: { listStyleType: 'disc', }, - numbered: { + numbered_list_item: { listStyleType: 'decimal', }, }, diff --git a/packages/notion-to-jsx/src/components/Renderer/components/MemoizedComponents.tsx b/packages/notion-to-jsx/src/components/Renderer/components/MemoizedComponents.tsx index 679759a..00fe61a 100644 --- a/packages/notion-to-jsx/src/components/Renderer/components/MemoizedComponents.tsx +++ b/packages/notion-to-jsx/src/components/Renderer/components/MemoizedComponents.tsx @@ -1,17 +1,15 @@ -import React from 'react'; +import { memo } from 'react'; import RichText, { RichTextItem, RichTextProps } from './RichText/RichTexts'; import { Image, ImageProps } from './Image'; -import Bookmark, { type BookmarkProps } from './Bookmark/Bookmark'; +import Bookmark, { type Props as BookmarkProps } from './Bookmark/Bookmark'; + import LinkPreview, { type LinkPreviewProps } from './LinkPreview/LinkPreview'; -export const MemoizedRichText = React.memo( - RichText, - (prev, next) => { - return JSON.stringify(prev.richTexts) === JSON.stringify(next.richTexts); - } -); +export const MemoizedRichText = memo(RichText, (prev, next) => { + return JSON.stringify(prev.richTexts) === JSON.stringify(next.richTexts); +}); -export const MemoizedImage = React.memo(Image, (prev, next) => { +export const MemoizedImage = memo(Image, (prev, next) => { return ( prev.src === next.src && prev.alt === next.alt && @@ -19,14 +17,11 @@ export const MemoizedImage = React.memo(Image, (prev, next) => { ); }); -export const MemoizedBookmark = React.memo( - Bookmark, - (prev, next) => { - return prev.url === next.url; - } -); +export const MemoizedBookmark = memo(Bookmark, (prev, next) => { + return prev.url === next.url; +}); -export const MemoizedLinkPreview = React.memo( +export const MemoizedLinkPreview = memo( LinkPreview, (prev, next) => { return prev.url === next.url; diff --git a/packages/notion-to-jsx/src/components/Renderer/components/Quote/Quote.tsx b/packages/notion-to-jsx/src/components/Renderer/components/Quote/Quote.tsx index 05ba591..f19a1db 100644 --- a/packages/notion-to-jsx/src/components/Renderer/components/Quote/Quote.tsx +++ b/packages/notion-to-jsx/src/components/Renderer/components/Quote/Quote.tsx @@ -1,15 +1,13 @@ -import React from 'react'; import { MemoizedRichText } from '../MemoizedComponents'; import { container } from './styles.css'; import { RichTextItem } from '../RichText/RichTexts'; -import { richText } from '../RichText/styles.css'; export interface QuoteProps { richTexts: RichTextItem[]; tabIndex?: number; } -const Quote: React.FC = ({ richTexts, tabIndex }) => { +const Quote = ({ richTexts, tabIndex }: QuoteProps) => { return (
    diff --git a/packages/notion-to-jsx/src/components/Renderer/components/RichText/RichTexts.tsx b/packages/notion-to-jsx/src/components/Renderer/components/RichText/RichTexts.tsx index 6c5005f..0a8d6c1 100644 --- a/packages/notion-to-jsx/src/components/Renderer/components/RichText/RichTexts.tsx +++ b/packages/notion-to-jsx/src/components/Renderer/components/RichText/RichTexts.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import { ReactNode } from 'react'; import { richText, link } from './styles.css'; // 지원하는 Notion 색상 타입 정의 @@ -50,20 +50,20 @@ export interface RichTextItem { }; } -export interface RichTextProps { - richTexts: RichTextItem[]; -} - /** * 링크 컴포넌트를 생성하는 함수 */ -const renderLink = (href: string, content: React.ReactNode) => ( +const renderLink = (href: string, content: ReactNode) => ( {content} ); -const RichTexts: React.FC = ({ richTexts }) => { +export interface RichTextProps { + richTexts: RichTextItem[]; +} + +const RichTexts = ({ richTexts }: RichTextProps) => { return ( <> {richTexts.map((text, index) => { diff --git a/packages/notion-to-jsx/src/components/Renderer/components/Table/Table.tsx b/packages/notion-to-jsx/src/components/Renderer/components/Table/Table.tsx index 78f5af0..f338487 100644 --- a/packages/notion-to-jsx/src/components/Renderer/components/Table/Table.tsx +++ b/packages/notion-to-jsx/src/components/Renderer/components/Table/Table.tsx @@ -1,27 +1,23 @@ -import React from 'react'; -import { tableContainer, table, headerCell, hasRowHeader } from './styles.css'; +import { tableContainer, table, headerCell } from './styles.css'; import TableRow from './TableRow'; -import { NotionBlock } from '../../../../types'; +import { TableBlock } from '../../../../types'; interface TableProps { - block: NotionBlock; - tabIndex?: number; + block: TableBlock; } -const Table: React.FC = ({ block, tabIndex = 0 }) => { +const Table = ({ block }: TableProps) => { if (!block.table || !block.children) { return null; } const { table_width, has_column_header, has_row_header } = block.table; const rows = - block.children?.filter( - (child: NotionBlock) => child.type === 'table_row' - ) || []; + block.children?.filter((child) => child.type === 'table_row') || []; return (
    -
  • +
    {rows.length > 0 && ( <> {has_column_header && rows[0] && ( @@ -32,20 +28,13 @@ const Table: React.FC = ({ block, tabIndex = 0 }) => { {/* 유효한 row만 매핑하도록 필터링 추가 */} {rows - .filter( - (row): row is NotionBlock => - row !== undefined && row.type === 'table_row' - ) - .map((row: NotionBlock, rowIndex: number) => { + .filter((row) => row !== undefined && row.type === 'table_row') + .map((row, rowIndex: number) => { // 열 헤더가 있고 첫 번째 행이면 이미 thead에서 렌더링되었으므로 건너뜁니다 if (has_column_header && rowIndex === 0) { return null; } - const actualRowIndex = has_column_header - ? rowIndex - 1 - : rowIndex; - // 타입 체크를 통해 row가 실제 Block 타입임을 확인합니다 return ( = ({ +const TableRow = ({ rowBlock, cellClassName = '', rowHeaderIndex = -1, -}) => { +}: TableRowProps) => { if (!rowBlock.table_row?.cells) { return null; } diff --git a/packages/notion-to-jsx/src/components/Renderer/components/Table/index.ts b/packages/notion-to-jsx/src/components/Renderer/components/Table/index.ts index 7d14179..3e0d7e7 100644 --- a/packages/notion-to-jsx/src/components/Renderer/components/Table/index.ts +++ b/packages/notion-to-jsx/src/components/Renderer/components/Table/index.ts @@ -1,5 +1,3 @@ import Table from './Table'; -import TableRow from './TableRow'; - -export { TableRow }; +export { default as TableRow } from './TableRow'; export default Table; diff --git a/packages/notion-to-jsx/src/components/Renderer/components/Toggle/Toggle.tsx b/packages/notion-to-jsx/src/components/Renderer/components/Toggle/Toggle.tsx index 9cfe515..09e7ef9 100644 --- a/packages/notion-to-jsx/src/components/Renderer/components/Toggle/Toggle.tsx +++ b/packages/notion-to-jsx/src/components/Renderer/components/Toggle/Toggle.tsx @@ -1,22 +1,20 @@ -import React, { useState } from 'react'; -import { NotionBlock } from '../../../../types'; -import { - toggleContainer, - toggleHeader, - toggleIcon, - toggleIconOpen, - toggleContent +import { useState, KeyboardEvent } from 'react'; +import { ToggleBlock } from '../../../../types'; +import { + toggleContainer, + toggleHeader, + toggleIcon, + toggleIconOpen, + toggleContent, } from './styles.css'; import { RichTexts } from '../../components/RichText'; import BlockRenderer from '../../components/Block/BlockRenderer'; interface ToggleProps { - block: NotionBlock; - tabIndex?: number; - onFocus?: () => void; + block: ToggleBlock; } -const Toggle: React.FC = ({ block, tabIndex = 0, onFocus }) => { +const Toggle = ({ block }: ToggleProps) => { const [isOpen, setIsOpen] = useState(false); // Toggle이 없거나 children이 없는 경우 렌더링하지 않음 @@ -28,7 +26,7 @@ const Toggle: React.FC = ({ block, tabIndex = 0, onFocus }) => { setIsOpen(!isOpen); }; - const handleKeyDown = (e: React.KeyboardEvent) => { + const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleToggle(); @@ -37,12 +35,10 @@ const Toggle: React.FC = ({ block, tabIndex = 0, onFocus }) => { return (
    -
    @@ -54,12 +50,8 @@ const Toggle: React.FC = ({ block, tabIndex = 0, onFocus }) => { {isOpen && block.children && (
    - {block.children.map((childBlock, index) => ( - + {block.children.map((childBlock) => ( + ))}
    )} diff --git a/packages/notion-to-jsx/src/components/Renderer/components/Typography/Typography.tsx b/packages/notion-to-jsx/src/components/Renderer/components/Typography/Typography.tsx index 099c81c..cccc524 100644 --- a/packages/notion-to-jsx/src/components/Renderer/components/Typography/Typography.tsx +++ b/packages/notion-to-jsx/src/components/Renderer/components/Typography/Typography.tsx @@ -1,15 +1,13 @@ -import React from 'react'; +import { HTMLAttributes, PropsWithChildren } from 'react'; import { paragraph, heading1, heading2, heading3 } from './styles.css'; -interface TypographyProps extends React.HTMLAttributes { - children: React.ReactNode; -} +type TypographyProps = PropsWithChildren>; -export const Paragraph: React.FC = ({ +export const Paragraph = ({ className, children, ...props -}) => { +}: TypographyProps) => { return (

    {children} @@ -17,11 +15,11 @@ export const Paragraph: React.FC = ({ ); }; -export const Heading1: React.FC = ({ +export const Heading1 = ({ className, children, ...props -}) => { +}: TypographyProps) => { return (

    {children} @@ -29,11 +27,11 @@ export const Heading1: React.FC = ({ ); }; -export const Heading2: React.FC = ({ +export const Heading2 = ({ className, children, ...props -}) => { +}: TypographyProps) => { return (

    {children} @@ -41,11 +39,11 @@ export const Heading2: React.FC = ({ ); }; -export const Heading3: React.FC = ({ +export const Heading3 = ({ className, children, ...props -}) => { +}: TypographyProps) => { return (

    {children} diff --git a/packages/notion-to-jsx/src/components/Renderer/index.tsx b/packages/notion-to-jsx/src/components/Renderer/index.tsx index 09de447..fd45baf 100644 --- a/packages/notion-to-jsx/src/components/Renderer/index.tsx +++ b/packages/notion-to-jsx/src/components/Renderer/index.tsx @@ -1,107 +1,100 @@ -import React, { useState, useMemo, useCallback } from 'react'; -import { container } from './styles.css'; +import { useMemo, memo } from 'react'; -import { NotionBlock } from '../../types'; -import { useKeyboardNavigation } from '../../hooks/useKeyboardNavigation'; import { ListBlocksRenderer } from './components/List'; import { BlockRenderer } from './components/Block'; -import '../../styles/reset.css'; -import { darkTheme, lightTheme } from '../../styles/theme.css'; import Title from '../Title'; import Cover from '../Cover'; +import { + BulletedListItemBlock, + NotionBlock, + NumberedListItemBlock, +} from '../../types'; +import { container } from './styles.css'; +import '../../styles/reset.css'; +import { darkTheme, lightTheme } from '../../styles/theme.css'; + interface Props { blocks: NotionBlock[]; title?: string; cover?: string; isDarkMode?: boolean; - onBlockFocus?: (index: number) => void; } -export const Renderer: React.FC = React.memo( - ({ blocks, isDarkMode = false, title, cover, onBlockFocus }) => { - const theme = isDarkMode ? darkTheme : lightTheme; - const [focusedIndex, setFocusedIndex] = useState(-1); - - const handleBlockFocus = useCallback( - (index: number) => { - setFocusedIndex(index); - onBlockFocus?.(index); - }, - [onBlockFocus] - ); - - const renderedBlocks = useMemo(() => { - const result: JSX.Element[] = []; - - for (let i = 0; i < blocks.length; i++) { - const block = blocks[i]; - if (!block) break; - - // 리스트 아이템 타입 처리를 위한 공통 함수 - const handleListItem = (listType: 'bulleted' | 'numbered') => { - const listItemType = `${listType}_list_item`; - - if ( - block.type === listItemType && - (i === 0 || blocks[i - 1]?.type !== listItemType) - ) { - result.push( - - ); - - // 연속된 같은 타입의 리스트 아이템 건너뛰기 - while ( - i + 1 < blocks.length && - blocks[i + 1] && - blocks[i + 1]?.type === listItemType - ) { - i++; - } - - return true; - } - return false; - }; +const Renderer = memo(({ blocks, isDarkMode = false, title, cover }: Props) => { + const theme = isDarkMode ? darkTheme : lightTheme; + + const renderedBlocks = useMemo(() => { + const result: JSX.Element[] = []; - // 순서대로 각 리스트 타입 처리 시도 - if (handleListItem('bulleted') || handleListItem('numbered')) { - // 리스트 아이템이 처리되었으므로 다음 블록으로 진행 - continue; - } else { - // 리스트 아이템이 아닌 일반 블록 처리 + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]; + if (!block) break; + + // 리스트 아이템 타입 처리를 위한 공통 함수 + const handleListItem = (listType: 'bulleted' | 'numbered') => { + const listItemType = `${listType}_list_item` as ( + | BulletedListItemBlock + | NumberedListItemBlock + )['type']; + + if ( + block.type === listItemType && + (i === 0 || blocks[i - 1]?.type !== listItemType) + ) { result.push( - handleBlockFocus(i)} + blocks={ + blocks as (BulletedListItemBlock | NumberedListItemBlock)[] + } + startIndex={i} + type={listItemType} /> ); + + // 연속된 같은 타입의 리스트 아이템 건너뛰기 + while ( + i + 1 < blocks.length && + blocks[i + 1] && + blocks[i + 1]?.type === listItemType + ) { + i++; + } + + return true; } + + return false; + }; + + // 순서대로 각 리스트 타입 처리 시도 + if (handleListItem('bulleted') || handleListItem('numbered')) { + // 리스트 아이템이 처리되었으므로 다음 블록으로 진행 + continue; + } else { + // 리스트 아이템이 아닌 일반 블록 처리 + result.push(); } + } - return result; - }, [blocks, handleBlockFocus]); - - return ( - <> - {cover && } -
    - {title && } - {renderedBlocks} - </article> - </> - ); - } -); + return result; + }, [blocks]); + + return ( + <> + {cover && <Cover src={cover} alt={title || 'Notion page content'} />} + <article + className={`${theme} ${container}`} + aria-label={title || 'Notion page content'} + > + {title && <Title title={title} />} + {renderedBlocks} + </article> + </> + ); +}); Renderer.displayName = 'Renderer'; + +export default Renderer; diff --git a/packages/notion-to-jsx/src/components/Skeleton/index.tsx b/packages/notion-to-jsx/src/components/Skeleton/index.tsx index d5b0132..76c6b8e 100644 --- a/packages/notion-to-jsx/src/components/Skeleton/index.tsx +++ b/packages/notion-to-jsx/src/components/Skeleton/index.tsx @@ -1,4 +1,3 @@ -import { FC } from 'react'; import * as styles from './styles.css'; type SkeletonProps = { @@ -25,12 +24,12 @@ type SkeletonProps = { * 콘텐츠 로딩 중에 표시되는 물결 효과가 있는 스켈레톤 컴포넌트입니다. * 이미지, 텍스트 등의 로딩 상태를 표시하는 데 사용합니다. */ -const Skeleton: FC<SkeletonProps> = ({ +const Skeleton = ({ variant = 'rect', width, height, className, -}) => { +}: SkeletonProps) => { const getVariantClass = () => { switch (variant) { case 'circle': diff --git a/packages/notion-to-jsx/src/hooks/useIntersectionObserver.ts b/packages/notion-to-jsx/src/hooks/useIntersectionObserver.ts deleted file mode 100644 index c8eaebb..0000000 --- a/packages/notion-to-jsx/src/hooks/useIntersectionObserver.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useEffect, useRef, RefObject } from 'react'; - -interface IntersectionObserverOptions { - onIntersect: () => void; - threshold?: number; - rootMargin?: string; - enabled?: boolean; -} - -export const useIntersectionObserver = <T extends HTMLElement>({ - onIntersect, - threshold = 0, - rootMargin = '0px', - enabled = true, -}: IntersectionObserverOptions): RefObject<T> => { - const ref = useRef<T>(null); - - useEffect(() => { - if (!enabled) return; - - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - onIntersect(); - } - }); - }, - { - threshold, - rootMargin, - } - ); - - const element = ref.current; - if (element) { - observer.observe(element); - } - - return () => { - if (element) { - observer.unobserve(element); - } - }; - }, [enabled, onIntersect, rootMargin, threshold]); - - return ref; -}; diff --git a/packages/notion-to-jsx/src/index.ts b/packages/notion-to-jsx/src/index.ts index 0a17456..b8b4771 100644 --- a/packages/notion-to-jsx/src/index.ts +++ b/packages/notion-to-jsx/src/index.ts @@ -1,2 +1,2 @@ -export * from './components/Renderer'; +export { default as Renderer } from './components/Renderer'; export * from './types'; diff --git a/packages/notion-to-jsx/src/types/index.ts b/packages/notion-to-jsx/src/types/index.ts index da22fe2..25452d6 100644 --- a/packages/notion-to-jsx/src/types/index.ts +++ b/packages/notion-to-jsx/src/types/index.ts @@ -1,81 +1,179 @@ +import { OpenGraphData } from '../components/Renderer/components/Bookmark/type'; import { RichTextItem } from '../components/Renderer/components/RichText/RichTexts'; -export interface NotionBlock { +// 기본 공통 속성 +interface BaseNotionBlock { object: 'block'; id: string; - type: - | 'paragraph' - | 'heading_1' - | 'heading_2' - | 'heading_3' - | 'bulleted_list_item' - | 'numbered_list_item' - | 'code' - | 'image' - | 'bookmark' - | 'table' - | 'table_row' - | 'quote' - | 'toggle'; - paragraph?: { - rich_text: RichTextItem[]; - color: string; + children?: NotionBlock[]; + has_children?: boolean; + parent?: { + type: string; + [key: string]: any; }; - heading_1?: { - rich_text: RichTextItem[]; - color: string; +} + +// 링크 프리뷰 블록 +interface LinkPreviewBlock extends BaseNotionBlock { + type: 'link_preview'; + link_preview: { + url: string; }; - heading_2?: { +} + +// 단락 블록 +interface ParagraphBlock extends BaseNotionBlock { + type: 'paragraph'; + paragraph: { rich_text: RichTextItem[]; color: string; }; - heading_3?: { +} + +// 제목 1 블록 +interface Heading1Block extends BaseNotionBlock { + type: 'heading_1'; + heading_1: { rich_text: RichTextItem[]; color: string; }; - bulleted_list_item?: { +} + +// 제목 2 블록 +interface Heading2Block extends BaseNotionBlock { + type: 'heading_2'; + heading_2: { rich_text: RichTextItem[]; color: string; }; - numbered_list_item?: { +} + +// 제목 3 블록 +interface Heading3Block extends BaseNotionBlock { + type: 'heading_3'; + heading_3: { rich_text: RichTextItem[]; color: string; }; - code?: { +} + +// 코드 블록 +interface CodeBlock extends BaseNotionBlock { + type: 'code'; + code: { rich_text: RichTextItem[]; language: string; caption: RichTextItem[]; }; - image?: { +} + +// 이미지 블록 +interface ImageBlock extends BaseNotionBlock { + type: 'image'; + image: { type: 'file' | 'external'; file?: { url: string; expiry_time: string }; external?: { url: string }; caption: RichTextItem[]; + format: { + block_width: number; + block_height: number; + block_aspect_ratio: number; + }; }; - bookmark?: { +} + +// 북마크 블록 +interface BookmarkBlock extends BaseNotionBlock { + type: 'bookmark'; + bookmark: { url: string; caption: RichTextItem[]; + metadata?: OpenGraphData; }; - table?: { +} + +// 테이블 블록 +export interface TableBlock extends BaseNotionBlock { + type: 'table'; + table: { table_width: number; has_column_header: boolean; has_row_header: boolean; }; - table_row?: { + children?: TableRowBlock[]; +} + +// 테이블 행 블록 +export interface TableRowBlock extends BaseNotionBlock { + type: 'table_row'; + table_row: { cells: RichTextItem[][]; }; - quote?: { +} + +// 인용 블록 +interface QuoteBlock extends BaseNotionBlock { + type: 'quote'; + quote: { rich_text: RichTextItem[]; color: string; }; - toggle?: { +} + +// 토글 블록 +export interface ToggleBlock extends BaseNotionBlock { + type: 'toggle'; + toggle: { rich_text: RichTextItem[]; color: string; }; - children?: NotionBlock[]; - has_children?: boolean; - parent?: { - type: string; - [key: string]: any; +} + +// 목록 항목 블록 (불릿) +export interface BulletedListItemBlock extends BaseNotionBlock { + type: 'bulleted_list_item'; + bulleted_list_item: { + rich_text: RichTextItem[]; + color: string; }; } + +// 목록 항목 블록 (번호) +export interface NumberedListItemBlock extends BaseNotionBlock { + type: 'numbered_list_item'; + numbered_list_item: { + rich_text: RichTextItem[]; + color: string; + }; +} + +// 컬럼 리스트 블록 +export interface ColumnListBlock extends BaseNotionBlock { + type: 'column_list'; + children?: ColumnBlock[]; +} + +// 컬럼 블록 +export interface ColumnBlock extends BaseNotionBlock { + type: 'column'; +} + +// 최종 NotionBlock 타입은 모든 블록 타입의 유니온 +export type NotionBlock = + | LinkPreviewBlock + | ParagraphBlock + | Heading1Block + | Heading2Block + | Heading3Block + | CodeBlock + | ImageBlock + | BookmarkBlock + | TableBlock + | TableRowBlock + | QuoteBlock + | ToggleBlock + | BulletedListItemBlock + | NumberedListItemBlock + | ColumnListBlock + | ColumnBlock;