diff --git a/craft/src/editor/Canvas.tsx b/craft/src/editor/Canvas.tsx index 087ed88..19ac7e4 100644 --- a/craft/src/editor/Canvas.tsx +++ b/craft/src/editor/Canvas.tsx @@ -9,6 +9,7 @@ import { useIsMobile } from '../hooks/useIsMobile'; import { useMobileChrome } from '../state/MobileChromeContext'; import { useSitesmithModal } from '../state/SitesmithContext'; import { insertBlock } from '../panels/left/blocks-registry'; +import { openSectionLibrary } from '../panels/sections/SectionLibraryModal'; interface CanvasProps { /** Item 10: when false, applies `.guides-off` to hide the dashed drop-target guides. */ @@ -109,8 +110,8 @@ const ZonePreview: React.FC<{ craftState: string | null; zone: 'header' | 'foote * opt back in. */ -/** "Add a section" action. Kept as one named function so #52 can point it at - * the section library with a one-line swap. */ +/** Blank-section insert (no longer wired to the empty-state button, which + * opens the section library). */ export function addSectionToRoot(query: any, actions: any): string | null { return insertBlock(query, actions, 'section', { parentId: 'ROOT', select: true }); } @@ -141,7 +142,7 @@ export const EmptyCanvasHint: React.FC = () => { -
; +const Grab: React.FC = () => { extrasApi = useProjectExtras(); return null; }; + +function mount() { + h = renderEditorHarness({ + wrap: (tree) => ( + {tree} + ), + }); + h.mountChild(<>); +} +const $ = (sel: string) => h.container.querySelector(sel) as HTMLElement | null; +const $$ = (sel: string) => [...h.container.querySelectorAll(sel)] as HTMLElement[]; +const rootKids = () => h.query.node('ROOT').get().data.nodes as string[]; +const type = (el: HTMLInputElement, v: string) => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!; + h.act(() => { setter.call(el, v); el.dispatchEvent(new Event('input', { bubbles: true })); }); +}; + +beforeEach(() => __resetCommandsForTests()); +afterEach(() => { h?.unmount(); closeSectionLibrary(); }); + +describe('SectionLibraryModal', () => { + test('closed by default; Browse sections button in the Blocks panel opens it', () => { + mount(); + expect($('.section-lib')).toBeNull(); + h.act(() => { ($$('button').find((b) => b.textContent?.includes('Browse sections')))!.click(); }); + expect($('.section-lib')).not.toBeNull(); + expect($$('.section-lib-card').length).toBeGreaterThan(0); + }); + + test('thumbnails are sandboxed, script-less iframes in the site palette', () => { + mount(); + h.act(() => openSectionLibrary()); + const frame = $('iframe.section-lib-frame') as HTMLIFrameElement; + expect(frame.getAttribute('sandbox')).toBe(''); + expect(frame.getAttribute('srcdoc')).toContain('--wsb-primary:'); + }); + + test('family chip filters and search narrows', () => { + mount(); + h.act(() => openSectionLibrary()); + const chip = (l: string) => $$('.section-lib-chip').find((c) => c.textContent === l)!; + h.act(() => chip('Hero').click()); + expect($$('.section-lib-card').map((c) => c.dataset.sectionId)).toEqual(['hero-centered']); + h.act(() => chip('All').click()); + type($('.section-lib-search') as HTMLInputElement, 'form'); + expect($$('.section-lib-card').map((c) => c.dataset.sectionId)).toContain('contact-form'); + type($('.section-lib-search') as HTMLInputElement, 'zzzz'); + expect($$('.section-lib-card')).toHaveLength(0); + }); + + test('clicking a card inserts the section, selects it and closes', () => { + mount(); + h.act(() => openSectionLibrary()); + h.act(() => { ($('[data-section-id="hero-centered"] .section-lib-pick') as HTMLElement).click(); }); + expect(rootKids()).toHaveLength(1); + expect(h.query.getEvent('selected').all()).toEqual([rootKids()[0]]); + expect($('.section-lib')).toBeNull(); + }); + + test('registers insert.section-library and site.save-as-section (only for a top-level section)', () => { + mount(); + expect(getCommand('insert.section-library')).toBeTruthy(); + const save = getCommand('site.save-as-section')!; + const ctx = (ids: string[]) => ({ query: h.query, selectedIds: ids } as any); + expect(save.when!(ctx([]))).toBe(false); + h.act(() => openSectionLibrary()); + h.act(() => { ($('[data-section-id="hero-centered"] .section-lib-pick') as HTMLElement).click(); }); + const id = rootKids()[0]; + expect(save.when!(ctx([id]))).toBe(true); + const inner = h.query.node(id).decendants(true)[0]; + expect(save.when!(ctx([inner]))).toBe(false); + }); + + test('save current section -> extras.customSections, shown under My sections, deletable', () => { + mount(); + h.act(() => openSectionLibrary()); + h.act(() => { ($('[data-section-id="hero-centered"] .section-lib-pick') as HTMLElement).click(); }); + h.act(() => openSectionLibrary()); + h.act(() => { $$('button').find((b) => b.textContent?.includes('Save current section'))!.click(); }); + type($('input[aria-label="Section name"]') as HTMLInputElement, 'My hero'); + h.act(() => { $$('button').find((b) => b.textContent === 'Save')!.click(); }); + const saved = extrasApi.get('customSections', []); + expect(saved).toHaveLength(1); + expect(saved[0]).toMatchObject({ name: 'My hero', family: 'custom' }); + expect(saved[0].tree.type.resolvedName).toBe('Section'); + expect($$('.section-lib-card').map((c) => c.dataset.sectionId)).toEqual([saved[0].id]); + h.act(() => { ($('.section-lib-delete') as HTMLElement).click(); }); + expect(extrasApi.get('customSections', [])).toHaveLength(0); + }); +}); diff --git a/craft/src/panels/sections/SectionLibraryModal.tsx b/craft/src/panels/sections/SectionLibraryModal.tsx new file mode 100644 index 0000000..8383464 --- /dev/null +++ b/craft/src/panels/sections/SectionLibraryModal.tsx @@ -0,0 +1,219 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import { useEditor } from '@craftjs/core'; +import { Modal } from '../../ui/Modal'; +import { useSiteDesign } from '../../state/SiteDesignContext'; +import { useProjectExtras } from '../../state/ProjectExtrasContext'; +import { useNotices } from '../../state/NoticeContext'; +import { useRegisterCommands } from '../../editor/commands'; +import { + CUSTOM_SECTIONS_KEY, CustomSection, SECTION_FAMILIES, SectionDef, SectionFamily, + insertSection, isTopLevelSection, nodeTreeToSerializedTree, searchSections, useSections, +} from '../../sections/catalog'; +import { buildSectionPreviewDoc } from '../../sections/thumbnails'; + +// ---- open state: module-level so the command, the empty-state button and the +// Blocks panel button can open the modal without a provider ---- +interface LibraryState { open: boolean; saving: boolean } +let state: LibraryState = { open: false, saving: false }; +const listeners = new Set<() => void>(); +const setState = (s: LibraryState) => { state = s; listeners.forEach((l) => l()); }; +const subscribe = (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; }; + +export function openSectionLibrary(opts: { saving?: boolean } = {}): void { + setState({ open: true, saving: !!opts.saving }); +} +export function closeSectionLibrary(): void { setState({ open: false, saving: false }); } +export function useSectionLibraryState(): LibraryState { + return useSyncExternalStore(subscribe, () => state, () => state); +} + +/** Renders its iframe only once scrolled into view (60 live iframes at once is heavy). */ +const LazyPreview: React.FC<{ def: SectionDef; title: string }> = ({ def, title }) => { + const { design } = useSiteDesign(); + const ref = useRef(null); + const [seen, setSeen] = useState(typeof IntersectionObserver === 'undefined'); + useEffect(() => { + if (seen || !ref.current) return; + const io = new IntersectionObserver((entries) => { + if (entries.some((e) => e.isIntersecting)) { setSeen(true); io.disconnect(); } + }); + io.observe(ref.current); + return () => io.disconnect(); + }, [seen]); + const doc = useMemo(() => { + if (!seen) return ''; + try { return buildSectionPreviewDoc(def, design); } catch { return ''; } + }, [seen, def, design]); + return ( +
+ {doc && ( +