cms-renderer 0.6.3 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +203 -293
- package/dist/lib/block-renderer.js +2 -1
- package/dist/lib/block-renderer.js.map +1 -1
- package/dist/lib/block-toolbar.js +64 -74
- package/dist/lib/block-toolbar.js.map +1 -1
- package/dist/lib/client-editable-block.js +96 -81
- package/dist/lib/client-editable-block.js.map +1 -1
- package/dist/lib/custom-schemas.js.map +1 -1
- package/dist/lib/docs-markdown.d.ts +17 -0
- package/dist/lib/docs-markdown.js +258 -0
- package/dist/lib/docs-markdown.js.map +1 -0
- package/dist/lib/renderer.js +6 -3
- package/dist/lib/renderer.js.map +1 -1
- package/dist/lib/schema.d.ts +8 -0
- package/dist/lib/schema.js +8 -2
- package/dist/lib/schema.js.map +1 -1
- package/dist/lib/types.d.ts +4 -1
- package/dist/lib/types.js +5 -0
- package/dist/lib/types.js.map +1 -1
- package/package.json +16 -5
- package/styles/docs-markdown.css +181 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../lib/block-renderer.tsx"],"sourcesContent":["/**\n * Block Renderer Component\n *\n * Dispatches block data to the appropriate component using the ComponentMap pattern.\n * This is the main entry point for rendering blocks from the CMS.\n */\n\nimport React from 'react';\nimport { ClientEditableBlock } from './client-editable-block';\nimport type { BlockComponentRegistry, BlockData, ResolvedRouteParams } from './types';\n\ntype TextInfo = {\n value: string;\n path: Array<string | number>;\n parentType?: React.ElementType;\n key?: React.Key | null;\n inSvg?: boolean;\n};\n\ntype ElementInfo = {\n element: React.ReactElement;\n path: Array<string | number>;\n};\n\ntype WalkVisitors = {\n /**\n * Called for every string/number child encountered.\n * Return:\n * - same string (or modified)\n * - a ReactNode (e.g. wrap in <span/>)\n */\n onText?: (info: TextInfo) => React.ReactNode;\n\n /**\n * Called for every ReactElement encountered (after children are processed).\n * Return:\n * - same element\n * - a cloned/modified element\n */\n onElement?: (info: ElementInfo) => React.ReactElement;\n};\n\n/**\n * Recursively maps a ReactNode tree, allowing transformations of text nodes and/or elements.\n * SSR-safe: does not touch DOM APIs.\n */\nexport function walkReactNode(\n node: React.ReactNode,\n visitors: WalkVisitors,\n ctx: {\n path?: Array<string | number>;\n parentType?: React.ElementType;\n key?: React.Key | null;\n inSvg?: boolean;\n } = {}\n): React.ReactNode {\n const path = ctx.path ?? [];\n\n // Fast-path primitives\n if (node == null || typeof node === 'boolean') return node;\n\n if (typeof node === 'string' || typeof node === 'number') {\n const value = String(node);\n return visitors.onText\n ? visitors.onText({ value, path, parentType: ctx.parentType, key: ctx.key, inSvg: ctx.inSvg })\n : node;\n }\n\n // Arrays\n if (Array.isArray(node)) {\n return node.map((child, i) => {\n // biome-ignore lint/suspicious/noExplicitAny: React child key access\n const childKey = (child as any)?.key ?? null;\n const result = walkReactNode(child, visitors, {\n path: [...path, i],\n parentType: ctx.parentType,\n key: childKey,\n inSvg: ctx.inSvg,\n });\n // Ensure array children have keys\n if (React.isValidElement(result) && result.key == null) {\n return React.cloneElement(result, { key: childKey ?? `arr-${path.join('-')}-${i}` });\n }\n return result;\n });\n }\n\n // ReactElement (including Fragment)\n if (React.isValidElement(node)) {\n // biome-ignore lint/suspicious/noExplicitAny: React element props access\n const el = node as React.ReactElement<any>;\n const elProps = el.props as Record<string, unknown> | null;\n\n // Track SVG context so we never inject <span> inside SVG subtrees\n const nextInSvg = ctx.inSvg || el.type === 'svg';\n\n // Recurse into children (if any)\n const hasChildren = elProps && 'children' in elProps;\n const nextChildren = hasChildren\n ? React.Children.map(elProps.children as React.ReactNode, (child, i) => {\n // biome-ignore lint/suspicious/noExplicitAny: React child key access\n const childKey = (child as any)?.key ?? null;\n const result = walkReactNode(child, visitors, {\n path: [...path, 'children', i],\n parentType: el.type as React.ElementType,\n key: childKey,\n inSvg: nextInSvg,\n });\n // Ensure children have keys\n if (React.isValidElement(result) && result.key == null) {\n return React.cloneElement(result, { key: childKey ?? `child-${path.join('-')}-${i}` });\n }\n return result;\n })\n : (elProps?.children as React.ReactNode);\n\n // Only clone if children changed (or if you want to force a clone)\n const cloned = hasChildren\n ? React.cloneElement(el, undefined, nextChildren as React.ReactNode)\n : el;\n\n return visitors.onElement ? visitors.onElement({ element: cloned, path }) : cloned;\n }\n\n // Functions, symbols, portals, etc. are rare here; return as-is\n return node;\n}\n\n// -----------------------------------------------------------------------------\n// Content Value Extraction\n// -----------------------------------------------------------------------------\n\ntype ContentMatch = {\n contentPath: string;\n value: string;\n};\n\n/**\n * Extracts all string values from a content object with their paths.\n * Returns a Map where keys are string values and values are arrays of content paths.\n */\nfunction extractContentValues(\n content: Record<string, unknown>,\n basePath: string[] = []\n): Map<string, ContentMatch[]> {\n const map = new Map<string, ContentMatch[]>();\n\n function walk(obj: unknown, path: string[]) {\n if (typeof obj === 'string' && obj.trim() !== '') {\n const contentPath = path.join('.');\n const existing = map.get(obj) || [];\n existing.push({ contentPath, value: obj });\n map.set(obj, existing);\n } else if (Array.isArray(obj)) {\n for (let index = 0; index < obj.length; index++) {\n walk(obj[index], [...path, String(index)]);\n }\n } else if (obj && typeof obj === 'object') {\n for (const [key, value] of Object.entries(obj)) {\n walk(value, [...path, key]);\n }\n }\n }\n\n walk(content, basePath);\n return map;\n}\n\n// -----------------------------------------------------------------------------\n// CmsEditableInit — render once per page in edit mode\n// -----------------------------------------------------------------------------\n\n/**\n * Renders the shared CMS edit-mode styles and click-routing script.\n * Place this once at the top of your page when edit_mode is active.\n */\nexport function CmsEditableInit() {\n return (\n <>\n <style>{`\n [data-cms-editable] {\n cursor: pointer;\n border-radius: 2px;\n }\n [data-cms-editable]:hover {\n outline: 2px solid #3b82f6;\n outline-offset: 2px;\n }\n .cms-block-toolbar {\n position: fixed;\n display: flex;\n gap: 4px;\n background: #1f2937;\n border-radius: 6px;\n padding: 4px;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);\n transition: opacity 0.15s ease;\n z-index: 99999;\n pointer-events: auto;\n }\n .cms-block-toolbar button {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n border: none;\n background: transparent;\n color: #9ca3af;\n border-radius: 4px;\n cursor: pointer;\n transition: background 0.15s ease, color 0.15s ease;\n }\n .cms-block-toolbar button:hover {\n background: #374151;\n color: #fff;\n }\n .cms-block-toolbar button.delete:hover {\n background: #dc2626;\n color: #fff;\n }\n .cms-block-toolbar button:disabled {\n opacity: 0.4;\n cursor: not-allowed;\n }\n .cms-block-toolbar button:disabled:hover {\n background: transparent;\n color: #9ca3af;\n }\n .cms-block-toolbar svg {\n width: 16px;\n height: 16px;\n }\n `}</style>\n <script\n // biome-ignore lint/security/noDangerouslySetInnerHtml: Inline script for iframe postMessage click routing\n dangerouslySetInnerHTML={{\n __html: `\n (function() {\n if (!window.__cmsEditableInitialized) {\n window.__cmsEditableInitialized = true;\n\n document.addEventListener('click', function(e) {\n if (e.target.closest('.cms-block-toolbar')) return;\n\n var editableTarget = e.target.closest('[data-cms-editable]');\n if (editableTarget) {\n var message = {\n type: 'cms-editable-click',\n blockId: editableTarget.getAttribute('data-block-id'),\n blockType: editableTarget.getAttribute('data-block-type'),\n contentPath: editableTarget.getAttribute('data-content-path')\n };\n if (window.parent && window.parent !== window) {\n window.parent.postMessage(message, '*');\n }\n return;\n }\n\n var blockTarget = e.target.closest('[data-cms-block]');\n if (blockTarget) {\n var message = {\n type: 'cms-editable-click',\n blockId: blockTarget.getAttribute('data-block-id'),\n blockType: blockTarget.getAttribute('data-block-type'),\n contentPath: null\n };\n if (window.parent && window.parent !== window) {\n window.parent.postMessage(message, '*');\n }\n }\n });\n }\n })();\n `,\n }}\n />\n </>\n );\n}\n\n// -----------------------------------------------------------------------------\n// Props\n// -----------------------------------------------------------------------------\n\ninterface BlockRendererProps {\n /**\n * The block data to render.\n * Must have a `type` field that maps to a registered component.\n */\n block: BlockData;\n registry: Partial<BlockComponentRegistry>;\n /**\n * If true, renders the component without any tree walking or editable wrappers.\n */\n disableEditable?: boolean;\n /**\n * Resolved route parameters from parametric routes.\n * Each key is a param name (e.g., \"country\") with its value, schema name, and full document.\n */\n routeParams?: ResolvedRouteParams;\n /**\n * The current URL path (e.g. \"/en/test\").\n * When provided, enables path-namespaced component lookup.\n * Registry keys like \"/{lang}/test Article\" will be matched against this path,\n * where `{x}` and `(x)` are treated as single-segment wildcards.\n */\n path?: string;\n}\n\n// -----------------------------------------------------------------------------\n// Path-namespaced component resolution\n// -----------------------------------------------------------------------------\n\n/**\n * Returns true if each segment of `path` matches the corresponding segment in\n * `pattern`, where a pattern segment wrapped in `{…}` or `(…)` is a wildcard.\n */\nfunction pathMatchesPattern(path: string, pattern: string): boolean {\n const pathSegs = path.split('/').filter(Boolean);\n const patternSegs = pattern.split('/').filter(Boolean);\n if (pathSegs.length !== patternSegs.length) return false;\n for (let i = 0; i < patternSegs.length; i++) {\n const seg = patternSegs[i];\n if (!seg) return false;\n if ((seg.startsWith('{') && seg.endsWith('}')) || (seg.startsWith('(') && seg.endsWith(')'))) {\n continue;\n }\n if (seg !== pathSegs[i]) return false;\n }\n return true;\n}\n\n/**\n * Resolves the component for `blockType` from the registry.\n *\n * When `path` is provided, keys of the form `\"/{pattern} BlockType\"` are\n * checked first. The first key whose path pattern matches the current path\n * and whose block-type suffix matches `blockType` wins. Falls back to a\n * direct `registry[blockType]` lookup.\n */\nfunction resolveComponent(\n registry: Partial<BlockComponentRegistry>,\n blockType: string,\n path?: string\n): BlockComponentRegistry[string] | undefined {\n if (path) {\n for (const key of Object.keys(registry)) {\n if (!key.startsWith('/')) continue;\n const spaceIdx = key.indexOf(' ');\n if (spaceIdx === -1) continue;\n const pathPattern = key.slice(0, spaceIdx);\n const registeredType = key.slice(spaceIdx + 1);\n if (registeredType !== blockType) continue;\n if (pathMatchesPattern(path, pathPattern)) {\n return registry[key];\n }\n }\n }\n return registry[blockType];\n}\n\n// -----------------------------------------------------------------------------\n// Component\n// -----------------------------------------------------------------------------\n\n/**\n * Renders a single block by dispatching to the appropriate component.\n *\n * Uses the ComponentMap pattern: the block's `type` field determines which\n * component renders the block's `content`.\n *\n * In editable mode, wraps the block in a ClientEditableBlock that:\n * - Stamps data-cms-block attributes directly on the component's root element\n * - Injects data-cms-editable spans around matching text nodes\n * - Portals the BlockToolbar into the component's root element\n *\n * Render CmsEditableInit once at the page level to include the shared styles\n * and click-routing script.\n */\nexport function BlockRenderer({\n block,\n registry,\n disableEditable,\n routeParams,\n path,\n}: BlockRendererProps) {\n const Component = resolveComponent(registry, block.type, path);\n\n if (!Component) {\n // Log warning in development, render nothing in production\n if (process.env.NODE_ENV === 'development') {\n console.warn(`[BlockRenderer] Unknown block type: ${block.type}`);\n }\n return null;\n }\n\n // biome-ignore lint/suspicious/noExplicitAny: Type safety ensured by BlockData discriminated union\n const component = <Component content={block.content as any} routeParams={routeParams} />;\n\n if (disableEditable) {\n return component;\n }\n\n // Build content entries for client-side text matching\n const contentValueMap = extractContentValues(block.content as Record<string, unknown>);\n const contentEntries = Array.from(contentValueMap.entries())\n .map(([value, matches]) => ({ v: value, p: matches[0]?.contentPath }))\n .filter((e): e is { v: string; p: string } => !!e.p);\n\n return (\n <ClientEditableBlock blockId={block.id} blockType={block.type} contentEntries={contentEntries}>\n {component}\n </ClientEditableBlock>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../lib/block-renderer.tsx"],"sourcesContent":["/**\n * Block Renderer Component\n *\n * Dispatches block data to the appropriate component using the ComponentMap pattern.\n * This is the main entry point for rendering blocks from the CMS.\n */\n\nimport React from 'react';\nimport { ClientEditableBlock } from './client-editable-block';\nimport type { BlockComponentRegistry, BlockData, ResolvedRouteParams } from './types';\n\ntype TextInfo = {\n value: string;\n path: Array<string | number>;\n parentType?: React.ElementType;\n key?: React.Key | null;\n inSvg?: boolean;\n};\n\ntype ElementInfo = {\n element: React.ReactElement;\n path: Array<string | number>;\n};\n\ntype WalkVisitors = {\n /**\n * Called for every string/number child encountered.\n * Return:\n * - same string (or modified)\n * - a ReactNode (e.g. wrap in <span/>)\n */\n onText?: (info: TextInfo) => React.ReactNode;\n\n /**\n * Called for every ReactElement encountered (after children are processed).\n * Return:\n * - same element\n * - a cloned/modified element\n */\n onElement?: (info: ElementInfo) => React.ReactElement;\n};\n\n/**\n * Recursively maps a ReactNode tree, allowing transformations of text nodes and/or elements.\n * SSR-safe: does not touch DOM APIs.\n */\nexport function walkReactNode(\n node: React.ReactNode,\n visitors: WalkVisitors,\n ctx: {\n path?: Array<string | number>;\n parentType?: React.ElementType;\n key?: React.Key | null;\n inSvg?: boolean;\n } = {}\n): React.ReactNode {\n const path = ctx.path ?? [];\n\n // Fast-path primitives\n if (node == null || typeof node === 'boolean') return node;\n\n if (typeof node === 'string' || typeof node === 'number') {\n const value = String(node);\n return visitors.onText\n ? visitors.onText({ value, path, parentType: ctx.parentType, key: ctx.key, inSvg: ctx.inSvg })\n : node;\n }\n\n // Arrays\n if (Array.isArray(node)) {\n return node.map((child, i) => {\n // biome-ignore lint/suspicious/noExplicitAny: React child key access\n const childKey = (child as any)?.key ?? null;\n const result = walkReactNode(child, visitors, {\n path: [...path, i],\n parentType: ctx.parentType,\n key: childKey,\n inSvg: ctx.inSvg,\n });\n // Ensure array children have keys\n if (React.isValidElement(result) && result.key == null) {\n return React.cloneElement(result, { key: childKey ?? `arr-${path.join('-')}-${i}` });\n }\n return result;\n });\n }\n\n // ReactElement (including Fragment)\n if (React.isValidElement(node)) {\n // biome-ignore lint/suspicious/noExplicitAny: React element props access\n const el = node as React.ReactElement<any>;\n const elProps = el.props as Record<string, unknown> | null;\n\n // Track SVG context so we never inject <span> inside SVG subtrees\n const nextInSvg = ctx.inSvg || el.type === 'svg';\n\n // Recurse into children (if any)\n const hasChildren = elProps && 'children' in elProps;\n const nextChildren = hasChildren\n ? React.Children.map(elProps.children as React.ReactNode, (child, i) => {\n // biome-ignore lint/suspicious/noExplicitAny: React child key access\n const childKey = (child as any)?.key ?? null;\n const result = walkReactNode(child, visitors, {\n path: [...path, 'children', i],\n parentType: el.type as React.ElementType,\n key: childKey,\n inSvg: nextInSvg,\n });\n // Ensure children have keys\n if (React.isValidElement(result) && result.key == null) {\n return React.cloneElement(result, { key: childKey ?? `child-${path.join('-')}-${i}` });\n }\n return result;\n })\n : (elProps?.children as React.ReactNode);\n\n // Only clone if children changed (or if you want to force a clone)\n const cloned = hasChildren\n ? React.cloneElement(el, undefined, nextChildren as React.ReactNode)\n : el;\n\n return visitors.onElement ? visitors.onElement({ element: cloned, path }) : cloned;\n }\n\n // Functions, symbols, portals, etc. are rare here; return as-is\n return node;\n}\n\n// -----------------------------------------------------------------------------\n// Content Value Extraction\n// -----------------------------------------------------------------------------\n\ntype ContentMatch = {\n contentPath: string;\n value: string;\n};\n\n/**\n * Extracts all string values from a content object with their paths.\n * Returns a Map where keys are string values and values are arrays of content paths.\n */\nfunction extractContentValues(\n content: Record<string, unknown>,\n basePath: string[] = []\n): Map<string, ContentMatch[]> {\n const map = new Map<string, ContentMatch[]>();\n\n function walk(obj: unknown, path: string[]) {\n if (typeof obj === 'string' && obj.trim() !== '') {\n const contentPath = path.join('.');\n const existing = map.get(obj) || [];\n existing.push({ contentPath, value: obj });\n map.set(obj, existing);\n } else if (Array.isArray(obj)) {\n for (let index = 0; index < obj.length; index++) {\n walk(obj[index], [...path, String(index)]);\n }\n } else if (obj && typeof obj === 'object') {\n for (const [key, value] of Object.entries(obj)) {\n walk(value, [...path, key]);\n }\n }\n }\n\n walk(content, basePath);\n return map;\n}\n\n// -----------------------------------------------------------------------------\n// CmsEditableInit — render once per page in edit mode\n// -----------------------------------------------------------------------------\n\n/**\n * Renders the shared CMS edit-mode styles and click-routing script.\n * Place this once at the top of your page when edit_mode is active.\n */\nexport function CmsEditableInit() {\n return (\n <>\n <style>{`\n [data-cms-editable] {\n cursor: pointer;\n border-radius: 2px;\n }\n [data-cms-editable]:hover {\n outline: 2px solid #3b82f6;\n outline-offset: 2px;\n }\n .cms-block-toolbar {\n position: fixed;\n display: flex;\n gap: 4px;\n background: #1f2937;\n border-radius: 6px;\n padding: 4px;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);\n transition: opacity 0.15s ease;\n z-index: 99999;\n pointer-events: auto;\n }\n .cms-block-toolbar button {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n border: none;\n background: transparent;\n color: #9ca3af;\n border-radius: 4px;\n cursor: pointer;\n transition: background 0.15s ease, color 0.15s ease;\n }\n .cms-block-toolbar button:hover {\n background: #374151;\n color: #fff;\n }\n .cms-block-toolbar button.delete:hover {\n background: #dc2626;\n color: #fff;\n }\n .cms-block-toolbar button:disabled {\n opacity: 0.4;\n cursor: not-allowed;\n }\n .cms-block-toolbar button:disabled:hover {\n background: transparent;\n color: #9ca3af;\n }\n .cms-block-toolbar svg {\n width: 16px;\n height: 16px;\n }\n `}</style>\n <script\n // biome-ignore lint/security/noDangerouslySetInnerHtml: Inline script for iframe postMessage click routing\n dangerouslySetInnerHTML={{\n __html: `\n (function() {\n if (!window.__cmsEditableInitialized) {\n window.__cmsEditableInitialized = true;\n\n document.addEventListener('click', function(e) {\n if (e.target.closest('.cms-block-toolbar')) return;\n\n var editableTarget = e.target.closest('[data-cms-editable]');\n if (editableTarget) {\n var message = {\n type: 'cms-editable-click',\n blockId: editableTarget.getAttribute('data-block-id'),\n blockType: editableTarget.getAttribute('data-block-type'),\n contentPath: editableTarget.getAttribute('data-content-path')\n };\n if (window.parent && window.parent !== window) {\n window.parent.postMessage(message, '*');\n }\n return;\n }\n\n var blockTarget = e.target.closest('[data-cms-block]');\n if (blockTarget) {\n var message = {\n type: 'cms-editable-click',\n blockId: blockTarget.getAttribute('data-block-id'),\n blockType: blockTarget.getAttribute('data-block-type'),\n contentPath: null\n };\n if (window.parent && window.parent !== window) {\n window.parent.postMessage(message, '*');\n }\n }\n });\n }\n })();\n `,\n }}\n />\n </>\n );\n}\n\n// -----------------------------------------------------------------------------\n// Props\n// -----------------------------------------------------------------------------\n\ninterface BlockRendererProps {\n /**\n * The block data to render.\n * Must have a `type` field that maps to a registered component.\n */\n block: BlockData;\n registry: Partial<BlockComponentRegistry>;\n /**\n * If true, renders the component without any tree walking or editable wrappers.\n */\n disableEditable?: boolean;\n /**\n * Resolved route parameters from parametric routes.\n * Each key is a param name (e.g., \"country\") with its value, schema name, and full document.\n */\n routeParams?: ResolvedRouteParams;\n /**\n * The current URL path (e.g. \"/en/test\").\n * When provided, enables path-namespaced component lookup.\n * Registry keys like \"/{lang}/test Article\" will be matched against this path,\n * where `{x}` and `(x)` are treated as single-segment wildcards.\n */\n path?: string;\n}\n\n// -----------------------------------------------------------------------------\n// Path-namespaced component resolution\n// -----------------------------------------------------------------------------\n\n/**\n * Returns true if each segment of `path` matches the corresponding segment in\n * `pattern`, where a pattern segment wrapped in `{…}` or `(…)` is a wildcard.\n */\nfunction pathMatchesPattern(path: string, pattern: string): boolean {\n const pathSegs = path.split('/').filter(Boolean);\n const patternSegs = pattern.split('/').filter(Boolean);\n if (pathSegs.length !== patternSegs.length) return false;\n for (let i = 0; i < patternSegs.length; i++) {\n const seg = patternSegs[i];\n if (!seg) return false;\n if ((seg.startsWith('{') && seg.endsWith('}')) || (seg.startsWith('(') && seg.endsWith(')'))) {\n continue;\n }\n if (seg !== pathSegs[i]) return false;\n }\n return true;\n}\n\n/**\n * Resolves the component for `blockType` from the registry.\n *\n * When `path` is provided, keys of the form `\"/{pattern} BlockType\"` are\n * checked first. The first key whose path pattern matches the current path\n * and whose block-type suffix matches `blockType` wins. Falls back to a\n * direct `registry[blockType]` lookup.\n */\nfunction resolveComponent(\n registry: Partial<BlockComponentRegistry>,\n blockType: string,\n path?: string\n): BlockComponentRegistry[string] | undefined {\n if (path) {\n for (const key of Object.keys(registry)) {\n if (!key.startsWith('/')) continue;\n const spaceIdx = key.indexOf(' ');\n if (spaceIdx === -1) continue;\n const pathPattern = key.slice(0, spaceIdx);\n const registeredType = key.slice(spaceIdx + 1);\n if (registeredType !== blockType) continue;\n if (pathMatchesPattern(path, pathPattern)) {\n return registry[key];\n }\n }\n }\n return registry[blockType];\n}\n\n// -----------------------------------------------------------------------------\n// Component\n// -----------------------------------------------------------------------------\n\n/**\n * Renders a single block by dispatching to the appropriate component.\n *\n * Uses the ComponentMap pattern: the block's `type` field determines which\n * component renders the block's `content`.\n *\n * In editable mode, wraps the block in a ClientEditableBlock that:\n * - Stamps data-cms-block attributes directly on the component's root element\n * - Injects data-cms-editable spans around matching text nodes\n * - Portals the BlockToolbar into the component's root element\n *\n * Render CmsEditableInit once at the page level to include the shared styles\n * and click-routing script.\n */\nexport function BlockRenderer({\n block,\n registry,\n disableEditable,\n routeParams,\n path,\n}: BlockRendererProps) {\n const Component = resolveComponent(registry, block.type, path);\n\n if (!Component) {\n // Log warning in development, render nothing in production\n if (process.env.NODE_ENV === 'development') {\n console.warn(`[BlockRenderer] Unknown block type: ${block.type}`);\n }\n return null;\n }\n\n // Extract language code from route params if any param is bound to the language schema.\n const language = routeParams\n ? Object.values(routeParams).find((p) => p.schemaName === 'language')?.value\n : undefined;\n\n const component = (\n <Component content={block.content} routeParams={routeParams} language={language} />\n );\n\n if (disableEditable) {\n return component;\n }\n\n // Build content entries for client-side text matching\n const contentValueMap = extractContentValues(block.content as Record<string, unknown>);\n const contentEntries = Array.from(contentValueMap.entries())\n .map(([value, matches]) => ({ v: value, p: matches[0]?.contentPath }))\n .filter((e): e is { v: string; p: string } => !!e.p);\n\n return (\n <ClientEditableBlock blockId={block.id} blockType={block.type} contentEntries={contentEntries}>\n {component}\n </ClientEditableBlock>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -2,85 +2,75 @@
|
|
|
2
2
|
"use client";
|
|
3
3
|
|
|
4
4
|
// lib/block-toolbar.tsx
|
|
5
|
-
import {
|
|
5
|
+
import { ChevronDownIcon, ChevronUpIcon, PlusIcon, TrashIcon } from "@heroicons/react/24/outline";
|
|
6
6
|
import { forwardRef } from "react";
|
|
7
7
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
-
var BlockToolbar = forwardRef(
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
var BlockToolbar = forwardRef(function BlockToolbar2({ blockId }, ref) {
|
|
9
|
+
const handleAction = (action) => {
|
|
10
|
+
if (typeof window !== "undefined" && window.parent && window.parent !== window) {
|
|
11
|
+
window.parent.postMessage({ type: "cms-block-action", action, blockId }, "*");
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
return /* @__PURE__ */ jsxs("div", { ref, className: "cms-block-toolbar", "data-cms-toolbar": "true", children: [
|
|
15
|
+
/* @__PURE__ */ jsx(
|
|
16
|
+
"button",
|
|
17
|
+
{
|
|
18
|
+
type: "button",
|
|
19
|
+
title: "Move up",
|
|
20
|
+
"aria-label": "Move up",
|
|
21
|
+
"data-action": "move-up",
|
|
22
|
+
onClick: (e) => {
|
|
23
|
+
e.stopPropagation();
|
|
24
|
+
handleAction("move-up");
|
|
25
|
+
},
|
|
26
|
+
children: /* @__PURE__ */ jsx(ChevronUpIcon, {})
|
|
27
|
+
}
|
|
28
|
+
),
|
|
29
|
+
/* @__PURE__ */ jsx(
|
|
30
|
+
"button",
|
|
31
|
+
{
|
|
32
|
+
type: "button",
|
|
33
|
+
title: "Move down",
|
|
34
|
+
"aria-label": "Move down",
|
|
35
|
+
"data-action": "move-down",
|
|
36
|
+
onClick: (e) => {
|
|
37
|
+
e.stopPropagation();
|
|
38
|
+
handleAction("move-down");
|
|
39
|
+
},
|
|
40
|
+
children: /* @__PURE__ */ jsx(ChevronDownIcon, {})
|
|
41
|
+
}
|
|
42
|
+
),
|
|
43
|
+
/* @__PURE__ */ jsx(
|
|
44
|
+
"button",
|
|
45
|
+
{
|
|
46
|
+
type: "button",
|
|
47
|
+
title: "Add block",
|
|
48
|
+
"aria-label": "Add block",
|
|
49
|
+
"data-action": "add-block",
|
|
50
|
+
onClick: (e) => {
|
|
51
|
+
e.stopPropagation();
|
|
52
|
+
handleAction("add-block");
|
|
53
|
+
},
|
|
54
|
+
children: /* @__PURE__ */ jsx(PlusIcon, {})
|
|
13
55
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
"
|
|
56
|
+
),
|
|
57
|
+
/* @__PURE__ */ jsx(
|
|
58
|
+
"button",
|
|
17
59
|
{
|
|
18
|
-
|
|
19
|
-
className: "
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"data-action": "move-up",
|
|
29
|
-
onClick: (e) => {
|
|
30
|
-
e.stopPropagation();
|
|
31
|
-
handleAction("move-up");
|
|
32
|
-
},
|
|
33
|
-
children: /* @__PURE__ */ jsx(ChevronUp, {})
|
|
34
|
-
}
|
|
35
|
-
),
|
|
36
|
-
/* @__PURE__ */ jsx(
|
|
37
|
-
"button",
|
|
38
|
-
{
|
|
39
|
-
type: "button",
|
|
40
|
-
title: "Move down",
|
|
41
|
-
"aria-label": "Move down",
|
|
42
|
-
"data-action": "move-down",
|
|
43
|
-
onClick: (e) => {
|
|
44
|
-
e.stopPropagation();
|
|
45
|
-
handleAction("move-down");
|
|
46
|
-
},
|
|
47
|
-
children: /* @__PURE__ */ jsx(ChevronDown, {})
|
|
48
|
-
}
|
|
49
|
-
),
|
|
50
|
-
/* @__PURE__ */ jsx(
|
|
51
|
-
"button",
|
|
52
|
-
{
|
|
53
|
-
type: "button",
|
|
54
|
-
title: "Add block",
|
|
55
|
-
"aria-label": "Add block",
|
|
56
|
-
"data-action": "add-block",
|
|
57
|
-
onClick: (e) => {
|
|
58
|
-
e.stopPropagation();
|
|
59
|
-
handleAction("add-block");
|
|
60
|
-
},
|
|
61
|
-
children: /* @__PURE__ */ jsx(Plus, {})
|
|
62
|
-
}
|
|
63
|
-
),
|
|
64
|
-
/* @__PURE__ */ jsx(
|
|
65
|
-
"button",
|
|
66
|
-
{
|
|
67
|
-
type: "button",
|
|
68
|
-
className: "delete",
|
|
69
|
-
title: "Delete block",
|
|
70
|
-
"aria-label": "Delete block",
|
|
71
|
-
"data-action": "delete",
|
|
72
|
-
onClick: (e) => {
|
|
73
|
-
e.stopPropagation();
|
|
74
|
-
handleAction("delete");
|
|
75
|
-
},
|
|
76
|
-
children: /* @__PURE__ */ jsx(Trash2, {})
|
|
77
|
-
}
|
|
78
|
-
)
|
|
79
|
-
]
|
|
60
|
+
type: "button",
|
|
61
|
+
className: "delete",
|
|
62
|
+
title: "Delete block",
|
|
63
|
+
"aria-label": "Delete block",
|
|
64
|
+
"data-action": "delete",
|
|
65
|
+
onClick: (e) => {
|
|
66
|
+
e.stopPropagation();
|
|
67
|
+
handleAction("delete");
|
|
68
|
+
},
|
|
69
|
+
children: /* @__PURE__ */ jsx(TrashIcon, {})
|
|
80
70
|
}
|
|
81
|
-
)
|
|
82
|
-
}
|
|
83
|
-
);
|
|
71
|
+
)
|
|
72
|
+
] });
|
|
73
|
+
});
|
|
84
74
|
export {
|
|
85
75
|
BlockToolbar
|
|
86
76
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../lib/block-toolbar.tsx"],"sourcesContent":["'use client';\n\nimport {
|
|
1
|
+
{"version":3,"sources":["../../lib/block-toolbar.tsx"],"sourcesContent":["'use client';\n\nimport { ChevronDownIcon, ChevronUpIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline';\nimport { forwardRef } from 'react';\n\n/**\n * Block Toolbar Component\n *\n * Provides move up/down and delete controls for blocks in edit mode.\n * Position is managed imperatively by the parent via a forwarded ref —\n * the toolbar follows the mouse cursor and has no internal layout logic.\n */\n\nexport const BlockToolbar = forwardRef<HTMLDivElement, { blockId: string }>(function BlockToolbar(\n { blockId },\n ref\n) {\n const handleAction = (action: string) => {\n if (typeof window !== 'undefined' && window.parent && window.parent !== window) {\n window.parent.postMessage({ type: 'cms-block-action', action, blockId }, '*');\n }\n };\n\n return (\n <div ref={ref} className=\"cms-block-toolbar\" data-cms-toolbar=\"true\">\n <button\n type=\"button\"\n title=\"Move up\"\n aria-label=\"Move up\"\n data-action=\"move-up\"\n onClick={(e) => {\n e.stopPropagation();\n handleAction('move-up');\n }}\n >\n <ChevronUpIcon />\n </button>\n <button\n type=\"button\"\n title=\"Move down\"\n aria-label=\"Move down\"\n data-action=\"move-down\"\n onClick={(e) => {\n e.stopPropagation();\n handleAction('move-down');\n }}\n >\n <ChevronDownIcon />\n </button>\n <button\n type=\"button\"\n title=\"Add block\"\n aria-label=\"Add block\"\n data-action=\"add-block\"\n onClick={(e) => {\n e.stopPropagation();\n handleAction('add-block');\n }}\n >\n <PlusIcon />\n </button>\n <button\n type=\"button\"\n className=\"delete\"\n title=\"Delete block\"\n aria-label=\"Delete block\"\n data-action=\"delete\"\n onClick={(e) => {\n e.stopPropagation();\n handleAction('delete');\n }}\n >\n <TrashIcon />\n </button>\n </div>\n );\n});\n"],"mappings":";;;;AAEA,SAAS,iBAAiB,eAAe,UAAU,iBAAiB;AACpE,SAAS,kBAAkB;AAqBvB,SAWI,KAXJ;AAXG,IAAM,eAAe,WAAgD,SAASA,cACnF,EAAE,QAAQ,GACV,KACA;AACA,QAAM,eAAe,CAAC,WAAmB;AACvC,QAAI,OAAO,WAAW,eAAe,OAAO,UAAU,OAAO,WAAW,QAAQ;AAC9E,aAAO,OAAO,YAAY,EAAE,MAAM,oBAAoB,QAAQ,QAAQ,GAAG,GAAG;AAAA,IAC9E;AAAA,EACF;AAEA,SACE,qBAAC,SAAI,KAAU,WAAU,qBAAoB,oBAAiB,QAC5D;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,OAAM;AAAA,QACN,cAAW;AAAA,QACX,eAAY;AAAA,QACZ,SAAS,CAAC,MAAM;AACd,YAAE,gBAAgB;AAClB,uBAAa,SAAS;AAAA,QACxB;AAAA,QAEA,8BAAC,iBAAc;AAAA;AAAA,IACjB;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,OAAM;AAAA,QACN,cAAW;AAAA,QACX,eAAY;AAAA,QACZ,SAAS,CAAC,MAAM;AACd,YAAE,gBAAgB;AAClB,uBAAa,WAAW;AAAA,QAC1B;AAAA,QAEA,8BAAC,mBAAgB;AAAA;AAAA,IACnB;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,OAAM;AAAA,QACN,cAAW;AAAA,QACX,eAAY;AAAA,QACZ,SAAS,CAAC,MAAM;AACd,YAAE,gBAAgB;AAClB,uBAAa,WAAW;AAAA,QAC1B;AAAA,QAEA,8BAAC,YAAS;AAAA;AAAA,IACZ;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,WAAU;AAAA,QACV,OAAM;AAAA,QACN,cAAW;AAAA,QACX,eAAY;AAAA,QACZ,SAAS,CAAC,MAAM;AACd,YAAE,gBAAgB;AAClB,uBAAa,QAAQ;AAAA,QACvB;AAAA,QAEA,8BAAC,aAAU;AAAA;AAAA,IACb;AAAA,KACF;AAEJ,CAAC;","names":["BlockToolbar"]}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"use client";
|
|
3
3
|
|
|
4
4
|
// lib/client-editable-block.tsx
|
|
5
|
-
import { useCallback, useLayoutEffect as useLayoutEffect2, useRef as useRef2, useState } from "react";
|
|
5
|
+
import { useCallback, useEffect, useLayoutEffect as useLayoutEffect2, useRef as useRef2, useState } from "react";
|
|
6
6
|
import { createPortal } from "react-dom";
|
|
7
7
|
|
|
8
8
|
// lib/block-outline.tsx
|
|
@@ -44,85 +44,75 @@ function BlockOutline({ blockRect }) {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
// lib/block-toolbar.tsx
|
|
47
|
-
import {
|
|
47
|
+
import { ChevronDownIcon, ChevronUpIcon, PlusIcon, TrashIcon } from "@heroicons/react/24/outline";
|
|
48
48
|
import { forwardRef } from "react";
|
|
49
49
|
import { jsx as jsx2, jsxs } from "react/jsx-runtime";
|
|
50
|
-
var BlockToolbar = forwardRef(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
var BlockToolbar = forwardRef(function BlockToolbar2({ blockId }, ref) {
|
|
51
|
+
const handleAction = (action) => {
|
|
52
|
+
if (typeof window !== "undefined" && window.parent && window.parent !== window) {
|
|
53
|
+
window.parent.postMessage({ type: "cms-block-action", action, blockId }, "*");
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
return /* @__PURE__ */ jsxs("div", { ref, className: "cms-block-toolbar", "data-cms-toolbar": "true", children: [
|
|
57
|
+
/* @__PURE__ */ jsx2(
|
|
58
|
+
"button",
|
|
59
|
+
{
|
|
60
|
+
type: "button",
|
|
61
|
+
title: "Move up",
|
|
62
|
+
"aria-label": "Move up",
|
|
63
|
+
"data-action": "move-up",
|
|
64
|
+
onClick: (e) => {
|
|
65
|
+
e.stopPropagation();
|
|
66
|
+
handleAction("move-up");
|
|
67
|
+
},
|
|
68
|
+
children: /* @__PURE__ */ jsx2(ChevronUpIcon, {})
|
|
55
69
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
"
|
|
70
|
+
),
|
|
71
|
+
/* @__PURE__ */ jsx2(
|
|
72
|
+
"button",
|
|
59
73
|
{
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
"aria-label": "Move up",
|
|
70
|
-
"data-action": "move-up",
|
|
71
|
-
onClick: (e) => {
|
|
72
|
-
e.stopPropagation();
|
|
73
|
-
handleAction("move-up");
|
|
74
|
-
},
|
|
75
|
-
children: /* @__PURE__ */ jsx2(ChevronUp, {})
|
|
76
|
-
}
|
|
77
|
-
),
|
|
78
|
-
/* @__PURE__ */ jsx2(
|
|
79
|
-
"button",
|
|
80
|
-
{
|
|
81
|
-
type: "button",
|
|
82
|
-
title: "Move down",
|
|
83
|
-
"aria-label": "Move down",
|
|
84
|
-
"data-action": "move-down",
|
|
85
|
-
onClick: (e) => {
|
|
86
|
-
e.stopPropagation();
|
|
87
|
-
handleAction("move-down");
|
|
88
|
-
},
|
|
89
|
-
children: /* @__PURE__ */ jsx2(ChevronDown, {})
|
|
90
|
-
}
|
|
91
|
-
),
|
|
92
|
-
/* @__PURE__ */ jsx2(
|
|
93
|
-
"button",
|
|
94
|
-
{
|
|
95
|
-
type: "button",
|
|
96
|
-
title: "Add block",
|
|
97
|
-
"aria-label": "Add block",
|
|
98
|
-
"data-action": "add-block",
|
|
99
|
-
onClick: (e) => {
|
|
100
|
-
e.stopPropagation();
|
|
101
|
-
handleAction("add-block");
|
|
102
|
-
},
|
|
103
|
-
children: /* @__PURE__ */ jsx2(Plus, {})
|
|
104
|
-
}
|
|
105
|
-
),
|
|
106
|
-
/* @__PURE__ */ jsx2(
|
|
107
|
-
"button",
|
|
108
|
-
{
|
|
109
|
-
type: "button",
|
|
110
|
-
className: "delete",
|
|
111
|
-
title: "Delete block",
|
|
112
|
-
"aria-label": "Delete block",
|
|
113
|
-
"data-action": "delete",
|
|
114
|
-
onClick: (e) => {
|
|
115
|
-
e.stopPropagation();
|
|
116
|
-
handleAction("delete");
|
|
117
|
-
},
|
|
118
|
-
children: /* @__PURE__ */ jsx2(Trash2, {})
|
|
119
|
-
}
|
|
120
|
-
)
|
|
121
|
-
]
|
|
74
|
+
type: "button",
|
|
75
|
+
title: "Move down",
|
|
76
|
+
"aria-label": "Move down",
|
|
77
|
+
"data-action": "move-down",
|
|
78
|
+
onClick: (e) => {
|
|
79
|
+
e.stopPropagation();
|
|
80
|
+
handleAction("move-down");
|
|
81
|
+
},
|
|
82
|
+
children: /* @__PURE__ */ jsx2(ChevronDownIcon, {})
|
|
122
83
|
}
|
|
123
|
-
)
|
|
124
|
-
|
|
125
|
-
|
|
84
|
+
),
|
|
85
|
+
/* @__PURE__ */ jsx2(
|
|
86
|
+
"button",
|
|
87
|
+
{
|
|
88
|
+
type: "button",
|
|
89
|
+
title: "Add block",
|
|
90
|
+
"aria-label": "Add block",
|
|
91
|
+
"data-action": "add-block",
|
|
92
|
+
onClick: (e) => {
|
|
93
|
+
e.stopPropagation();
|
|
94
|
+
handleAction("add-block");
|
|
95
|
+
},
|
|
96
|
+
children: /* @__PURE__ */ jsx2(PlusIcon, {})
|
|
97
|
+
}
|
|
98
|
+
),
|
|
99
|
+
/* @__PURE__ */ jsx2(
|
|
100
|
+
"button",
|
|
101
|
+
{
|
|
102
|
+
type: "button",
|
|
103
|
+
className: "delete",
|
|
104
|
+
title: "Delete block",
|
|
105
|
+
"aria-label": "Delete block",
|
|
106
|
+
"data-action": "delete",
|
|
107
|
+
onClick: (e) => {
|
|
108
|
+
e.stopPropagation();
|
|
109
|
+
handleAction("delete");
|
|
110
|
+
},
|
|
111
|
+
children: /* @__PURE__ */ jsx2(TrashIcon, {})
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
] });
|
|
115
|
+
});
|
|
126
116
|
|
|
127
117
|
// lib/client-editable-block.tsx
|
|
128
118
|
import { Fragment, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
@@ -210,7 +200,9 @@ function ClientEditableBlock({
|
|
|
210
200
|
const cursorElRef = useRef2(null);
|
|
211
201
|
const toolbarElRef = useRef2(null);
|
|
212
202
|
const toolbarVisibleRef = useRef2(false);
|
|
213
|
-
const [
|
|
203
|
+
const [mounted, setMounted] = useState(false);
|
|
204
|
+
const [hoverOutlineRect, setHoverOutlineRect] = useState(null);
|
|
205
|
+
const [selectedOutlineRect, setSelectedOutlineRect] = useState(null);
|
|
214
206
|
const getBlockRoot = useCallback(() => {
|
|
215
207
|
return sentinelRef.current?.nextElementSibling ?? null;
|
|
216
208
|
}, []);
|
|
@@ -272,6 +264,9 @@ function ClientEditableBlock({
|
|
|
272
264
|
}
|
|
273
265
|
}
|
|
274
266
|
}, [blockId, blockType, contentEntries, getBlockRoot]);
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
setMounted(true);
|
|
269
|
+
}, []);
|
|
275
270
|
useLayoutEffect2(() => {
|
|
276
271
|
ensureCmsGlobals();
|
|
277
272
|
const blockRoot = getBlockRoot();
|
|
@@ -289,9 +284,12 @@ function ClientEditableBlock({
|
|
|
289
284
|
el.style.top = `${top}px`;
|
|
290
285
|
el.style.left = `${left}px`;
|
|
291
286
|
};
|
|
287
|
+
const refreshSelectedOutline = () => {
|
|
288
|
+
setSelectedOutlineRect((current) => current ? blockRoot.getBoundingClientRect() : current);
|
|
289
|
+
};
|
|
292
290
|
const showCursor = (e) => {
|
|
293
291
|
if (toolbarVisibleRef.current) return;
|
|
294
|
-
|
|
292
|
+
setHoverOutlineRect(blockRoot.getBoundingClientRect());
|
|
295
293
|
const el = cursorElRef.current;
|
|
296
294
|
if (!el) return;
|
|
297
295
|
el.style.top = `${e.clientY}px`;
|
|
@@ -307,7 +305,7 @@ function ClientEditableBlock({
|
|
|
307
305
|
};
|
|
308
306
|
const hideCursor = () => {
|
|
309
307
|
cursorElRef.current?.classList.remove("cms-cursor-visible");
|
|
310
|
-
|
|
308
|
+
setHoverOutlineRect(null);
|
|
311
309
|
};
|
|
312
310
|
const handleContextMenu = (e) => {
|
|
313
311
|
if (e.target.closest("[data-cms-toolbar]")) return;
|
|
@@ -330,6 +328,16 @@ function ClientEditableBlock({
|
|
|
330
328
|
hideCursor();
|
|
331
329
|
toolbarElRef.current?.classList.remove("cms-toolbar-visible");
|
|
332
330
|
toolbarVisibleRef.current = false;
|
|
331
|
+
refreshSelectedOutline();
|
|
332
|
+
};
|
|
333
|
+
const handleWindowMessage = (event) => {
|
|
334
|
+
if (event.data?.type !== "cms-select-block") return;
|
|
335
|
+
const selectedBlockId = typeof event.data.blockId === "string" ? event.data.blockId : null;
|
|
336
|
+
if (selectedBlockId === blockId) {
|
|
337
|
+
setSelectedOutlineRect(blockRoot.getBoundingClientRect());
|
|
338
|
+
} else {
|
|
339
|
+
setSelectedOutlineRect(null);
|
|
340
|
+
}
|
|
333
341
|
};
|
|
334
342
|
blockRoot.addEventListener("mouseenter", showCursor);
|
|
335
343
|
blockRoot.addEventListener("mousemove", moveCursor);
|
|
@@ -337,6 +345,8 @@ function ClientEditableBlock({
|
|
|
337
345
|
blockRoot.addEventListener("contextmenu", handleContextMenu);
|
|
338
346
|
document.addEventListener("click", handleClickOutside);
|
|
339
347
|
window.addEventListener("scroll", hideOnScroll, { passive: true, capture: true });
|
|
348
|
+
window.addEventListener("resize", refreshSelectedOutline, { passive: true });
|
|
349
|
+
window.addEventListener("message", handleWindowMessage);
|
|
340
350
|
injectSpans();
|
|
341
351
|
const observer = new MutationObserver(injectSpans);
|
|
342
352
|
observerRef.current = observer;
|
|
@@ -354,14 +364,19 @@ function ClientEditableBlock({
|
|
|
354
364
|
blockRoot.removeEventListener("contextmenu", handleContextMenu);
|
|
355
365
|
document.removeEventListener("click", handleClickOutside);
|
|
356
366
|
window.removeEventListener("scroll", hideOnScroll, { capture: true });
|
|
367
|
+
window.removeEventListener("resize", refreshSelectedOutline);
|
|
368
|
+
window.removeEventListener("message", handleWindowMessage);
|
|
357
369
|
};
|
|
358
370
|
}, [injectSpans, blockId, blockType, getBlockRoot]);
|
|
359
371
|
return /* @__PURE__ */ jsxs2(Fragment, { children: [
|
|
360
372
|
/* @__PURE__ */ jsx3("span", { ref: sentinelRef, style: { display: "none" }, "aria-hidden": true }),
|
|
361
373
|
children,
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
374
|
+
mounted && (selectedOutlineRect ?? hoverOutlineRect) && createPortal(
|
|
375
|
+
/* @__PURE__ */ jsx3(BlockOutline, { blockRect: selectedOutlineRect ?? hoverOutlineRect }),
|
|
376
|
+
document.body
|
|
377
|
+
),
|
|
378
|
+
mounted && createPortal(/* @__PURE__ */ jsx3("div", { ref: cursorElRef, className: "cms-block-cursor" }), document.body),
|
|
379
|
+
mounted && createPortal(/* @__PURE__ */ jsx3(BlockToolbar, { ref: toolbarElRef, blockId }), document.body)
|
|
365
380
|
] });
|
|
366
381
|
}
|
|
367
382
|
export {
|