import * as React from "react"; import { PureComponent, ReactNode, memo, useContext, useEffect, useLayoutEffect, useEffect as useEffectFallback, useRef } from "react"; import ReactDOM from "react-dom"; import { EventEmitter } from "../EventEmitter"; const canUseDOM = typeof window !== "undefined" && typeof document !== "undefined"; const useIsomorphicLayoutEffect = canUseDOM ? useLayoutEffect : useEffectFallback; type BlockId = string | symbol; type BlockNamespace = string | symbol; interface NamespaceRecord { key: BlockNamespace, blocks: Record, blockSubscriptions: Record void>, } type NamespaceArg = BlockNamespace | NamespaceRecord; const DefaultNamespace = Symbol("default_block_namespace"); class BlockStore { private namespaces: Record = {}; protected useNamespace(key: NamespaceArg, f: (ns: NamespaceRecord) => T): T { key ??= DefaultNamespace; if (typeof key == 'object') { return f(key); } else { let ns = this.namespaces[key]; if (!ns) { ns = this.namespaces[key] = { key: key, blocks: {}, blockSubscriptions: {}, } } const x = f(ns); if (!Object.keys(ns.blocks).length && !Object.keys(ns.blockSubscriptions).length) { delete this.namespaces[key]; } return x; } } private emitter = new EventEmitter<{ block_registered: [block: BlockId, namespace: BlockNamespace], block_unregistered: [block: BlockId, namespace: BlockNamespace], block_switched: [block: BlockId, namespace: BlockNamespace], }>(); listen = this.emitter.on; protected transitionBlock(namespace: NamespaceRecord, id: BlockId, transaction: () => void) { const current_block = this.activeBlock(namespace, id); transaction(); const new_block = this.activeBlock(namespace, id); if (current_block != new_block) { namespace.blockSubscriptions[id]?.(new_block); this.emitter.emit('block_switched', [id, namespace.key]); } } registerBlock(blk: Block_) { this.useNamespace(blk.namespace, (ns) => { const key = blk.name; const registered = ns.blocks[key] ||= []; this.transitionBlock(ns, key, () => { registered.push(blk); }) this.emitter.emit('block_registered', [key, ns.key]); }); } unregisterBlock(blk: Block_) { this.useNamespace(blk.namespace, (ns) => { const key = blk.name; const registered = ns.blocks[key] ||= []; this.transitionBlock(ns, key, () => { const index = registered.indexOf(blk); if (index > -1) { registered.splice(index, 1); } }); this.emitter.emit('block_unregistered', [key, ns.key]); if (!registered.length) { delete ns.blocks[key]; } }) } subscribeBlock(namespace: NamespaceArg, blk: BlockId, callback: (block: Block_) => void) { this.useNamespace(namespace, (ns) => { if (ns.blockSubscriptions[blk]) { throw new Error(`Block ${blk.toString()} is already in use`); } ns.blockSubscriptions[blk] = callback; callback(this.activeBlock(ns, blk)); }); return () => { this.useNamespace(namespace, (ns) => { delete ns.blockSubscriptions[blk]; }); } } isBlockProvided(namespace: NamespaceArg, blk: BlockId) { return this.useNamespace(namespace, (ns) => { return !!ns.blocks[blk]?.length; }) } activeBlock(namespace: NamespaceArg, blk: BlockId) { return this.useNamespace(namespace, (ns) => { if (!ns.blocks[blk]?.length) return null; return ns.blocks[blk].reduce((last, current) => current.priority >= last.priority ? current : last); }) } } const BlockContext = React.createContext(null) const BlockNamespaceContext = React.createContext(null) export const ProvideBlocks: React.FC<{ children?: ReactNode, namespace?: string }> = memo(({ namespace, children }) => { const localStore = useRef(); const superStore = useContext(BlockContext); if (!superStore) { localStore.current ??= new BlockStore(); } let content = <>{children} if (localStore.current) { content = {content} } if (namespace) { content = {content} } return content; }) // ==================== Block ==================== // interface BlockProps { block: BlockId, namespace?: string, priority?: number, eager?: boolean, cache?: number | boolean, suppressed?: boolean, store?: BlockStore, children?: ReactNode, } class Block_ extends PureComponent { get store() { return this.props.store! } state = { shouldRender: this.props.eager, } get name() { return this.props.block; } get priority() { return this.props.priority || 0 } get namespace() { return this.props.namespace || null } private _element: HTMLDivElement; get element() { if (!this._element) { if (!canUseDOM) return null as any; this._element = document.createElement("div"); this._element.classList.add(`mlcl-block-content`); this._element.setAttribute("data-mlcl-block-namespace", (this.namespace ?? "").toString()); this._element.setAttribute("data-mlcl-block", this.name.toString()); } return this._element; } private unusedTimer; protected setUsed(used: boolean) { if (this.unusedTimer) clearTimeout(this.unusedTimer); if (used) { this.setState({ shouldRender: true }); return } if (this.props.eager) return; if (this.props.cache !== true) { this.unusedTimer = setTimeout(() => { this.setState({ shouldRender: false }); }, this.props.cache || 5); } } protected updateRegistration() { if (!this.props.suppressed) { this.store.registerBlock(this); } else { this.store.unregisterBlock(this); } } componentDidMount(): void { if (!canUseDOM) return; this.updateRegistration(); } componentDidUpdate(prevProps: Readonly): void { if (prevProps.suppressed != this.props.suppressed) { this.updateRegistration(); } } componentWillUnmount(): void { this.store.unregisterBlock(this); if (this.unusedTimer) clearTimeout(this.unusedTimer); } private mountedParent: HTMLElement; private mountedPlaceholder: HTMLDivElement; mountOutput(newPlaceholder: HTMLDivElement) { const newParent = newPlaceholder.parentNode! as HTMLElement; if (newPlaceholder === this.mountedPlaceholder) return; if (this.mountedPlaceholder) throw new Error("Block is already mounted") this.setUsed(true); newParent.replaceChild( this.element, newPlaceholder, ); this.mountedParent = newParent; this.mountedPlaceholder = newPlaceholder; } unmountOutput(expectedPlaceholder?: HTMLDivElement) { if (expectedPlaceholder && expectedPlaceholder != this.mountedPlaceholder) return; if (this.mountedParent && this.mountedPlaceholder) { this.mountedParent.replaceChild( this.mountedPlaceholder, this.element, ); this.mountedParent = undefined; this.mountedPlaceholder = undefined; } this.setUsed(false); } render() { if (!this.state.shouldRender) return null; if (!canUseDOM || !this.element) return null; return ReactDOM.createPortal( this.props.children, this.element ); } } const InternalBlock: React.FC = ({ store, namespace, ...props }) => { if (store === undefined) store = useContext(BlockContext); if (namespace === undefined) namespace = useContext(BlockNamespaceContext); return } // ==================== UseBlock ==================== // interface UseBlockProps { block: BlockId, namespace?: string, store?: BlockStore, } const UseBlock_: React.FC = memo(({ store, namespace, block }) => { const placeholder_ref = useRef(); useIsomorphicLayoutEffect(() => { let activeBlock: Block_; const unsubscribe = store.subscribeBlock(namespace, block, (block) => { if (block == activeBlock) return; if (activeBlock) activeBlock.unmountOutput(placeholder_ref.current); activeBlock = block; if (activeBlock) activeBlock.mountOutput(placeholder_ref.current); }) return () => { if (activeBlock) activeBlock.unmountOutput(placeholder_ref.current); unsubscribe(); } }, [block, namespace, store]); return
; }) UseBlock_.displayName = "UseBlock_"; export const UseBlock: React.FC = memo(({ namespace, block, store, children }) => { if (store === undefined) store = useContext(BlockContext); if (namespace === undefined) namespace = useContext(BlockNamespaceContext); return <> {children && } }) UseBlock.displayName = "UseBlock"; // ==================== Public API ==================== // const BlockApi = { Use: UseBlock, Provide: ProvideBlocks, _useStore: () => useContext(BlockContext), useIsProvided: (block: BlockId | BlockId[], mode: "any" | "all", opts?: { store?: BlockStore, namespace?: BlockNamespace }) => { if (!Array.isArray(block)) block = [block]; let store = opts?.store; store ??= useContext(BlockContext); let namespace = opts?.namespace; namespace ??= useContext(BlockNamespaceContext); const blocksRef = useRef(block); blocksRef.current = block; function checkProvided() { const blocks = blocksRef.current; const predicate = id => store.isBlockProvided(namespace, id); if (mode == "any") { return blocks.some(predicate); } else { return blocks.every(predicate); } } const [blockProvided, setBlockProvided] = React.useState(checkProvided); useEffect(() => { return store.listen(['block_registered', 'block_unregistered'], (id, ns) => { if (ns !== namespace) return; const block = blocksRef.current; if (block.includes(id)) { setBlockProvided(checkProvided()); } }) }, [store]); return blockProvided; }, IfBlockProvided: (props: { block: BlockId | BlockId[], any?: boolean, namespace?: BlockNamespace, children: React.ReactNode }) => { const provided = BlockApi.useIsProvided(props.block, props.any ? "any" : "all", { namespace: props.namespace }); return provided ? <>{props.children} : null; }, } Object.assign(InternalBlock, BlockApi); /** * Block-based rendering in React, similar to Django templates. Mainly intended for use in libraries as a replacement for passing elements as props. * * Blocks may receive an optional `priority` prop, which determines which block is rendered when multiple blocks are defined with the same name. * It is generally intended that Blocks are singletons, but there are some use cases where it is easier to override a Block multiple times * * Example Usage: * ```tsx * * * Optional Default Content * * * * * User Overridden Content * * * * ``` * * NB: Blocks are rendered where they are defined, not where they are used. * This means that `Block`s (and their descendants) will receive Context from where they are placed in the hierarchy, not from where `UseBlock` is placed. */ export const Block: typeof InternalBlock & typeof BlockApi = InternalBlock as any;