diff --git a/craft/src/editor/commands.ts b/craft/src/editor/commands.ts index f6431fc..6a245ae 100644 --- a/craft/src/editor/commands.ts +++ b/craft/src/editor/commands.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'; import { useEditor } from '@craftjs/core'; import type { DeviceMode } from '../types'; import { useDevice } from '../state/DeviceContext'; @@ -213,3 +213,21 @@ export function useCommandShortcuts(): void { export function useRegisterCommands(cmds: Command[], deps: unknown[]): void { useEffect(() => registerCommands(cmds), deps); // eslint-disable-line react-hooks/exhaustive-deps } + +/** + * One registry command as a UI affordance (context-menu entry, header button): + * whether it is registered at all, whether its `when()` passes for the live + * selection, and a runner. Re-renders when the registry or the selection changes. + */ +export function useCommandState(id: string): { registered: boolean; enabled: boolean; run: () => Promise } { + const ctx = useCommandContext(); + const runCommand = useRunCommand(); + const [, bump] = useReducer((n: number) => n + 1, 0); + useEffect(() => subscribeCommands(bump), []); + const cmd = getCommand(id); + return { + registered: !!cmd, + enabled: !!cmd && isEnabled(cmd, liveContext(ctx)), + run: () => runCommand(id), + }; +} diff --git a/craft/src/panels/context-menu/ContextMenu.test.tsx b/craft/src/panels/context-menu/ContextMenu.test.tsx index 771ee1c..8932e4f 100644 --- a/craft/src/panels/context-menu/ContextMenu.test.tsx +++ b/craft/src/panels/context-menu/ContextMenu.test.tsx @@ -10,6 +10,8 @@ import { SaveGuardProvider } from '../../state/SaveGuardContext'; import { SiteDesignProvider, useSiteDesign, SiteDesignContextValue } from '../../state/SiteDesignContext'; import { CanvasTokenStyle } from '../../editor/CanvasTokenStyle'; import { setStyleClipboard, getStyleClipboard, extractStyle } from '../../utils/style-clipboard'; +import { registerCommand } from '../../editor/commands'; +import { isTopLevelSection } from '../../sections/catalog'; let h: EditorHarness; beforeEach(() => setStyleClipboard(null)); @@ -122,3 +124,45 @@ describe('ContextMenu style items', () => { expect(document.querySelector('.wsb-presets-popover')!.textContent).toContain('No saved presets'); }); }); + +describe('ContextMenu section commands (#52 / #58)', () => { + const offs: Array<() => void> = []; + afterEach(() => { offs.splice(0).forEach((off) => off()); }); + const register = (id: string, run: (ctx: { selectedIds: string[] }) => void) => + offs.push(registerCommand({ id, title: id, group: 'site', when: (ctx) => ctx.selectedIds.length === 1 && isTopLevelSection(ctx.query, ctx.selectedIds[0]), run })); + + test('entries are absent while the commands are not registered', () => { + h = renderEditorHarness(); + const a = add(React.createElement(Heading, {})); + h.act(() => { h.actions.selectNode(a); }); + mountMenu(a); + expect(btn('Save as section')).toBeUndefined(); + expect(btn('Regenerate this section')).toBeUndefined(); + }); + + test('"Save as section" and "Regenerate this section" invoke their registry commands for the selected top-level node', () => { + const saved: string[][] = []; + const regenerated: string[][] = []; + register('site.save-as-section', (ctx) => { saved.push(ctx.selectedIds); }); + register('edit.regenerate-section', (ctx) => { regenerated.push(ctx.selectedIds); }); + h = renderEditorHarness(); + const a = add(React.createElement(Heading, {})); + h.act(() => { h.actions.selectNode(a); }); + mountMenu(a); + expect(btn('Save as section').disabled).toBe(false); + expect(btn('Regenerate this section').disabled).toBe(false); + click('Save as section'); + click('Regenerate this section'); + expect(saved).toEqual([[a]]); + expect(regenerated).toEqual([[a]]); + }); + + test('disabled when the command\'s when() is false (ROOT)', () => { + register('site.save-as-section', () => {}); + register('edit.regenerate-section', () => {}); + h = renderEditorHarness(); + mountMenu('ROOT'); + expect(btn('Save as section').disabled).toBe(true); + expect(btn('Regenerate this section').disabled).toBe(true); + }); +}); diff --git a/craft/src/panels/context-menu/ContextMenu.tsx b/craft/src/panels/context-menu/ContextMenu.tsx index 00a6ac2..cc29e26 100644 --- a/craft/src/panels/context-menu/ContextMenu.tsx +++ b/craft/src/panels/context-menu/ContextMenu.tsx @@ -4,7 +4,7 @@ import { useSitesmithModal } from '../../state/SitesmithContext'; import { buildSitesmithTarget } from '../../utils/sitesmith-target'; import { regenerateTreeIds } from '../../utils/craft-tree'; import { getClipboardTree, setClipboardTree } from '../../hooks/clipboard'; -import { getCommand } from '../../editor/commands'; +import { getCommand, useCommandState } from '../../editor/commands'; import { formatShortcut } from '../../utils/shortcuts'; import { useNodeActions } from '../../hooks/useNodeActions'; import { useNotices } from '../../state/NoticeContext'; @@ -128,6 +128,9 @@ export const ContextMenu: React.FC = ({ // the exact same behavior. Wrapped here purely to also `onClose()` the // menu after each action, same as before the extraction. const nodeActions = useNodeActions(nodeId); + // #52 / #58: section commands live in the registry; the menu only invokes them. + const saveAsSection = useCommandState('site.save-as-section'); + const regenerateSection = useCommandState('edit.regenerate-section'); const duplicate = useCallback(() => { nodeActions.duplicate(); @@ -361,6 +364,22 @@ export const ContextMenu: React.FC = ({ disabled: isRoot || !!instance, dividerAfter: true, }, + ...(saveAsSection.registered + ? [{ + label: 'Save as section\u2026', + icon: 'archive', + action: () => { void saveAsSection.run(); onClose(); }, + disabled: isRoot || !saveAsSection.enabled, + }] + : []), + ...(regenerateSection.registered + ? [{ + label: 'Regenerate this section', + icon: 'refresh', + action: () => { void regenerateSection.run(); onClose(); }, + disabled: isRoot || !regenerateSection.enabled, + }] + : []), ...(instance ? [ { label: 'Edit master', icon: 'globe', action: editMaster }, diff --git a/craft/src/panels/left/LayersPanel.test.tsx b/craft/src/panels/left/LayersPanel.test.tsx index 73c410b..7f35862 100644 --- a/craft/src/panels/left/LayersPanel.test.tsx +++ b/craft/src/panels/left/LayersPanel.test.tsx @@ -271,6 +271,38 @@ describe('LayersPanel drag / rename / hide (#47)', () => { hn.unmount(); }); + test('a disallowed target keeps dropEffect "move" so the browser still fires drop (refusal notice shows)', () => { + const hn = mount(); + const target = rowOf(hn, 'inner'); + target.getBoundingClientRect = () => ({ top: 0, height: 30, left: 0, right: 0, bottom: 30, width: 0, x: 0, y: 0, toJSON() {} }) as DOMRect; + hn.act(() => { fire(rowOf(hn, 'sec'), 'dragstart'); }); + let over: any; + hn.act(() => { over = fire(target, 'dragover', { clientY: 15 }); }); + expect(over.defaultPrevented).toBe(true); + expect(over.dataTransfer.dropEffect).toBe('move'); + // ...but no drop line is drawn for the refused target. + expect(hn.container.querySelector('[data-drop]')).toBeNull(); + hn.unmount(); + }); + + test('renaming to the unchanged default name stores no custom.label; to the default again clears one', () => { + const hn = mount(); + const rename = (value: string) => { + hn.act(() => { rowOf(hn, 'h').dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); }); + const input = hn.container.querySelector('.layer-rename-input') as HTMLInputElement; + input.value = value; + hn.act(() => { input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); }); + }; + rename('Heading'); + expect('label' in hn.query.node('h').get().data.custom).toBe(false); + expect(JSON.parse(hn.getSerialized()).h.custom).toEqual({}); + rename('Pricing'); + expect(hn.query.node('h').get().data.custom.label).toBe('Pricing'); + rename(' Heading '); + expect('label' in hn.query.node('h').get().data.custom).toBe(false); + hn.unmount(); + }); + test('linked slots are not draggable; normal rows are', () => { const hn = mount(); expect(rowOf(hn, 'inner').draggable).toBe(false); @@ -357,3 +389,22 @@ describe('LayersPanel drag / rename / hide (#47)', () => { hn.unmount(); }); }); + +describe('LayersPanel type icons for local-business blocks (#60)', () => { + test.each([ + ['OpeningHours', 'Opening Hours', 'fa-clock-o'], + ['StickyCallBar', 'Call Bar', 'fa-phone'], + ['ReviewsStrip', 'Reviews', 'fa-star'], + ['BookingEmbed', 'Booking', 'fa-calendar'], + ])('%s (%s) row uses %s', (resolvedName, displayName, icon) => { + const n = { + type: { resolvedName }, isCanvas: false, props: {}, displayName, + custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT', + }; + const harness = renderEditorHarness({ initialState: stateWith({ b: n }, ['b']) }); + harness.mountChild(); + const row = harness.container.querySelector('.layer-node-row[data-layer-id="b"]')!; + expect(row.querySelector(`i.${icon}`)).not.toBeNull(); + harness.unmount(); + }); +}); diff --git a/craft/src/panels/left/LayersPanel.tsx b/craft/src/panels/left/LayersPanel.tsx index a0fe604..7612f98 100644 --- a/craft/src/panels/left/LayersPanel.tsx +++ b/craft/src/panels/left/LayersPanel.tsx @@ -63,6 +63,11 @@ const TYPE_ICONS: Record = { 'Submit Button': 'fa-paper-plane', 'Contact Form': 'fa-envelope', 'Subscribe Form': 'fa-paper-plane', + // Local business (#60) + 'Opening Hours': 'fa-clock-o', + 'Call Bar': 'fa-phone', + Reviews: 'fa-star', + Booking: 'fa-calendar', }; const DEFAULT_ICON = 'fa-cube'; @@ -294,12 +299,15 @@ const LayerNodeImpl: React.FC = ({ nodeId, depth }) => { const commitRename = useCallback((value: string) => { ui.set({ renamingId: null }); - const v = value.trim(); + const typed = value.trim(); + // Typing the default name back means "no custom label": never store a + // redundant custom.label equal to the type name. + const v = typed === displayName ? '' : typed; if (v === customLabel) return; actions.setCustom(nodeId, (c: any) => { if (v) c.label = v; else delete c.label; }); - }, [ui, actions, nodeId, customLabel]); + }, [ui, actions, nodeId, customLabel, displayName]); const toggleHidden = useCallback((e: React.SyntheticEvent) => { e.stopPropagation(); @@ -338,7 +346,10 @@ const LayerNodeImpl: React.FC = ({ nodeId, depth }) => { e.preventDefault(); const drop = resolveDrop(e); const ok = !!drop && dragId !== nodeId && moveLayerAllowed(query, dragId, drop.parentId); - if (e.dataTransfer) e.dataTransfer.dropEffect = ok ? 'move' : 'none'; + // Always 'move': with dropEffect 'none' the browser never fires `drop`, so + // a refused target could not explain itself. Disallowed targets get no drop + // line here and are refused (with a notice) in handleDrop via moveLayer. + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; ui.set(ok && drop ? { dropTargetId: nodeId, dropWhere: drop.where } : { dropTargetId: null, dropWhere: null }); }; const handleDrop = (e: React.DragEvent) => { diff --git a/craft/src/panels/right/GuidedStyles.followups.test.tsx b/craft/src/panels/right/GuidedStyles.followups.test.tsx new file mode 100644 index 0000000..3c02da6 --- /dev/null +++ b/craft/src/panels/right/GuidedStyles.followups.test.tsx @@ -0,0 +1,93 @@ +import { describe, test, expect, afterEach } from 'vitest'; +import React from 'react'; +import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness'; +import { SiteDesignProvider } from '../../state/SiteDesignContext'; +import { PageProvider } from '../../state/PageContext'; +import { SitesmithProvider } from '../../state/SitesmithContext'; +import { insertBlock } from '../left/blocks-registry'; +import { Heading } from '../../components/basic/Heading'; +import { Container } from '../../components/layout/Container'; +import { registerCommand } from '../../editor/commands'; +import { isTopLevelSection } from '../../sections/catalog'; +import { GuidedStyles } from './GuidedStyles'; + +/** Integration follow-ups: #60 business-panel classification and the #58 Regenerate button. */ +let h: EditorHarness; +const offs: Array<() => void> = []; +afterEach(() => { h?.unmount(); offs.splice(0).forEach((off) => off()); }); + +function mount() { + h = renderEditorHarness(); + h.mountChild( + + + , + ); +} +const select = (id: string) => h.act(() => h.actions.selectNode(id)); +const header = () => h.container.querySelector('[data-testid="type-header"]')?.textContent?.trim(); +const text = () => h.container.textContent || ''; +const regenBtn = () => h.container.querySelector('button[aria-label="Regenerate this section"]'); + +describe('GuidedStyles: local-business blocks use BusinessStylePanel (#60)', () => { + test.each([ + ['opening-hours', 'Opening Hours', 'Label color'], + ['call-bar', 'Call Bar', 'Call button label'], + ['reviews-strip', 'Reviews', 'Card background'], + ['booking-embed', 'Booking', 'Booking URL (Calendly / Square)'], + ])('%s -> header "%s" and the business control "%s"', (blockId, displayName, label) => { + mount(); + let id: string | null = null; + h.act(() => { id = insertBlock(h.query, h.actions, blockId); }); + select(id!); + expect(header()).toBe(displayName); + expect(text()).toContain(label); + expect(h.container.querySelector('.guided-type-header .fa-briefcase')).not.toBeNull(); + }); +}); + +describe('GuidedStyles: Regenerate button (#58)', () => { + const add = (el: React.ReactElement, parent = 'ROOT'): string => { + let id = ''; + h.act(() => { + const tree = h.query.parseReactElement(el).toNodeTree(); + h.actions.addNodeTree(tree, parent); + id = tree.rootNodeId; + }); + return id; + }; + const registerRegenerate = (runs: string[][]) => + offs.push(registerCommand({ + id: 'edit.regenerate-section', title: 'Regenerate this section', group: 'edit', + when: (ctx) => ctx.selectedIds.length === 1 && isTopLevelSection(ctx.query, ctx.selectedIds[0]), + run: (ctx) => { runs.push(ctx.selectedIds); }, + })); + + test('shown for a top-level section and runs the registry command', () => { + const runs: string[][] = []; + registerRegenerate(runs); + mount(); + const section = add(); + select(section); + expect(regenBtn()).not.toBeNull(); + h.act(() => { regenBtn()!.click(); }); + expect(runs).toEqual([[section]]); + }); + + test('hidden when the command\'s when() is false (nested node)', () => { + registerRegenerate([]); + mount(); + const section = add(); + const inner = add(, section); + select(inner); + expect(header()).toBe('Heading'); + expect(regenBtn()).toBeNull(); + }); + + test('hidden when the command is not registered (standalone, no Sitesmith)', () => { + mount(); + const section = add(); + select(section); + expect(regenBtn()).toBeNull(); + }); +}); diff --git a/craft/src/panels/right/GuidedStyles.tsx b/craft/src/panels/right/GuidedStyles.tsx index 80b890f..aba574a 100644 --- a/craft/src/panels/right/GuidedStyles.tsx +++ b/craft/src/panels/right/GuidedStyles.tsx @@ -18,10 +18,12 @@ import { BackgroundSectionStylePanel, GenericPropsEditor, HtmlStylePanel, + BusinessStylePanel, } from './styles'; import { DeviceBanner } from './styles/responsive-panel'; import { setBulkSelection } from './styles/shared'; import { CORE_COMMANDS } from '../../editor/core-commands'; +import { useCommandState } from '../../editor/commands'; import { useGlobalSections } from '../../state/GlobalSectionsContext'; import type { OverridePatch } from '../../utils/global-sections'; @@ -152,6 +154,8 @@ export const GuidedStyles: React.FC<{ emptyFallback?: 'site' | 'placeholder'; }> = ({ emptyFallback = 'site' }) => { const { open: openSitesmith } = useSitesmithModal(); + // #58: Regenerate lives in the registry (Sitesmith, WHP only); hidden when its when() is false. + const regenerate = useCommandState('edit.regenerate-section'); const { query } = useEditor(); const { selected, selectedType, nodeProps, selectedIds, mixedTypes, inInstance } = useEditor((state) => { @@ -245,6 +249,8 @@ export const GuidedStyles: React.FC<{ // Utility types that need minimal controls. HTML is deliberately NOT here // -- it gets HtmlStylePanel, which shows the code editor and nothing else. const isUtility = /^divider$|^spacer$/i.test(typeName); + // #60 local-business blocks (displayNames in components/business/*). + const isBusiness = /^opening hours$|^call bar$|^reviews$|^booking$/i.test(typeName); // Icon for the type badge const typeIcon = isText ? 'fa-font' @@ -260,6 +266,7 @@ export const GuidedStyles: React.FC<{ : isSection ? 'fa-th-large' : isHtml ? 'fa-code' : isUtility ? 'fa-ellipsis-h' + : isBusiness ? 'fa-briefcase' : 'fa-cube'; const handleAskSitesmith = () => { @@ -289,6 +296,17 @@ export const GuidedStyles: React.FC<{ > Ask Sitesmith + {regenerate.enabled && ( + + )} {inInstance && } {!inInstance && <> @@ -336,8 +354,11 @@ export const GuidedStyles: React.FC<{ {/* UTILITY (Divider, Spacer) -- use generic but it works well for these */} {isUtility && } + {/* LOCAL BUSINESS (Opening Hours, Call Bar, Reviews, Booking) */} + {isBusiness && } + {/* FALLBACK: Anything not matched above */} - {!isText && !isButton && !isImage && !isBgSection && !isContainer && !isHero && !isNav && !isMedia && !isForm && !isSocial && !isPricing && !isSection && !isUtility && !isHtml && ( + {!isText && !isButton && !isImage && !isBgSection && !isContainer && !isHero && !isNav && !isMedia && !isForm && !isSocial && !isPricing && !isSection && !isUtility && !isHtml && !isBusiness && ( )} } diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css index ee91337..4d1996f 100644 --- a/craft/src/styles/editor.css +++ b/craft/src/styles/editor.css @@ -3324,3 +3324,14 @@ body { } .section-lib-chip-global i { margin-right: 3px; } .section-lib-usage { display: block; font-size: 11px; color: var(--color-text-muted); } + +/* #followups -- integration follow-ups (#58 Regenerate button in the type header) */ +.guided-regenerate-btn { + display: inline-flex; align-items: center; gap: 4px; + padding: 4px 8px; font-size: 11px; font-weight: 600; + color: var(--color-text); background: transparent; + border: 1px solid var(--color-border); border-radius: var(--radius-sm); cursor: pointer; +} +.guided-regenerate-btn:hover { background: var(--color-bg-hover); } +.guided-regenerate-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 1px; } +/* /#followups */