|
| 1 | +import * as React from 'react'; |
| 2 | + |
| 3 | +import { Block } from './Block'; |
| 4 | +import { type Modifier, type TextInlineNode } from './Text'; |
| 5 | + |
| 6 | +/* ------------------------------------------------------------------------------------------------- |
| 7 | + * TypeScript types and utils |
| 8 | + * -----------------------------------------------------------------------------------------------*/ |
| 9 | + |
| 10 | +interface LinkInlineNode { |
| 11 | + type: 'link'; |
| 12 | + url: string; |
| 13 | + children: TextInlineNode[]; |
| 14 | +} |
| 15 | + |
| 16 | +interface ListItemInlineNode { |
| 17 | + type: 'list-item'; |
| 18 | + children: DefaultInlineNode[]; |
| 19 | +} |
| 20 | + |
| 21 | +// Inline node types |
| 22 | +type DefaultInlineNode = TextInlineNode | LinkInlineNode; |
| 23 | +type NonTextInlineNode = Exclude<DefaultInlineNode, TextInlineNode> | ListItemInlineNode; |
| 24 | + |
| 25 | +interface ParagraphBlockNode { |
| 26 | + type: 'paragraph'; |
| 27 | + children: DefaultInlineNode[]; |
| 28 | +} |
| 29 | + |
| 30 | +interface QuoteBlockNode { |
| 31 | + type: 'quote'; |
| 32 | + children: DefaultInlineNode[]; |
| 33 | +} |
| 34 | + |
| 35 | +interface CodeBlockNode { |
| 36 | + type: 'code'; |
| 37 | + children: DefaultInlineNode[]; |
| 38 | +} |
| 39 | + |
| 40 | +interface HeadingBlockNode { |
| 41 | + type: 'heading'; |
| 42 | + level: 1 | 2 | 3 | 4 | 5 | 6; |
| 43 | + children: DefaultInlineNode[]; |
| 44 | +} |
| 45 | + |
| 46 | +interface ListBlockNode { |
| 47 | + type: 'list'; |
| 48 | + format: 'ordered' | 'unordered'; |
| 49 | + children: (ListItemInlineNode | ListBlockNode)[]; |
| 50 | +} |
| 51 | + |
| 52 | +interface ImageBlockNode { |
| 53 | + type: 'image'; |
| 54 | + image: { |
| 55 | + name: string; |
| 56 | + alternativeText?: string | null; |
| 57 | + url: string; |
| 58 | + caption?: string | null; |
| 59 | + width: number; |
| 60 | + height: number; |
| 61 | + formats?: Record<string, unknown>; |
| 62 | + hash: string; |
| 63 | + ext: string; |
| 64 | + mime: string; |
| 65 | + size: number; |
| 66 | + previewUrl?: string | null; |
| 67 | + provider: string; |
| 68 | + provider_metadata?: unknown | null; |
| 69 | + createdAt: string; |
| 70 | + updatedAt: string; |
| 71 | + }; |
| 72 | + children: [{ type: 'text'; text: '' }]; |
| 73 | +} |
| 74 | + |
| 75 | +// Block node types |
| 76 | +type RootNode = |
| 77 | + | ParagraphBlockNode |
| 78 | + | QuoteBlockNode |
| 79 | + | CodeBlockNode |
| 80 | + | HeadingBlockNode |
| 81 | + | ListBlockNode |
| 82 | + | ImageBlockNode; |
| 83 | +type Node = RootNode | NonTextInlineNode; |
| 84 | + |
| 85 | +// Util to convert a node to the props of the corresponding React component |
| 86 | +type GetPropsFromNode<T> = Omit<T, 'type' | 'children'> & { children?: React.ReactNode }; |
| 87 | + |
| 88 | +// Map of all block types to their matching React component |
| 89 | +type BlocksComponents = { |
| 90 | + [K in Node['type']]: React.ComponentType< |
| 91 | + // Find the BlockProps in the union that match the type key of the current BlockNode |
| 92 | + // and use it as the component props |
| 93 | + GetPropsFromNode<Extract<Node, { type: K }>> |
| 94 | + >; |
| 95 | +}; |
| 96 | + |
| 97 | +// Map of all inline types to their matching React component |
| 98 | +type ModifiersComponents = { |
| 99 | + [K in Modifier]: React.ComponentType<{ children: React.ReactNode }>; |
| 100 | +}; |
| 101 | + |
| 102 | +/* ------------------------------------------------------------------------------------------------- |
| 103 | + * Default blocks and modifiers components |
| 104 | + * -----------------------------------------------------------------------------------------------*/ |
| 105 | + |
| 106 | +interface ComponentsContextValue { |
| 107 | + blocks: BlocksComponents; |
| 108 | + modifiers: ModifiersComponents; |
| 109 | + missingBlockTypes: string[]; |
| 110 | + missingModifierTypes: string[]; |
| 111 | +} |
| 112 | + |
| 113 | +const defaultComponents: ComponentsContextValue = { |
| 114 | + blocks: { |
| 115 | + paragraph: (props) => <p>{props.children}</p>, |
| 116 | + quote: (props) => <blockquote>{props.children}</blockquote>, |
| 117 | + code: (props) => ( |
| 118 | + <pre> |
| 119 | + <code>{props.children}</code> |
| 120 | + </pre> |
| 121 | + ), |
| 122 | + heading: ({ level, children }) => { |
| 123 | + switch (level) { |
| 124 | + case 1: |
| 125 | + return <h1>{children}</h1>; |
| 126 | + case 2: |
| 127 | + return <h2>{children}</h2>; |
| 128 | + case 3: |
| 129 | + return <h3>{children}</h3>; |
| 130 | + case 4: |
| 131 | + return <h4>{children}</h4>; |
| 132 | + case 5: |
| 133 | + return <h5>{children}</h5>; |
| 134 | + case 6: |
| 135 | + return <h6>{children}</h6>; |
| 136 | + } |
| 137 | + }, |
| 138 | + link: (props) => <a href={props.url}>{props.children}</a>, |
| 139 | + list: (props) => { |
| 140 | + if (props.format === 'ordered') { |
| 141 | + return <ol>{props.children}</ol>; |
| 142 | + } |
| 143 | + |
| 144 | + return <ul>{props.children}</ul>; |
| 145 | + }, |
| 146 | + 'list-item': (props) => <li>{props.children}</li>, |
| 147 | + image: (props) => <img src={props.image.url} alt={props.image.alternativeText || undefined} />, |
| 148 | + }, |
| 149 | + modifiers: { |
| 150 | + bold: (props) => <strong>{props.children}</strong>, |
| 151 | + italic: (props) => <em>{props.children}</em>, |
| 152 | + underline: (props) => <u>{props.children}</u>, |
| 153 | + strikethrough: (props) => <del>{props.children}</del>, |
| 154 | + code: (props) => <code>{props.children}</code>, |
| 155 | + }, |
| 156 | + missingBlockTypes: [], |
| 157 | + missingModifierTypes: [], |
| 158 | +}; |
| 159 | + |
| 160 | +/* ------------------------------------------------------------------------------------------------- |
| 161 | + * Context to pass blocks and inline components to the nested components |
| 162 | + * -----------------------------------------------------------------------------------------------*/ |
| 163 | + |
| 164 | +const ComponentsContext = React.createContext<ComponentsContextValue>(defaultComponents); |
| 165 | + |
| 166 | +interface ComponentsProviderProps { |
| 167 | + children: React.ReactNode; |
| 168 | + value?: ComponentsContextValue; |
| 169 | +} |
| 170 | + |
| 171 | +// Provide default value so we don't need to import defaultComponents in all tests |
| 172 | +const ComponentsProvider = ({ children, value = defaultComponents }: ComponentsProviderProps) => { |
| 173 | + const memoizedValue = React.useMemo(() => value, [value]); |
| 174 | + |
| 175 | + return <ComponentsContext.Provider value={memoizedValue}>{children}</ComponentsContext.Provider>; |
| 176 | +}; |
| 177 | + |
| 178 | +function useComponentsContext() { |
| 179 | + return React.useContext(ComponentsContext); |
| 180 | +} |
| 181 | + |
| 182 | +/* ------------------------------------------------------------------------------------------------- |
| 183 | + * BlocksRenderer |
| 184 | + * -----------------------------------------------------------------------------------------------*/ |
| 185 | + |
| 186 | +interface BlocksRendererProps { |
| 187 | + content: RootNode[]; |
| 188 | + blocks?: Partial<BlocksComponents>; |
| 189 | + modifiers?: Partial<ModifiersComponents>; |
| 190 | +} |
| 191 | + |
| 192 | +const BlocksRenderer = (props: BlocksRendererProps) => { |
| 193 | + // Merge default blocks with the ones provided by the user |
| 194 | + const blocks = { |
| 195 | + ...defaultComponents.blocks, |
| 196 | + ...props.blocks, |
| 197 | + }; |
| 198 | + |
| 199 | + // Merge default modifiers with the ones provided by the user |
| 200 | + const modifiers = { |
| 201 | + ...defaultComponents.modifiers, |
| 202 | + ...props.modifiers, |
| 203 | + }; |
| 204 | + |
| 205 | + // Use refs because we can mutate them and avoid triggering re-renders |
| 206 | + const missingBlockTypes = React.useRef<string[]>([]); |
| 207 | + const missingModifierTypes = React.useRef<string[]>([]); |
| 208 | + |
| 209 | + return ( |
| 210 | + <ComponentsProvider |
| 211 | + value={{ |
| 212 | + blocks, |
| 213 | + modifiers, |
| 214 | + missingBlockTypes: missingBlockTypes.current, |
| 215 | + missingModifierTypes: missingModifierTypes.current, |
| 216 | + }} |
| 217 | + > |
| 218 | + {/* TODO use WeakMap instead of index as the key */} |
| 219 | + {props.content.map((content, index) => ( |
| 220 | + <Block content={content} key={index} /> |
| 221 | + ))} |
| 222 | + </ComponentsProvider> |
| 223 | + ); |
| 224 | +}; |
| 225 | + |
| 226 | +/* ------------------------------------------------------------------------------------------------- |
| 227 | + * Exports |
| 228 | + * -----------------------------------------------------------------------------------------------*/ |
| 229 | + |
| 230 | +export type { RootNode, Node, GetPropsFromNode }; |
| 231 | +export { ComponentsProvider, useComponentsContext, BlocksRenderer }; |
0 commit comments