import React from 'react' ; import throttle from 'lodash.throttle' ; import { getBlockIcon , getBlockTitle , getTextContent , getPageTableOfContents , getBlockParentPage , uuidToId } from 'notion-utils' ; import * as types from 'notion-types' ; import { PageIcon } from './components/page-icon' ; import { PageTitle } from './components/page-title' ; import { LinkIcon } from './icons/link-icon' ; import { Header } from './components/header' ; import { GoogleDrive } from './components/google-drive' ; import { Audio } from './components/audio' ; import { File } from './components/file' ; import { Equation } from './components/equation' ; import { GracefulImage } from './components/graceful-image' ; import { LazyImage } from './components/lazy-image' ; import { useNotionContext } from './context' ; import { cs , getListNumber , isUrl } from './utils' ; import { Text } from './components/text' ; import { SyncPointerBlock } from './components/sync-pointer-block' ; import { AssetWrapper } from './components/asset-wrapper' ; interface BlockProps { block : types . Block ; level : number ; className ? : string ; bodyClassName ? : string ; header ? : React . ElementType ; footer ? : React . ReactNode ; pageHeader ? : React . ReactNode ; pageFooter ? : React . ReactNode ; pageAside ? : React . ReactNode ; pageCover ? : React . ReactNode ; hideBlockId ? : boolean ; disableHeader ? : boolean ; } const tocIndentLevelCache = { } ; export const Block = ( props ) => { const { components , fullPage , darkMode , recordMap , mapPageUrl , mapImageUrl , showTableOfContents , minTableOfContentsItems , defaultPageIcon , defaultPageCover , defaultPageCoverPosition } = useNotionContext ( ) ; const { block , children , level , className , bodyClassName , header , footer , pageHeader , pageFooter , pageAside , pageCover , hideBlockId , disableHeader } = props ; if ( ! block ) { return null ; } if ( level === 0 && block . type === 'collection_view' ) { ; ( block as any ) . type = 'collection_view_page' ; } const blockId = hideBlockId ? 'notion-block' : `notion-block-${ uuidToId ( block . id ) }` ; switch ( block . type ) { case 'collection_view_page' : case 'page' : if ( level === 0 ) { const { page_icon = defaultPageIcon , page_cover = defaultPageCover , page_cover_position = defaultPageCoverPosition , page_full_width , page_small_text } = block . format || { } ; if ( fullPage ) { const properties = block . type === 'page' ? block . properties : { title : recordMap . collection [ block . collection_id ] ?. value ?. name } ; const coverPosition = ( 1 - ( page_cover_position || 0.5 ) ) * 100 ; const pageIcon = getBlockIcon ( block , recordMap ) ?? defaultPageIcon ; const isPageIconUrl = pageIcon && isUrl ( pageIcon ) ; const toc = getPageTableOfContents ( block as types . PageBlock , recordMap ) ; const hasToc = showTableOfContents && toc . length >= minTableOfContentsItems ; const hasAside = ( hasToc || pageAside ) && ! page_full_width ; const [ activeSection , setActiveSection ] = React . useState ( null ) ; const throttleMs = 100 ; const actionSectionScrollSpy = throttle ( ( ) => { const sections = document . getElementsByClassName ( 'notion-h' ) ; let prevBBox = null ; let currentSectionId = activeSection ; for ( let i = 0 ; i < sections . length ; ++ i ) { const section = sections [ i ] ; if ( ! section || ! ( section instanceof Element ) ) continue ; if ( ! currentSectionId ) { currentSectionId = section . getAttribute ( 'data-id' ) ; } const bbox = section . getBoundingClientRect ( ) ; const prevHeight = prevBBox ? bbox . top - prevBBox . bottom : 0 ; const offset = Math . max ( 150 , prevHeight / 4 ) ; if ( bbox . top - offset < 0 ) { currentSectionId = section . getAttribute ( 'data-id' ) ; prevBBox = bbox ; continue ; } break ; } setActiveSection ( currentSectionId ) ; } , throttleMs ) ; if ( hasToc ) { React . useEffect ( ( ) => { window . addEventListener ( 'scroll' , actionSectionScrollSpy ) ; actionSectionScrollSpy ( ) ; return ( ) => { window . removeEventListener ( 'scroll' , actionSectionScrollSpy ) ; } ; } , [ ] ) ; } const hasPageCover = pageCover || page_cover ; return ( < div className = { cs ( 'notion' , 'notion-app' , darkMode ? 'dark-mode' : 'light-mode' , blockId , className ) } > < div className = 'notion-viewport' / > < div className = 'notion-frame' > { ! disableHeader && < Header header = { header } />} < div className = 'notion-page-scroller' > { hasPageCover ? ( pageCover ? ( pageCover ) : ( < LazyImage src = { mapImageUrl ( page_cover , block ) } alt = { getTextContent ( properties ?. title ) } className = 'notion-page-cover' style = { { objectPosition : `center ${ coverPosition }%` } } />)) : null} < main className = { cs ( 'notion-page' , hasPageCover ? 'notion-page-has-cover' : 'notion-page-no-cover' , page_icon ? 'notion-page-has-icon' : 'notion-page-no-icon' , isPageIconUrl ? 'notion-page-has-image-icon' : 'notion-page-has-text-icon' , 'notion-full-page' , page_full_width && 'notion-full-width' , page_small_text && 'notion-small-text' , bodyClassName ) } > { page_icon && ( < div className = 'notion-page-icon-wrapper' > < PageIcon block = { block } defaultIcon = { defaultPageIcon } / > < /div>)} { pageHeader } < h1 className = 'notion-title' > < Text value = { properties ?. title } block = { block } / > < /h1> { block . type === 'page' && block . parent_table === 'collection' && ( < components . collectionRow block = { block } />)} { block . type === 'collection_view_page' && ( < components . collection block = { block } />)} < div className = { cs ( 'notion-page-content' , hasAside && 'notion-page-content-has-aside' , hasToc && 'notion-page-content-has-toc' ) } > < article className = 'notion-page-content-inner' > { children } < /article> { hasAside && ( < aside className = 'notion-aside' > { hasToc && ( < div className = 'notion-aside-table-of-contents' > < div className = 'notion-aside-table-of-contents-header' > Table of Contents < /div> < nav className = { cs ( 'notion-table-of-contents' , ! darkMode && 'notion-gray' ) } > { toc . map ( ( tocItem ) => { const id = uuidToId ( tocItem . id ) ; return ( < a key = { id } href = { `#${ id }` } className = { cs ( 'notion-table-of-contents-item' , `notion-table-of-contents-item-indent-level-${ tocItem . indentLevel } `, activeSection === id &&
                                            'notion-table-of-contents-active-item')}>
                                      <span className='notion-table-of-contents-item-body' style={{
                                            display: 'inline-block',
                                            marginLeft: tocItem.indentLevel * 16
                                        }}>
                                        {tocItem.text}
                                      </span>
                                    </a>);
                                })}
                              </nav>
                            </div>)}

                          {pageAside}
                        </aside>)}
                    </div>

                    {pageFooter}
                  </main>

                  {footer}
                </div>
              </div>
            </div>);
                }
                else {
                    return (<main className={cs('notion', darkMode ? 'dark-mode' : 'light-mode', 'notion-page', page_full_width && 'notion-full-width', page_small_text && 'notion-small-text', blockId, className, bodyClassName)}>
              <div className='notion-viewport'/>

              {pageHeader}

              {block.type === 'page' && block.parent_table === 'collection' && (<components.collectionRow block={block}/>)}

              {block.type === 'collection_view_page' && (<components.collection block={block}/>)}

              {children}

              {pageFooter}
            </main>);
                }
            }
            else {
                const blockColor = block.format?.block_color;
                return (<components.pageLink className={cs('notion-page-link', blockColor && ` notion - $ { blockColor } `, blockId)} href={mapPageUrl(block.id)}>
            <PageTitle block={block}/>
          </components.pageLink>);
            }
        case 'header':
        // fallthrough
        case 'sub_header':
        // fallthrough
        case 'sub_sub_header': {
            if (!block.properties)
                return null;
            const blockColor = block.format?.block_color;
            const id = uuidToId(block.id);
            const title = getTextContent(block.properties.title) || ` Notion Header $ { id } `;
            // we use a cache here because constructing the ToC is non-trivial
            let indentLevel = tocIndentLevelCache[block.id];
            let indentLevelClass;
            if (indentLevel === undefined) {
                const page = getBlockParentPage(block, recordMap);
                if (page) {
                    const toc = getPageTableOfContents(page, recordMap);
                    const tocItem = toc.find((tocItem) => tocItem.id === block.id);
                    if (tocItem) {
                        indentLevel = tocItem.indentLevel;
                        tocIndentLevelCache[block.id] = indentLevel;
                    }
                }
            }
            if (indentLevel !== undefined) {
                indentLevelClass = ` notion - h - indent - $ { indentLevel } `;
            }
            const isH1 = block.type === 'header';
            const isH2 = block.type === 'sub_header';
            const isH3 = block.type === 'sub_sub_header';
            const classNameStr = cs(isH1 && 'notion-h notion-h1', isH2 && 'notion-h notion-h2', isH3 && 'notion-h notion-h3', blockColor && ` notion - $ { blockColor } `, indentLevelClass, blockId);
            const innerHeader = (<span>
          <div id={id} className='notion-header-anchor'/>

          <a className='notion-hash-link' href={` #$ { id } `} title={title}>
            <LinkIcon />
          </a>

          <span className='notion-h-title'>
            <Text value={block.properties.title} block={block}/>
          </span>
        </span>);
            //page title takes the h1 so all header blocks are greater
            if (isH1) {
                return (<h2 className={classNameStr} data-id={id}>
            {innerHeader}
          </h2>);
            }
            else if (isH2) {
                return (<h3 className={classNameStr} data-id={id}>
            {innerHeader}
          </h3>);
            }
            else {
                return (<h4 className={classNameStr} data-id={id}>
            {innerHeader}
          </h4>);
            }
        }
        case 'divider':
            return <hr className={cs('notion-hr', blockId)}/>;
        case 'text':
            if (!block.properties && !block.content?.length) {
                return <div className={cs('notion-blank', blockId)}>&nbsp;</div>;
            }
            const blockColor = block.format?.block_color;
            return (<div className={cs('notion-text', blockColor && ` notion - $ { blockColor } `, blockId)}>
          {block.properties?.title && (<Text value={block.properties.title} block={block}/>)}

          {children && <div className='notion-text-children'>{children}</div>}
        </div>);
        case 'bulleted_list':
        // fallthrough
        case 'numbered_list':
            const wrapList = (content, start?) => block.type === 'bulleted_list' ? (<ul className={cs('notion-list', 'notion-list-disc', blockId)}>
            {content}
          </ul>) : (<ol start={start} className={cs('notion-list', 'notion-list-numbered', blockId)}>
            {content}
          </ol>);
            let output = null;
            if (block.content) {
                output = (<>
            {block.properties && (<li>
                <Text value={block.properties.title} block={block}/>
              </li>)}
            {wrapList(children)}
          </>);
            }
            else {
                output = block.properties ? (<li>
            <Text value={block.properties.title} block={block}/>
          </li>) : null;
            }
            const isTopLevel = block.type !== recordMap.block[block.parent_id]?.value?.type;
            const start = getListNumber(block.id, recordMap.block);
            return isTopLevel ? wrapList(output, start) : output;
        case 'tweet':
        // fallthrough
        case 'maps':
        // fallthrough
        case 'pdf':
        // fallthrough
        case 'figma':
        // fallthrough
        case 'typeform':
        // fallthrough
        case 'codepen':
        // fallthrough
        case 'excalidraw':
        // fallthrough
        case 'image':
        // fallthrough
        case 'gist':
        // fallthrough
        case 'embed':
        // fallthrough
        case 'video':
            return <AssetWrapper blockId={blockId} block={block}/>;
        case 'drive':
            const properties = block.format?.drive_properties;
            if (!properties) {
                //check if this drive actually needs to be embeded ex. google sheets.
                if (block.format?.display_source) {
                    return <AssetWrapper blockId={blockId} block={block}/>;
                }
            }
            return (<GoogleDrive block={block as types.GoogleDriveBlock} className={blockId}/>);
        case 'audio':
            return <Audio block={block as types.AudioBlock} className={blockId}/>;
        case 'file':
            return <File block={block as types.FileBlock} className={blockId}/>;
        case 'equation':
            const math = block.properties.title[0][0];
            if (!math)
                return null;
            return <Equation math={math} block className={blockId}/>;
        case 'code': {
            if (block.properties.title) {
                const content = getBlockTitle(block, recordMap);
                const language = block.properties.language
                    ? block.properties.language[0][0]
                    : '';
                const caption = block.properties.caption;
                // TODO: add className
                return (<>
            <components.code key={block.id} language={language || ''} code={content}/>
            {caption && (<figcaption className='notion-asset-caption'>
                <Text value={caption} block={block}/>
              </figcaption>)}
          </>);
            }
            break;
        }
        case 'column_list':
            return <div className={cs('notion-row', blockId)}>{children}</div>;
        case 'column':
            // note: notion uses 46px
            const spacerWidth = ` min ( 32 px , 4 vw ) `;
            const ratio = block.format?.column_ratio || 0.5;
            const parent = recordMap.block[block.parent_id]?.value;
            const columns = parent?.content?.length || Math.max(2, Math.ceil(1.0 / ratio));
            const width = ` calc ( ( 100 % - ( $ { columns - 1 } * $ { spacerWidth } ) ) * $ { ratio } ) `;
            const style = { width };
            return (<>
          <div className={cs('notion-column', blockId)} style={style}>
            {children}
          </div>

          <div className='notion-spacer'/>
        </>);
        case 'quote': {
            if (!block.properties)
                return null;
            const blockColor = block.format?.block_color;
            return (<blockquote className={cs('notion-quote', blockColor && ` notion - $ { blockColor } `, blockId)}>
          <Text value={block.properties.title} block={block}/>
        </blockquote>);
        }
        case 'collection_view':
            return <components.collection block={block} className={blockId}/>;
        case 'callout':
            if (components.callout) {
                return <components.callout block={block} className={blockId}/>;
            }
            else {
                return (<div className={cs('notion-callout', block.format?.block_color &&
                        ` notion - $ { block . format ?. block_color } _co `, blockId)}>
            <PageIcon block={block}/>

            <div className='notion-callout-text'>
              <Text value={block.properties?.title} block={block}/>
              {children}
            </div>
          </div>);
            }
        case 'bookmark':
            if (!block.properties)
                return null;
            let title = getTextContent(block.properties?.title);
            if (!title) {
                title = getTextContent(block.properties?.link);
            }
            if (title) {
                if (title.startsWith('http')) {
                    try {
                        const url = new URL(title);
                        title = url.hostname;
                    }
                    catch (err) {
                        // ignore invalid links
                    }
                }
            }
            return (<div className='notion-row'>
          <components.link target='_blank' rel='noopener noreferrer' className={cs('notion-bookmark', block.format?.block_color && ` notion - $ { block . format . block_color } `, blockId)} href={block.properties.link[0][0]}>
            <div>
              {title && (<div className='notion-bookmark-title'>
                  <Text value={[[title]]} block={block}/>
                </div>)}

              {block.properties?.description && (<div className='notion-bookmark-description'>
                  <Text value={block.properties?.description} block={block}/>
                </div>)}

              <div className='notion-bookmark-link'>
                {block.format?.bookmark_icon && (<GracefulImage src={block.format?.bookmark_icon} alt={title} loading='lazy'/>)}

                <div>
                  <Text value={block.properties?.link} block={block}/>
                </div>
              </div>
            </div>

            {block.format?.bookmark_cover && (<div className='notion-bookmark-image'>
                <GracefulImage src={block.format?.bookmark_cover} alt={getTextContent(block.properties?.title)} loading='lazy'/>
              </div>)}
          </components.link>
        </div>);
        case 'toggle':
            return (<details className={cs('notion-toggle', blockId)}>
          <summary>
            <Text value={block.properties?.title} block={block}/>
          </summary>

          <div>{children}</div>
        </details>);
        case 'table_of_contents': {
            const page = getBlockParentPage(block, recordMap);
            if (!page)
                return null;
            const toc = getPageTableOfContents(page, recordMap);
            const blockColor = block.format?.block_color;
            return (<div className={cs('notion-table-of-contents', blockColor && ` notion - $ { blockColor } `, blockId)}>
          {toc.map((tocItem) => (<a key={tocItem.id} href={` #$ { uuidToId ( tocItem . id ) } `} className='notion-table-of-contents-item'>
              <span className='notion-table-of-contents-item-body' style={{
                        display: 'inline-block',
                        marginLeft: tocItem.indentLevel * 24
                    }}>
                {tocItem.text}
              </span>
            </a>))}
        </div>);
        }
        case 'to_do':
            const isChecked = block.properties?.checked?.[0]?.[0] === 'Yes';
            return (<div className={cs('notion-to-do', blockId)}>
          <div className='notion-to-do-item'>
            <components.checkbox blockId={blockId} isChecked={isChecked}/>

            <div className={cs('notion-to-do-body', isChecked && ` notion - to - do - checked `)}>
              <Text value={block.properties?.title} block={block}/>
            </div>
          </div>

          <div className='notion-to-do-children'>{children}</div>
        </div>);
        case 'transclusion_container':
            return <div className={cs('notion-sync-block', blockId)}>{children}</div>;
        case 'transclusion_reference':
            return <SyncPointerBlock block={block} level={level + 1} {...props}/>;
        case 'alias':
            const blockPointerId = block?.format?.alias_pointer?.id;
            const linkedBlock = recordMap.block[blockPointerId]?.value;
            if (!linkedBlock) {
                console.log('"p" missing block', blockPointerId);
                return null;
            }
            return (<components.pageLink className={cs('notion-page-link', blockPointerId)} href={mapPageUrl(blockPointerId)}>
          <PageTitle block={linkedBlock}/>
        </components.pageLink>);
        default:
            if (process.env.NODE_ENV !== 'production') {
                console.log('Unsupported type ' + (block as any).type, JSON.stringify(block, null, 2));
            }
            return <div />;
    }
    return null;
};
