diff --git a/craft/CLAUDE.md b/craft/CLAUDE.md index b918c25..555d58e 100644 --- a/craft/CLAUDE.md +++ b/craft/CLAUDE.md @@ -41,14 +41,12 @@ craft/ │ │ └── Canvas.tsx # Craft.js with device-width switching │ │ │ ├── components/ -│ │ ├── resolver.ts # Component map for Craft.js serialization (20 components) +│ │ ├── resolver.ts # Component map for Craft.js serialization (39 components) │ │ ├── layout/ │ │ │ ├── Container.tsx # Generic container (div/section/article/header/footer/main) │ │ │ ├── Section.tsx # Full-width section with centered inner container │ │ │ ├── ColumnLayout.tsx # Flex columns (1-6, with split ratios) -│ │ │ ├── BackgroundSection.tsx # Section with background image/gradient overlay -│ │ │ ├── HeaderZone.tsx # Page-level header zone wrapper -│ │ │ └── FooterZone.tsx # Page-level footer zone wrapper +│ │ │ └── BackgroundSection.tsx # Section with background image/gradient overlay │ │ ├── basic/ │ │ │ ├── Heading.tsx # Inline-editable heading (h1-h6) │ │ │ ├── TextBlock.tsx # Inline-editable paragraph @@ -73,7 +71,8 @@ craft/ │ ├── panels/ │ │ ├── topbar/ │ │ │ ├── TopBar.tsx # Back button, domain badge, device switcher, undo/redo, save, templates -│ │ │ └── TemplateModal.tsx # Template browser with categories and one-click loading +│ │ │ ├── TemplateModal.tsx # Template browser with categories and one-click loading +│ │ │ └── HeadCodeModal.tsx # Edits SiteDesign.headCode (site-wide, not per-page) │ │ ├── left/ │ │ │ ├── LeftPanel.tsx # Tabs: Blocks | Pages | Layers | Assets │ │ │ ├── BlocksPanel.tsx # Draggable block toolbox with categories @@ -81,9 +80,10 @@ craft/ │ │ │ ├── LayersPanel.tsx # Component hierarchy tree view │ │ │ └── AssetsPanel.tsx # Asset browser with upload, drag-drop, thumbnails │ │ ├── right/ -│ │ │ ├── RightPanel.tsx # Tabs: Styles | Settings | Head -│ │ │ ├── GuidedStyles.tsx # Context-aware style panel (shows selected type) -│ │ │ └── SiteDesignPanel.tsx # Site-wide design tokens editor (Basic/Advanced tabs) +│ │ │ ├── RightPanel.tsx # Single "Styles" tab -- renders GuidedStyles only +│ │ │ ├── GuidedStyles.tsx # Context-aware dispatcher: picks a StylePanel by selected type +│ │ │ ├── SiteDesignPanel.tsx # Site-wide design tokens editor (Basic/Advanced tabs) +│ │ │ └── styles/ # Per-type StylePanels (Text, Button, Image, Container, Nav, Form, etc.) │ │ └── context-menu/ │ │ └── ContextMenu.tsx # Right-click context menu (duplicate, copy, paste, delete, etc.) │ │ @@ -98,7 +98,8 @@ craft/ │ │ └── definitions.ts # 16 template definitions across 4 categories │ │ │ ├── ui/ -│ │ └── SettingsTabs.tsx # Reusable General/Style/Advanced tabs for component settings +│ │ ├── AssetPicker.tsx # Reusable image/video source picker (upload/browse-uploaded/URL), full+compact variants +│ │ └── Modal.tsx # Reusable modal dialog shell │ │ │ ├── constants/ │ │ └── presets.ts # Color, font, spacing, radius, gradient, device width presets @@ -165,7 +166,7 @@ The PHP wrapper (`/docker/whp/web/site-builder/index.php`) injects `WHP_CONFIG` 5. **API compatibility** - The save endpoint sends data in the same format as the GrapesJS version (`{ site_id, name, html, css, grapesjs: serializedJson }`), so the PHP backend doesn't need changes. -6. **Component-based architecture** - Each visual element is a React component that doubles as a Craft.js `UserComponent`. All rendering, settings UI, and HTML export are co-located in one file. +6. **Component-based architecture** - Each visual element is a React component that doubles as a Craft.js `UserComponent`. Rendering and HTML export are co-located in one file; style editing is handled by a shared per-type `StylePanel` in `src/panels/right/styles/` rather than a per-component settings panel. 7. **Site Design Tokens** - A `SiteDesignContext` provides 17 design properties (colors, fonts, radii, nav style) that components can reference. Templates import their own design tokens when loaded. @@ -188,28 +189,21 @@ export const MyComponent: UserComponent = ({ text, style }) => return
{ if (r) connect(drag(r)); }} style={style}>{text}
; }; -// 3. Settings panel (rendered in right panel when selected) -const MyComponentSettings: React.FC = () => { - const { actions: { setProp }, props } = useNode((node) => ({ - props: node.data.props as MyComponentProps, - })); - return
/* preset buttons, inputs, etc. */
; -}; - -// 4. Craft config (displayName, default props, rules, related settings) +// 3. Craft config (displayName, default props, rules) MyComponent.craft = { displayName: 'My Component', props: { text: 'Default text', style: {} }, rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true }, - related: { settings: MyComponentSettings }, }; -// 5. HTML export (static method for serializing to HTML string) +// 4. HTML export (static method for serializing to HTML string) (MyComponent as any).toHtml = (props: MyComponentProps, childrenHtml: string) => { return { html: `
${childrenHtml}
` }; }; ``` +Style editing for the new type is added separately as a `StylePanel` under `src/panels/right/styles/` (or reuses an existing generic one), and wired into `GuidedStyles.tsx`'s type dispatch -- components no longer carry their own settings UI. + ### Component Resolver All components must be registered in `src/components/resolver.ts`. This map is passed to `` so Craft.js can serialize/deserialize the node tree. @@ -270,7 +264,7 @@ The editor auto-saves every 30 seconds when running inside WHP. The save status - `whpConfig` - The full config object (or null in standalone mode) - `isWHP` - Boolean shorthand for whether we're running inside WHP -## All Components (22) +## All Components (39) | # | Component | Type | File | Features | |---|-----------|------|------|----------| @@ -278,24 +272,41 @@ The editor auto-saves every 30 seconds when running inside WHP. The save status | 2 | Section | Layout | `layout/Section.tsx` | Full-width with centered inner container, bg color/gradient, vertical padding, inner max-width | | 3 | ColumnLayout | Layout | `layout/ColumnLayout.tsx` | 1-6 columns, split ratios (50-50, 30-70, 70-30, 33-33-33, 25-25-25-25, etc.), gap control | | 4 | BackgroundSection | Layout | `layout/BackgroundSection.tsx` | Section with background image, gradient overlay, parallax-ready | -| 5 | HeaderZone | Layout | `layout/HeaderZone.tsx` | Page-level header wrapper zone, used by PageContext | -| 6 | FooterZone | Layout | `layout/FooterZone.tsx` | Page-level footer wrapper zone, used by PageContext | -| 7 | Heading | Basic | `basic/Heading.tsx` | Inline-editable, h1-h6 level, color, font family/size/weight, text align | -| 8 | TextBlock | Basic | `basic/TextBlock.tsx` | Inline-editable paragraph, color, font family/size/weight, text align, line height | -| 9 | ButtonLink | Basic | `basic/ButtonLink.tsx` | Link text/URL/target, 8 color presets (auto text contrast), radius, padding, font size | +| 5 | Heading | Basic | `basic/Heading.tsx` | Inline-editable, h1-h6 level, color, font family/size/weight, text align | +| 6 | TextBlock | Basic | `basic/TextBlock.tsx` | Inline-editable paragraph, color, font family/size/weight, text align, line height | +| 7 | ButtonLink | Basic | `basic/ButtonLink.tsx` | Link text/URL/target, 8 color presets (auto text contrast), radius, padding, font size | +| 8 | Logo | Basic | `basic/Logo.tsx` | Text or image logo, link href, font family/size/weight, image width | +| 9 | Menu | Basic | `basic/Menu.tsx` | Link list with optional CTA styling, horizontal/vertical orientation, alignment, hover colors | | 10 | Navbar | Basic | `basic/Navbar.tsx` | Text or image logo, page links, external links, CTA buttons, light/dark nav style | | 11 | Footer | Basic | `basic/Footer.tsx` | Footer with links, copyright, social links | | 12 | Divider | Basic | `basic/Divider.tsx` | Horizontal rule with color and thickness controls | | 13 | Spacer | Basic | `basic/Spacer.tsx` | Vertical spacing element with height control | -| 14 | ImageBlock | Media | `media/ImageBlock.tsx` | SVG placeholder, URL input, upload, browse assets, alt text, width/height, object-fit, radius | -| 15 | VideoBlock | Media | `media/VideoBlock.tsx` | YouTube, Vimeo, direct files (.mp4/.webm/.ogg), background mode, autoplay, loop | -| 16 | HeroSimple | Section | `sections/HeroSimple.tsx` | Pre-built hero with heading, subtext, CTA button, gradient/color background | -| 17 | FeaturesGrid | Section | `sections/FeaturesGrid.tsx` | 3-column feature cards with icons, titles, descriptions | -| 18 | CTASection | Section | `sections/CTASection.tsx` | Call-to-action banner with heading, text, button | -| 19 | FormContainer | Form | `forms/FormContainer.tsx` | Form wrapper with action URL and method | -| 20 | InputField | Form | `forms/InputField.tsx` | Text input with label, placeholder, type (text/email/tel/password/number) | -| 21 | TextareaField | Form | `forms/TextareaField.tsx` | Textarea with label and placeholder | -| 22 | FormButton | Form | `forms/FormButton.tsx` | Submit button with color and style controls | +| 14 | Icon | Basic | `basic/Icon.tsx` | Font Awesome icon, size/color, background shape, optional link | +| 15 | ImageBlock | Media | `media/ImageBlock.tsx` | SVG placeholder, URL input, upload, browse assets, alt text, width/height, object-fit, radius | +| 16 | VideoBlock | Media | `media/VideoBlock.tsx` | YouTube, Vimeo, direct files (.mp4/.webm/.ogg), background mode, autoplay, loop | +| 17 | MapEmbed | Media | `media/MapEmbed.tsx` | Embedded map by address, zoom level, height | +| 18 | HeroSimple | Section | `sections/HeroSimple.tsx` | Pre-built hero with heading, subtext, CTA button, gradient/color background | +| 19 | FeaturesGrid | Section | `sections/FeaturesGrid.tsx` | 3-column feature cards with icons, titles, descriptions | +| 20 | CTASection | Section | `sections/CTASection.tsx` | Call-to-action banner with heading, text, button | +| 21 | Countdown | Section | `sections/Countdown.tsx` | Countdown timer to a target date, heading, digit/label colors | +| 22 | Testimonials | Section | `sections/Testimonials.tsx` | Grid or single-layout testimonial cards, star color, card background | +| 23 | FormContainer | Form | `forms/FormContainer.tsx` | Form wrapper with action URL and method | +| 24 | InputField | Form | `forms/InputField.tsx` | Text input with label, placeholder, type (text/email/tel/password/number) | +| 25 | TextareaField | Form | `forms/TextareaField.tsx` | Textarea with label and placeholder | +| 26 | FormButton | Form | `forms/FormButton.tsx` | Submit button with color and style controls | +| 27 | ContactForm | Form | `forms/ContactForm.tsx` | Configurable field list, recipient email, success message or thank-you URL redirect | +| 28 | StarRating | Basic | `basic/StarRating.tsx` | Star rating display, rating/max stars, filled/empty color | +| 29 | SocialLinks | Basic | `basic/SocialLinks.tsx` | Social icon links, size/color/shape, gap, alignment | +| 30 | CallToAction | Section | `sections/CallToAction.tsx` | Heading/description with 1-2 buttons, color/gradient/image background with overlay | +| 31 | Accordion | Section | `sections/Accordion.tsx` | Expand/collapse item list, header/content colors | +| 32 | Tabs | Section | `sections/Tabs.tsx` | Tabbed content panels, active/inactive tab colors | +| 33 | PricingTable | Section | `sections/PricingTable.tsx` | Pricing plan cards, featured plan highlight, bullet list | +| 34 | Gallery | Section | `sections/Gallery.tsx` | Image grid gallery, configurable columns/gap, optional lightbox | +| 35 | ContentSlider | Section | `sections/ContentSlider.tsx` | Auto-playing image/content slider, dots/arrows, configurable interval | +| 36 | NumberCounter | Section | `sections/NumberCounter.tsx` | Animated stat counters, columns, number/label colors | +| 37 | SubscribeForm | Form | `forms/SubscribeForm.tsx` | Inline/stacked email signup form, heading, button color | +| 38 | SearchBar | Basic | `basic/SearchBar.tsx` | Search input with optional button, placeholder text | +| 39 | HtmlBlock | Basic | `basic/HtmlBlock.tsx` | Raw/custom HTML embed block, sanitized on export | ## Site Design Tokens @@ -351,7 +362,7 @@ Templates are loaded via the Template Modal (opened from TopBar). Loading a temp ## Multi-Page System Pages are managed through `PageContext`: -- Each page has: `id`, `name`, `slug`, `craftState`, `headCode` +- Each page has: `id`, `name`, `slug`, `craftState` (`headCode` is site-wide only, on `SiteDesignContext`/`SiteDesign`, edited via the TopBar's Head Code modal -- not a per-page field) - Header and Footer are stored as separate "page" entries with fixed IDs (`__header__`, `__footer__`) - Page switching serializes the current canvas, stores it, then deserializes the target page - Header/Footer editing puts the canvas in a distinct mode @@ -392,21 +403,22 @@ The Assets panel (`AssetsPanel.tsx`) provides: - Delete asset - Integration with WHP API for server-side storage -Image and Video components also have inline asset selection (browse button in settings). +Image and video fields elsewhere in the editor (ImageStylePanel, MediaStylePanel, HeroStylePanel, NavStylePanel, BackgroundSectionStylePanel, and array-editor cards like FeaturesEditor) use the shared `AssetPicker` (`src/ui/AssetPicker.tsx`) for upload / browse-uploaded / paste-URL, in a `full` or `compact` variant depending on space. ## Adding New Components 1. Create `src/components//.tsx` following the pattern above 2. Add the component to `src/components/resolver.ts` 3. Add a block entry in `src/panels/left/BlocksPanel.tsx` under the appropriate category -4. Implement the `toHtml` static for HTML export -5. Build and test: `npm run dev`, drag the block onto the canvas, verify settings panel, verify HTML export +4. Add or extend a `StylePanel` in `src/panels/right/styles/` and wire it into `GuidedStyles.tsx`'s type dispatch so the new component is editable when selected +5. Implement the `toHtml` static for HTML export +6. Build and test: `npm run dev`, drag the block onto the canvas, verify the style panel, verify HTML export ### Checklist for a new component: - [ ] Props interface with `style?: CSSProperties` - [ ] `useNode()` with `connect(drag(ref))` on the root element -- [ ] Settings panel using `useNode()` with `setProp()` -- [ ] `.craft` config with `displayName`, default `props`, `rules`, `related.settings` +- [ ] `StylePanel` entry (new or reused) wired into `GuidedStyles.tsx` +- [ ] `.craft` config with `displayName`, default `props`, `rules` - [ ] `.toHtml()` static method using `cssPropsToString()` - [ ] Registered in `resolver.ts` - [ ] Block added to `BlocksPanel.tsx` @@ -421,7 +433,7 @@ The editor uses a dark theme defined via CSS custom properties in `src/styles/ed - **Border:** `#2d2d3a` - **Font:** Inter -All editor chrome (panels, topbar, settings) is styled via `editor.css`. User content on the canvas uses inline styles exclusively. +All editor chrome (panels, topbar, style panels) is styled via `editor.css`. User content on the canvas uses inline styles exclusively. ## Presets @@ -446,7 +458,7 @@ Every component has a static `toHtml(props, childrenHtml)` method. The `html-exp ## Testing Approach -- **Manual testing:** Run `npm run dev`, drag components, edit props, verify settings panels +- **Manual testing:** Run `npm run dev`, drag components, edit props, verify style panels - **Type checking:** `tsc --noEmit` (part of build step) - **HTML export:** Verify `toHtml()` output matches expected HTML structure - **Device preview:** Switch between desktop/tablet/mobile and verify responsive behavior @@ -455,9 +467,9 @@ Every component has a static `toHtml(props, childrenHtml)` method. The `html-exp ## Development Notes - Path alias `@/` maps to `./src/` (configured in both tsconfig.json and vite.config.ts) -- `GuidedStyles` shows selected component type and delegates to per-component settings panels +- `GuidedStyles` shows the selected component type and dispatches to a shared `StylePanel` in `src/panels/right/styles/` (components no longer carry their own settings UI) - Text components (Heading, TextBlock) use `contentEditable` for inline editing when selected - Button/link navigation is prevented in the editor via `e.preventDefault()` - Image upload integrates with WHP API; in standalone mode falls back to local `blob:` URLs - Auto-save runs every 30 seconds when connected to WHP API -- The SettingsTabs UI component provides a reusable General/Style/Advanced tab layout for component settings +- `AssetPicker` (`src/ui/AssetPicker.tsx`) is the shared upload/browse/URL control reused across StylePanels for every image and video field diff --git a/craft/src/components/basic/ButtonLink.toHtml.test.ts b/craft/src/components/basic/ButtonLink.toHtml.test.ts new file mode 100644 index 0000000..82fe9fe --- /dev/null +++ b/craft/src/components/basic/ButtonLink.toHtml.test.ts @@ -0,0 +1,54 @@ +import { describe, test, expect } from 'vitest'; +import { ButtonLink } from './ButtonLink'; + +const toHtml = (ButtonLink as any).toHtml; + +describe('ButtonLink.toHtml href sanitization (attacker-controlled `href` prop)', () => { + test('a javascript: URL is neutralized', () => { + const { html } = toHtml({ href: 'javascript:alert(1)', text: 'Click' }, ''); + expect(html).not.toContain('javascript:alert'); + }); + + test('a quote-breakout href does not escape the href attribute', () => { + const malicious = '">'; + const { html } = toHtml({ href: malicious, text: 'Click' }, ''); + expect(html).not.toContain(''); + }); + + test('a normal href still renders correctly', () => { + const { html } = toHtml({ href: 'https://example.com', text: 'Click' }, ''); + expect(html).toContain('href="https://example.com"'); + }); +}); + +describe('ButtonLink.toHtml target (boolean-gated, not raw interpolation)', () => { + test('an attribute-breakout value for target does not reach the output raw', () => { + const malicious = '_blank" onmouseover="alert(1)' as any; + const { html } = toHtml({ href: '#', text: 'x', target: malicious }, ''); + expect(html).not.toContain('onmouseover'); + }); + + test('target="_blank" still adds rel=noopener noreferrer', () => { + const { html } = toHtml({ href: '#', text: 'x', target: '_blank' }, ''); + expect(html).toContain('target="_blank"'); + expect(html).toContain('rel="noopener noreferrer"'); + }); +}); + +describe('ButtonLink.toHtml text escaping (attacker-controlled `text` prop)', () => { + test('a tag-breakout attempt in text is neutralized (no injected element)', () => { + const { html } = toHtml({ href: '#', text: '' }, ''); + expect(html).not.toContain(' { + const { html } = toHtml({ href: '#', text: 'Tom & Jerry' }, ''); + expect(html).toContain('Tom & Jerry'); + }); + + test('a normal text value still renders unchanged', () => { + const { html } = toHtml({ href: '#', text: 'Click Me' }, ''); + expect(html).toContain('>Click Me'); + }); +}); diff --git a/craft/src/components/basic/ButtonLink.tsx b/craft/src/components/basic/ButtonLink.tsx index 6cbfdcb..2b46a4f 100644 --- a/craft/src/components/basic/ButtonLink.tsx +++ b/craft/src/components/basic/ButtonLink.tsx @@ -1,6 +1,7 @@ import React, { CSSProperties } from 'react'; import { useNode, UserComponent } from '@craftjs/core'; import { cssPropsToString } from '../../utils/style-helpers'; +import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape'; interface ButtonLinkProps { text?: string; @@ -44,150 +45,6 @@ export const ButtonLink: UserComponent = ({ ); }; -/* ---------- Settings panel ---------- */ - -const ButtonLinkSettings: React.FC = () => { - const { actions: { setProp }, props } = useNode((node) => ({ - props: node.data.props as ButtonLinkProps, - })); - - const colorPresets = [ - { bg: '#3b82f6', color: '#ffffff', label: 'Blue' }, - { bg: '#10b981', color: '#ffffff', label: 'Green' }, - { bg: '#ef4444', color: '#ffffff', label: 'Red' }, - { bg: '#f59e0b', color: '#18181b', label: 'Amber' }, - { bg: '#8b5cf6', color: '#ffffff', label: 'Purple' }, - { bg: '#18181b', color: '#ffffff', label: 'Dark' }, - { bg: '#ffffff', color: '#18181b', label: 'White' }, - { bg: 'transparent', color: '#3b82f6', label: 'Ghost' }, - ]; - const radiusPresets = ['0px', '4px', '8px', '12px', '9999px']; - const paddingPresets = ['8px 16px', '10px 20px', '12px 24px', '14px 32px', '16px 40px']; - - return ( -
-
- - setProp((p: ButtonLinkProps) => { p.text = e.target.value; })} - style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }} - /> -
- -
- - setProp((p: ButtonLinkProps) => { p.href = e.target.value; })} - placeholder="https://..." - style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }} - /> -
- -
- -
- {(['_self', '_blank'] as const).map((t) => ( - - ))} -
-
- -
- -
- {colorPresets.map((preset) => ( -
-
- -
- -
- {radiusPresets.map((r) => ( - - ))} -
-
- -
- -
- {paddingPresets.map((p) => ( - - ))} -
-
- -
- - setProp((p: ButtonLinkProps) => { p.style = { ...p.style, fontSize: e.target.value }; })} - style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11 }} - /> -
-
- ); -}; - /* ---------- Craft config ---------- */ ButtonLink.craft = { @@ -211,9 +68,6 @@ ButtonLink.craft = { canMoveIn: () => false, canMoveOut: () => true, }, - related: { - settings: ButtonLinkSettings, - }, }; /* ---------- HTML export ---------- */ @@ -224,9 +78,9 @@ ButtonLink.craft = { textDecoration: 'none', ...props.style, }); - const escapedText = (props.text || '').replace(//g, '>'); + const escapedText = escapeHtml(props.text || ''); const targetAttr = props.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : ''; return { - html: `${escapedText}`, + html: `${escapedText}`, }; }; diff --git a/craft/src/components/basic/Divider.toHtml.test.ts b/craft/src/components/basic/Divider.toHtml.test.ts new file mode 100644 index 0000000..db186b5 --- /dev/null +++ b/craft/src/components/basic/Divider.toHtml.test.ts @@ -0,0 +1,34 @@ +import { describe, test, expect } from 'vitest'; +import { Divider } from './Divider'; + +const toHtml = (Divider as any).toHtml; + +describe('Divider.toHtml normal rendering', () => { + test('renders thickness/color into the border-top style', () => { + const { html } = toHtml({ thickness: '2px', color: '#ff0000' }, ''); + expect(html).toContain('border-top:2px solid #ff0000'); + }); +}); + +describe('Divider.toHtml XSS hardening (thickness/color into style=)', () => { + test('a thickness value with an attribute-breakout string cannot escape style=""', () => { + const malicious = '1px" onmouseover="alert(1)'; + const { html } = toHtml({ thickness: malicious as any, color: '#000' }, ''); + // The quote must not survive unescaped -- otherwise it closes style="" + // early and "onmouseover" becomes a live, attacker-controlled attribute. + expect(html).not.toMatch(/"\s+onmouseover="/); + expect(html).not.toMatch(/style="[^"]*"[^>]*onmouseover/); + }); + + test('a color value with a '; + const { html } = toHtml({ thickness: '1px', color: malicious as any }, ''); + expect(html).not.toContain(''); + }); + + test('a non-string thickness (object) does not raw-splice into style=""', () => { + const malicious = { toString: () => '1px" onmouseover="alert(1)' }; + const { html } = toHtml({ thickness: malicious as any, color: '#000' }, ''); + expect(html).not.toMatch(/"\s+onmouseover="/); + }); +}); diff --git a/craft/src/components/basic/Divider.tsx b/craft/src/components/basic/Divider.tsx index aa349f5..707c07d 100644 --- a/craft/src/components/basic/Divider.tsx +++ b/craft/src/components/basic/Divider.tsx @@ -34,59 +34,6 @@ export const Divider: UserComponent = ({ ); }; -/* ---------- Settings panel ---------- */ - -const DividerSettings: React.FC = () => { - const { actions: { setProp }, props } = useNode((node) => ({ - props: node.data.props as DividerProps, - })); - - const colorPresets = ['#e4e4e7', '#d4d4d8', '#a1a1aa', '#3f3f46', '#18181b', '#3b82f6', '#ef4444', '#10b981']; - const thicknessPresets = ['1px', '2px', '3px', '4px', '6px']; - - return ( -
-
- -
- {colorPresets.map((c) => ( -
-
- -
- -
- {thicknessPresets.map((t) => ( - - ))} -
-
-
- ); -}; - /* ---------- Craft config ---------- */ Divider.craft = { @@ -101,9 +48,6 @@ Divider.craft = { canMoveIn: () => false, canMoveOut: () => true, }, - related: { - settings: DividerSettings, - }, }; /* ---------- HTML export ---------- */ diff --git a/craft/src/components/basic/Footer.editguard.test.tsx b/craft/src/components/basic/Footer.editguard.test.tsx new file mode 100644 index 0000000..95a03ff --- /dev/null +++ b/craft/src/components/basic/Footer.editguard.test.tsx @@ -0,0 +1,105 @@ +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'; + +/* Footer only needs useNode from @craftjs/core. Mock it following the + DOM-harness pattern in src/state/PageContext.slug.test.tsx (no + @testing-library/react in this repo) so we can drive `selected` across + re-renders and observe setProp calls without a real tree. */ +let mockSelected = false; +let lastCommittedProps: { text: string } = { text: '' }; +const setPropSpy = vi.fn((updater: (p: any) => void) => { + updater(lastCommittedProps); +}); + +vi.mock('@craftjs/core', () => ({ + useNode: (collect?: (node: any) => any) => { + const node = { events: { selected: mockSelected } }; + return { + connectors: { connect: (el: any) => el, drag: (el: any) => el }, + actions: { setProp: setPropSpy }, + ...(collect ? collect(node) : {}), + }; + }, +})); + +import { Footer } from './Footer'; + +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); + }); +} + +function rerender(ui: React.ReactElement) { + act(() => { + root.render(ui); + }); +} + +beforeEach(() => { + mockSelected = false; + lastCommittedProps = { text: 'Original' }; + setPropSpy.mockClear(); +}); + +describe('Footer edit-guard (mirrors Heading.tsx mechanism)', () => { + test('deselecting without a real blur still commits the in-progress edit', () => { + mockSelected = true; + render(