import React from 'react' import throttle from 'lodash.throttle' import { getBlockIcon, getTextContent, getPageTableOfContents, getBlockParentPage, uuidToId } from 'notion-utils' import * as types from 'notion-types' import { Checkbox } from './components/checkbox' import { PageIcon } from './components/page-icon' import { PageTitle } from './components/page-title' import { LinkIcon } from './icons/link-icon' import { PageHeader } from './components/page-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 { 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 footer?: React.ReactNode pageHeader?: React.ReactNode pageFooter?: React.ReactNode pageAside?: React.ReactNode pageCover?: React.ReactNode hideBlockId?: boolean } const tocIndentLevelCache: { [blockId: string]: number } = {} export const Block: React.FC = (props) => { const { components, fullPage, darkMode, recordMap, mapPageUrl, mapImageUrl, showTableOfContents, minTableOfContentsItems, defaultPageIcon, defaultPageCover, defaultPageCoverPosition } = useNotionContext() const { block, children, level, className, bodyClassName, footer, pageHeader, pageFooter, pageAside, pageCover, hideBlockId } = props if (!block) { return null } // ugly hack to make viewing raw collection views work properly // e.g., 6d886ca87ab94c21a16e3b82b43a57fb 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': // fallthrough 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 // this scrollspy logic was originally based on // https://github.com/Purii/react-use-scrollspy const actionSectionScrollSpy = throttle(() => { const sections = document.getElementsByClassName('notion-h') let prevBBox: DOMRect = 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) // GetBoundingClientRect returns values relative to viewport if (bbox.top - offset < 0) { currentSectionId = section.getAttribute('data-id') prevBBox = bbox continue } // No need to continue loop, if last element has been detected break } setActiveSection(currentSectionId) }, throttleMs) if (hasToc) { React.useEffect(() => { window.addEventListener('scroll', actionSectionScrollSpy) actionSectionScrollSpy() return () => { window.removeEventListener('scroll', actionSectionScrollSpy) } }, []) } const hasPageCover = pageCover || page_cover return (
{hasPageCover ? ( pageCover ? ( pageCover ) : ( ) ) : null}
{page_icon && (
)} {pageHeader} {block.type === 'page' && block.parent_table === 'collection' && ( )} {block.type === 'collection_view_page' && ( )}
{children}
{hasAside && ( )}
{pageFooter}
{footer}
) } else { return (
{pageHeader} {block.type === 'page' && block.parent_table === 'collection' && ( )} {block.type === 'collection_view_page' && ( )} {children} {pageFooter}
) } } else { const blockColor = block.format?.block_color return ( ) } 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: string 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 = (
) //page title takes the h1 so all header blocks are greater if (isH1) { return (

{innerHeader}

) } else if (isH2) { return (

{innerHeader}

) } else { return (

{innerHeader}

) } } case 'divider': return
case 'text': if (!block.properties && !block.content?.length) { return
 
} const blockColor = block.format?.block_color return (
{block.properties?.title && ( )} {children &&
{children}
}
) case 'bulleted_list': // fallthrough case 'numbered_list': const wrapList = (content: React.ReactNode, start?: number) => block.type === 'bulleted_list' ? (
    {content}
) : (
    {content}
) let output: JSX.Element | null = null if (block.content) { output = ( <> {block.properties && (
  • )} {wrapList(children)} ) } else { output = block.properties ? (
  • ) : 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 return case 'gist': // fallthrough case 'embed': // fallthrough case 'video': return 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 } } return ( ) case 'audio': return