diff --git a/craft/src/components/media/ImageBlock.render.test.tsx b/craft/src/components/media/ImageBlock.render.test.tsx new file mode 100644 index 0000000..d87dcb4 --- /dev/null +++ b/craft/src/components/media/ImageBlock.render.test.tsx @@ -0,0 +1,62 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; + +/* ImageBlock only needs useNode from @craftjs/core. Mock it following the + DOM-harness pattern in src/components/basic/Footer.editguard.test.tsx (no + @testing-library/react in this repo) so we can render the real component + tree and inspect the emitted without a real . */ +vi.mock('@craftjs/core', () => ({ + useNode: (collect?: (node: any) => any) => { + const node = { events: { selected: false } }; + return { + connectors: { connect: (el: any) => el, drag: (el: any) => el }, + actions: { setProp: vi.fn() }, + ...(collect ? collect(node) : {}), + }; + }, +})); + +import { ImageBlock } from './ImageBlock'; + +let container: HTMLDivElement; +let root: Root; + +function render(ui: React.ReactElement) { + container = document.createElement('div'); + document.body.appendChild(container); + act(() => { + root = createRoot(container); + root.render(ui); + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('ImageBlock render falls back to the placeholder for an explicit empty src (Bug 1)', () => { + test('src="" (explicit, overrides the default parameter) still renders a non-empty placeholder src', () => { + render(); + const img = container.querySelector('img')!; + expect(img.getAttribute('src')).not.toBe(''); + expect(img.getAttribute('src')).toMatch(/^data:image\/svg\+xml/); + container.remove(); + }); + + test('src=undefined (default parameter path) still renders the placeholder (unchanged behavior)', () => { + render(); + const img = container.querySelector('img')!; + expect(img.getAttribute('src')).not.toBe(''); + expect(img.getAttribute('src')).toMatch(/^data:image\/svg\+xml/); + container.remove(); + }); + + test('a real src is rendered unchanged', () => { + render(); + const img = container.querySelector('img')!; + expect(img.getAttribute('src')).toBe('https://example.com/photo.jpg'); + container.remove(); + }); +}); diff --git a/craft/src/components/media/ImageBlock.tsx b/craft/src/components/media/ImageBlock.tsx index 11052fc..050ee41 100644 --- a/craft/src/components/media/ImageBlock.tsx +++ b/craft/src/components/media/ImageBlock.tsx @@ -3,7 +3,7 @@ import { useNode, UserComponent } from '@craftjs/core'; import { cssPropsToString } from '../../utils/style-helpers'; import { escapeAttr, safeUrl } from '../../utils/escape'; -const PLACEHOLDER_SRC = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300'%3E%3Cdefs%3E%3ClinearGradient id='bg' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%23f1f5f9'/%3E%3Cstop offset='100%25' stop-color='%23e2e8f0'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23bg)' width='400' height='300' rx='12'/%3E%3Crect x='2' y='2' width='396' height='296' rx='10' fill='none' stroke='%23cbd5e1' stroke-width='2' stroke-dasharray='8 4'/%3E%3Cg transform='translate(200,110)'%3E%3Crect x='-28' y='-28' width='56' height='56' rx='12' fill='%23cbd5e1' opacity='0.5'/%3E%3Cpath d='M-12 8 L-4 -2 L2 4 L8 -6 L16 8Z' fill='%2394a3b8'/%3E%3Ccircle cx='-6' cy='-10' r='5' fill='%2394a3b8'/%3E%3C/g%3E%3Ctext x='200' y='160' text-anchor='middle' fill='%2364748b' font-family='Inter,sans-serif' font-size='15' font-weight='500'%3EDrop image here%3C/text%3E%3Ctext x='200' y='182' text-anchor='middle' fill='%2394a3b8' font-family='Inter,sans-serif' font-size='12'%3Eor click to upload%3C/text%3E%3C/svg%3E"; +export const PLACEHOLDER_SRC = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300'%3E%3Cdefs%3E%3ClinearGradient id='bg' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%23f1f5f9'/%3E%3Cstop offset='100%25' stop-color='%23e2e8f0'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23bg)' width='400' height='300' rx='12'/%3E%3Crect x='2' y='2' width='396' height='296' rx='10' fill='none' stroke='%23cbd5e1' stroke-width='2' stroke-dasharray='8 4'/%3E%3Cg transform='translate(200,110)'%3E%3Crect x='-28' y='-28' width='56' height='56' rx='12' fill='%23cbd5e1' opacity='0.5'/%3E%3Cpath d='M-12 8 L-4 -2 L2 4 L8 -6 L16 8Z' fill='%2394a3b8'/%3E%3Ccircle cx='-6' cy='-10' r='5' fill='%2394a3b8'/%3E%3C/g%3E%3Ctext x='200' y='160' text-anchor='middle' fill='%2364748b' font-family='Inter,sans-serif' font-size='15' font-weight='500'%3EDrop image here%3C/text%3E%3Ctext x='200' y='182' text-anchor='middle' fill='%2394a3b8' font-family='Inter,sans-serif' font-size='12'%3Eor click to upload%3C/text%3E%3C/svg%3E"; interface ImageBlockProps { src?: string; @@ -66,7 +66,7 @@ export const ImageBlock: UserComponent = ({ imgRef.current = ref; if (ref) connect(drag(ref)); }} - src={src} + src={src || PLACEHOLDER_SRC} alt={alt || 'Image'} onDrop={handleDrop} onDragOver={handleDragOver} diff --git a/craft/src/panels/left/BlocksPanel.tsx b/craft/src/panels/left/BlocksPanel.tsx index fc612cb..4ab34f6 100644 --- a/craft/src/panels/left/BlocksPanel.tsx +++ b/craft/src/panels/left/BlocksPanel.tsx @@ -123,7 +123,7 @@ const categories: CategoryDef[] = [ label: 'Media', blocks: [ { id: 'image', label: 'Image', icon: 'fa-image', - component: }, + component: }, { id: 'video', label: 'Video', icon: 'fa-play-circle', component: }, { id: 'map-embed', label: 'Map', icon: 'fa-map-marker', diff --git a/craft/src/panels/right/styles/ImageStylePanel.tsx b/craft/src/panels/right/styles/ImageStylePanel.tsx index 413c506..659a814 100644 --- a/craft/src/panels/right/styles/ImageStylePanel.tsx +++ b/craft/src/panels/right/styles/ImageStylePanel.tsx @@ -11,6 +11,7 @@ import { useNodeProp, } from './shared'; import { AssetPicker } from '../../../ui/AssetPicker'; +import { PLACEHOLDER_SRC } from '../../../components/media/ImageBlock'; /* ---------- IMAGE (with upload/browse/drop) ---------- */ export const ImageStylePanel: React.FC = ({ selectedId, nodeProps }) => { @@ -33,7 +34,7 @@ export const ImageStylePanel: React.FC = ({ selectedId, nodePro Image Source actions.setProp(selectedId, (props: any) => { props.src = url; })} + onChange={(url) => actions.setProp(selectedId, (props: any) => { props.src = url || PLACEHOLDER_SRC; })} variant="full" />