Compare commits
164
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c712a69c4a | ||
|
|
d460e8ac33 | ||
|
|
2b1569202a | ||
|
|
5c44dd545c | ||
|
|
2ac62c4e9e | ||
|
|
25dfcbb725 | ||
|
|
9bf78fd72d | ||
|
|
2dcc2b4d21 | ||
|
|
4e0fc78a30 | ||
|
|
85dfe181aa | ||
|
|
a698f014b0 | ||
|
|
204ea5e078 | ||
|
|
0291ddce9a | ||
|
|
f7da654c11 | ||
|
|
d6666f6f79 | ||
|
|
4a426e3513 | ||
|
|
6e628d68dd | ||
|
|
8f17b74e2b | ||
|
|
54572f648a | ||
|
|
8bf525c600 | ||
|
|
d07bc7789d | ||
|
|
b03425ac39 | ||
|
|
9750a6c2bf | ||
|
|
5b19ae97af | ||
|
|
88df4f2888 | ||
|
|
e1b4ab735c | ||
|
|
ff1e8fc096 | ||
|
|
0419291259 | ||
|
|
1d9460c173 | ||
|
|
8d69982a0b | ||
|
|
fb40e3ece4 | ||
|
|
9591fcb8a7 | ||
|
|
f80811318b | ||
|
|
cb9fea6656 | ||
|
|
b1afdcb585 | ||
|
|
f00e4db3dc | ||
|
|
b9552faa30 | ||
|
|
77f35c4e9e | ||
|
|
2c8425ffb0 | ||
|
|
2a071bc7ab | ||
|
|
2a8a26687b | ||
|
|
979331b12d | ||
|
|
329a782052 | ||
|
|
da558fd52d | ||
|
|
8aeadefa88 | ||
|
|
2066059c35 | ||
|
|
473fe8d421 | ||
|
|
5d6ae7946b | ||
|
|
1c85ab93eb | ||
|
|
67228f24b4 | ||
|
|
613a44c4d4 | ||
|
|
5685670895 | ||
|
|
72f85a97e5 | ||
|
|
ab28ad8f2c | ||
|
|
05e00c572d | ||
|
|
458069afb6 | ||
|
|
b3e5009aec | ||
|
|
eeb0660d83 | ||
|
|
138e1a8273 | ||
|
|
1b12b79a0d | ||
|
|
621bb21d52 | ||
|
|
3f3c6fb851 | ||
|
|
802938ec1a | ||
|
|
3e43aee6e9 | ||
|
|
bf4a9f48eb | ||
|
|
86455413d0 | ||
|
|
0cbc58f8d1 | ||
|
|
92841e3f35 | ||
|
|
e892ee0e53 | ||
|
|
591a51dcc2 | ||
|
|
7ba91d9829 | ||
|
|
e12fb89ada | ||
|
|
25507cb57a | ||
|
|
9d3bbc2c46 | ||
|
|
25e674badd | ||
|
|
e5f30a4a56 | ||
|
|
36c3b2f503 | ||
|
|
5acf172511 | ||
|
|
ebb8fe1027 | ||
|
|
b88b242b6a | ||
|
|
8f51b5144a | ||
|
|
4733fe84b5 | ||
|
|
3cd4ad0154 | ||
|
|
6421306849 | ||
|
|
e40d55b2f2 | ||
|
|
1958b8e0b5 | ||
|
|
99cc4c79f2 | ||
|
|
4b8dd8baee | ||
|
|
46ebd253f3 | ||
|
|
a036843728 | ||
|
|
177eda93d0 | ||
|
|
8029126ab7 | ||
|
|
c2aac870e7 | ||
|
|
9969adca72 | ||
|
|
9b532e36e8 | ||
|
|
c83db99ae4 | ||
|
|
605a6ba9f3 | ||
|
|
f3c175436a | ||
|
|
b2b9278d3c | ||
|
|
71e675489c | ||
|
|
fdd088b4bf | ||
|
|
cdcc3969bc | ||
|
|
2887cc9bdf | ||
|
|
a0cdbd1d82 | ||
|
|
f0728a9070 | ||
|
|
36ce256760 | ||
|
|
cf56f2a388 | ||
|
|
e1d381dc8a | ||
|
|
d4ee09e54c | ||
|
|
7973ee9ba8 | ||
|
|
2ee66ebae9 | ||
|
|
a7c00119e6 | ||
|
|
232dc3c980 | ||
|
|
65a10a1ef9 | ||
|
|
4674a99ec9 | ||
|
|
c50b385661 | ||
|
|
296c10d019 | ||
|
|
b46b6915a5 | ||
|
|
4b36ce0d6a | ||
|
|
1a88baa95d | ||
|
|
97123c4c58 | ||
|
|
4c001e1af4 | ||
|
|
fb4e9f87be | ||
|
|
7179287087 | ||
|
|
48d0441be3 | ||
|
|
fad1882117 | ||
|
|
cce984508f | ||
|
|
94140990c2 | ||
|
|
02e99f7623 | ||
|
|
aaa305cc3e | ||
|
|
97cb439508 | ||
|
|
d20b77e66d | ||
|
|
5a26e4ef43 | ||
|
|
1cfb51f181 | ||
|
|
814ad29b91 | ||
|
|
b9c5d3dd1c | ||
|
|
6b9c258d26 | ||
|
|
4877a63a3b | ||
|
|
cf5d30382a | ||
|
|
66117d375e | ||
|
|
53c40f856f | ||
|
|
d0925d9e2d | ||
|
|
7b747f775f | ||
|
|
330032eea3 | ||
|
|
5e60415311 | ||
|
|
87dd4340f7 | ||
|
|
a1ec51afc3 | ||
|
|
43627bddb0 | ||
|
|
849f432330 | ||
|
|
6428f93cec | ||
|
|
906695379b | ||
|
|
069ea1235a | ||
|
|
ac0347ae5f | ||
|
|
5c5066c20b | ||
|
|
0f943bacc7 | ||
|
|
2ca1ff0cf9 | ||
|
|
e651becdbe | ||
|
|
b4d71340e1 | ||
|
|
bf55ee85b9 | ||
|
|
cf3457aa15 | ||
|
|
f6243d3ffe | ||
|
|
8d094a9c67 | ||
|
|
14a957f57c | ||
|
|
bd15a33984 |
+58
-46
@@ -41,14 +41,12 @@ craft/
|
||||
│ │ └── Canvas.tsx # Craft.js <Frame> 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<MyComponentProps> = ({ text, style }) =>
|
||||
return <div ref={(r) => { if (r) connect(drag(r)); }} style={style}>{text}</div>;
|
||||
};
|
||||
|
||||
// 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 <div>/* preset buttons, inputs, etc. */</div>;
|
||||
};
|
||||
|
||||
// 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: `<div style="...">${childrenHtml}</div>` };
|
||||
};
|
||||
```
|
||||
|
||||
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 `<Editor resolver={componentResolver}>` 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/<category>/<ComponentName>.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
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>Site Builder</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
|
||||
Generated
+3406
File diff suppressed because it is too large
Load Diff
+19
-2
@@ -8,20 +8,37 @@
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "playwright test tests/site-builder.spec.ts --reporter=list",
|
||||
"test:headed": "playwright test tests/site-builder.spec.ts --reporter=list --headed"
|
||||
"test:headed": "playwright test tests/site-builder.spec.ts --reporter=list --headed",
|
||||
"test:e2e:sitesmith": "playwright test tests/sitesmith.spec.ts --reporter=list",
|
||||
"test:unit": "vitest run",
|
||||
"test:unit:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.3",
|
||||
"@codemirror/commands": "^6.10.4",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.43.6",
|
||||
"@craftjs/core": "^0.2.10",
|
||||
"@craftjs/layers": "^0.2.7",
|
||||
"dompurify": "^3.4.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/ui": "^4.1.7",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.5"
|
||||
"vite": "^6.0.5",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -1,23 +1,35 @@
|
||||
import React from 'react';
|
||||
import { Editor } from '@craftjs/core';
|
||||
import { EditorShell } from './editor/EditorShell';
|
||||
import { RenderNode } from './editor/RenderNode';
|
||||
import { componentResolver } from './components/resolver';
|
||||
import { WhpConfig } from './types';
|
||||
import { EditorConfigProvider } from './state/EditorConfigContext';
|
||||
import { SiteDesignProvider } from './state/SiteDesignContext';
|
||||
import { PageProvider } from './state/PageContext';
|
||||
import { SitesmithProvider, useSitesmithModal } from './state/SitesmithContext';
|
||||
import { SitesmithModal } from './panels/sitesmith/SitesmithModal';
|
||||
|
||||
interface AppProps {
|
||||
whpConfig: WhpConfig | null;
|
||||
}
|
||||
|
||||
const SitesmithModalMount: React.FC = () => {
|
||||
const { isOpen, target, close } = useSitesmithModal();
|
||||
if (!isOpen) return null;
|
||||
return <SitesmithModal target={target} onClose={close} />;
|
||||
};
|
||||
|
||||
export const App: React.FC<AppProps> = ({ whpConfig }) => {
|
||||
return (
|
||||
<EditorConfigProvider config={whpConfig}>
|
||||
<SiteDesignProvider>
|
||||
<Editor resolver={componentResolver} enabled={true}>
|
||||
<Editor resolver={componentResolver} enabled={true} onRender={RenderNode}>
|
||||
<PageProvider>
|
||||
<SitesmithProvider>
|
||||
<EditorShell />
|
||||
<SitesmithModalMount />
|
||||
</SitesmithProvider>
|
||||
</PageProvider>
|
||||
</Editor>
|
||||
</SiteDesignProvider>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
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 = '"><script>alert(1)</script>';
|
||||
const { html } = toHtml({ href: malicious, text: 'Click' }, '');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
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: '</a><img src=x onerror=alert(1)>' }, '');
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).toContain('<img');
|
||||
});
|
||||
|
||||
test('ampersand is escaped for well-formed text content (consistency with escapeHtml)', () => {
|
||||
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</a>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ButtonLink.toHtml hover state (scoped <style> block)', () => {
|
||||
test('no hover props -- no <style> block, no class added', () => {
|
||||
const { html } = toHtml({ href: '#', text: 'x' }, '', 'node-1');
|
||||
expect(html).not.toContain('<style>');
|
||||
expect(html).not.toContain('class=');
|
||||
});
|
||||
|
||||
test('hoverBg/hoverColor emit a scoped :hover rule scoped to the node id', () => {
|
||||
const { html } = toHtml({ href: '#', text: 'x', hoverBg: '#111111', hoverColor: '#eeeeee' }, '', 'node-42');
|
||||
expect(html).toMatch(/<style>\.btn_[a-z0-9]+:hover\{background-color:#111111;color:#eeeeee\}<\/style>/);
|
||||
expect(html).toMatch(/class="btn_[a-z0-9]+"/);
|
||||
});
|
||||
|
||||
test('two different node ids produce different scope classes (no collision)', () => {
|
||||
const a = toHtml({ href: '#', text: 'x', hoverBg: '#111111' }, '', 'node-a').html;
|
||||
const b = toHtml({ href: '#', text: 'x', hoverBg: '#111111' }, '', 'node-b').html;
|
||||
const scopeOf = (html: string) => html.match(/btn_[a-z0-9]+/)?.[0];
|
||||
expect(scopeOf(a)).toBeTruthy();
|
||||
expect(scopeOf(a)).not.toBe(scopeOf(b));
|
||||
});
|
||||
|
||||
test('an XSS breakout attempt in hoverBg cannot close the <style> element', () => {
|
||||
const malicious = '</style><script>alert(1)</script>';
|
||||
const { html } = toHtml({ href: '#', text: 'x', hoverBg: malicious }, '', 'node-1');
|
||||
expect(html).not.toContain('</style><script>');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('a rule-breakout attempt in hoverColor cannot inject a second selector/rule', () => {
|
||||
const malicious = 'red;}body{background:red';
|
||||
const { html } = toHtml({ href: '#', text: 'x', hoverColor: malicious }, '', 'node-1');
|
||||
expect(html).not.toContain('}body{');
|
||||
expect(html).not.toContain(';}');
|
||||
// The whole export is still exactly one <style> element -- no new rule
|
||||
// or element was opened by the malicious value.
|
||||
expect((html.match(/<style>/g) || []).length).toBe(1);
|
||||
expect((html.match(/<\/style>/g) || []).length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ButtonLink.craft.props exposes target + hover + box-model + animation/visibility', () => {
|
||||
test('target defaults to _self, hoverBg/hoverColor blank', () => {
|
||||
const props = (ButtonLink as any).craft.props;
|
||||
expect(props.target).toBe('_self');
|
||||
expect(props.hoverBg).toBe('');
|
||||
expect(props.hoverColor).toBe('');
|
||||
});
|
||||
|
||||
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
||||
const props = (ButtonLink as any).craft.props;
|
||||
expect(props.animation).toBe('');
|
||||
expect(props.animationDelay).toBe('0');
|
||||
expect(props.hideOnDesktop).toBe(false);
|
||||
expect(props.hideOnTablet).toBe(false);
|
||||
expect(props.hideOnMobile).toBe(false);
|
||||
});
|
||||
|
||||
test('style carries blank/default box-model keys', () => {
|
||||
const style = (ButtonLink as any).craft.props.style;
|
||||
expect(style).toHaveProperty('marginTop');
|
||||
expect(style.border).toBe('none');
|
||||
expect(style.boxShadow).toBe('none');
|
||||
expect(style.opacity).toBe('1');
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,24 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, safeUrl, cssValue, scopeId } from '../../utils/escape';
|
||||
|
||||
interface ButtonLinkProps {
|
||||
text?: string;
|
||||
href?: string;
|
||||
target?: '_self' | '_blank';
|
||||
style?: CSSProperties;
|
||||
/** Background color applied on `:hover` via a scoped `<style>` block
|
||||
* (editor preview does not show hover state -- only the published
|
||||
* export). Blank means "no hover background override". */
|
||||
hoverBg?: string;
|
||||
/** Text color applied on `:hover`, same scoped `<style>` block. */
|
||||
hoverColor?: string;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
export const ButtonLink: UserComponent<ButtonLinkProps> = ({
|
||||
@@ -14,6 +26,8 @@ export const ButtonLink: UserComponent<ButtonLinkProps> = ({
|
||||
href = '#',
|
||||
target = '_self',
|
||||
style = {},
|
||||
hoverBg = '',
|
||||
hoverColor = '',
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
@@ -22,6 +36,8 @@ export const ButtonLink: UserComponent<ButtonLinkProps> = ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const [hovered, setHovered] = React.useState(false);
|
||||
|
||||
return (
|
||||
<a
|
||||
ref={(ref: HTMLAnchorElement | null) => { if (ref) connect(drag(ref)); }}
|
||||
@@ -31,12 +47,16 @@ export const ButtonLink: UserComponent<ButtonLinkProps> = ({
|
||||
// Prevent navigation inside editor
|
||||
e.preventDefault();
|
||||
}}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
textDecoration: 'none',
|
||||
cursor: 'pointer',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
...(hovered && hoverBg ? { backgroundColor: hoverBg } : {}),
|
||||
...(hovered && hoverColor ? { color: hoverColor } : {}),
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
@@ -44,150 +64,6 @@ export const ButtonLink: UserComponent<ButtonLinkProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- 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 (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.text || ''}
|
||||
onChange={(e) => 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 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Link URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.href || ''}
|
||||
onChange={(e) => 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 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Target</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['_self', '_blank'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setProp((p: ButtonLinkProps) => { p.target = t; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.target === t ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{t === '_self' ? 'Same Tab' : 'New Tab'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{colorPresets.map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
onClick={() => setProp((p: ButtonLinkProps) => {
|
||||
p.style = {
|
||||
...p.style,
|
||||
backgroundColor: preset.bg,
|
||||
color: preset.color,
|
||||
border: preset.bg === 'transparent' ? `1px solid ${preset.color}` : 'none',
|
||||
};
|
||||
})}
|
||||
title={preset.label}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4,
|
||||
border: preset.bg === 'transparent' ? `2px solid ${preset.color}` : '1px solid #3f3f46',
|
||||
backgroundColor: preset.bg, cursor: 'pointer',
|
||||
outline: props.style?.backgroundColor === preset.bg ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Border Radius</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{radiusPresets.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setProp((p: ButtonLinkProps) => { p.style = { ...p.style, borderRadius: r }; })}
|
||||
style={{
|
||||
padding: '2px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.style?.borderRadius === r ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Padding</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{paddingPresets.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setProp((pr: ButtonLinkProps) => { pr.style = { ...pr.style, padding: p }; })}
|
||||
style={{
|
||||
padding: '2px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.style?.padding === p ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Font Size</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. 16px"
|
||||
value={(props.style?.fontSize as string) || ''}
|
||||
onChange={(e) => 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 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
ButtonLink.craft = {
|
||||
@@ -196,6 +72,8 @@ ButtonLink.craft = {
|
||||
text: 'Click Me',
|
||||
href: '#',
|
||||
target: '_self',
|
||||
hoverBg: '',
|
||||
hoverColor: '',
|
||||
style: {
|
||||
backgroundColor: '#3b82f6',
|
||||
color: '#ffffff',
|
||||
@@ -204,29 +82,55 @@ ButtonLink.craft = {
|
||||
fontWeight: '600',
|
||||
fontSize: '16px',
|
||||
border: 'none',
|
||||
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
|
||||
boxShadow: 'none',
|
||||
opacity: '1',
|
||||
},
|
||||
animation: '',
|
||||
animationDelay: '0',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: ButtonLinkSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(ButtonLink as any).toHtml = (props: ButtonLinkProps, _childrenHtml: string) => {
|
||||
(ButtonLink as any).toHtml = (props: ButtonLinkProps, _childrenHtml: string, nodeId?: string) => {
|
||||
const styleStr = cssPropsToString({
|
||||
display: 'inline-block',
|
||||
textDecoration: 'none',
|
||||
...props.style,
|
||||
});
|
||||
const escapedText = (props.text || '').replace(/</g, '<').replace(/>/g, '>');
|
||||
const escapedText = escapeHtml(props.text || '');
|
||||
const targetAttr = props.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||
|
||||
// Scoped hover style -- same pattern as Navbar/Menu: a deterministic,
|
||||
// per-node class (via scopeId) avoids two ButtonLink instances on the
|
||||
// same page colliding on a shared `.btn-link:hover` rule. hoverBg/
|
||||
// hoverColor are sanitized through cssValue -- they land inside a
|
||||
// `<style>` element, the worst-case XSS sink (an unescaped `<`/`>` or
|
||||
// `{`/`}` could close the rule/element and open a `<script>`).
|
||||
const hoverBg = cssValue(props.hoverBg);
|
||||
const hoverColor = cssValue(props.hoverColor);
|
||||
let hoverCss = '';
|
||||
let cls = '';
|
||||
if (hoverBg || hoverColor) {
|
||||
const scope = scopeId(nodeId, (props.href || '') + (props.text || ''), 'btn');
|
||||
cls = ` class="${scope}"`;
|
||||
const decls = [
|
||||
hoverBg ? `background-color:${hoverBg}` : '',
|
||||
hoverColor ? `color:${hoverColor}` : '',
|
||||
].filter(Boolean).join(';');
|
||||
hoverCss = `<style>.${scope}:hover{${decls}}</style>`;
|
||||
}
|
||||
|
||||
return {
|
||||
html: `<a href="${props.href || '#'}"${targetAttr}${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</a>`,
|
||||
html: `${hoverCss}<a href="${escapeAttr(safeUrl(props.href || '#'))}"${targetAttr}${cls}${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</a>`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 </style><script> breakout is neutralized', () => {
|
||||
const malicious = '#000</style><script>alert(1)</script>';
|
||||
const { html } = toHtml({ thickness: '1px', color: malicious as any }, '');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
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="/);
|
||||
});
|
||||
});
|
||||
@@ -34,59 +34,6 @@ export const Divider: UserComponent<DividerProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- 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 (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{colorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: DividerProps) => { p.color = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.color === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Thickness</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{thicknessPresets.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setProp((p: DividerProps) => { p.thickness = t; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.thickness === t ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Divider.craft = {
|
||||
@@ -101,9 +48,6 @@ Divider.craft = {
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: DividerSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
@@ -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 <Editor> 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(<Footer text="Original" />);
|
||||
|
||||
const el = container.querySelector('footer')!;
|
||||
act(() => {
|
||||
el.innerText = 'Edited footer text';
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
|
||||
// No blur event fired -- simulate selection clearing (e.g. clicking
|
||||
// elsewhere) which is the scenario that used to lose the edit.
|
||||
mockSelected = false;
|
||||
rerender(<Footer text="Original" />);
|
||||
|
||||
expect(setPropSpy).toHaveBeenCalled();
|
||||
expect(lastCommittedProps.text).toBe('Edited footer text');
|
||||
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test('a real blur still commits the edit (existing behavior preserved)', () => {
|
||||
mockSelected = true;
|
||||
render(<Footer text="Original" />);
|
||||
|
||||
const el = container.querySelector('footer')!;
|
||||
act(() => {
|
||||
el.innerText = 'Blurred edit';
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
// React delegates onBlur via native 'focusout' (which bubbles) rather
|
||||
// than 'blur' (which doesn't) -- dispatch what React actually listens for.
|
||||
el.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(setPropSpy).toHaveBeenCalled();
|
||||
expect(lastCommittedProps.text).toBe('Blurred edit');
|
||||
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test('deselecting with no edit made does not call setProp', () => {
|
||||
mockSelected = true;
|
||||
render(<Footer text="Original" />);
|
||||
|
||||
mockSelected = false;
|
||||
rerender(<Footer text="Original" />);
|
||||
|
||||
expect(setPropSpy).not.toHaveBeenCalled();
|
||||
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Footer } from './Footer';
|
||||
|
||||
const toHtml = (Footer as any).toHtml;
|
||||
|
||||
describe('Footer.toHtml text escaping (attacker-controlled `text` prop)', () => {
|
||||
test('a tag-breakout attempt in text is neutralized (no injected element)', () => {
|
||||
const { html } = toHtml({ text: '</footer><img src=x onerror=alert(1)>' }, '');
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).toContain('<img');
|
||||
});
|
||||
|
||||
test('ampersand is escaped for well-formed text content (consistency with escapeHtml)', () => {
|
||||
const { html } = toHtml({ text: 'Terms & Conditions' }, '');
|
||||
expect(html).toContain('Terms & Conditions');
|
||||
});
|
||||
|
||||
test('a normal copyright text value still renders unchanged', () => {
|
||||
const { html } = toHtml({ text: '© 2026 MySite. All rights reserved.' }, '');
|
||||
expect(html).toContain('© 2026 MySite. All rights reserved.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Footer (F4: box-model + animation + visibility props on craft.props)', () => {
|
||||
test('craft.props includes animation/visibility defaults so the panel controls always render', () => {
|
||||
const craftProps = (Footer as any).craft.props;
|
||||
expect(craftProps).toHaveProperty('animation', 'none');
|
||||
expect(craftProps).toHaveProperty('animationDelay', '0');
|
||||
expect(craftProps).toHaveProperty('hideOnDesktop', false);
|
||||
expect(craftProps).toHaveProperty('hideOnTablet', false);
|
||||
expect(craftProps).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,16 @@
|
||||
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml } from '../../utils/escape';
|
||||
|
||||
interface FooterProps {
|
||||
text?: string;
|
||||
style?: CSSProperties;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
}
|
||||
|
||||
export const Footer: UserComponent<FooterProps> = ({
|
||||
@@ -20,16 +26,33 @@ export const Footer: UserComponent<FooterProps> = ({
|
||||
}));
|
||||
|
||||
const elRef = useRef<HTMLElement | null>(null);
|
||||
const editedTextRef = useRef<string | null>(null);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
const commitText = useCallback(() => {
|
||||
if (elRef.current) {
|
||||
const newText = elRef.current.innerText;
|
||||
editedTextRef.current = newText;
|
||||
setProp((p: FooterProps) => { p.text = newText; }, 500);
|
||||
}
|
||||
}, [setProp]);
|
||||
|
||||
// Commit on blur
|
||||
const handleBlur = useCallback(() => { commitText(); }, [commitText]);
|
||||
|
||||
// Also commit on deselect via effect -- covers the case where selection
|
||||
// clears without a real blur (e.g. clicking a different element that
|
||||
// steals selection programmatically), which used to lose the in-progress
|
||||
// edit. Mirrors Heading.tsx's mechanism.
|
||||
useEffect(() => {
|
||||
if (elRef.current && !selected) {
|
||||
if (!selected && editedTextRef.current !== null) {
|
||||
setProp((p: FooterProps) => { p.text = editedTextRef.current!; }, 500);
|
||||
editedTextRef.current = null;
|
||||
}
|
||||
}, [selected, setProp]);
|
||||
|
||||
// Set DOM text on mount and when text prop changes externally (not during editing)
|
||||
useEffect(() => {
|
||||
if (elRef.current && !selected && editedTextRef.current === null) {
|
||||
elRef.current.innerText = text || '';
|
||||
}
|
||||
}, [text, selected]);
|
||||
@@ -43,6 +66,12 @@ export const Footer: UserComponent<FooterProps> = ({
|
||||
contentEditable={selected}
|
||||
suppressContentEditableWarning
|
||||
onBlur={handleBlur}
|
||||
onInput={() => {
|
||||
// Track that we have unsaved edits
|
||||
if (elRef.current) {
|
||||
editedTextRef.current = elRef.current.innerText;
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '24px 20px',
|
||||
textAlign: 'center',
|
||||
@@ -56,67 +85,6 @@ export const Footer: UserComponent<FooterProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const FooterSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as FooterProps,
|
||||
}));
|
||||
|
||||
const bgPresets = ['#ffffff', '#f8fafc', '#18181b', '#0f172a', '#1e293b'];
|
||||
const colorPresets = ['#18181b', '#3f3f46', '#71717a', '#a1a1aa', '#e4e4e7', '#ffffff'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Footer Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.text || ''}
|
||||
onChange={(e) => setProp((p: FooterProps) => { p.text = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: FooterProps) => { p.style = { ...p.style, backgroundColor: c }; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Text Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{colorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: FooterProps) => { p.style = { ...p.style, color: c }; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.style?.color === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Footer.craft = {
|
||||
@@ -129,15 +97,17 @@ Footer.craft = {
|
||||
fontSize: '14px',
|
||||
padding: '24px 20px',
|
||||
},
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
animation: 'none',
|
||||
animationDelay: '0',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: FooterSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
@@ -148,6 +118,6 @@ Footer.craft = {
|
||||
textAlign: 'center',
|
||||
...props.style,
|
||||
});
|
||||
const escapedText = (props.text || '').replace(/</g, '<').replace(/>/g, '>');
|
||||
const escapedText = escapeHtml(props.text || '');
|
||||
return { html: `<footer${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</footer>` };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Heading } from './Heading';
|
||||
|
||||
const toHtml = (Heading as any).toHtml;
|
||||
|
||||
describe('Heading.toHtml level allowlist (adversarial re-review, same class as C1)', () => {
|
||||
test('a malicious level value clamps to h2 -- no injected <img>, no broken-out tag', () => {
|
||||
const { html } = toHtml({ text: 'x', level: 'h2><img src=x onerror=alert(1)' }, '');
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).not.toContain('onerror');
|
||||
expect(html.startsWith('<h2')).toBe(true);
|
||||
expect(html.endsWith('</h2>')).toBe(true);
|
||||
});
|
||||
|
||||
test('a numeric out-of-range level (99) clamps to h2', () => {
|
||||
const { html } = toHtml({ text: 'x', level: 99 as any }, '');
|
||||
expect(html.startsWith('<h2')).toBe(true);
|
||||
expect(html.endsWith('</h2>')).toBe(true);
|
||||
});
|
||||
|
||||
test('a non-heading string level clamps to h2', () => {
|
||||
const { html } = toHtml({ text: 'x', level: 'script' as any }, '');
|
||||
expect(html.startsWith('<h2')).toBe(true);
|
||||
expect(html).not.toContain('<script');
|
||||
});
|
||||
|
||||
test('a normal valid level (h4) still emits <h4', () => {
|
||||
const { html } = toHtml({ text: 'x', level: 'h4' }, '');
|
||||
expect(html).toContain('<h4');
|
||||
expect(html).toContain('</h4>');
|
||||
});
|
||||
|
||||
test('all valid levels h1-h6 still work', () => {
|
||||
for (const level of ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']) {
|
||||
const { html } = toHtml({ text: 'x', level }, '');
|
||||
expect(html.startsWith(`<${level}`)).toBe(true);
|
||||
expect(html.endsWith(`</${level}>`)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Heading.toHtml text escaping (attacker-controlled `text` prop)', () => {
|
||||
test('a tag-breakout attempt in text is neutralized (no injected element)', () => {
|
||||
const { html } = toHtml({ text: '</h2><img src=x onerror=alert(1)>', level: 'h2' }, '');
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).toContain('<img');
|
||||
});
|
||||
|
||||
test('ampersand is escaped for well-formed text content (consistency with escapeHtml)', () => {
|
||||
const { html } = toHtml({ text: 'Fish & Chips', level: 'h2' }, '');
|
||||
expect(html).toContain('Fish & Chips');
|
||||
});
|
||||
|
||||
test('a normal text value still renders unchanged', () => {
|
||||
const { html } = toHtml({ text: 'Hello world', level: 'h2' }, '');
|
||||
expect(html).toBe('<h2>Hello world</h2>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Heading.toHtml typography depth (line-height/letter-spacing/transform/style/decoration)', () => {
|
||||
test('line-height, letter-spacing, text-transform all flow into the style attribute', () => {
|
||||
const { html } = toHtml({
|
||||
text: 'x',
|
||||
level: 'h2',
|
||||
style: { lineHeight: '1.25', letterSpacing: '0.05em', textTransform: 'uppercase' },
|
||||
}, '');
|
||||
expect(html).toContain('line-height:1.25');
|
||||
expect(html).toContain('letter-spacing:0.05em');
|
||||
expect(html).toContain('text-transform:uppercase');
|
||||
});
|
||||
|
||||
test('italic + underline toggles emit font-style and text-decoration', () => {
|
||||
const { html } = toHtml({
|
||||
text: 'x',
|
||||
level: 'h2',
|
||||
style: { fontStyle: 'italic', textDecoration: 'underline' },
|
||||
}, '');
|
||||
expect(html).toContain('font-style:italic');
|
||||
expect(html).toContain('text-decoration:underline');
|
||||
});
|
||||
|
||||
test('a custom font-size (not one of the presets) still flows through', () => {
|
||||
const { html } = toHtml({ text: 'x', level: 'h2', style: { fontSize: '42px' } }, '');
|
||||
expect(html).toContain('font-size:42px');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Heading.craft.props exposes the box-model + animation/visibility rollout', () => {
|
||||
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
||||
const props = (Heading as any).craft.props;
|
||||
expect(props.animation).toBe('');
|
||||
expect(props.animationDelay).toBe('0');
|
||||
expect(props.hideOnDesktop).toBe(false);
|
||||
expect(props.hideOnTablet).toBe(false);
|
||||
expect(props.hideOnMobile).toBe(false);
|
||||
});
|
||||
|
||||
test('style carries blank/default box-model + typography-depth keys', () => {
|
||||
const style = (Heading as any).craft.props.style;
|
||||
expect(style).toHaveProperty('marginTop');
|
||||
expect(style).toHaveProperty('paddingTop');
|
||||
expect(style).toHaveProperty('lineHeight');
|
||||
expect(style).toHaveProperty('letterSpacing');
|
||||
expect(style).toHaveProperty('textTransform');
|
||||
expect(style.border).toBe('none');
|
||||
expect(style.boxShadow).toBe('none');
|
||||
expect(style.opacity).toBe('1');
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,20 @@
|
||||
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { SettingsTabs } from '../../ui/SettingsTabs';
|
||||
import { TypographyControl } from '../../ui/TypographyControl';
|
||||
import { AdvancedTab } from '../../ui/AdvancedTab';
|
||||
import { escapeHtml } from '../../utils/escape';
|
||||
|
||||
type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
||||
|
||||
// `level` is settable via the AI `update_props` path and from deserialized
|
||||
// saved state -- neither type-checked at runtime -- and is interpolated
|
||||
// directly into the tag position (`React.createElement(level, ...)` /
|
||||
// `<${tag}` in `toHtml`). A malicious value like `h2><img src=x
|
||||
// onerror=alert(1)` (or a non-h1-6 string) must never reach that position
|
||||
// unchecked. Anything not in this allowlist clamps to `'h2'`.
|
||||
const ALLOWED_HEADING_LEVELS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const;
|
||||
const sanitizeHeadingLevel = (level: unknown): HeadingLevel =>
|
||||
(ALLOWED_HEADING_LEVELS as readonly unknown[]).includes(level) ? (level as HeadingLevel) : 'h2';
|
||||
|
||||
interface HeadingProps {
|
||||
text?: string;
|
||||
level?: HeadingLevel;
|
||||
@@ -33,6 +41,7 @@ export const Heading: UserComponent<HeadingProps> = ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const safeLevel = sanitizeHeadingLevel(level);
|
||||
const elRef = useRef<HTMLElement | null>(null);
|
||||
const editedTextRef = useRef<string | null>(null);
|
||||
|
||||
@@ -62,7 +71,7 @@ export const Heading: UserComponent<HeadingProps> = ({
|
||||
}
|
||||
}, [text, selected]);
|
||||
|
||||
return React.createElement(level, {
|
||||
return React.createElement(safeLevel, {
|
||||
ref: (ref: HTMLElement | null): void => {
|
||||
elRef.current = ref;
|
||||
if (ref) connect(drag(ref));
|
||||
@@ -80,76 +89,6 @@ export const Heading: UserComponent<HeadingProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const HeadingSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as HeadingProps,
|
||||
}));
|
||||
|
||||
const levels: HeadingLevel[] = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
|
||||
|
||||
return (
|
||||
<SettingsTabs
|
||||
general={
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.3px' }}>Heading Level</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{levels.map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
onClick={() => setProp((p: HeadingProps) => { p.level = l; })}
|
||||
style={{
|
||||
flex: 1, padding: '4px 0', borderRadius: 4, border: '1px solid #3f3f46', cursor: 'pointer',
|
||||
background: props.level === l ? '#3b82f6' : '#27272a', color: props.level === l ? '#fff' : '#a1a1aa',
|
||||
fontSize: 12, fontWeight: 600,
|
||||
}}
|
||||
>{l.toUpperCase()}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.3px' }}>Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.text || ''}
|
||||
onChange={(e) => setProp((p: HeadingProps) => { p.text = e.target.value; })}
|
||||
style={{ width: '100%', padding: '6px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 13 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
style={
|
||||
<TypographyControl
|
||||
style={props.style || {}}
|
||||
onChange={(updates) => setProp((p: HeadingProps) => { p.style = { ...p.style, ...updates }; })}
|
||||
/>
|
||||
}
|
||||
advanced={
|
||||
<AdvancedTab
|
||||
style={props.style || {}}
|
||||
onStyleChange={(updates) => setProp((p: HeadingProps) => { p.style = { ...p.style, ...updates }; })}
|
||||
cssId={props.cssId || ''}
|
||||
onCssIdChange={(id) => setProp((p: HeadingProps) => { p.cssId = id; })}
|
||||
cssClass={props.cssClass || ''}
|
||||
onCssClassChange={(cls) => setProp((p: HeadingProps) => { p.cssClass = cls; })}
|
||||
hideOnDesktop={props.hideOnDesktop}
|
||||
onHideOnDesktopChange={(v) => setProp((p: HeadingProps) => { p.hideOnDesktop = v; })}
|
||||
hideOnTablet={props.hideOnTablet}
|
||||
onHideOnTabletChange={(v) => setProp((p: HeadingProps) => { p.hideOnTablet = v; })}
|
||||
hideOnMobile={props.hideOnMobile}
|
||||
onHideOnMobileChange={(v) => setProp((p: HeadingProps) => { p.hideOnMobile = v; })}
|
||||
animation={props.animation}
|
||||
onAnimationChange={(v) => setProp((p: HeadingProps) => { p.animation = v; })}
|
||||
animationDelay={props.animationDelay}
|
||||
onAnimationDelayChange={(v) => setProp((p: HeadingProps) => { p.animationDelay = v; })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Heading.craft = {
|
||||
displayName: 'Heading',
|
||||
props: {
|
||||
@@ -161,21 +100,33 @@ Heading.craft = {
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
color: '#1f2937',
|
||||
marginBottom: '16px',
|
||||
lineHeight: '',
|
||||
letterSpacing: '',
|
||||
textTransform: '' as CSSProperties['textTransform'],
|
||||
fontStyle: '' as CSSProperties['fontStyle'],
|
||||
textDecoration: '',
|
||||
marginTop: '', marginRight: '', marginLeft: '',
|
||||
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
opacity: '1',
|
||||
},
|
||||
animation: '',
|
||||
animationDelay: '0',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: HeadingSettings,
|
||||
},
|
||||
};
|
||||
|
||||
(Heading as any).toHtml = (props: HeadingProps, _childrenHtml: string) => {
|
||||
const tag = props.level || 'h2';
|
||||
const safeText = (props.text || '').replace(/</g, '<').replace(/>/g, '>');
|
||||
const tag = sanitizeHeadingLevel(props.level);
|
||||
const safeText = escapeHtml(props.text || '');
|
||||
const styleStr = cssPropsToString(props.style);
|
||||
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${safeText}</${tag}>` };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { purifyHtml } from './HtmlBlock';
|
||||
|
||||
describe('purifyHtml', () => {
|
||||
test('strips script tags', () => {
|
||||
expect(purifyHtml('<p>ok</p><script>alert(1)</script>')).not.toContain('<script');
|
||||
});
|
||||
test('strips on-event handlers', () => {
|
||||
const out = purifyHtml('<a onclick="bad()" href="/x">x</a>');
|
||||
expect(out).not.toContain('onclick');
|
||||
expect(out).toContain('href="/x"');
|
||||
});
|
||||
test('blocks javascript: URLs', () => {
|
||||
expect(purifyHtml('<a href="javascript:void(0)">x</a>')).not.toContain('javascript:');
|
||||
});
|
||||
test('allows YouTube iframe', () => {
|
||||
const out = purifyHtml('<iframe src="https://www.youtube.com/embed/abc" allowfullscreen></iframe>');
|
||||
expect(out).toContain('youtube.com/embed/abc');
|
||||
});
|
||||
test('strips form/input', () => {
|
||||
expect(purifyHtml('<form><input name="x"></form>')).not.toContain('<form');
|
||||
});
|
||||
});
|
||||
|
||||
describe('purifyHtml iframe sandboxing (M-6)', () => {
|
||||
test('forces a restrictive sandbox attribute onto every iframe', () => {
|
||||
const out = purifyHtml('<iframe src="https://example.com/"></iframe>');
|
||||
expect(out).toMatch(/<iframe[^>]*\bsandbox="[^"]+"/);
|
||||
});
|
||||
|
||||
test('sandbox value omits allow-top-navigation (no top-level nav escape)', () => {
|
||||
const out = purifyHtml('<iframe src="https://example.com/"></iframe>');
|
||||
const sandbox = out.match(/sandbox="([^"]*)"/)![1];
|
||||
expect(sandbox).not.toMatch(/allow-top-navigation/);
|
||||
});
|
||||
|
||||
test('legitimate embeds (YouTube) still work and get sandboxed too', () => {
|
||||
const out = purifyHtml('<iframe src="https://www.youtube.com/embed/abc" allowfullscreen></iframe>');
|
||||
expect(out).toContain('youtube.com/embed/abc');
|
||||
expect(out).toMatch(/<iframe[^>]*\bsandbox="[^"]+"/);
|
||||
});
|
||||
|
||||
test('adds referrerpolicy=no-referrer to iframes', () => {
|
||||
const out = purifyHtml('<iframe src="https://example.com/"></iframe>');
|
||||
expect(out).toContain('referrerpolicy="no-referrer"');
|
||||
});
|
||||
|
||||
test('script/on* attributes are still stripped alongside the sandboxed iframe', () => {
|
||||
const out = purifyHtml('<iframe src="https://example.com/" onload="alert(1)"></iframe><script>alert(2)</script>');
|
||||
expect(out).not.toContain('onload');
|
||||
expect(out).not.toContain('<script');
|
||||
});
|
||||
|
||||
test('repeated calls do not leak/accumulate the hook (no duplicate sandbox attr, no cross-call state)', () => {
|
||||
purifyHtml('<iframe src="https://a.example/"></iframe>');
|
||||
purifyHtml('<iframe src="https://b.example/"></iframe>');
|
||||
const out = purifyHtml('<iframe src="https://c.example/"></iframe>');
|
||||
const sandboxMatches = out.match(/sandbox="/g) || [];
|
||||
expect(sandboxMatches.length).toBe(1);
|
||||
});
|
||||
|
||||
test('a non-iframe element sanitized alongside an iframe is not touched by the hook', () => {
|
||||
const out = purifyHtml('<p>hi</p><iframe src="https://example.com/"></iframe>');
|
||||
expect(out).toContain('<p>hi</p>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { HtmlBlock } from './HtmlBlock';
|
||||
|
||||
const toHtml = (HtmlBlock as any).toHtml;
|
||||
|
||||
describe('HtmlBlock.toHtml sanitizes raw code (A4.1)', () => {
|
||||
test('strips <script> and on-handlers from exported output', () => {
|
||||
const { html } = toHtml({ code: '<script>alert(1)</script><p onclick="x">hi</p>' }, '');
|
||||
expect(html).not.toContain('<script');
|
||||
expect(html).not.toContain('onclick');
|
||||
expect(html).toContain('<p>hi</p>');
|
||||
});
|
||||
|
||||
test('does not wrap output in an unsanitized element carrying the style prop raw', () => {
|
||||
// toHtml only ever returns the sanitized `code` blob -- there is no
|
||||
// wrapper <div style="..."> in the exported HTML, so a malicious
|
||||
// `style` prop (e.g. an attacker-controlled object with a breakout
|
||||
// toString()) has nothing to splice into.
|
||||
const malicious = { toString: () => 'color:red" onmouseover="alert(1)' } as any;
|
||||
const { html } = toHtml({ code: '<p>hi</p>', style: malicious }, '');
|
||||
expect(html).not.toMatch(/onmouseover/);
|
||||
expect(html).not.toMatch(/<div/);
|
||||
expect(html).toBe('<p>hi</p>');
|
||||
});
|
||||
});
|
||||
@@ -1,104 +1,80 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import React, { CSSProperties, useMemo } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
interface HtmlBlockProps {
|
||||
code: string;
|
||||
style?: CSSProperties;
|
||||
aiName?: string;
|
||||
node_id?: string;
|
||||
}
|
||||
|
||||
export const HtmlBlock: UserComponent<HtmlBlockProps> = ({
|
||||
code = '',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
const PURIFY_CONFIG = {
|
||||
ALLOWED_TAGS: [
|
||||
'a','p','br','hr','div','span','section','article',
|
||||
'header','footer','main','aside','nav',
|
||||
'ul','ol','li',
|
||||
'h1','h2','h3','h4','h5','h6',
|
||||
'em','strong','b','i','u','s',
|
||||
'blockquote','code','pre',
|
||||
'img','figure','figcaption',
|
||||
'iframe',
|
||||
],
|
||||
ALLOWED_ATTR: [
|
||||
'href','src','alt','title','target','rel',
|
||||
'width','height','class',
|
||||
'allowfullscreen','allow','frameborder',
|
||||
'sandbox','referrerpolicy',
|
||||
],
|
||||
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|data:image\/[a-z]+;base64,):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
|
||||
FORBID_TAGS: ['script','style','object','embed','link','meta','form','input','button','select','textarea'],
|
||||
FORBID_ATTR: [/^on/i],
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
// M-6: `<iframe>` is allowed (maps/video embeds are a legitimate use case)
|
||||
// but an iframe with a `src` and NO `sandbox` attribute is a clickjacking/
|
||||
// phishing vector (DOMPurify already strips <script>/on*=, but an
|
||||
// unsandboxed iframe still gets full script execution, same-origin-ish
|
||||
// access via document.domain tricks, top-level navigation, etc., inside
|
||||
// itself). This hook force-sets a restrictive sandbox on every iframe that
|
||||
// survives sanitization, keeping `allow-scripts`/`allow-same-origin`/
|
||||
// `allow-popups`/`allow-forms` (needed for interactive maps/video/oauth
|
||||
// popups) but deliberately omitting `allow-top-navigation` so an embedded
|
||||
// page can never redirect/hijack the parent tab.
|
||||
const IFRAME_SANDBOX_HOOK = (node: Element): void => {
|
||||
if (node.nodeName === 'IFRAME') {
|
||||
node.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-popups allow-forms');
|
||||
node.setAttribute('referrerpolicy', 'no-referrer');
|
||||
}
|
||||
};
|
||||
|
||||
export function purifyHtml(input: string): string {
|
||||
// Hook is added immediately before sanitize() and removed immediately
|
||||
// after, scoped tightly to this single call -- so it can never leak onto
|
||||
// (or accumulate duplicate copies across) any other DOMPurify.sanitize()
|
||||
// call elsewhere in the app, and repeated purifyHtml() calls never stack
|
||||
// multiple copies of the same hook.
|
||||
DOMPurify.addHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK);
|
||||
try {
|
||||
return DOMPurify.sanitize(input || '', PURIFY_CONFIG as any) as unknown as string;
|
||||
} finally {
|
||||
DOMPurify.removeHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK as any);
|
||||
}
|
||||
}
|
||||
|
||||
export const HtmlBlock: UserComponent<HtmlBlockProps> = ({ code = '', style = {} }) => {
|
||||
const { connectors: { connect, drag }, selected } = useNode((node) => ({ selected: node.events.selected }));
|
||||
const clean = useMemo(() => purifyHtml(code), [code]);
|
||||
const setRef = (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); };
|
||||
return React.createElement('div', {
|
||||
ref: setRef,
|
||||
style: {
|
||||
minHeight: '40px',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: code }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const HtmlBlockSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as HtmlBlockProps,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div style={{
|
||||
padding: '8px 10px',
|
||||
background: '#44200a',
|
||||
border: '1px solid #92400e',
|
||||
borderRadius: 6,
|
||||
fontSize: 11,
|
||||
color: '#fbbf24',
|
||||
lineHeight: 1.4,
|
||||
}}>
|
||||
This block renders raw HTML. Use with caution.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>HTML Code</label>
|
||||
<textarea
|
||||
value={props.code || ''}
|
||||
onChange={(e) => setProp((p: HtmlBlockProps) => { p.code = e.target.value; })}
|
||||
placeholder="<div>Your HTML here...</div>"
|
||||
rows={16}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
background: '#1a1a2e',
|
||||
color: '#a5f3fc',
|
||||
border: '1px solid #3f3f46',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
fontFamily: '"Source Code Pro", "Fira Code", monospace',
|
||||
lineHeight: 1.5,
|
||||
resize: 'vertical',
|
||||
boxSizing: 'border-box',
|
||||
whiteSpace: 'pre',
|
||||
tabSize: 2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Outer container style */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Padding</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['0px', '8px', '16px', '24px', '32px'].map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setProp((pr: HtmlBlockProps) => { pr.style = { ...pr.style, padding: p }; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.style?.padding === p ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
dangerouslySetInnerHTML: { __html: clean },
|
||||
});
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
@@ -114,14 +90,12 @@ HtmlBlock.craft = {
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: HtmlBlockSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(HtmlBlock as any).toHtml = (props: HtmlBlockProps, _childrenHtml: string) => {
|
||||
// Output the raw code as-is
|
||||
return { html: props.code || '' };
|
||||
// Run through the same DOMPurify config used for the live editor preview
|
||||
// so exported pages can't carry <script>/on*= payloads either.
|
||||
return { html: purifyHtml(props.code || '') };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
const toHtml = (Icon as any).toHtml;
|
||||
|
||||
describe('Icon.toHtml normal rendering', () => {
|
||||
test('renders icon class, size/color style, and link href', () => {
|
||||
const { html } = toHtml({ icon: 'fa-star', size: '32px', color: '#3b82f6', link: 'https://example.com' }, '');
|
||||
expect(html).toContain('class="fa fa-star"');
|
||||
expect(html).toContain('font-size:32px');
|
||||
expect(html).toContain('color:#3b82f6');
|
||||
expect(html).toContain('href="https://example.com"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Icon.toHtml XSS hardening', () => {
|
||||
test('an icon name with an attribute-breakout string is escaped, not raw-concatenated', () => {
|
||||
const malicious = 'star"><script>alert(1)</script>';
|
||||
const { html } = toHtml({ icon: malicious as any }, '');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
expect(html).not.toMatch(/class="fa star"><script>/);
|
||||
});
|
||||
|
||||
test('a size value with an attribute-breakout string cannot escape style=""', () => {
|
||||
const malicious = '24px" onerror="alert(1)';
|
||||
const { html } = toHtml({ size: malicious as any }, '');
|
||||
expect(html).not.toMatch(/"\s+onerror="/);
|
||||
});
|
||||
|
||||
test('a bgSize/bgColor breakout via background wrapper is neutralized', () => {
|
||||
const malicious = '56px" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ bgShape: 'circle', bgColor: '#fff', bgSize: malicious as any }, '');
|
||||
expect(html).not.toMatch(/"\s+onmouseover="/);
|
||||
});
|
||||
|
||||
test('a javascript: link is neutralized to an empty href', () => {
|
||||
const { html } = toHtml({ link: 'javascript:alert(1)' }, '');
|
||||
expect(html).not.toContain('javascript:alert(1)');
|
||||
});
|
||||
|
||||
test('a link value with an attribute-breakout string cannot escape href=""', () => {
|
||||
const malicious = 'https://example.com" onclick="alert(1)';
|
||||
const { html } = toHtml({ link: malicious as any }, '');
|
||||
expect(html).not.toMatch(/"\s+onclick="/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Icon.toHtml bgShape/bgColor/bgSize rendering (built but, until this panel update, unexposed)', () => {
|
||||
test('bgShape="circle" + bgColor render a colored 50%-radius background box', () => {
|
||||
const { html } = toHtml({ icon: 'fa-star', bgShape: 'circle', bgColor: '#3b82f6', bgSize: '64px' }, '');
|
||||
expect(html).toContain('background-color:#3b82f6');
|
||||
expect(html).toContain('border-radius:50%');
|
||||
expect(html).toContain('width:64px');
|
||||
expect(html).toContain('height:64px');
|
||||
});
|
||||
|
||||
test('bgShape="none" (default) renders the bare icon with no background wrapper', () => {
|
||||
const { html } = toHtml({ icon: 'fa-star' }, '');
|
||||
expect(html).not.toContain('background-color');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Icon.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||
const props = (Icon as any).craft.props;
|
||||
expect(props).toHaveProperty('animation', '');
|
||||
expect(props).toHaveProperty('animationDelay', '');
|
||||
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||
expect(props).toHaveProperty('hideOnTablet', false);
|
||||
expect(props).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeAttr, safeUrl } from '../../utils/escape';
|
||||
|
||||
interface IconProps {
|
||||
icon?: string;
|
||||
@@ -11,17 +12,13 @@ interface IconProps {
|
||||
bgSize?: string;
|
||||
link?: string;
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const COMMON_ICONS = [
|
||||
'fa-star', 'fa-heart', 'fa-check', 'fa-phone', 'fa-envelope',
|
||||
'fa-map-marker', 'fa-globe', 'fa-facebook', 'fa-twitter', 'fa-instagram',
|
||||
'fa-linkedin', 'fa-youtube', 'fa-github', 'fa-arrow-right', 'fa-arrow-down',
|
||||
'fa-play', 'fa-search', 'fa-user', 'fa-lock', 'fa-cog',
|
||||
'fa-home', 'fa-comment', 'fa-camera', 'fa-music', 'fa-shopping-cart',
|
||||
'fa-calendar', 'fa-clock-o', 'fa-thumbs-up', 'fa-lightbulb-o', 'fa-rocket',
|
||||
];
|
||||
|
||||
function getBgBorderRadius(shape: string): string {
|
||||
if (shape === 'circle') return '50%';
|
||||
if (shape === 'rounded') return '8px';
|
||||
@@ -91,176 +88,6 @@ export const Icon: UserComponent<IconProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const IconSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as IconProps,
|
||||
}));
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
|
||||
};
|
||||
|
||||
const sizePresets = ['24px', '32px', '48px', '64px'];
|
||||
const colorPresets = ['#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899', '#18181b', '#ffffff'];
|
||||
const shapePresets: Array<{ label: string; value: IconProps['bgShape'] }> = [
|
||||
{ label: 'None', value: 'none' },
|
||||
{ label: 'Circle', value: 'circle' },
|
||||
{ label: 'Square', value: 'square' },
|
||||
{ label: 'Rounded', value: 'rounded' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{/* Icon picker */}
|
||||
<div>
|
||||
<label style={labelStyle}>Icon</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: 4, maxHeight: 200, overflowY: 'auto' }}>
|
||||
{COMMON_ICONS.map((ic) => (
|
||||
<button
|
||||
key={ic}
|
||||
onClick={() => setProp((p: IconProps) => { p.icon = ic; })}
|
||||
title={ic}
|
||||
style={{
|
||||
padding: '6px', fontSize: 16, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.icon === ic ? '#3b82f6' : '#27272a',
|
||||
color: props.icon === ic ? '#fff' : '#e4e4e7',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<i className={`fa ${ic}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom icon class */}
|
||||
<div>
|
||||
<label style={labelStyle}>Custom Icon Class</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.icon || ''}
|
||||
onChange={(e) => setProp((p: IconProps) => { p.icon = e.target.value; })}
|
||||
placeholder="fa-star"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Size */}
|
||||
<div>
|
||||
<label style={labelStyle}>Size</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{sizePresets.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setProp((p: IconProps) => { p.size = s; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.size === s ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color */}
|
||||
<div>
|
||||
<label style={labelStyle}>Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{colorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: IconProps) => { p.color = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.color === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background shape */}
|
||||
<div>
|
||||
<label style={labelStyle}>Background Shape</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{shapePresets.map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
onClick={() => setProp((p: IconProps) => { p.bgShape = s.value; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.bgShape === s.value ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background color */}
|
||||
{props.bgShape !== 'none' && (
|
||||
<div>
|
||||
<label style={labelStyle}>Background Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', '#18181b', '#f1f5f9', '#ffffff'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: IconProps) => { p.bgColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.bgColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background size */}
|
||||
{props.bgShape !== 'none' && (
|
||||
<div>
|
||||
<label style={labelStyle}>Background Size</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.bgSize || '56px'}
|
||||
onChange={(e) => setProp((p: IconProps) => { p.bgSize = e.target.value; })}
|
||||
placeholder="56px"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link */}
|
||||
<div>
|
||||
<label style={labelStyle}>Link URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.link || ''}
|
||||
onChange={(e) => setProp((p: IconProps) => { p.link = e.target.value; })}
|
||||
placeholder="https://..."
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Icon.craft = {
|
||||
@@ -274,15 +101,17 @@ Icon.craft = {
|
||||
bgSize: '56px',
|
||||
link: '',
|
||||
style: {},
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: IconSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
@@ -300,7 +129,7 @@ Icon.craft = {
|
||||
} = props;
|
||||
|
||||
const iconStyle = cssPropsToString({ fontSize: size, color, lineHeight: '1' });
|
||||
let iconHtml = `<i class="fa ${icon}"${iconStyle ? ` style="${iconStyle}"` : ''}></i>`;
|
||||
let iconHtml = `<i class="fa ${escapeAttr(icon)}"${iconStyle ? ` style="${iconStyle}"` : ''}></i>`;
|
||||
|
||||
const hasBg = bgShape !== 'none' && bgColor !== 'transparent';
|
||||
if (hasBg) {
|
||||
@@ -317,7 +146,7 @@ Icon.craft = {
|
||||
}
|
||||
|
||||
if (link) {
|
||||
iconHtml = `<a href="${link}" style="text-decoration:none;color:inherit">${iconHtml}</a>`;
|
||||
iconHtml = `<a href="${escapeAttr(safeUrl(link))}" style="text-decoration:none;color:inherit">${iconHtml}</a>`;
|
||||
}
|
||||
|
||||
const wrapperStyle = cssPropsToString({ display: 'inline-block', ...style });
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Logo } from './Logo';
|
||||
|
||||
/*
|
||||
* Regression coverage for Logo.toHtml -- audited during the toHtml
|
||||
* attribute-XSS sweep (see task-cssxss-brief.md) and found already fully
|
||||
* sanitized (href/src via escapeAttr(safeUrl()), alt/text via escapeAttr /
|
||||
* escapeHtml, imageWidth/fontSize/etc. routed through cssPropsToString which
|
||||
* sanitizes every value regardless of declared type). No fix was required;
|
||||
* these tests lock that behavior in against regressions.
|
||||
*/
|
||||
const toHtml = (Logo as any).toHtml;
|
||||
|
||||
describe('Logo.toHtml href sanitization (attacker-controlled `href` prop)', () => {
|
||||
test('a javascript: URL is neutralized', () => {
|
||||
const { html } = toHtml({ href: 'javascript:alert(1)' }, '');
|
||||
expect(html).not.toContain('javascript:alert');
|
||||
});
|
||||
|
||||
test('a quote-breakout href does not escape the anchor attribute', () => {
|
||||
const malicious = '"><script>alert(1)</script>';
|
||||
const { html } = toHtml({ href: malicious }, '');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logo.toHtml image src/alt sanitization (type="image")', () => {
|
||||
test('a javascript: imageSrc is neutralized', () => {
|
||||
const { html } = toHtml({ type: 'image', imageSrc: 'javascript:alert(1)', text: 'Logo' }, '');
|
||||
expect(html).not.toContain('javascript:alert');
|
||||
});
|
||||
|
||||
test('a quote-breakout alt (from `text`) does not escape the img attribute', () => {
|
||||
const malicious = '"><script>alert(1)</script>';
|
||||
const { html } = toHtml({ type: 'image', imageSrc: 'https://example.com/logo.png', text: malicious }, '');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('a non-numeric imageWidth (attribute-breakout attempt) does not escape the style attribute', () => {
|
||||
const malicious = '1"><script>alert(1)</script>';
|
||||
const { html } = toHtml({ type: 'image', imageSrc: 'https://example.com/logo.png', imageWidth: malicious }, '');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logo.toHtml download attribute (F3: link-to-file toggle)', () => {
|
||||
test('download:true emits the download attribute', () => {
|
||||
const { html } = toHtml({ href: '/resume.pdf', download: true, text: 'Resume' }, '');
|
||||
expect(html).toMatch(/<a href="\/resume\.pdf" download/);
|
||||
});
|
||||
|
||||
test('no download prop -> no download attribute emitted', () => {
|
||||
const { html } = toHtml({ href: '/', text: 'MySite' }, '');
|
||||
expect(html).not.toContain('download');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logo (F4: box-model + animation + visibility props on craft.props)', () => {
|
||||
test('craft.props includes animation/visibility defaults so the panel controls always render', () => {
|
||||
const craftProps = (Logo as any).craft.props;
|
||||
expect(craftProps).toHaveProperty('animation', 'none');
|
||||
expect(craftProps).toHaveProperty('animationDelay', '0');
|
||||
expect(craftProps).toHaveProperty('hideOnDesktop', false);
|
||||
expect(craftProps).toHaveProperty('hideOnTablet', false);
|
||||
expect(craftProps).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logo.toHtml text-logo styling sanitization', () => {
|
||||
test('a quote-breakout color does not escape the span style attribute', () => {
|
||||
const malicious = 'red" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ type: 'text', text: 'MySite', color: malicious }, '');
|
||||
// The raw `"` must never survive un-escaped inside the style attribute
|
||||
// value -- if it did, `onmouseover` would land as a REAL new HTML
|
||||
// attribute (breakout) rather than being inert CSS-value garbage inside
|
||||
// a properly-escaped style="...".
|
||||
expect(html).not.toMatch(/style="[^"]*"[^>]*onmouseover/);
|
||||
});
|
||||
|
||||
test('a normal logo renders as expected', () => {
|
||||
const { html } = toHtml({ type: 'text', text: 'MySite', href: '/' }, '');
|
||||
expect(html).toContain('href="/"');
|
||||
expect(html).toContain('MySite');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { CSSProperties, useCallback, useRef, useState } from 'react';
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { useSiteDesign } from '../../state/SiteDesignContext';
|
||||
import { escapeHtml, escapeAttr, safeUrl, safeImageUrl } from '../../utils/escape';
|
||||
|
||||
/* ---------- Types ---------- */
|
||||
|
||||
@@ -11,35 +12,18 @@ interface LogoProps {
|
||||
imageSrc?: string;
|
||||
imageWidth?: string;
|
||||
href?: string;
|
||||
/** Adds the `download` attribute to the exported anchor (F3: link to a file). */
|
||||
download?: boolean;
|
||||
fontFamily?: string;
|
||||
fontSize?: string;
|
||||
fontWeight?: string;
|
||||
color?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/* ---------- Image upload helper ---------- */
|
||||
|
||||
async function uploadToWhp(file: File): Promise<string | null> {
|
||||
const cfg = (window as any).WHP_CONFIG;
|
||||
if (!cfg) return URL.createObjectURL(file);
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
const resp = await fetch(`${cfg.apiUrl}?action=upload_asset&site_id=${cfg.siteId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': cfg.csrfToken },
|
||||
body: formData,
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success && data.url) return data.url;
|
||||
return null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/* ---------- Helper: escape HTML ---------- */
|
||||
function esc(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
}
|
||||
|
||||
/* ---------- Component ---------- */
|
||||
@@ -50,6 +34,7 @@ export const Logo: UserComponent<LogoProps> = ({
|
||||
imageSrc = '',
|
||||
imageWidth = '120px',
|
||||
href = '/',
|
||||
download = false,
|
||||
fontFamily = 'Inter, sans-serif',
|
||||
fontSize = '20px',
|
||||
fontWeight = '700',
|
||||
@@ -67,6 +52,7 @@ export const Logo: UserComponent<LogoProps> = ({
|
||||
<a
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
href={href}
|
||||
download={download || undefined}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
@@ -96,269 +82,6 @@ export const Logo: UserComponent<LogoProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const LogoSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as LogoProps,
|
||||
}));
|
||||
|
||||
const { design } = useSiteDesign();
|
||||
const logoType = props.type || 'text';
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showBrowser, setShowBrowser] = useState(false);
|
||||
const [browserAssets, setBrowserAssets] = useState<any[]>([]);
|
||||
const [browserLoading, setBrowserLoading] = useState(false);
|
||||
|
||||
const fontFamilies = [
|
||||
{ label: 'Inter', value: 'Inter, sans-serif' },
|
||||
{ label: 'Roboto', value: 'Roboto, sans-serif' },
|
||||
{ label: 'Poppins', value: 'Poppins, sans-serif' },
|
||||
{ label: 'Montserrat', value: 'Montserrat, sans-serif' },
|
||||
{ label: 'Playfair', value: 'Playfair Display, serif' },
|
||||
{ label: 'Merriweather', value: 'Merriweather, serif' },
|
||||
{ label: 'Source Code', value: 'Source Code Pro, monospace' },
|
||||
{ label: 'Open Sans', value: 'Open Sans, sans-serif' },
|
||||
];
|
||||
|
||||
const handleLogoUpload = useCallback(async (file: File) => {
|
||||
const url = await uploadToWhp(file);
|
||||
if (url) setProp((p: LogoProps) => { p.imageSrc = url; });
|
||||
}, [setProp]);
|
||||
|
||||
const handleBrowse = useCallback(async () => {
|
||||
if (showBrowser) { setShowBrowser(false); return; }
|
||||
const cfg = (window as any).WHP_CONFIG;
|
||||
if (!cfg) return;
|
||||
setBrowserLoading(true);
|
||||
try {
|
||||
const resp = await fetch(`${cfg.apiUrl}?action=list_assets&site_id=${cfg.siteId}`);
|
||||
const data = await resp.json();
|
||||
if (data.success && Array.isArray(data.assets)) {
|
||||
const images = data.assets.filter((a: any) => (a.type || '').startsWith('image'));
|
||||
setBrowserAssets(images);
|
||||
setShowBrowser(true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Browse failed:', e);
|
||||
} finally {
|
||||
setBrowserLoading(false);
|
||||
}
|
||||
}, [showBrowser]);
|
||||
|
||||
/* ---- Shared styles ---- */
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
const btnSmall: CSSProperties = {
|
||||
padding: '2px 6px', fontSize: 11, background: '#27272a', color: '#a1a1aa',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
|
||||
};
|
||||
const btnActive: CSSProperties = {
|
||||
...btnSmall, background: '#3b82f6', color: '#fff', borderColor: '#3b82f6',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{/* Type toggle */}
|
||||
<div>
|
||||
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Logo Type</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
onClick={() => setProp((p: LogoProps) => { p.type = 'text'; })}
|
||||
style={logoType === 'text' ? btnActive : btnSmall}
|
||||
>
|
||||
<i className="fa fa-font" style={{ marginRight: 3 }} />Text
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setProp((p: LogoProps) => { p.type = 'image'; })}
|
||||
style={logoType === 'image' ? btnActive : btnSmall}
|
||||
>
|
||||
<i className="fa fa-image" style={{ marginRight: 3 }} />Image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{logoType === 'text' ? (
|
||||
<>
|
||||
<div>
|
||||
<label style={labelStyle}>Logo Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.text || ''}
|
||||
onChange={(e) => setProp((p: LogoProps) => { p.text = e.target.value; })}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Font Family</label>
|
||||
<select
|
||||
value={props.fontFamily || 'Inter, sans-serif'}
|
||||
onChange={(e) => setProp((p: LogoProps) => { p.fontFamily = e.target.value; })}
|
||||
style={{ ...inputStyle, cursor: 'pointer' }}
|
||||
>
|
||||
{fontFamilies.map((f) => (
|
||||
<option key={f.value} value={f.value}>{f.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>Size</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.fontSize || '20px'}
|
||||
onChange={(e) => setProp((p: LogoProps) => { p.fontSize = e.target.value; })}
|
||||
placeholder="20px"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>Weight</label>
|
||||
<select
|
||||
value={props.fontWeight || '700'}
|
||||
onChange={(e) => setProp((p: LogoProps) => { p.fontWeight = e.target.value; })}
|
||||
style={{ ...inputStyle, cursor: 'pointer' }}
|
||||
>
|
||||
<option value="300">Light</option>
|
||||
<option value="400">Normal</option>
|
||||
<option value="500">Medium</option>
|
||||
<option value="600">Semi</option>
|
||||
<option value="700">Bold</option>
|
||||
<option value="800">Extra Bold</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input
|
||||
type="color"
|
||||
value={props.color || design.textColor}
|
||||
onChange={(e) => setProp((p: LogoProps) => { p.color = e.target.value; })}
|
||||
style={{ width: 28, height: 24, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
|
||||
/>
|
||||
<span style={{ fontSize: 10, color: '#71717a' }}>{props.color || 'Auto'}</span>
|
||||
<button
|
||||
onClick={() => setProp((p: LogoProps) => { p.color = undefined; })}
|
||||
style={{ ...btnSmall, fontSize: 9, padding: '2px 4px' }}
|
||||
title="Reset to auto"
|
||||
>Auto</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Image logo controls */}
|
||||
{props.imageSrc ? (
|
||||
<div style={{ borderRadius: 6, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
|
||||
<img src={props.imageSrc} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 80, objectFit: 'contain', background: '#18181b' }} />
|
||||
<button
|
||||
onClick={() => setProp((p: LogoProps) => { p.imageSrc = ''; })}
|
||||
style={{ position: 'absolute', top: 4, right: 4, width: 20, height: 20, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
title="Remove image"
|
||||
>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{ padding: '14px 12px', border: '2px dashed #3f3f46', borderRadius: 6, textAlign: 'center', color: '#71717a', fontSize: 11, cursor: 'pointer' }}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = '#3b82f6'; }}
|
||||
onDragLeave={(e) => { e.currentTarget.style.borderColor = '#3f3f46'; }}
|
||||
onDrop={async (e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.borderColor = '#3f3f46';
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file && file.type.startsWith('image/')) await handleLogoUpload(file);
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-cloud-upload" style={{ fontSize: 18, display: 'block', marginBottom: 4, color: '#3b82f6' }} />
|
||||
Drop logo or click to upload
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{ flex: 1, padding: '6px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#3b82f6', color: '#fff', fontWeight: 500 }}
|
||||
>
|
||||
<i className="fa fa-upload" style={{ marginRight: 3 }} /> Upload
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBrowse}
|
||||
style={{ flex: 1, padding: '6px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: showBrowser ? '#3b82f6' : '#27272a', color: showBrowser ? '#fff' : '#e4e4e7' }}
|
||||
>
|
||||
<i className={`fa ${browserLoading ? 'fa-spinner fa-spin' : 'fa-folder-open'}`} style={{ marginRight: 3 }} /> Browse
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Browse grid */}
|
||||
{showBrowser && (
|
||||
<div style={{ maxHeight: 150, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, background: '#18181b', borderRadius: 6, padding: 4 }}>
|
||||
{browserAssets.map(asset => (
|
||||
<div
|
||||
key={asset.name}
|
||||
onClick={() => { setProp((p: LogoProps) => { p.imageSrc = asset.url; }); setShowBrowser(false); }}
|
||||
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#3b82f6'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
|
||||
>
|
||||
<img src={asset.url} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
</div>
|
||||
))}
|
||||
{browserAssets.length === 0 && (
|
||||
<p style={{ gridColumn: '1 / -1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: '8px 0', margin: 0 }}>No images uploaded yet.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
|
||||
onChange={(e) => { const file = e.target.files?.[0]; if (file) handleLogoUpload(file); e.target.value = ''; }} />
|
||||
|
||||
{/* URL paste input */}
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={props.imageSrc || ''}
|
||||
onChange={(e) => setProp((p: LogoProps) => { p.imageSrc = e.target.value; })}
|
||||
placeholder="Or paste image URL..."
|
||||
style={{ ...inputStyle, fontSize: 10, color: '#71717a' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Logo Width</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.imageWidth || '120px'}
|
||||
onChange={(e) => setProp((p: LogoProps) => { p.imageWidth = e.target.value; })}
|
||||
placeholder="120px"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Link URL */}
|
||||
<div>
|
||||
<label style={labelStyle}>Link URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.href || '/'}
|
||||
onChange={(e) => setProp((p: LogoProps) => { p.href = e.target.value; })}
|
||||
placeholder="/"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Logo.craft = {
|
||||
@@ -373,16 +96,19 @@ Logo.craft = {
|
||||
fontSize: '20px',
|
||||
fontWeight: '700',
|
||||
color: undefined,
|
||||
download: false,
|
||||
style: {},
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
animation: 'none',
|
||||
animationDelay: '0',
|
||||
} as LogoProps,
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: LogoSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
@@ -393,7 +119,7 @@ Logo.craft = {
|
||||
let innerHtml: string;
|
||||
if (props.type === 'image' && props.imageSrc) {
|
||||
const imgStyle = cssPropsToString({ width: props.imageWidth || '120px', height: 'auto', display: 'block' });
|
||||
innerHtml = `<img src="${esc(props.imageSrc)}" alt="${esc(props.text || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} />`;
|
||||
innerHtml = `<img src="${escapeAttr(safeImageUrl(props.imageSrc))}" alt="${escapeAttr(props.text || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} />`;
|
||||
} else {
|
||||
const spanStyle = cssPropsToString({
|
||||
fontWeight: props.fontWeight || '700',
|
||||
@@ -401,7 +127,7 @@ Logo.craft = {
|
||||
fontFamily: props.fontFamily || 'Inter, sans-serif',
|
||||
color: props.color || '#1f2937',
|
||||
});
|
||||
innerHtml = `<span${spanStyle ? ` style="${spanStyle}"` : ''}>${esc(props.text || 'MySite')}</span>`;
|
||||
innerHtml = `<span${spanStyle ? ` style="${spanStyle}"` : ''}>${escapeHtml(props.text || 'MySite')}</span>`;
|
||||
}
|
||||
|
||||
const aStyle = cssPropsToString({
|
||||
@@ -411,8 +137,9 @@ Logo.craft = {
|
||||
flexShrink: '0',
|
||||
...props.style,
|
||||
});
|
||||
const downloadAttr = props.download ? ' download' : '';
|
||||
|
||||
return {
|
||||
html: `<a href="${esc(href)}"${aStyle ? ` style="${aStyle}"` : ''}>${innerHtml}</a>`,
|
||||
html: `<a href="${escapeAttr(safeUrl(href))}"${downloadAttr}${aStyle ? ` style="${aStyle}"` : ''}>${innerHtml}</a>`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Menu } from './Menu';
|
||||
|
||||
const toHtml = (Menu as any).toHtml;
|
||||
|
||||
describe('Menu.toHtml deterministic + unique scope ids (thread node id, no Math.random)', () => {
|
||||
test('same node id -> identical output across calls (deterministic)', () => {
|
||||
const { html: html1 } = toHtml({}, '', 'node-menu1');
|
||||
const { html: html2 } = toHtml({}, '', 'node-menu1');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
|
||||
test('different node ids -> different, non-colliding scope classes (identical default links, no collision)', () => {
|
||||
const { html: html1 } = toHtml({}, '', 'node-menu1');
|
||||
const { html: html2 } = toHtml({}, '', 'node-menu2');
|
||||
const cls1 = html1.match(/\.([a-z0-9_]+-link):hover/)![1];
|
||||
const cls2 = html2.match(/\.([a-z0-9_]+-link):hover/)![1];
|
||||
expect(cls1).not.toBe(cls2);
|
||||
});
|
||||
|
||||
test('the anchor class= and the <style> hover rule use the SAME scope', () => {
|
||||
const { html } = toHtml({}, '', 'node-menu1');
|
||||
const hoverCls = html.match(/\.([a-z0-9_]+-link):hover/)![1];
|
||||
expect(html).toContain(`class="${hoverCls}"`);
|
||||
});
|
||||
|
||||
test('no nodeId (legacy 2-arg call): still deterministic across repeated calls, not random', () => {
|
||||
const { html: html1 } = toHtml({}, '');
|
||||
const { html: html2 } = toHtml({}, '');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Menu.toHtml download attribute (F3: link-to-file toggle)', () => {
|
||||
test('a link with download:true emits the download attribute', () => {
|
||||
const { html } = toHtml({ links: [{ text: 'Brochure', href: '/brochure.pdf', download: true }] }, '', 'node-dl1');
|
||||
expect(html).toMatch(/<a href="\/brochure\.pdf"[^>]* download[^>]*>Brochure<\/a>/);
|
||||
});
|
||||
|
||||
test('a link without download does not emit the attribute', () => {
|
||||
const { html } = toHtml({ links: [{ text: 'Home', href: '/' }] }, '', 'node-dl2');
|
||||
expect(html).not.toContain(' download');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Menu (F4: box-model + animation + visibility props on craft.props)', () => {
|
||||
test('craft.props includes animation/visibility defaults so the panel controls always render', () => {
|
||||
const craftProps = (Menu as any).craft.props;
|
||||
expect(craftProps).toHaveProperty('animation', 'none');
|
||||
expect(craftProps).toHaveProperty('animationDelay', '0');
|
||||
expect(craftProps).toHaveProperty('hideOnDesktop', false);
|
||||
expect(craftProps).toHaveProperty('hideOnTablet', false);
|
||||
expect(craftProps).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Menu.toHtml XSS hardening (linkHoverColor into <style>)', () => {
|
||||
test('a linkHoverColor value containing </style><script> is neutralized', () => {
|
||||
const malicious = '#fff}</style><script>alert(1)</script><style>{';
|
||||
const { html } = toHtml({ linkHoverColor: malicious }, '', 'node-xss');
|
||||
expect(html).not.toContain('</style><script');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('a normal linkHoverColor still renders in the hover rule', () => {
|
||||
const { html } = toHtml({ linkHoverColor: '#ff0000' }, '', 'node-normal');
|
||||
expect(html).toMatch(/:hover\s*\{\s*color:\s*#ff0000/);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { CSSProperties, useState } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { usePages } from '../../state/PageContext';
|
||||
import { escapeHtml, escapeAttr, safeUrl, scopeId, cssValue } from '../../utils/escape';
|
||||
|
||||
/* ---------- Types ---------- */
|
||||
|
||||
@@ -10,6 +10,8 @@ interface MenuLink {
|
||||
href: string;
|
||||
isExternal?: boolean;
|
||||
isCta?: boolean;
|
||||
/** Adds the `download` attribute to the exported anchor (F3: links to files). */
|
||||
download?: boolean;
|
||||
}
|
||||
|
||||
interface MenuProps {
|
||||
@@ -23,6 +25,11 @@ interface MenuProps {
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
fontSize?: string;
|
||||
style?: CSSProperties;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
}
|
||||
|
||||
/* ---------- Defaults ---------- */
|
||||
@@ -34,11 +41,6 @@ const defaultLinks: MenuLink[] = [
|
||||
{ text: 'Contact', href: '#contact', isCta: true },
|
||||
];
|
||||
|
||||
/* ---------- Helper: escape HTML ---------- */
|
||||
function esc(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/* ---------- Component ---------- */
|
||||
|
||||
export const Menu: UserComponent<MenuProps> = ({
|
||||
@@ -80,6 +82,7 @@ export const Menu: UserComponent<MenuProps> = ({
|
||||
href={link.href}
|
||||
target={link.isExternal ? '_blank' : undefined}
|
||||
rel={link.isExternal ? 'noopener noreferrer' : undefined}
|
||||
download={link.download || undefined}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
onMouseEnter={() => setHoveredLink(i)}
|
||||
onMouseLeave={() => setHoveredLink(null)}
|
||||
@@ -104,325 +107,6 @@ export const Menu: UserComponent<MenuProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const MenuSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as MenuProps,
|
||||
}));
|
||||
|
||||
const { pages } = usePages();
|
||||
const links = props.links || defaultLinks;
|
||||
|
||||
/* Drag state for reordering */
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||
|
||||
/* ---- Link management ---- */
|
||||
|
||||
const updateLink = (index: number, field: keyof MenuLink, value: string | boolean) => {
|
||||
setProp((p: MenuProps) => {
|
||||
const updated = [...(p.links || defaultLinks)];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
p.links = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addLink = (link?: Partial<MenuLink>) => {
|
||||
setProp((p: MenuProps) => {
|
||||
p.links = [...(p.links || defaultLinks), { text: 'Link', href: '#', ...link }];
|
||||
});
|
||||
};
|
||||
|
||||
const removeLink = (index: number) => {
|
||||
setProp((p: MenuProps) => {
|
||||
const updated = [...(p.links || defaultLinks)];
|
||||
updated.splice(index, 1);
|
||||
p.links = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const moveLink = (fromIdx: number, toIdx: number) => {
|
||||
if (fromIdx === toIdx) return;
|
||||
setProp((p: MenuProps) => {
|
||||
const updated = [...(p.links || defaultLinks)];
|
||||
const [moved] = updated.splice(fromIdx, 1);
|
||||
updated.splice(toIdx, 0, moved);
|
||||
p.links = updated;
|
||||
});
|
||||
};
|
||||
|
||||
/* ---- Shared styles ---- */
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
const sectionStyle: CSSProperties = {
|
||||
borderBottom: '1px solid #27272a', paddingBottom: 12,
|
||||
};
|
||||
const btnSmall: CSSProperties = {
|
||||
padding: '2px 6px', fontSize: 11, background: '#27272a', color: '#a1a1aa',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
|
||||
};
|
||||
const btnActive: CSSProperties = {
|
||||
...btnSmall, background: '#3b82f6', color: '#fff', borderColor: '#3b82f6',
|
||||
};
|
||||
|
||||
const textColorPresets = ['#1f2937', '#374151', '#3f3f46', '#6b7280', '#ffffff', '#e4e4e7', '#a1a1aa', '#3b82f6'];
|
||||
const gapPresets = ['8px', '16px', '24px', '32px', '40px'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
|
||||
{/* ===== Style Section ===== */}
|
||||
<div style={sectionStyle}>
|
||||
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Menu Style</label>
|
||||
|
||||
{/* Link color */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={labelStyle}>Link Color</label>
|
||||
<div style={{ display: 'flex', gap: 3, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{textColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: MenuProps) => { p.linkColor = c; })}
|
||||
style={{
|
||||
width: 22, height: 22, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.linkColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<input
|
||||
type="color"
|
||||
value={props.linkColor || '#3f3f46'}
|
||||
onChange={(e) => setProp((p: MenuProps) => { p.linkColor = e.target.value; })}
|
||||
style={{ width: 22, height: 22, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
|
||||
title="Custom color"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hover color */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={labelStyle}>Hover Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input
|
||||
type="color"
|
||||
value={props.linkHoverColor || '#3b82f6'}
|
||||
onChange={(e) => setProp((p: MenuProps) => { p.linkHoverColor = e.target.value; })}
|
||||
style={{ width: 28, height: 24, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
|
||||
/>
|
||||
<span style={{ fontSize: 10, color: '#71717a' }}>{props.linkHoverColor || '#3b82f6'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA button colors */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={labelStyle}>CTA Button</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div>
|
||||
<span style={{ fontSize: 9, color: '#71717a' }}>BG</span>
|
||||
<input
|
||||
type="color"
|
||||
value={props.ctaBgColor || '#3b82f6'}
|
||||
onChange={(e) => setProp((p: MenuProps) => { p.ctaBgColor = e.target.value; })}
|
||||
style={{ display: 'block', width: 28, height: 20, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontSize: 9, color: '#71717a' }}>Text</span>
|
||||
<input
|
||||
type="color"
|
||||
value={props.ctaTextColor || '#ffffff'}
|
||||
onChange={(e) => setProp((p: MenuProps) => { p.ctaTextColor = e.target.value; })}
|
||||
style={{ display: 'block', width: 28, height: 20, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Font size */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={labelStyle}>Font Size</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.fontSize || '14px'}
|
||||
onChange={(e) => setProp((p: MenuProps) => { p.fontSize = e.target.value; })}
|
||||
placeholder="14px"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Alignment */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={labelStyle}>Alignment</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['left', 'center', 'right'] as const).map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
onClick={() => setProp((p: MenuProps) => { p.alignment = a; })}
|
||||
style={(props.alignment || 'right') === a ? btnActive : btnSmall}
|
||||
>
|
||||
{a.charAt(0).toUpperCase() + a.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Orientation */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={labelStyle}>Orientation</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['horizontal', 'vertical'] as const).map((o) => (
|
||||
<button
|
||||
key={o}
|
||||
onClick={() => setProp((p: MenuProps) => { p.orientation = o; })}
|
||||
style={(props.orientation || 'horizontal') === o ? btnActive : btnSmall}
|
||||
>
|
||||
{o.charAt(0).toUpperCase() + o.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gap */}
|
||||
<div>
|
||||
<label style={labelStyle}>Gap</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{gapPresets.map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
onClick={() => setProp((p: MenuProps) => { p.gap = g; })}
|
||||
style={(props.gap || '24px') === g ? btnActive : btnSmall}
|
||||
>
|
||||
{g}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== Links Section ===== */}
|
||||
<div>
|
||||
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Links</label>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{links.map((link, i) => (
|
||||
<div
|
||||
key={i}
|
||||
draggable
|
||||
onDragStart={() => setDragIdx(i)}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOverIdx(i); }}
|
||||
onDragEnd={() => {
|
||||
if (dragIdx !== null && dragOverIdx !== null) {
|
||||
moveLink(dragIdx, dragOverIdx);
|
||||
}
|
||||
setDragIdx(null);
|
||||
setDragOverIdx(null);
|
||||
}}
|
||||
style={{
|
||||
background: dragOverIdx === i && dragIdx !== null && dragIdx !== i ? '#1e293b' : '#1e1e22',
|
||||
borderRadius: 6,
|
||||
padding: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
border: dragOverIdx === i && dragIdx !== null && dragIdx !== i ? '1px solid #3b82f6' : '1px solid transparent',
|
||||
transition: 'background 0.1s, border-color 0.1s',
|
||||
}}
|
||||
>
|
||||
{/* Row 1: drag handle + text + delete */}
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<span
|
||||
style={{ cursor: 'grab', color: '#52525b', fontSize: 12, padding: '0 2px', userSelect: 'none', flexShrink: 0 }}
|
||||
title="Drag to reorder"
|
||||
>
|
||||
<i className="fa fa-bars" />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={link.text}
|
||||
onChange={(e) => updateLink(i, 'text', e.target.value)}
|
||||
placeholder="Text"
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeLink(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flexShrink: 0 }}
|
||||
title="Delete link"
|
||||
>
|
||||
<i className="fa fa-trash" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Row 2: URL */}
|
||||
<input
|
||||
type="text"
|
||||
value={link.href}
|
||||
onChange={(e) => updateLink(i, 'href', e.target.value)}
|
||||
placeholder="URL (e.g. /about or https://...)"
|
||||
style={inputStyle}
|
||||
/>
|
||||
|
||||
{/* Row 3: checkboxes */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 3, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={!!link.isExternal} onChange={(e) => updateLink(i, 'isExternal', e.target.checked)} />
|
||||
External
|
||||
</label>
|
||||
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 3, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={!!link.isCta} onChange={(e) => updateLink(i, 'isCta', e.target.checked)} />
|
||||
CTA
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add link button */}
|
||||
<button
|
||||
onClick={() => addLink()}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Link
|
||||
</button>
|
||||
|
||||
{/* Add page dropdown */}
|
||||
<select
|
||||
onChange={(e) => {
|
||||
const page = pages.find(p => p.id === e.target.value);
|
||||
if (page) {
|
||||
addLink({
|
||||
text: page.name,
|
||||
href: page.slug === 'index' ? '/' : page.slug,
|
||||
isExternal: false,
|
||||
isCta: false,
|
||||
});
|
||||
}
|
||||
e.target.value = '';
|
||||
}}
|
||||
value=""
|
||||
style={{
|
||||
marginTop: 4, width: '100%', padding: '6px', fontSize: 11,
|
||||
background: '#1e293b', color: '#93c5fd',
|
||||
border: '1px solid #334155', borderRadius: 4, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<option value="">+ Add Page...</option>
|
||||
{pages.map(p => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.slug === 'index' ? '/' : p.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Menu.craft = {
|
||||
@@ -438,28 +122,34 @@ Menu.craft = {
|
||||
orientation: 'horizontal',
|
||||
fontSize: '14px',
|
||||
style: {},
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
animation: 'none',
|
||||
animationDelay: '0',
|
||||
} as MenuProps,
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: MenuSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Menu as any).toHtml = (props: MenuProps, _childrenHtml: string) => {
|
||||
const linkCol = props.linkColor || '#3f3f46';
|
||||
const hoverCol = props.linkHoverColor || '#3b82f6';
|
||||
const ctaBg = props.ctaBgColor || '#3b82f6';
|
||||
const ctaText = props.ctaTextColor || '#ffffff';
|
||||
const gap = props.gap || '24px';
|
||||
(Menu as any).toHtml = (props: MenuProps, _childrenHtml: string, nodeId?: string) => {
|
||||
// Sanitized once here -- linkCol/hoverCol/ctaBg/ctaText/gap/fSize are raw
|
||||
// string-interpolation sinks below (hoverCol goes into a <style> block,
|
||||
// the worst case: </style> breakout -> arbitrary <script>), see
|
||||
// task-cssxss-brief.md.
|
||||
const linkCol = cssValue(props.linkColor) || '#3f3f46';
|
||||
const hoverCol = cssValue(props.linkHoverColor) || '#3b82f6';
|
||||
const ctaBg = cssValue(props.ctaBgColor) || '#3b82f6';
|
||||
const ctaText = cssValue(props.ctaTextColor) || '#ffffff';
|
||||
const gap = cssValue(props.gap) || '24px';
|
||||
const orientation = props.orientation || 'horizontal';
|
||||
const alignment = props.alignment || 'right';
|
||||
const fSize = props.fontSize || '14px';
|
||||
const fSize = cssValue(props.fontSize) || '14px';
|
||||
|
||||
const justifyMap: Record<string, string> = { left: 'flex-start', center: 'center', right: 'flex-end' };
|
||||
|
||||
@@ -477,12 +167,16 @@ Menu.craft = {
|
||||
|
||||
const links = props.links || defaultLinks;
|
||||
|
||||
// Unique ID suffix for scoped CSS
|
||||
const scopeId = `menu-${Math.random().toString(36).slice(2, 8)}`;
|
||||
// Scope for the hover CSS classes below. Deterministic AND unique: scoped
|
||||
// on the Craft node id so two Menu instances with identical/default links
|
||||
// don't collide on the same `.menu-link`/`.menu-cta` class names (which
|
||||
// would let one instance's hover styling bleed into the other's).
|
||||
const scope = scopeId(nodeId, JSON.stringify(links) + orientation + alignment, 'menu');
|
||||
|
||||
const linksHtml = links.map((link) => {
|
||||
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||
const cls = link.isCta ? `${scopeId}-cta` : `${scopeId}-link`;
|
||||
const downloadAttr = link.download ? ' download' : '';
|
||||
const cls = link.isCta ? `${scope}-cta` : `${scope}-link`;
|
||||
const linkStyle = cssPropsToString({
|
||||
textDecoration: 'none',
|
||||
fontSize: fSize,
|
||||
@@ -493,12 +187,12 @@ Menu.craft = {
|
||||
borderRadius: link.isCta ? '6px' : '0',
|
||||
transition: 'color 0.15s, background-color 0.15s',
|
||||
});
|
||||
return `<a href="${esc(link.href)}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${esc(link.text)}</a>`;
|
||||
return `<a href="${escapeAttr(safeUrl(link.href || '#'))}" class="${cls}"${target}${downloadAttr}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
|
||||
}).join('\n ');
|
||||
|
||||
const hoverCss = `<style>
|
||||
.${scopeId}-link:hover { color: ${hoverCol} !important; }
|
||||
.${scopeId}-cta:hover { filter: brightness(1.1); }
|
||||
.${scope}-link:hover { color: ${hoverCol} !important; }
|
||||
.${scope}-cta:hover { filter: brightness(1.1); }
|
||||
</style>`;
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Navbar } from './Navbar';
|
||||
|
||||
const toHtml = (Navbar as any).toHtml;
|
||||
|
||||
describe('Navbar.toHtml hamburger accessibility (F2.3)', () => {
|
||||
test('mobile toggle button has an accessible name, aria-expanded, and aria-controls', () => {
|
||||
const { html } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
|
||||
expect(html).toMatch(/class="navbar-hamburger"[^>]*aria-label="Toggle navigation menu"/);
|
||||
expect(html).toMatch(/aria-expanded="false"/);
|
||||
expect(html).toMatch(/aria-controls="[^"]+"/);
|
||||
});
|
||||
|
||||
test('aria-controls target id exists on the links container', () => {
|
||||
const { html } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
|
||||
const controls = html.match(/aria-controls="([^"]+)"/)![1];
|
||||
expect(html).toContain(`id="${controls}"`);
|
||||
});
|
||||
|
||||
test('toggle script flips aria-expanded on click', () => {
|
||||
const { html } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
|
||||
expect(html).toMatch(/setAttribute\(['"]aria-expanded['"]/);
|
||||
});
|
||||
|
||||
test('no mobile menu: no hamburger button emitted', () => {
|
||||
const { html } = toHtml({ showMobileMenu: false }, '', 'node-nav1');
|
||||
expect(html).not.toContain('navbar-hamburger');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navbar.toHtml node-scoped ids/hover styles (M-1: two navbars must not collide)', () => {
|
||||
test('no bare unscoped id="navbar-links" is emitted', () => {
|
||||
const { html } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
|
||||
expect(html).not.toContain('id="navbar-links"');
|
||||
});
|
||||
|
||||
test('two different node ids produce different links-container ids', () => {
|
||||
const { html: html1 } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
|
||||
const { html: html2 } = toHtml({ showMobileMenu: true }, '', 'node-nav2');
|
||||
const id1 = html1.match(/id="([^"]+)"/)![1];
|
||||
const id2 = html2.match(/id="([^"]+)"/)![1];
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
|
||||
test('aria-controls always equals the actual links-container id', () => {
|
||||
const { html } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
|
||||
const controls = html.match(/aria-controls="([^"]+)"/)![1];
|
||||
const linksId = html.match(/id="([^"]+)"/)![1];
|
||||
expect(controls).toBe(linksId);
|
||||
});
|
||||
|
||||
test('hover style selectors are scoped per-instance, not bare .navbar-link/.navbar-cta', () => {
|
||||
const { html } = toHtml({ hoverColor: '#ff0000' }, '', 'node-nav1');
|
||||
// A selector rule that STARTS the line with .navbar-link:hover (i.e. not
|
||||
// preceded by a per-instance ancestor class) would be the old, unscoped,
|
||||
// globally-colliding form.
|
||||
expect(html).not.toMatch(/^\s*\.navbar-link:hover/m);
|
||||
expect(html).not.toMatch(/^\s*\.navbar-cta:hover/m);
|
||||
// still present, just scoped under a per-instance ancestor class
|
||||
expect(html).toMatch(/\.navbar-link:hover/);
|
||||
expect(html).toMatch(/\.[\w-]+ \.navbar-link:hover/);
|
||||
});
|
||||
|
||||
test('two navbars with different hoverColor do not leak style onto each other (scoped selectors differ)', () => {
|
||||
const { html: html1 } = toHtml({ hoverColor: '#ff0000' }, '', 'node-nav1');
|
||||
const { html: html2 } = toHtml({ hoverColor: '#00ff00' }, '', 'node-nav2');
|
||||
const scope1 = html1.match(/<style>\s*\.([\w-]+)\s/)![1];
|
||||
const scope2 = html2.match(/<style>\s*\.([\w-]+)\s/)![1];
|
||||
expect(scope1).not.toBe(scope2);
|
||||
expect(html1).toContain(`.${scope1} .navbar-link:hover`);
|
||||
expect(html2).toContain(`.${scope2} .navbar-link:hover`);
|
||||
});
|
||||
|
||||
test('a normal single navbar still renders its hover style (visual output preserved)', () => {
|
||||
const { html } = toHtml({ hoverColor: '#ff0000' }, '', 'node-nav1');
|
||||
expect(html).toMatch(/:hover\s*\{\s*color:\s*#ff0000/);
|
||||
});
|
||||
|
||||
test('same node id -> identical output across calls (deterministic)', () => {
|
||||
const { html: html1 } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
|
||||
const { html: html2 } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navbar.toHtml download attribute (F3: link-to-file toggle)', () => {
|
||||
test('a link with download:true emits the download attribute', () => {
|
||||
const { html } = toHtml({ links: [{ text: 'Brochure', href: '/brochure.pdf', download: true }] }, '', 'node-dl1');
|
||||
expect(html).toMatch(/<a href="\/brochure\.pdf"[^>]* download[^>]*>Brochure<\/a>/);
|
||||
});
|
||||
|
||||
test('a link without download does not emit the attribute', () => {
|
||||
const { html } = toHtml({ links: [{ text: 'Home', href: '/' }] }, '', 'node-dl2');
|
||||
expect(html).not.toContain(' download');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navbar (F4: box-model + animation + visibility props on craft.props)', () => {
|
||||
test('craft.props includes animation/visibility defaults so the panel controls always render', () => {
|
||||
const craftProps = (Navbar as any).craft.props;
|
||||
expect(craftProps).toHaveProperty('animation', 'none');
|
||||
expect(craftProps).toHaveProperty('animationDelay', '0');
|
||||
expect(craftProps).toHaveProperty('hideOnDesktop', false);
|
||||
expect(craftProps).toHaveProperty('hideOnTablet', false);
|
||||
expect(craftProps).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navbar.toHtml XSS hardening (hoverColor/backgroundColor/ctaColor into <style>)', () => {
|
||||
test('a hoverColor value containing </style><script> is neutralized in the hover <style> block', () => {
|
||||
const malicious = '#fff}</style><script>alert(1)</script><style>{';
|
||||
const { html } = toHtml({ hoverColor: malicious }, '');
|
||||
expect(html).not.toContain('</style><script');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('a backgroundColor value containing </style><script> is neutralized (mobile media-query rule)', () => {
|
||||
const malicious = '#fff}</style><script>alert(2)</script><style>{';
|
||||
const { html } = toHtml({ backgroundColor: malicious, showMobileMenu: true }, '');
|
||||
expect(html).not.toContain('</style><script');
|
||||
expect(html).not.toContain('<script>alert(2)</script>');
|
||||
});
|
||||
|
||||
test('a ctaColor value containing </style><script> is neutralized', () => {
|
||||
const malicious = '#fff}</style><script>alert(3)</script><style>{';
|
||||
const { html } = toHtml({ ctaColor: malicious }, '');
|
||||
expect(html).not.toContain('</style><script');
|
||||
expect(html).not.toContain('<script>alert(3)</script>');
|
||||
});
|
||||
|
||||
test('a textColor value containing a quote breakout does not escape the hamburger span style attribute', () => {
|
||||
const malicious = '#333" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ textColor: malicious, showMobileMenu: true }, '');
|
||||
expect(html).not.toMatch(/style="[^"]*"[^>]*onmouseover/);
|
||||
});
|
||||
|
||||
test('normal colors still render correctly', () => {
|
||||
const { html } = toHtml({ hoverColor: '#ff0000', backgroundColor: '#123456', ctaColor: '#00ff00' }, '');
|
||||
expect(html).toMatch(/:hover\s*\{\s*color:\s*#ff0000/);
|
||||
expect(html).toContain('background-color:#123456');
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { CSSProperties, useCallback, useRef, useState } from 'react';
|
||||
import React, { CSSProperties, useState } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { usePages } from '../../state/PageContext';
|
||||
import { useSiteDesign } from '../../state/SiteDesignContext';
|
||||
import { escapeHtml, escapeAttr, safeUrl, safeImageUrl, cssValue, scopeId } from '../../utils/escape';
|
||||
|
||||
/* ---------- Types ---------- */
|
||||
|
||||
@@ -11,6 +11,8 @@ interface NavLink {
|
||||
href: string;
|
||||
isExternal?: boolean;
|
||||
isCta?: boolean;
|
||||
/** Adds the `download` attribute to the exported anchor (F3: links to files). */
|
||||
download?: boolean;
|
||||
}
|
||||
|
||||
interface NavbarProps {
|
||||
@@ -33,6 +35,11 @@ interface NavbarProps {
|
||||
isSticky?: boolean;
|
||||
showMobileMenu?: boolean;
|
||||
style?: CSSProperties;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
}
|
||||
|
||||
/* ---------- Defaults ---------- */
|
||||
@@ -51,30 +58,6 @@ const PADDING_PRESETS = [
|
||||
{ label: 'Spacious', value: '24px 48px' },
|
||||
];
|
||||
|
||||
/* ---------- Image upload helper (same as ImageBlock) ---------- */
|
||||
|
||||
async function uploadToWhp(file: File): Promise<string | null> {
|
||||
const cfg = (window as any).WHP_CONFIG;
|
||||
if (!cfg) return URL.createObjectURL(file);
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
const resp = await fetch(`${cfg.apiUrl}?action=upload_asset&site_id=${cfg.siteId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': cfg.csrfToken },
|
||||
body: formData,
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success && data.url) return data.url;
|
||||
return null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/* ---------- Helper: escape HTML ---------- */
|
||||
function esc(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/* ---------- Component ---------- */
|
||||
|
||||
export const Navbar: UserComponent<NavbarProps> = ({
|
||||
@@ -173,6 +156,7 @@ export const Navbar: UserComponent<NavbarProps> = ({
|
||||
href={link.href}
|
||||
target={link.isExternal ? '_blank' : undefined}
|
||||
rel={link.isExternal ? 'noopener noreferrer' : undefined}
|
||||
download={link.download || undefined}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
onMouseEnter={() => setHoveredLink(i)}
|
||||
onMouseLeave={() => setHoveredLink(null)}
|
||||
@@ -198,595 +182,6 @@ export const Navbar: UserComponent<NavbarProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const NavbarSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as NavbarProps,
|
||||
}));
|
||||
|
||||
const { pages } = usePages();
|
||||
const { design } = useSiteDesign();
|
||||
|
||||
const links = props.links || defaultLinks;
|
||||
const logoType = props.logoType || 'text';
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showBrowser, setShowBrowser] = useState(false);
|
||||
const [browserAssets, setBrowserAssets] = useState<any[]>([]);
|
||||
const [browserLoading, setBrowserLoading] = useState(false);
|
||||
|
||||
/* Drag state for reordering */
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||
|
||||
const bgPresets = ['#ffffff', '#f8fafc', '#f9fafb', '#18181b', '#0f172a', '#1e293b', '#1f2937', '#111827'];
|
||||
const textColorPresets = ['#1f2937', '#374151', '#3f3f46', '#6b7280', '#ffffff', '#e4e4e7', '#a1a1aa', '#3b82f6'];
|
||||
const fontFamilies = [
|
||||
{ label: 'Inter', value: 'Inter, sans-serif' },
|
||||
{ label: 'Roboto', value: 'Roboto, sans-serif' },
|
||||
{ label: 'Poppins', value: 'Poppins, sans-serif' },
|
||||
{ label: 'Montserrat', value: 'Montserrat, sans-serif' },
|
||||
{ label: 'Playfair', value: 'Playfair Display, serif' },
|
||||
{ label: 'Merriweather', value: 'Merriweather, serif' },
|
||||
];
|
||||
|
||||
/* ---- Link management ---- */
|
||||
|
||||
const updateLink = (index: number, field: keyof NavLink, value: string | boolean) => {
|
||||
setProp((p: NavbarProps) => {
|
||||
const updated = [...(p.links || defaultLinks)];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
p.links = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addLink = (link?: Partial<NavLink>) => {
|
||||
setProp((p: NavbarProps) => {
|
||||
p.links = [...(p.links || defaultLinks), { text: 'Link', href: '#', ...link }];
|
||||
});
|
||||
};
|
||||
|
||||
const removeLink = (index: number) => {
|
||||
setProp((p: NavbarProps) => {
|
||||
const updated = [...(p.links || defaultLinks)];
|
||||
updated.splice(index, 1);
|
||||
p.links = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const moveLink = (fromIdx: number, toIdx: number) => {
|
||||
if (fromIdx === toIdx) return;
|
||||
setProp((p: NavbarProps) => {
|
||||
const updated = [...(p.links || defaultLinks)];
|
||||
const [moved] = updated.splice(fromIdx, 1);
|
||||
updated.splice(toIdx, 0, moved);
|
||||
p.links = updated;
|
||||
});
|
||||
};
|
||||
|
||||
/* ---- Image upload for logo ---- */
|
||||
|
||||
const handleLogoUpload = useCallback(async (file: File) => {
|
||||
const url = await uploadToWhp(file);
|
||||
if (url) setProp((p: NavbarProps) => { p.logoImage = url; });
|
||||
}, [setProp]);
|
||||
|
||||
const handleBrowse = useCallback(async () => {
|
||||
if (showBrowser) { setShowBrowser(false); return; }
|
||||
const cfg = (window as any).WHP_CONFIG;
|
||||
if (!cfg) return;
|
||||
setBrowserLoading(true);
|
||||
try {
|
||||
const resp = await fetch(`${cfg.apiUrl}?action=list_assets&site_id=${cfg.siteId}`);
|
||||
const data = await resp.json();
|
||||
if (data.success && Array.isArray(data.assets)) {
|
||||
const images = data.assets.filter((a: any) => (a.type || '').startsWith('image'));
|
||||
setBrowserAssets(images);
|
||||
setShowBrowser(true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Browse failed:', e);
|
||||
} finally {
|
||||
setBrowserLoading(false);
|
||||
}
|
||||
}, [showBrowser]);
|
||||
|
||||
/* ---- Shared styles ---- */
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
const sectionStyle: CSSProperties = {
|
||||
borderBottom: '1px solid #27272a', paddingBottom: 12,
|
||||
};
|
||||
const swatchStyle = (color: string, active: boolean): CSSProperties => ({
|
||||
width: 22, height: 22, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: color, cursor: 'pointer',
|
||||
outline: active ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
});
|
||||
const btnSmall: CSSProperties = {
|
||||
padding: '2px 6px', fontSize: 11, background: '#27272a', color: '#a1a1aa',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
|
||||
};
|
||||
const btnActive: CSSProperties = {
|
||||
...btnSmall, background: '#3b82f6', color: '#fff', borderColor: '#3b82f6',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
|
||||
{/* ===== Logo Section ===== */}
|
||||
<div style={sectionStyle}>
|
||||
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Logo</label>
|
||||
|
||||
{/* Logo type toggle */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
|
||||
<button
|
||||
onClick={() => setProp((p: NavbarProps) => { p.logoType = 'text'; })}
|
||||
style={logoType === 'text' ? btnActive : btnSmall}
|
||||
>
|
||||
<i className="fa fa-font" style={{ marginRight: 3 }} />Text
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setProp((p: NavbarProps) => { p.logoType = 'image'; })}
|
||||
style={logoType === 'image' ? btnActive : btnSmall}
|
||||
>
|
||||
<i className="fa fa-image" style={{ marginRight: 3 }} />Image
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{logoType === 'text' ? (
|
||||
<>
|
||||
{/* Text logo controls */}
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<label style={labelStyle}>Logo Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.logoText || ''}
|
||||
onChange={(e) => setProp((p: NavbarProps) => { p.logoText = e.target.value; })}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<label style={labelStyle}>Font Family</label>
|
||||
<select
|
||||
value={props.logoFontFamily || 'Inter, sans-serif'}
|
||||
onChange={(e) => setProp((p: NavbarProps) => { p.logoFontFamily = e.target.value; })}
|
||||
style={{ ...inputStyle, cursor: 'pointer' }}
|
||||
>
|
||||
{fontFamilies.map((f) => (
|
||||
<option key={f.value} value={f.value}>{f.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 6 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>Size</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.logoFontSize || '20px'}
|
||||
onChange={(e) => setProp((p: NavbarProps) => { p.logoFontSize = e.target.value; })}
|
||||
placeholder="20px"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>Color</label>
|
||||
<div style={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<input
|
||||
type="color"
|
||||
value={props.logoColor || design.textColor}
|
||||
onChange={(e) => setProp((p: NavbarProps) => { p.logoColor = e.target.value; })}
|
||||
style={{ width: 28, height: 24, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setProp((p: NavbarProps) => { p.logoColor = undefined; })}
|
||||
style={{ ...btnSmall, fontSize: 9, padding: '2px 4px' }}
|
||||
title="Reset to auto"
|
||||
>Auto</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Image logo controls */}
|
||||
{props.logoImage ? (
|
||||
<div style={{ marginBottom: 8, borderRadius: 6, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
|
||||
<img src={props.logoImage} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 80, objectFit: 'contain', background: '#18181b' }} />
|
||||
<button
|
||||
onClick={() => setProp((p: NavbarProps) => { p.logoImage = ''; })}
|
||||
style={{ position: 'absolute', top: 4, right: 4, width: 20, height: 20, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
title="Remove image"
|
||||
>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{ padding: '14px 12px', border: '2px dashed #3f3f46', borderRadius: 6, textAlign: 'center', color: '#71717a', fontSize: 11, cursor: 'pointer', marginBottom: 8 }}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = '#3b82f6'; }}
|
||||
onDragLeave={(e) => { e.currentTarget.style.borderColor = '#3f3f46'; }}
|
||||
onDrop={async (e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.borderColor = '#3f3f46';
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file && file.type.startsWith('image/')) await handleLogoUpload(file);
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-cloud-upload" style={{ fontSize: 18, display: 'block', marginBottom: 4, color: '#3b82f6' }} />
|
||||
Drop logo or click to upload
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{ flex: 1, padding: '6px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#3b82f6', color: '#fff', fontWeight: 500 }}
|
||||
>
|
||||
<i className="fa fa-upload" style={{ marginRight: 3 }} /> Upload
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBrowse}
|
||||
style={{ flex: 1, padding: '6px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: showBrowser ? '#3b82f6' : '#27272a', color: showBrowser ? '#fff' : '#e4e4e7' }}
|
||||
>
|
||||
<i className={`fa ${browserLoading ? 'fa-spinner fa-spin' : 'fa-folder-open'}`} style={{ marginRight: 3 }} /> Browse
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Browse grid */}
|
||||
{showBrowser && (
|
||||
<div style={{ maxHeight: 150, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, marginBottom: 6, background: '#18181b', borderRadius: 6, padding: 4 }}>
|
||||
{browserAssets.map(asset => (
|
||||
<div
|
||||
key={asset.name}
|
||||
onClick={() => { setProp((p: NavbarProps) => { p.logoImage = asset.url; }); setShowBrowser(false); }}
|
||||
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#3b82f6'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
|
||||
>
|
||||
<img src={asset.url} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
</div>
|
||||
))}
|
||||
{browserAssets.length === 0 && (
|
||||
<p style={{ gridColumn: '1 / -1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: '8px 0', margin: 0 }}>No images uploaded yet.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
|
||||
onChange={(e) => { const file = e.target.files?.[0]; if (file) handleLogoUpload(file); e.target.value = ''; }} />
|
||||
|
||||
{/* URL input */}
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={props.logoImage || ''}
|
||||
onChange={(e) => setProp((p: NavbarProps) => { p.logoImage = e.target.value; })}
|
||||
placeholder="Or paste image URL..."
|
||||
style={{ ...inputStyle, fontSize: 10, color: '#71717a' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Logo Width</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.logoWidth || '120px'}
|
||||
onChange={(e) => setProp((p: NavbarProps) => { p.logoWidth = e.target.value; })}
|
||||
placeholder="120px"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Logo link URL (shared) */}
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<label style={labelStyle}>Logo Link URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.logoUrl || '/'}
|
||||
onChange={(e) => setProp((p: NavbarProps) => { p.logoUrl = e.target.value; })}
|
||||
placeholder="/"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== Nav Style Section ===== */}
|
||||
<div style={sectionStyle}>
|
||||
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Nav Style</label>
|
||||
|
||||
{/* Background color */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={labelStyle}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 3, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{bgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: NavbarProps) => { p.backgroundColor = c; })}
|
||||
style={swatchStyle(c, props.backgroundColor === c)}
|
||||
/>
|
||||
))}
|
||||
<input
|
||||
type="color"
|
||||
value={props.backgroundColor || '#ffffff'}
|
||||
onChange={(e) => setProp((p: NavbarProps) => { p.backgroundColor = e.target.value; })}
|
||||
style={{ width: 22, height: 22, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
|
||||
title="Custom color"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text color */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={labelStyle}>Text Color</label>
|
||||
<div style={{ display: 'flex', gap: 3, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{textColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: NavbarProps) => { p.textColor = c; })}
|
||||
style={swatchStyle(c, props.textColor === c)}
|
||||
/>
|
||||
))}
|
||||
<input
|
||||
type="color"
|
||||
value={props.textColor || '#3f3f46'}
|
||||
onChange={(e) => setProp((p: NavbarProps) => { p.textColor = e.target.value; })}
|
||||
style={{ width: 22, height: 22, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
|
||||
title="Custom color"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Link hover color */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={labelStyle}>Hover Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input
|
||||
type="color"
|
||||
value={props.hoverColor || '#3b82f6'}
|
||||
onChange={(e) => setProp((p: NavbarProps) => { p.hoverColor = e.target.value; })}
|
||||
style={{ width: 28, height: 24, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
|
||||
/>
|
||||
<span style={{ fontSize: 10, color: '#71717a' }}>{props.hoverColor || '#3b82f6'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA button colors */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={labelStyle}>CTA Button</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div>
|
||||
<span style={{ fontSize: 9, color: '#71717a' }}>BG</span>
|
||||
<input
|
||||
type="color"
|
||||
value={props.ctaColor || '#3b82f6'}
|
||||
onChange={(e) => setProp((p: NavbarProps) => { p.ctaColor = e.target.value; })}
|
||||
style={{ display: 'block', width: 28, height: 20, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontSize: 9, color: '#71717a' }}>Text</span>
|
||||
<input
|
||||
type="color"
|
||||
value={props.ctaTextColor || '#ffffff'}
|
||||
onChange={(e) => setProp((p: NavbarProps) => { p.ctaTextColor = e.target.value; })}
|
||||
style={{ display: 'block', width: 28, height: 20, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Padding presets */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={labelStyle}>Padding</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{PADDING_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.label}
|
||||
onClick={() => setProp((pr: NavbarProps) => { pr.padding = p.value; })}
|
||||
style={props.padding === p.value ? btnActive : btnSmall}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alignment */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={labelStyle}>Alignment</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['left', 'center', 'right', 'space-between'] as const).map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
onClick={() => setProp((p: NavbarProps) => { p.navAlignment = a; })}
|
||||
style={props.navAlignment === a || (!props.navAlignment && a === 'space-between') ? btnActive : btnSmall}
|
||||
>
|
||||
{a === 'space-between' ? 'Spread' : a.charAt(0).toUpperCase() + a.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sticky toggle */}
|
||||
<div style={{ marginBottom: 8, display: 'flex', gap: 8 }}>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!props.isSticky}
|
||||
onChange={(e) => setProp((p: NavbarProps) => { p.isSticky = e.target.checked; })}
|
||||
/>
|
||||
Sticky
|
||||
</label>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!props.showMobileMenu}
|
||||
onChange={(e) => setProp((p: NavbarProps) => { p.showMobileMenu = e.target.checked; })}
|
||||
/>
|
||||
Mobile Menu
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Design token quick apply */}
|
||||
<div>
|
||||
<label style={labelStyle}>Apply Design Token</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
onClick={() => setProp((p: NavbarProps) => {
|
||||
p.backgroundColor = '#ffffff';
|
||||
p.textColor = design.textColor;
|
||||
p.hoverColor = design.primaryColor;
|
||||
p.ctaColor = design.primaryColor;
|
||||
p.ctaTextColor = '#ffffff';
|
||||
})}
|
||||
style={btnSmall}
|
||||
>
|
||||
<i className="fa fa-sun-o" style={{ marginRight: 3 }} />Light
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setProp((p: NavbarProps) => {
|
||||
p.backgroundColor = '#0f172a';
|
||||
p.textColor = '#e4e4e7';
|
||||
p.hoverColor = design.primaryColor;
|
||||
p.ctaColor = design.primaryColor;
|
||||
p.ctaTextColor = '#ffffff';
|
||||
p.logoColor = '#ffffff';
|
||||
})}
|
||||
style={btnSmall}
|
||||
>
|
||||
<i className="fa fa-moon-o" style={{ marginRight: 3 }} />Dark
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== Links Section ===== */}
|
||||
<div>
|
||||
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Links</label>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{links.map((link, i) => (
|
||||
<div
|
||||
key={i}
|
||||
draggable
|
||||
onDragStart={() => setDragIdx(i)}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOverIdx(i); }}
|
||||
onDragEnd={() => {
|
||||
if (dragIdx !== null && dragOverIdx !== null) {
|
||||
moveLink(dragIdx, dragOverIdx);
|
||||
}
|
||||
setDragIdx(null);
|
||||
setDragOverIdx(null);
|
||||
}}
|
||||
style={{
|
||||
background: dragOverIdx === i && dragIdx !== null && dragIdx !== i ? '#1e293b' : '#1e1e22',
|
||||
borderRadius: 6,
|
||||
padding: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
border: dragOverIdx === i && dragIdx !== null && dragIdx !== i ? '1px solid #3b82f6' : '1px solid transparent',
|
||||
transition: 'background 0.1s, border-color 0.1s',
|
||||
}}
|
||||
>
|
||||
{/* Row 1: drag handle + text + delete */}
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<span
|
||||
style={{ cursor: 'grab', color: '#52525b', fontSize: 12, padding: '0 2px', userSelect: 'none', flexShrink: 0 }}
|
||||
title="Drag to reorder"
|
||||
>
|
||||
<i className="fa fa-bars" />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={link.text}
|
||||
onChange={(e) => updateLink(i, 'text', e.target.value)}
|
||||
placeholder="Text"
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeLink(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flexShrink: 0 }}
|
||||
title="Delete link"
|
||||
>
|
||||
<i className="fa fa-trash" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Row 2: URL */}
|
||||
<input
|
||||
type="text"
|
||||
value={link.href}
|
||||
onChange={(e) => updateLink(i, 'href', e.target.value)}
|
||||
placeholder="URL (e.g. /about or https://...)"
|
||||
style={inputStyle}
|
||||
/>
|
||||
|
||||
{/* Row 3: checkboxes */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 3, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={!!link.isExternal} onChange={(e) => updateLink(i, 'isExternal', e.target.checked)} />
|
||||
External
|
||||
</label>
|
||||
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 3, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={!!link.isCta} onChange={(e) => updateLink(i, 'isCta', e.target.checked)} />
|
||||
CTA
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add link button */}
|
||||
<button
|
||||
onClick={() => addLink()}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Link
|
||||
</button>
|
||||
|
||||
{/* Add page dropdown */}
|
||||
<select
|
||||
onChange={(e) => {
|
||||
const page = pages.find(p => p.id === e.target.value);
|
||||
if (page) {
|
||||
addLink({
|
||||
text: page.name,
|
||||
href: page.slug === 'index' ? '/' : page.slug,
|
||||
isExternal: false,
|
||||
isCta: false,
|
||||
});
|
||||
}
|
||||
e.target.value = '';
|
||||
}}
|
||||
value=""
|
||||
style={{
|
||||
marginTop: 4, width: '100%', padding: '6px', fontSize: 11,
|
||||
background: '#1e293b', color: '#93c5fd',
|
||||
border: '1px solid #334155', borderRadius: 4, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<option value="">+ Add Page...</option>
|
||||
{pages.map(p => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.slug === 'index' ? '/' : p.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Navbar.craft = {
|
||||
@@ -813,30 +208,48 @@ Navbar.craft = {
|
||||
style: {
|
||||
borderBottom: '1px solid #e4e4e7',
|
||||
},
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
animation: 'none',
|
||||
animationDelay: '0',
|
||||
} as NavbarProps,
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: NavbarSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Navbar as any).toHtml = (props: NavbarProps, _childrenHtml: string) => {
|
||||
const bgColor = props.backgroundColor || '#ffffff';
|
||||
const textCol = props.textColor || '#3f3f46';
|
||||
const hoverCol = props.hoverColor || '#3b82f6';
|
||||
const ctaCol = props.ctaColor || '#3b82f6';
|
||||
const ctaTextCol = props.ctaTextColor || '#ffffff';
|
||||
const pad = props.padding || '16px 24px';
|
||||
(Navbar as any).toHtml = (props: NavbarProps, _childrenHtml: string, nodeId?: string) => {
|
||||
// Sanitized once here -- these are raw string-interpolation sinks below
|
||||
// (hoverCol/bgColor go into a <style> block, the worst case: </style>
|
||||
// breakout -> arbitrary <script>), see task-cssxss-brief.md.
|
||||
const bgColor = cssValue(props.backgroundColor) || '#ffffff';
|
||||
const textCol = cssValue(props.textColor) || '#3f3f46';
|
||||
const hoverCol = cssValue(props.hoverColor) || '#3b82f6';
|
||||
const ctaCol = cssValue(props.ctaColor) || '#3b82f6';
|
||||
const ctaTextCol = cssValue(props.ctaTextColor) || '#ffffff';
|
||||
const pad = cssValue(props.padding) || '16px 24px';
|
||||
const alignment = props.navAlignment || 'space-between';
|
||||
const sticky = props.isSticky;
|
||||
const mobile = props.showMobileMenu;
|
||||
const logoUrl = props.logoUrl || '/';
|
||||
const links0 = props.links || defaultLinks;
|
||||
|
||||
// M-1: deterministic AND unique per-instance scope, keyed on the Craft
|
||||
// node id. Two Navbars on the same page previously emitted an identical
|
||||
// fixed id="navbar-links" (invalid duplicate-id HTML, ambiguous
|
||||
// aria-controls target) and unscoped `.navbar-link:hover`/`.navbar-cta:hover`
|
||||
// rules in each instance's own <style> block -- since both blocks target
|
||||
// the SAME global selector, the later one in the DOM silently overrides
|
||||
// the earlier one's hover color/behavior for BOTH navbars. Scoping the
|
||||
// links-container id and adding a per-instance class on the <nav> root
|
||||
// (used to prefix the hover selectors) eliminates both collisions.
|
||||
const scope = scopeId(nodeId, JSON.stringify(links0) + alignment + pad, 'nav');
|
||||
const linksId = `${scope}_links`;
|
||||
|
||||
const navStyle = cssPropsToString({
|
||||
display: 'flex',
|
||||
@@ -852,7 +265,7 @@ Navbar.craft = {
|
||||
let logoHtml: string;
|
||||
if (props.logoType === 'image' && props.logoImage) {
|
||||
const imgStyle = cssPropsToString({ width: props.logoWidth || '120px', height: 'auto', display: 'block' });
|
||||
logoHtml = `<a href="${esc(logoUrl)}" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><img src="${esc(props.logoImage)}" alt="${esc(props.logoText || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} /></a>`;
|
||||
logoHtml = `<a href="${escapeAttr(safeUrl(logoUrl))}" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><img src="${escapeAttr(safeImageUrl(props.logoImage))}" alt="${escapeAttr(props.logoText || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} /></a>`;
|
||||
} else {
|
||||
const logoStyle = cssPropsToString({
|
||||
fontWeight: '700',
|
||||
@@ -860,7 +273,7 @@ Navbar.craft = {
|
||||
fontFamily: props.logoFontFamily || 'Inter, sans-serif',
|
||||
color: props.logoColor || textCol,
|
||||
});
|
||||
logoHtml = `<a href="${esc(logoUrl)}" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><span${logoStyle ? ` style="${logoStyle}"` : ''}>${esc(props.logoText || 'MySite')}</span></a>`;
|
||||
logoHtml = `<a href="${escapeAttr(safeUrl(logoUrl))}" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><span${logoStyle ? ` style="${logoStyle}"` : ''}>${escapeHtml(props.logoText || 'MySite')}</span></a>`;
|
||||
}
|
||||
|
||||
// Links HTML
|
||||
@@ -877,32 +290,38 @@ Navbar.craft = {
|
||||
borderRadius: link.isCta ? '6px' : '0',
|
||||
transition: 'color 0.15s, background-color 0.15s',
|
||||
});
|
||||
return `<a href="${esc(link.href)}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${esc(link.text)}</a>`;
|
||||
return `<a href="${escapeAttr(safeUrl(link.href || "#"))}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
|
||||
}).join('\n ');
|
||||
|
||||
// Hamburger HTML for mobile
|
||||
// Hamburger HTML for mobile. The toggle needs an accessible name (there's
|
||||
// no visible text, just three bars) and must report its open/closed state
|
||||
// via aria-expanded, kept in sync with the .navbar-open class by the
|
||||
// inline onclick handler.
|
||||
const hamburgerHtml = mobile
|
||||
? `\n <button class="navbar-hamburger" onclick="this.parentElement.querySelector('.navbar-links').classList.toggle('navbar-open')" style="display:none;background:none;border:none;cursor:pointer;padding:4px;flex-direction:column;gap:4px">
|
||||
<span style="display:block;width:24px;height:2px;background-color:${esc(textCol)}"></span>
|
||||
<span style="display:block;width:24px;height:2px;background-color:${esc(textCol)}"></span>
|
||||
<span style="display:block;width:24px;height:2px;background-color:${esc(textCol)}"></span>
|
||||
? `\n <button class="navbar-hamburger" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="${escapeAttr(linksId)}" onclick="var m=document.getElementById('${linksId}');var open=m.classList.toggle('navbar-open');this.setAttribute('aria-expanded', open ? 'true' : 'false');" style="display:none;background:none;border:none;cursor:pointer;padding:4px;flex-direction:column;gap:4px">
|
||||
<span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span>
|
||||
<span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span>
|
||||
<span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span>
|
||||
</button>`
|
||||
: '';
|
||||
|
||||
// Hover CSS
|
||||
// Hover CSS -- scoped under `.${scope}` (a class on the <nav> root, added
|
||||
// below) so it can only ever match THIS instance's links/CTA, never bleed
|
||||
// into or get overridden by another Navbar instance's rules.
|
||||
const hoverCss = `<style>
|
||||
.navbar-link:hover { color: ${hoverCol} !important; }
|
||||
.navbar-cta:hover { filter: brightness(1.1); }${mobile ? `
|
||||
.${scope} .navbar-link:hover { color: ${hoverCol} !important; }
|
||||
.${scope} .navbar-cta:hover { filter: brightness(1.1); }${mobile ? `
|
||||
@media (max-width: 768px) {
|
||||
.navbar-hamburger { display: flex !important; }
|
||||
.navbar-links { display: none !important; position: absolute; top: 100%; left: 0; right: 0; flex-direction: column !important; background-color: ${bgColor}; padding: 12px 24px; gap: 12px !important; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
||||
.navbar-links.navbar-open { display: flex !important; }
|
||||
.${scope} .navbar-hamburger { display: flex !important; }
|
||||
.${scope} .navbar-links { display: none !important; position: absolute; top: 100%; left: 0; right: 0; flex-direction: column !important; background-color: ${bgColor}; padding: 12px 24px; gap: 12px !important; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
||||
.${scope} .navbar-links.navbar-open { display: flex !important; }
|
||||
}` : ''}
|
||||
</style>`;
|
||||
|
||||
// Add CSS class to each link for hover
|
||||
const linksHtmlWithClass = links.map((link) => {
|
||||
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||
const downloadAttr = link.download ? ' download' : '';
|
||||
const cls = link.isCta ? 'navbar-cta' : 'navbar-link';
|
||||
const linkStyle = cssPropsToString({
|
||||
textDecoration: 'none',
|
||||
@@ -914,14 +333,14 @@ Navbar.craft = {
|
||||
borderRadius: link.isCta ? '6px' : '0',
|
||||
transition: 'color 0.15s, background-color 0.15s',
|
||||
});
|
||||
return `<a href="${esc(link.href)}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${esc(link.text)}</a>`;
|
||||
return `<a href="${escapeAttr(safeUrl(link.href || "#"))}" class="${cls}"${target}${downloadAttr}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
|
||||
}).join('\n ');
|
||||
|
||||
return {
|
||||
html: `${hoverCss}
|
||||
<nav${navStyle ? ` style="${navStyle}${mobile ? ';position:relative' : ''}"` : ''}>
|
||||
<nav class="${scope}"${navStyle ? ` style="${navStyle}${mobile ? ';position:relative' : ''}"` : ''}>
|
||||
${logoHtml}${hamburgerHtml}
|
||||
<div class="navbar-links" style="display:flex;align-items:center;gap:24px">
|
||||
<div class="navbar-links" id="${linksId}" style="display:flex;align-items:center;gap:24px">
|
||||
${linksHtmlWithClass}
|
||||
</div>
|
||||
</nav>`,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { SearchBar } from './SearchBar';
|
||||
|
||||
const toHtml = (SearchBar as any).toHtml;
|
||||
|
||||
describe('SearchBar.toHtml decorative icons (F2.5)', () => {
|
||||
test('the input-adjacent search icon is aria-hidden', () => {
|
||||
const { html } = toHtml({}, '');
|
||||
const icons = html.match(/<i class="fa fa-search"[^>]*>/g) || [];
|
||||
expect(icons.length).toBeGreaterThan(0);
|
||||
icons.forEach((tag: string) => expect(tag).toContain('aria-hidden="true"'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('SearchBar.toHtml XSS hardening (placeholder/buttonText/showButton)', () => {
|
||||
test('a placeholder value with an attribute-breakout string cannot escape placeholder=""', () => {
|
||||
const malicious = 'Search..." onmouseover="alert(1)';
|
||||
const { html } = toHtml({ placeholder: malicious }, '');
|
||||
expect(html).not.toMatch(/"\s+onmouseover="/);
|
||||
});
|
||||
|
||||
test('a buttonText value with a script tag is escaped as text content, not raw HTML', () => {
|
||||
const malicious = '<script>alert(1)</script>';
|
||||
const { html } = toHtml({ buttonText: malicious, showButton: true }, '');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('a non-boolean showButton (string "false") still yields fixed, safe border-radius values', () => {
|
||||
const { html } = toHtml({ showButton: 'false' as any }, '');
|
||||
expect(html).toMatch(/border-radius:(8px 0 0 8px|8px)/);
|
||||
});
|
||||
});
|
||||
|
||||
// F2: SearchBar was purely decorative -- no action/method/input name, so
|
||||
// submitting did nothing. It now emits a real GET form.
|
||||
describe('SearchBar.toHtml is a functional GET search form (not decorative)', () => {
|
||||
test('defaults to a GET form action="/" with the query input named "q"', () => {
|
||||
const { html } = toHtml({}, '');
|
||||
expect(html).toMatch(/<form role="search" action="\/" method="GET"/);
|
||||
expect(html).toContain('<input type="search" name="q"');
|
||||
});
|
||||
|
||||
test('a configured action (real search-results page) is used verbatim', () => {
|
||||
const { html } = toHtml({ action: '/search' }, '');
|
||||
expect(html).toContain('action="/search"');
|
||||
});
|
||||
|
||||
test('a javascript: action is blocked via safeUrl and falls back to "/"', () => {
|
||||
const { html } = toHtml({ action: 'javascript:alert(1)' }, '');
|
||||
expect(html).toContain('action="/"');
|
||||
expect(html).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
test('an empty/whitespace action falls back to "/"', () => {
|
||||
const { html } = toHtml({ action: ' ' }, '');
|
||||
expect(html).toContain('action="/"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SearchBar.toHtml box-model style passthrough', () => {
|
||||
test('margin/border/box-shadow/opacity flow through via the style prop', () => {
|
||||
const { html } = toHtml({ style: { marginBottom: '14px', border: '1px solid #aaa', boxShadow: '0 1px 4px rgba(0,0,0,.1)', opacity: '0.9' } }, '');
|
||||
expect(html).toContain('margin-bottom:14px');
|
||||
expect(html).toContain('border:1px solid #aaa');
|
||||
expect(html).toContain('opacity:0.9');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SearchBar.craft.props includes animation/visibility defaults', () => {
|
||||
test('has blank/false defaults', () => {
|
||||
expect(SearchBar.craft!.props).toMatchObject({
|
||||
animation: '', animationDelay: '', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,29 @@
|
||||
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 SearchBarProps {
|
||||
placeholder?: string;
|
||||
buttonText?: string;
|
||||
showButton?: boolean;
|
||||
/** Where the search GET request is submitted -- a real search-results page
|
||||
* if the site has one, or '/' (site root) by default. The query is sent
|
||||
* as `?q=...`, the conventional param name search-results pages look for. */
|
||||
action?: string;
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
export const SearchBar: UserComponent<SearchBarProps> = ({
|
||||
placeholder = 'Search...',
|
||||
buttonText = 'Search',
|
||||
showButton = true,
|
||||
action = '/',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
@@ -26,6 +37,8 @@ export const SearchBar: UserComponent<SearchBarProps> = ({
|
||||
<form
|
||||
ref={(ref: HTMLFormElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
role="search"
|
||||
action={action}
|
||||
method="GET"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -50,6 +63,7 @@ export const SearchBar: UserComponent<SearchBarProps> = ({
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
placeholder={placeholder}
|
||||
style={{
|
||||
width: '100%',
|
||||
@@ -92,62 +106,6 @@ export const SearchBar: UserComponent<SearchBarProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const SearchBarSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as SearchBarProps,
|
||||
}));
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{/* Placeholder */}
|
||||
<div>
|
||||
<label style={labelStyle}>Placeholder</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.placeholder || ''}
|
||||
onChange={(e) => setProp((p: SearchBarProps) => { p.placeholder = e.target.value; })}
|
||||
placeholder="Search..."
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Show Button */}
|
||||
<div>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.showButton !== false}
|
||||
onChange={(e) => setProp((p: SearchBarProps) => { p.showButton = e.target.checked; })}
|
||||
/>
|
||||
Show Button
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Button Text */}
|
||||
{props.showButton !== false && (
|
||||
<div>
|
||||
<label style={labelStyle}>Button Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.buttonText || ''}
|
||||
onChange={(e) => setProp((p: SearchBarProps) => { p.buttonText = e.target.value; })}
|
||||
placeholder="Search"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
SearchBar.craft = {
|
||||
@@ -156,26 +114,29 @@ SearchBar.craft = {
|
||||
placeholder: 'Search...',
|
||||
buttonText: 'Search',
|
||||
showButton: true,
|
||||
action: '/',
|
||||
style: {},
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: SearchBarSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(SearchBar as any).toHtml = (props: SearchBarProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const {
|
||||
placeholder = 'Search...',
|
||||
buttonText = 'Search',
|
||||
showButton = true,
|
||||
action = '/',
|
||||
style = {},
|
||||
} = props;
|
||||
|
||||
@@ -189,14 +150,22 @@ SearchBar.craft = {
|
||||
const inputStyleStr = `width:100%;padding:12px 16px 12px 40px;font-size:15px;font-family:Inter,sans-serif;border:1px solid #d1d5db;border-radius:${showButton ? '8px 0 0 8px' : '8px'};background-color:#ffffff;color:#1f2937;outline:none;box-sizing:border-box`;
|
||||
|
||||
const btnHtml = showButton
|
||||
? `<button type="submit" style="padding:12px 20px;font-size:15px;font-weight:600;font-family:Inter,sans-serif;color:#ffffff;background-color:#3b82f6;border:none;border-radius:0 8px 8px 0;cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:6px"><i class="fa fa-search" style="font-size:13px"></i>${esc(buttonText)}</button>`
|
||||
? `<button type="submit" style="padding:12px 20px;font-size:15px;font-weight:600;font-family:Inter,sans-serif;color:#ffffff;background-color:#3b82f6;border:none;border-radius:0 8px 8px 0;cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:6px"><i class="fa fa-search" style="font-size:13px" aria-hidden="true"></i>${escapeHtml(buttonText)}</button>`
|
||||
: '';
|
||||
|
||||
// F2: previously a purely decorative <form> -- no action/method/input
|
||||
// name at all, so submitting did nothing. A real GET to `action` with the
|
||||
// query in the conventional `q` param makes this a functioning search
|
||||
// form on publish (routes to a real search-results page if the site has
|
||||
// one, or reloads '/' with ?q=... by default). `safeUrl` blocks
|
||||
// javascript:/vbscript:/data:text/html breakout via the action attribute.
|
||||
const actionAttr = escapeAttr(safeUrl(action) || '/');
|
||||
|
||||
return {
|
||||
html: `<form role="search"${formStyle ? ` style="${formStyle}"` : ''}>
|
||||
html: `<form role="search" action="${actionAttr}" method="GET"${formStyle ? ` style="${formStyle}"` : ''}>
|
||||
<div style="position:relative;flex:1">
|
||||
<i class="fa fa-search" style="position:absolute;left:14px;top:50%;transform:translateY(-50%);color:#9ca3af;font-size:14px;pointer-events:none"></i>
|
||||
<input type="search" placeholder="${esc(placeholder)}" style="${inputStyleStr}" />
|
||||
<i class="fa fa-search" style="position:absolute;left:14px;top:50%;transform:translateY(-50%);color:#9ca3af;font-size:14px;pointer-events:none" aria-hidden="true"></i>
|
||||
<input type="search" name="q" placeholder="${escapeAttr(placeholder)}" style="${inputStyleStr}" />
|
||||
</div>
|
||||
${btnHtml}
|
||||
</form>`,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { SocialLinks } from './SocialLinks';
|
||||
|
||||
const toHtml = (SocialLinks as any).toHtml;
|
||||
|
||||
describe('SocialLinks.toHtml accessibility (F2.5)', () => {
|
||||
test('icon-only links get an aria-label naming the platform', () => {
|
||||
const { html } = toHtml({ links: [{ platform: 'facebook', url: 'https://fb.example/x' }] }, '');
|
||||
expect(html).toMatch(/<a[^>]*aria-label="Facebook"/);
|
||||
});
|
||||
|
||||
test('the icon glyph itself is aria-hidden', () => {
|
||||
const { html } = toHtml({ links: [{ platform: 'twitter', url: '#' }] }, '');
|
||||
expect(html).toMatch(/<i class="fa fa-twitter"[^>]*aria-hidden="true"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SocialLinks.toHtml XSS hardening (iconSize/iconColor/iconBgColor/gap into style=)', () => {
|
||||
test('an iconSize value with an attribute-breakout string cannot escape style=""', () => {
|
||||
const malicious = '20px" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconSize: malicious as any }, '');
|
||||
expect(html).not.toMatch(/"\s+onmouseover="/);
|
||||
});
|
||||
|
||||
test('an iconColor value with an attribute-breakout string cannot escape style=""', () => {
|
||||
const malicious = '#fff" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconColor: malicious as any }, '');
|
||||
expect(html).not.toMatch(/"\s+onmouseover="/);
|
||||
});
|
||||
|
||||
test('an iconBgColor value with an attribute-breakout string cannot escape style=""', () => {
|
||||
const malicious = '#374151" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconShape: 'circle', iconBgColor: malicious as any }, '');
|
||||
expect(html).not.toMatch(/"\s+onmouseover="/);
|
||||
});
|
||||
|
||||
test('a gap value with an attribute-breakout string cannot escape the wrapper style=""', () => {
|
||||
const malicious = '10px" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], gap: malicious as any }, '');
|
||||
expect(html).not.toMatch(/"\s+onmouseover="/);
|
||||
});
|
||||
|
||||
test('a malicious platform key does not produce a raw class-attribute breakout', () => {
|
||||
const malicious = 'x"><script>alert(1)</script>';
|
||||
const { html } = toHtml({ links: [{ platform: malicious, url: '#' }] }, '');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('a link url with a javascript: scheme is neutralized', () => {
|
||||
const { html } = toHtml({ links: [{ platform: 'facebook', url: 'javascript:alert(1)' }] }, '');
|
||||
expect(html).not.toContain('javascript:alert(1)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SocialLinks.toHtml iconShape/gap emission (previously built but unexposed in SocialStylePanel)', () => {
|
||||
test('iconShape="circle" emits a 50% border-radius on the wrapping <a>', () => {
|
||||
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconShape: 'circle' }, '');
|
||||
expect(html).toMatch(/<a[^>]*style="[^"]*border-radius:50%[^"]*"/);
|
||||
});
|
||||
|
||||
test('iconShape="square" emits border-radius:0', () => {
|
||||
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconShape: 'square' }, '');
|
||||
expect(html).toMatch(/<a[^>]*style="[^"]*border-radius:0[^"]*"/);
|
||||
});
|
||||
|
||||
test('iconShape="rounded" emits a 6px border-radius', () => {
|
||||
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconShape: 'rounded' }, '');
|
||||
expect(html).toMatch(/<a[^>]*style="[^"]*border-radius:6px[^"]*"/);
|
||||
});
|
||||
|
||||
test('iconShape="none" omits the background box entirely (transparent bg, no fixed box size)', () => {
|
||||
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconShape: 'none' }, '');
|
||||
const aTag = html.match(/<a[^>]*>/)![0];
|
||||
expect(aTag).toContain('background-color:transparent');
|
||||
expect(aTag).not.toContain('border-radius');
|
||||
});
|
||||
|
||||
test('gap emits on the wrapper <div> style', () => {
|
||||
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], gap: '24px' }, '');
|
||||
expect(html).toMatch(/<div[^>]*style="[^"]*gap:24px[^"]*"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SocialLinks.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||
const props = (SocialLinks as any).craft.props;
|
||||
expect(props).toHaveProperty('animation', '');
|
||||
expect(props).toHaveProperty('animationDelay', '');
|
||||
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||
expect(props).toHaveProperty('hideOnTablet', false);
|
||||
expect(props).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeAttr, safeUrl, cssValue } from '../../utils/escape';
|
||||
|
||||
interface SocialLink {
|
||||
platform: string;
|
||||
@@ -16,6 +17,11 @@ interface SocialLinksProps {
|
||||
gap?: string;
|
||||
alignment?: 'left' | 'center' | 'right';
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const platformIcons: Record<string, string> = {
|
||||
@@ -29,6 +35,8 @@ const platformIcons: Record<string, string> = {
|
||||
pinterest: 'fa-pinterest',
|
||||
snapchat: 'fa-snapchat',
|
||||
whatsapp: 'fa-whatsapp',
|
||||
spotify: 'fa-spotify',
|
||||
twitch: 'fa-twitch',
|
||||
};
|
||||
|
||||
const platformLabels: Record<string, string> = {
|
||||
@@ -42,10 +50,10 @@ const platformLabels: Record<string, string> = {
|
||||
pinterest: 'Pinterest',
|
||||
snapchat: 'Snapchat',
|
||||
whatsapp: 'WhatsApp',
|
||||
spotify: 'Spotify',
|
||||
twitch: 'Twitch',
|
||||
};
|
||||
|
||||
const allPlatforms = Object.keys(platformIcons);
|
||||
|
||||
const defaultLinks: SocialLink[] = [
|
||||
{ platform: 'facebook', url: '#' },
|
||||
{ platform: 'twitter', url: '#' },
|
||||
@@ -135,235 +143,6 @@ export const SocialLinks: UserComponent<SocialLinksProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const SocialLinksSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as SocialLinksProps,
|
||||
}));
|
||||
|
||||
const links = props.links || defaultLinks;
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
|
||||
const updateLink = (index: number, field: keyof SocialLink, value: string) => {
|
||||
setProp((p: SocialLinksProps) => {
|
||||
const updated = [...(p.links || defaultLinks)];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
p.links = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addLink = (platform: string) => {
|
||||
setProp((p: SocialLinksProps) => {
|
||||
p.links = [...(p.links || defaultLinks), { platform, url: '#' }];
|
||||
});
|
||||
};
|
||||
|
||||
const removeLink = (index: number) => {
|
||||
setProp((p: SocialLinksProps) => {
|
||||
const updated = [...(p.links || defaultLinks)];
|
||||
updated.splice(index, 1);
|
||||
p.links = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const usedPlatforms = new Set(links.map((l) => l.platform));
|
||||
const availablePlatforms = allPlatforms.filter((p) => !usedPlatforms.has(p));
|
||||
|
||||
const sizePresets = ['14px', '18px', '20px', '24px', '28px', '32px'];
|
||||
const gapPresets = ['4px', '8px', '10px', '14px', '20px'];
|
||||
const iconColorPresets = ['#ffffff', '#18181b', '#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#f59e0b', '#a1a1aa'];
|
||||
const bgColorPresets = ['#374151', '#18181b', '#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#0ea5e9', 'transparent'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{/* Links Editor */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Social Links</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{links.map((link, i) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 14, width: 20, textAlign: 'center', flex: 'none' }}>
|
||||
<i className={`fa ${platformIcons[link.platform] || 'fa-link'}`} style={{ color: '#a1a1aa' }} />
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: '#e4e4e7', flex: 1 }}>
|
||||
{platformLabels[link.platform] || link.platform}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeLink(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={link.url}
|
||||
onChange={(e) => updateLink(i, 'url', e.target.value)}
|
||||
placeholder="https://..."
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{availablePlatforms.length > 0 && (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<select
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
addLink(e.target.value);
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
defaultValue=""
|
||||
style={{ ...inputStyle, width: '100%', padding: '6px', cursor: 'pointer' }}
|
||||
>
|
||||
<option value="">+ Add Platform...</option>
|
||||
{availablePlatforms.map((p) => (
|
||||
<option key={p} value={p}>{platformLabels[p]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alignment */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Alignment</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['left', 'center', 'right'] as const).map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
onClick={() => setProp((p: SocialLinksProps) => { p.alignment = a; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.alignment === a ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
>
|
||||
{a}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Icon Shape */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Icon Shape</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{(['none', 'circle', 'square', 'rounded'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setProp((p: SocialLinksProps) => { p.iconShape = s; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.iconShape === s ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Icon Size */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Icon Size</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{sizePresets.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setProp((p: SocialLinksProps) => { p.iconSize = s; })}
|
||||
style={{
|
||||
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.iconSize === s ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Icon Color */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Icon Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{iconColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: SocialLinksProps) => { p.iconColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.iconColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Icon Background Color */}
|
||||
{props.iconShape !== 'none' && (
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Icon Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: SocialLinksProps) => { p.iconBgColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4,
|
||||
border: c === 'transparent' ? '2px dashed #3f3f46' : '1px solid #3f3f46',
|
||||
backgroundColor: c === 'transparent' ? undefined : c,
|
||||
cursor: 'pointer',
|
||||
outline: props.iconBgColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gap */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Gap</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{gapPresets.map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
onClick={() => setProp((p: SocialLinksProps) => { p.gap = g; })}
|
||||
style={{
|
||||
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.gap === g ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{g}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
SocialLinks.craft = {
|
||||
@@ -377,24 +156,27 @@ SocialLinks.craft = {
|
||||
gap: '10px',
|
||||
alignment: 'center',
|
||||
style: {},
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: SocialLinksSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(SocialLinks as any).toHtml = (props: SocialLinksProps, _childrenHtml: string) => {
|
||||
const links = props.links || defaultLinks;
|
||||
const iconSize = props.iconSize || '20px';
|
||||
const iconColor = props.iconColor || '#ffffff';
|
||||
const iconBgColor = props.iconBgColor || '#374151';
|
||||
// Sanitized -- raw string-interpolation sinks in aStyle/getShapeStr below.
|
||||
const iconSize = cssValue(props.iconSize) || '20px';
|
||||
const iconColor = cssValue(props.iconColor) || '#ffffff';
|
||||
const iconBgColor = cssValue(props.iconBgColor) || '#374151';
|
||||
const iconShape = props.iconShape || 'circle';
|
||||
const gap = props.gap || '10px';
|
||||
const alignment = props.alignment || 'center';
|
||||
@@ -433,7 +215,11 @@ SocialLinks.craft = {
|
||||
if (hasBg) {
|
||||
aStyle += `;${getShapeStr()}`;
|
||||
}
|
||||
return `<a href="${link.url || '#'}" target="_blank" rel="noopener noreferrer" title="${title}" style="${aStyle}"><i class="fa ${iconClass}" style="font-size:${iconSize}"></i></a>`;
|
||||
// The link's only content is the icon glyph, so the glyph itself is
|
||||
// aria-hidden and the accessible name lives on the link (aria-label,
|
||||
// mirroring the existing `title` tooltip since title support in
|
||||
// screen readers is inconsistent).
|
||||
return `<a href="${escapeAttr(safeUrl(link.url || '#'))}" target="_blank" rel="noopener noreferrer" title="${escapeAttr(title)}" aria-label="${escapeAttr(title)}" style="${aStyle}"><i class="fa ${escapeAttr(iconClass)}" style="font-size:${iconSize}" aria-hidden="true"></i></a>`;
|
||||
}).join('\n ');
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Spacer } from './Spacer';
|
||||
|
||||
const toHtml = (Spacer as any).toHtml;
|
||||
|
||||
describe('Spacer.toHtml normal rendering', () => {
|
||||
test('renders height into the style attribute', () => {
|
||||
const { html } = toHtml({ height: '80px' }, '');
|
||||
expect(html).toContain('height:80px');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Spacer.toHtml XSS hardening (height into style=)', () => {
|
||||
test('a height value with an attribute-breakout string cannot escape style=""', () => {
|
||||
const malicious = '40px" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ height: malicious as any }, '');
|
||||
expect(html).not.toMatch(/"\s+onmouseover="/);
|
||||
expect(html).not.toMatch(/style="[^"]*"[^>]*onmouseover/);
|
||||
});
|
||||
|
||||
test('a height value with a </style><script> breakout is neutralized', () => {
|
||||
const malicious = '40px</style><script>alert(1)</script>';
|
||||
const { html } = toHtml({ height: malicious as any }, '');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
});
|
||||
@@ -33,51 +33,6 @@ export const Spacer: UserComponent<SpacerProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const SpacerSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as SpacerProps,
|
||||
}));
|
||||
|
||||
const heightPresets = ['20px', '40px', '60px', '80px', '120px'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Height</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{heightPresets.map((h) => (
|
||||
<button
|
||||
key={h}
|
||||
onClick={() => setProp((p: SpacerProps) => { p.height = h; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.height === h ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{h}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Custom Height</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.height || ''}
|
||||
onChange={(e) => setProp((p: SpacerProps) => { p.height = e.target.value; })}
|
||||
placeholder="e.g. 50px, 5rem"
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Spacer.craft = {
|
||||
@@ -91,9 +46,6 @@ Spacer.craft = {
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: SpacerSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { StarRating } from './StarRating';
|
||||
|
||||
const toHtml = (StarRating as any).toHtml;
|
||||
|
||||
describe('StarRating.toHtml accessibility (F2.2)', () => {
|
||||
test('wrapper has role="img" and a "Rating: N out of maxStars" aria-label', () => {
|
||||
const { html } = toHtml({ rating: 4.5, maxStars: 5 }, '');
|
||||
expect(html).toMatch(/<span role="img" aria-label="Rating: 4\.5 out of 5"/);
|
||||
});
|
||||
|
||||
test('individual star glyphs are aria-hidden', () => {
|
||||
const { html } = toHtml({ rating: 3, maxStars: 5 }, '');
|
||||
const glyphs = html.match(/<i class="fa fa-star"[^>]*>/g) || [];
|
||||
expect(glyphs.length).toBeGreaterThan(0);
|
||||
glyphs.forEach((tag: string) => expect(tag).toContain('aria-hidden="true"'));
|
||||
});
|
||||
|
||||
test('respects custom maxStars in the aria-label', () => {
|
||||
const { html } = toHtml({ rating: 2, maxStars: 10 }, '');
|
||||
expect(html).toContain('aria-label="Rating: 2 out of 10"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('StarRating.toHtml XSS hardening (filledColor/emptyColor/size into style=)', () => {
|
||||
test('a filledColor value containing a quote breakout is neutralized', () => {
|
||||
const malicious = '#f00" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ rating: 3, maxStars: 5, filledColor: malicious }, '');
|
||||
expect(html).not.toMatch(/style="[^"]*"[^>]*onmouseover/);
|
||||
});
|
||||
|
||||
test('a size value containing </style><script> is neutralized', () => {
|
||||
const malicious = '24px</style><script>alert(1)</script>';
|
||||
const { html } = toHtml({ rating: 3, maxStars: 5, size: malicious }, '');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('a normal filled color still renders', () => {
|
||||
const { html } = toHtml({ rating: 5, maxStars: 5, filledColor: '#ff9900' }, '');
|
||||
expect(html).toContain('color:#ff9900');
|
||||
});
|
||||
});
|
||||
|
||||
describe('StarRating.toHtml XSS hardening (rating/maxStars into aria-label, F2.2 CONFIRMED sink)', () => {
|
||||
test('a maxStars value with an attribute-breakout string is neutralized in aria-label', () => {
|
||||
const malicious = '5" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ rating: 3, maxStars: malicious as any }, '');
|
||||
expect(html).not.toMatch(/onmouseover/);
|
||||
expect(html).not.toMatch(/aria-label="Rating: 3 out of 5" onmouseover/);
|
||||
});
|
||||
|
||||
test('a rating value with an attribute-breakout string is neutralized in aria-label', () => {
|
||||
const malicious = '4.5" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ rating: malicious as any, maxStars: 5 }, '');
|
||||
expect(html).not.toMatch(/onmouseover/);
|
||||
});
|
||||
|
||||
test('a non-numeric maxStars does not blow up the star loop (no NaN glyph count, no huge output)', () => {
|
||||
const malicious = '5" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ rating: 3, maxStars: malicious as any }, '');
|
||||
const glyphs = html.match(/<i class="fa fa-star"/g) || [];
|
||||
// Falls back to a sane default star count rather than looping 0 or NaN times.
|
||||
expect(glyphs.length).toBeGreaterThan(0);
|
||||
expect(glyphs.length).toBeLessThanOrEqual(50);
|
||||
});
|
||||
|
||||
test('an absurdly large maxStars is clamped to a sane maximum instead of looping unboundedly', () => {
|
||||
const { html } = toHtml({ rating: 3, maxStars: 1e9 as any }, '');
|
||||
const glyphs = html.match(/<i class="fa fa-star"/g) || [];
|
||||
expect(glyphs.length).toBeLessThanOrEqual(50);
|
||||
});
|
||||
|
||||
test('normal numeric rating/maxStars still render the expected aria-label', () => {
|
||||
const { html } = toHtml({ rating: 4.5, maxStars: 5 }, '');
|
||||
expect(html).toMatch(/<span role="img" aria-label="Rating: 4\.5 out of 5"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StarRating.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||
const props = (StarRating as any).craft.props;
|
||||
expect(props).toHaveProperty('animation', '');
|
||||
expect(props).toHaveProperty('animationDelay', '');
|
||||
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||
expect(props).toHaveProperty('hideOnTablet', false);
|
||||
expect(props).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { cssValue, escapeAttr } from '../../utils/escape';
|
||||
|
||||
interface StarRatingProps {
|
||||
rating?: number;
|
||||
@@ -9,6 +10,11 @@ interface StarRatingProps {
|
||||
filledColor?: string;
|
||||
emptyColor?: string;
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
export const StarRating: UserComponent<StarRatingProps> = ({
|
||||
@@ -75,107 +81,6 @@ export const StarRating: UserComponent<StarRatingProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const StarRatingSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as StarRatingProps,
|
||||
}));
|
||||
|
||||
const sizePresets = ['16px', '20px', '24px', '32px', '40px'];
|
||||
const filledColorPresets = ['#f59e0b', '#eab308', '#f97316', '#ef4444', '#ec4899', '#3b82f6', '#10b981', '#18181b'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>
|
||||
Rating: {props.rating ?? 4.5}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={props.maxStars || 5}
|
||||
step={0.5}
|
||||
value={props.rating ?? 4.5}
|
||||
onChange={(e) => setProp((p: StarRatingProps) => { p.rating = parseFloat(e.target.value); })}
|
||||
style={{ width: '100%', accentColor: '#3b82f6' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Max Stars</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{[3, 4, 5, 6, 7, 10].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setProp((p: StarRatingProps) => {
|
||||
p.maxStars = n;
|
||||
if ((p.rating || 0) > n) p.rating = n;
|
||||
})}
|
||||
style={{
|
||||
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.maxStars === n ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Star Size</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{sizePresets.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setProp((p: StarRatingProps) => { p.size = s; })}
|
||||
style={{
|
||||
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.size === s ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Filled Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{filledColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: StarRatingProps) => { p.filledColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.filledColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Empty Color</label>
|
||||
<input
|
||||
type="color"
|
||||
value={props.emptyColor || '#d1d5db'}
|
||||
onChange={(e) => setProp((p: StarRatingProps) => { p.emptyColor = e.target.value; })}
|
||||
style={{ width: 32, height: 24, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', background: 'none', padding: 0 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
StarRating.craft = {
|
||||
@@ -187,25 +92,39 @@ StarRating.craft = {
|
||||
filledColor: '#f59e0b',
|
||||
emptyColor: '#d1d5db',
|
||||
style: {},
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: StarRatingSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(StarRating as any).toHtml = (props: StarRatingProps, _childrenHtml: string) => {
|
||||
const rating = props.rating ?? 4.5;
|
||||
const maxStars = props.maxStars || 5;
|
||||
const size = props.size || '24px';
|
||||
const filledColor = props.filledColor || '#f59e0b';
|
||||
const emptyColor = props.emptyColor || '#d1d5db';
|
||||
// `rating`/`maxStars` are declared `number` in TS but arrive unchecked at
|
||||
// runtime (AI update_props only validates node_id; deserialized saved
|
||||
// state is untyped JSON) -- a string like `5" onmouseover="alert(1)`
|
||||
// breaks out of the aria-label attribute below, and an uncoerced/unclamped
|
||||
// maxStars can also blow up the star-glyph loop (NaN, absurd loop count,
|
||||
// or -- observed -- a RangeError from string concatenation overflow with
|
||||
// e.g. maxStars=1e9). Coerce to numbers with sane fallbacks/clamps first.
|
||||
const ratingRaw = Number(props.rating);
|
||||
const rating = Number.isFinite(ratingRaw) ? ratingRaw : 4.5;
|
||||
const maxStarsRaw = Number(props.maxStars);
|
||||
const maxStars = Number.isFinite(maxStarsRaw)
|
||||
? Math.min(Math.max(Math.trunc(maxStarsRaw), 0), 50)
|
||||
: 5;
|
||||
// Sanitized -- raw string-interpolation sinks in the star glyphs below.
|
||||
const size = cssValue(props.size) || '24px';
|
||||
const filledColor = cssValue(props.filledColor) || '#f59e0b';
|
||||
const emptyColor = cssValue(props.emptyColor) || '#d1d5db';
|
||||
const wrapperStyle = cssPropsToString({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
@@ -216,15 +135,23 @@ StarRating.craft = {
|
||||
let starsHtml = '';
|
||||
for (let i = 1; i <= maxStars; i++) {
|
||||
if (i <= Math.floor(rating)) {
|
||||
starsHtml += `<i class="fa fa-star" style="color:${filledColor};font-size:${size}"></i>`;
|
||||
starsHtml += `<i class="fa fa-star" style="color:${filledColor};font-size:${size}" aria-hidden="true"></i>`;
|
||||
} else if (i === Math.ceil(rating) && rating % 1 !== 0) {
|
||||
starsHtml += `<span style="position:relative;display:inline-block;font-size:${size}"><i class="fa fa-star" style="color:${emptyColor}"></i><span style="position:absolute;left:0;top:0;overflow:hidden;width:50%"><i class="fa fa-star" style="color:${filledColor}"></i></span></span>`;
|
||||
starsHtml += `<span style="position:relative;display:inline-block;font-size:${size}" aria-hidden="true"><i class="fa fa-star" style="color:${emptyColor}"></i><span style="position:absolute;left:0;top:0;overflow:hidden;width:50%"><i class="fa fa-star" style="color:${filledColor}"></i></span></span>`;
|
||||
} else {
|
||||
starsHtml += `<i class="fa fa-star" style="color:${emptyColor};font-size:${size}"></i>`;
|
||||
starsHtml += `<i class="fa fa-star" style="color:${emptyColor};font-size:${size}" aria-hidden="true"></i>`;
|
||||
}
|
||||
}
|
||||
|
||||
// The star glyphs convey nothing to assistive tech on their own -- wrap
|
||||
// in role="img" with a textual equivalent, and hide the decorative glyphs
|
||||
// themselves (aria-hidden above) so AT doesn't announce each icon.
|
||||
// Belt-and-suspenders: rating/maxStars are already coerced to numbers
|
||||
// above, but the assembled label is still run through escapeAttr() in
|
||||
// case a decimal/negative/Infinity edge case produces odd (though no
|
||||
// longer dangerous) text.
|
||||
const ariaLabel = escapeAttr(`Rating: ${rating} out of ${maxStars}`);
|
||||
return {
|
||||
html: `<span${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>${starsHtml}</span>`,
|
||||
html: `<span role="img" aria-label="${ariaLabel}"${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>${starsHtml}</span>`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { TextBlock } from './TextBlock';
|
||||
|
||||
const toHtml = (TextBlock as any).toHtml;
|
||||
|
||||
describe('TextBlock.toHtml text escaping (attacker-controlled `text` prop)', () => {
|
||||
test('a tag-breakout attempt in text is neutralized (no injected element)', () => {
|
||||
const { html } = toHtml({ text: '</p><img src=x onerror=alert(1)>' }, '');
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).toContain('<img');
|
||||
});
|
||||
|
||||
test('ampersand is escaped for well-formed text content (consistency with escapeHtml)', () => {
|
||||
const { html } = toHtml({ text: 'Tom & Jerry' }, '');
|
||||
expect(html).toContain('Tom & Jerry');
|
||||
});
|
||||
|
||||
test('a normal text value still renders unchanged', () => {
|
||||
const { html } = toHtml({ text: 'Hello world' }, '');
|
||||
expect(html).toBe('<p>Hello world</p>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TextBlock.toHtml typography depth (line-height/letter-spacing/transform/style/decoration)', () => {
|
||||
test('line-height, letter-spacing, text-transform all flow into the style attribute', () => {
|
||||
const { html } = toHtml({
|
||||
text: 'x',
|
||||
style: { lineHeight: '1.75', letterSpacing: '-0.02em', textTransform: 'capitalize' },
|
||||
}, '');
|
||||
expect(html).toContain('line-height:1.75');
|
||||
expect(html).toContain('letter-spacing:-0.02em');
|
||||
expect(html).toContain('text-transform:capitalize');
|
||||
});
|
||||
|
||||
test('italic + underline toggles emit font-style and text-decoration', () => {
|
||||
const { html } = toHtml({ text: 'x', style: { fontStyle: 'italic', textDecoration: 'underline' } }, '');
|
||||
expect(html).toContain('font-style:italic');
|
||||
expect(html).toContain('text-decoration:underline');
|
||||
});
|
||||
|
||||
test('a custom font-size (not one of the presets) still flows through', () => {
|
||||
const { html } = toHtml({ text: 'x', style: { fontSize: '19px' } }, '');
|
||||
expect(html).toContain('font-size:19px');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TextBlock.craft.props exposes the box-model + animation/visibility rollout', () => {
|
||||
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
||||
const props = (TextBlock as any).craft.props;
|
||||
expect(props.animation).toBe('');
|
||||
expect(props.animationDelay).toBe('0');
|
||||
expect(props.hideOnDesktop).toBe(false);
|
||||
expect(props.hideOnTablet).toBe(false);
|
||||
expect(props.hideOnMobile).toBe(false);
|
||||
});
|
||||
|
||||
test('style carries blank/default box-model + typography-depth keys', () => {
|
||||
const style = (TextBlock as any).craft.props.style;
|
||||
expect(style).toHaveProperty('marginTop');
|
||||
expect(style).toHaveProperty('paddingTop');
|
||||
expect(style).toHaveProperty('letterSpacing');
|
||||
expect(style).toHaveProperty('textTransform');
|
||||
expect(style.border).toBe('none');
|
||||
expect(style.boxShadow).toBe('none');
|
||||
expect(style.opacity).toBe('1');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,7 @@
|
||||
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { SettingsTabs } from '../../ui/SettingsTabs';
|
||||
import { TypographyControl } from '../../ui/TypographyControl';
|
||||
import { AdvancedTab } from '../../ui/AdvancedTab';
|
||||
import { escapeHtml } from '../../utils/escape';
|
||||
|
||||
interface TextBlockProps {
|
||||
text?: string;
|
||||
@@ -75,58 +73,6 @@ export const TextBlock: UserComponent<TextBlockProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const TextBlockSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as TextBlockProps,
|
||||
}));
|
||||
|
||||
return (
|
||||
<SettingsTabs
|
||||
general={
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.3px' }}>Text Content</label>
|
||||
<textarea
|
||||
value={props.text || ''}
|
||||
onChange={(e) => setProp((p: TextBlockProps) => { p.text = e.target.value; })}
|
||||
rows={4}
|
||||
style={{ width: '100%', padding: '6px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12, resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
style={
|
||||
<TypographyControl
|
||||
style={props.style || {}}
|
||||
onChange={(updates) => setProp((p: TextBlockProps) => { p.style = { ...p.style, ...updates }; })}
|
||||
/>
|
||||
}
|
||||
advanced={
|
||||
<AdvancedTab
|
||||
style={props.style || {}}
|
||||
onStyleChange={(updates) => setProp((p: TextBlockProps) => { p.style = { ...p.style, ...updates }; })}
|
||||
cssId={props.cssId || ''}
|
||||
onCssIdChange={(id) => setProp((p: TextBlockProps) => { p.cssId = id; })}
|
||||
cssClass={props.cssClass || ''}
|
||||
onCssClassChange={(cls) => setProp((p: TextBlockProps) => { p.cssClass = cls; })}
|
||||
hideOnDesktop={props.hideOnDesktop}
|
||||
onHideOnDesktopChange={(v) => setProp((p: TextBlockProps) => { p.hideOnDesktop = v; })}
|
||||
hideOnTablet={props.hideOnTablet}
|
||||
onHideOnTabletChange={(v) => setProp((p: TextBlockProps) => { p.hideOnTablet = v; })}
|
||||
hideOnMobile={props.hideOnMobile}
|
||||
onHideOnMobileChange={(v) => setProp((p: TextBlockProps) => { p.hideOnMobile = v; })}
|
||||
animation={props.animation}
|
||||
onAnimationChange={(v) => setProp((p: TextBlockProps) => { p.animation = v; })}
|
||||
animationDelay={props.animationDelay}
|
||||
onAnimationDelayChange={(v) => setProp((p: TextBlockProps) => { p.animationDelay = v; })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
TextBlock.craft = {
|
||||
@@ -137,22 +83,33 @@ TextBlock.craft = {
|
||||
fontSize: '16px',
|
||||
lineHeight: '1.6',
|
||||
color: '#3f3f46',
|
||||
letterSpacing: '',
|
||||
textTransform: '' as CSSProperties['textTransform'],
|
||||
fontStyle: '' as CSSProperties['fontStyle'],
|
||||
textDecoration: '',
|
||||
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
|
||||
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
opacity: '1',
|
||||
},
|
||||
animation: '',
|
||||
animationDelay: '0',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: TextBlockSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(TextBlock as any).toHtml = (props: TextBlockProps, _childrenHtml: string) => {
|
||||
const styleStr = cssPropsToString(props.style);
|
||||
const escapedText = (props.text || '').replace(/</g, '<').replace(/>/g, '>');
|
||||
const escapedText = escapeHtml(props.text || '');
|
||||
return { html: `<p${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</p>` };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { ContactForm } from './ContactForm';
|
||||
|
||||
const toHtml = (ContactForm as any).toHtml;
|
||||
|
||||
describe('ContactForm.toHtml relay wiring', () => {
|
||||
test('with recipientEmail: emits marker, placeholder action, honeypot', () => {
|
||||
const { html } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '');
|
||||
expect(html).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" recipient="a@b.com" thankyou="\/thx"-->/);
|
||||
expect(html).toMatch(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
|
||||
expect(html).toContain('method="POST"');
|
||||
expect(html).toContain('name="_gotcha"');
|
||||
// marker id and action id match
|
||||
const mid = html.match(/id="(F_[0-9a-z]+)"/)![1];
|
||||
expect(html).toContain(`__WHP_FORM_ACTION__${mid}__`);
|
||||
});
|
||||
|
||||
test('without recipientEmail: no marker, falls back to formAction', () => {
|
||||
const { html } = toHtml({ formAction: '/legacy', fields: [] }, '');
|
||||
expect(html).not.toContain('WHP-FORM');
|
||||
expect(html).toContain('action="/legacy"');
|
||||
expect(html).not.toContain('_gotcha');
|
||||
// Backward-compat: ensure non-relay output is byte-identical (no extra blank lines from honeypot)
|
||||
expect(html).not.toMatch(/<form[^>]*>\n\s*\n/);
|
||||
});
|
||||
|
||||
test('without recipientEmail + real fields: byte-clean legacy output (realistic case)', () => {
|
||||
// The empty-fields case is NOT byte-identical to the old code (the old
|
||||
// template emitted a stray whitespace line when fields was empty; the new
|
||||
// ternary drops it). Real forms always have fields, so pin THAT scenario:
|
||||
// no marker, no honeypot, and no whitespace-only line between <form> and
|
||||
// the first field.
|
||||
const fields = [{ type: 'text', label: 'Name', name: 'name', placeholder: 'Your name', required: true }];
|
||||
const { html } = toHtml({ formAction: '/legacy', fields }, '');
|
||||
expect(html).not.toContain('WHP-FORM');
|
||||
expect(html).not.toContain('_gotcha');
|
||||
expect(html).toContain('action="/legacy"');
|
||||
expect(html).not.toMatch(/<form[^>]*>\n\s*\n/);
|
||||
// First field renders directly after the form tag (no stray blank line).
|
||||
expect(html).toMatch(/<form[^>]*>\n\s*<div/);
|
||||
expect(html).toContain('Name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContactForm.toHtml successMessage', () => {
|
||||
// The published form-sender relay (form-sender/app/submit.php) delivers
|
||||
// success via a full-page 303 redirect to thankYouUrl or a hosted
|
||||
// thanks.php page -- there is no in-page JS to reveal an inline success
|
||||
// element. So successMessage is emitted as a forward-compatible data
|
||||
// attribute for a future AJAX/JS submission mode, not a live DOM element.
|
||||
test('with successMessage set: emits it as an escaped data attribute on the form', () => {
|
||||
const { html } = toHtml({ successMessage: "We'll be in touch!", fields: [] }, '');
|
||||
expect(html).toContain('data-whp-success-message="We'll be in touch!"');
|
||||
});
|
||||
|
||||
test('without successMessage: no data attribute emitted', () => {
|
||||
const { html } = toHtml({ fields: [] }, '');
|
||||
expect(html).not.toContain('data-whp-success-message');
|
||||
});
|
||||
|
||||
test('escapes attribute-breakout attempts in successMessage', () => {
|
||||
const { html } = toHtml({ successMessage: 'x" onerror="alert(1)', fields: [] }, '');
|
||||
expect(html).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContactForm.toHtml relay marker deterministic + unique via node id (no Math.random)', () => {
|
||||
test('same node id -> identical marker+placeholder ids across two calls', () => {
|
||||
const { html: html1 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '', 'node-cf1');
|
||||
const { html: html2 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '', 'node-cf1');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
|
||||
test('marker id always equals the placeholder id it pairs with', () => {
|
||||
const { html } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '', 'node-cf1');
|
||||
const mid = html.match(/<!--WHP-FORM id="([^"]+)"/)![1];
|
||||
expect(html).toContain(`action="__WHP_FORM_ACTION__${mid}__"`);
|
||||
});
|
||||
|
||||
test('two different node ids -> different fids', () => {
|
||||
const { html: html1 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '', 'node-cf1');
|
||||
const { html: html2 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '', 'node-cf2');
|
||||
const mid1 = html1.match(/<!--WHP-FORM id="([^"]+)"/)![1];
|
||||
const mid2 = html2.match(/<!--WHP-FORM id="([^"]+)"/)![1];
|
||||
expect(mid1).not.toBe(mid2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContactForm.toHtml accessibility (F2.1)', () => {
|
||||
const fields = [
|
||||
{ type: 'text' as const, label: 'Name', name: 'name', placeholder: 'Your name', required: true },
|
||||
{ type: 'email' as const, label: 'Email', name: 'email', placeholder: 'you@example.com', required: true },
|
||||
];
|
||||
|
||||
test('each field label for= matches its control id=, and ids are unique', () => {
|
||||
const { html } = toHtml({ fields }, '');
|
||||
const labelIds = [...html.matchAll(/<label for="([^"]+)"/g)].map((m) => m[1]);
|
||||
const controlIds = [...html.matchAll(/<(?:input|textarea|select) id="([^"]+)"/g)].map((m) => m[1]);
|
||||
expect(labelIds.length).toBe(2);
|
||||
expect(controlIds.length).toBe(2);
|
||||
expect(labelIds).toEqual(controlIds);
|
||||
expect(new Set(controlIds).size).toBe(2);
|
||||
});
|
||||
|
||||
test('ids are deterministic across repeated calls with the same fields', () => {
|
||||
const { html: html1 } = toHtml({ fields }, '');
|
||||
const { html: html2 } = toHtml({ fields }, '');
|
||||
const ids1 = [...html1.matchAll(/<input id="([^"]+)"/g)].map((m) => m[1]);
|
||||
const ids2 = [...html2.matchAll(/<input id="([^"]+)"/g)].map((m) => m[1]);
|
||||
expect(ids1).toEqual(ids2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContactForm.toHtml field type attribute sanitization', () => {
|
||||
test('malicious field.type cannot break out of the input attribute; falls back to type="text"', () => {
|
||||
const fields = [{ type: 'text"><img src=x onerror=alert(1)>' as any, label: 'Name', name: 'name', placeholder: 'Your name', required: false }];
|
||||
const { html } = toHtml({ fields }, '');
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).not.toContain('onerror=');
|
||||
expect(html).toContain('type="text"');
|
||||
});
|
||||
|
||||
test('legitimate email field type still passes through unchanged', () => {
|
||||
const fields = [{ type: 'email' as const, label: 'Email', name: 'email', placeholder: 'you@example.com', required: false }];
|
||||
const { html } = toHtml({ fields }, '');
|
||||
expect(html).toContain('type="email"');
|
||||
});
|
||||
});
|
||||
|
||||
// F1: the field editor (FormStylePanel) can now create fields of every type
|
||||
// in sanitizeInputType's allowlist, plus textarea/select. Verify each
|
||||
// renders with the right control, label/for association, and required flag.
|
||||
describe('ContactForm.toHtml renders every configured field type/label/required', () => {
|
||||
const cases: { type: string; tag: string }[] = [
|
||||
{ type: 'text', tag: 'input' },
|
||||
{ type: 'email', tag: 'input' },
|
||||
{ type: 'tel', tag: 'input' },
|
||||
{ type: 'number', tag: 'input' },
|
||||
{ type: 'password', tag: 'input' },
|
||||
{ type: 'url', tag: 'input' },
|
||||
{ type: 'search', tag: 'input' },
|
||||
{ type: 'date', tag: 'input' },
|
||||
{ type: 'checkbox', tag: 'input' },
|
||||
{ type: 'radio', tag: 'input' },
|
||||
];
|
||||
|
||||
test.each(cases)('type=$type renders a sanitized <$tag type="$type"> with label + for/id wiring', ({ type, tag }) => {
|
||||
const fields = [{ type: type as any, label: `Field ${type}`, name: `f_${type}`, placeholder: '', required: true }];
|
||||
const { html } = toHtml({ fields }, '');
|
||||
expect(html).toContain(`<${tag}`);
|
||||
expect(html).toContain(`type="${type}"`);
|
||||
expect(html).toContain(`Field ${type}`);
|
||||
// required renders the input attribute AND the visual asterisk
|
||||
expect(html).toMatch(/ required/);
|
||||
expect(html).toContain('*</span>');
|
||||
const labelFor = html.match(/<label for="([^"]+)"/)![1];
|
||||
expect(html).toContain(`id="${labelFor}"`);
|
||||
});
|
||||
|
||||
test('type=textarea renders a <textarea>, not an <input>', () => {
|
||||
const fields = [{ type: 'textarea' as const, label: 'Message', name: 'message', placeholder: '', required: false }];
|
||||
const { html } = toHtml({ fields }, '');
|
||||
expect(html).toMatch(/<textarea[^>]*name="message"/);
|
||||
expect(html).not.toMatch(/<input[^>]*name="message"/);
|
||||
});
|
||||
|
||||
test('type=select renders a <select> with escaped <option> values from field.options', () => {
|
||||
const fields = [{ type: 'select' as const, label: 'Plan', name: 'plan', placeholder: 'Choose one', required: false, options: ['Basic', 'Pro', '"><script>alert(1)</script>'] }];
|
||||
const { html } = toHtml({ fields }, '');
|
||||
expect(html).toMatch(/<select[^>]*name="plan"/);
|
||||
expect(html).toContain('<option value="Basic">Basic</option>');
|
||||
expect(html).toContain('<option value="Pro">Pro</option>');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('a non-required field omits both the required attribute and the asterisk', () => {
|
||||
const fields = [{ type: 'text' as const, label: 'Nickname', name: 'nickname', placeholder: '', required: false }];
|
||||
const { html } = toHtml({ fields }, '');
|
||||
expect(html).not.toMatch(/ required/);
|
||||
expect(html).not.toContain('*</span>');
|
||||
});
|
||||
});
|
||||
|
||||
// Box-model / animation / visibility rollout (common enh-batch pattern):
|
||||
// these are top-level props consumed generically by the export's
|
||||
// buildDataAttrs() -- this just confirms the defaults are present on
|
||||
// craft.props so the panel controls render and the props survive save/load.
|
||||
describe('ContactForm.craft.props includes animation/visibility defaults', () => {
|
||||
test('has blank/false defaults for animation, animationDelay, hideOnDesktop/Tablet/Mobile', () => {
|
||||
expect(ContactForm.craft!.props).toMatchObject({
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,20 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { relayFormWiring } from '../../utils/form-relay-wiring';
|
||||
import { escapeHtml, escapeAttr, slugId, cssValue, sanitizeInputType } from '../../utils/escape';
|
||||
|
||||
// The allowlist enforced at export time lives in `sanitizeInputType`
|
||||
// (utils/escape.ts) -- this union is a superset (it also covers 'textarea'
|
||||
// and 'select', which take their own render branches instead of an
|
||||
// `<input type>`), kept in sync by hand since TS unions can't import a
|
||||
// runtime array.
|
||||
export type ContactFormFieldType =
|
||||
| 'text' | 'email' | 'tel' | 'number' | 'password' | 'url' | 'search' | 'date'
|
||||
| 'checkbox' | 'radio' | 'textarea' | 'select';
|
||||
|
||||
interface ContactFormField {
|
||||
type: 'text' | 'email' | 'tel' | 'textarea' | 'select';
|
||||
type: ContactFormFieldType;
|
||||
label: string;
|
||||
name: string;
|
||||
placeholder: string;
|
||||
@@ -21,6 +32,13 @@ interface ContactFormProps {
|
||||
labelColor?: string;
|
||||
inputBg?: string;
|
||||
inputBorder?: string;
|
||||
recipientEmail?: string;
|
||||
thankYouUrl?: string;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const defaultFields: ContactFormField[] = [
|
||||
@@ -133,213 +151,6 @@ export const ContactForm: UserComponent<ContactFormProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const fieldTypes: ContactFormField['type'][] = ['text', 'email', 'tel', 'textarea', 'select'];
|
||||
|
||||
const ContactFormSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as ContactFormProps,
|
||||
}));
|
||||
|
||||
const fields = props.fields || defaultFields;
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
|
||||
const updateField = (index: number, key: keyof ContactFormField, value: any) => {
|
||||
setProp((p: ContactFormProps) => {
|
||||
const updated = [...(p.fields || defaultFields)];
|
||||
updated[index] = { ...updated[index], [key]: value };
|
||||
p.fields = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addField = () => {
|
||||
setProp((p: ContactFormProps) => {
|
||||
p.fields = [...(p.fields || defaultFields), { type: 'text', label: 'New Field', name: 'new_field', placeholder: '', required: false }];
|
||||
});
|
||||
};
|
||||
|
||||
const removeField = (index: number) => {
|
||||
setProp((p: ContactFormProps) => {
|
||||
const updated = [...(p.fields || defaultFields)];
|
||||
updated.splice(index, 1);
|
||||
p.fields = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const submitColorPresets = ['#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#f59e0b', '#18181b', '#0ea5e9', '#ec4899'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{/* Form Action */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Form Action URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.formAction || ''}
|
||||
onChange={(e) => setProp((p: ContactFormProps) => { p.formAction = e.target.value; })}
|
||||
placeholder="https://... or /api/submit"
|
||||
style={{ ...inputStyle, padding: '4px 8px', fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Success Message */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Success Message</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.successMessage || ''}
|
||||
onChange={(e) => setProp((p: ContactFormProps) => { p.successMessage = e.target.value; })}
|
||||
style={{ ...inputStyle, padding: '4px 8px', fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Submit Button Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.submitText || ''}
|
||||
onChange={(e) => setProp((p: ContactFormProps) => { p.submitText = e.target.value; })}
|
||||
style={{ ...inputStyle, padding: '4px 8px', fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Submit Button Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{submitColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: ContactFormProps) => { p.submitColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.submitColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Label Color */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Label Color</label>
|
||||
<input
|
||||
type="color"
|
||||
value={props.labelColor || '#374151'}
|
||||
onChange={(e) => setProp((p: ContactFormProps) => { p.labelColor = e.target.value; })}
|
||||
style={{ width: 32, height: 24, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', background: 'none', padding: 0 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Input Background */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Input Background</label>
|
||||
<input
|
||||
type="color"
|
||||
value={props.inputBg || '#ffffff'}
|
||||
onChange={(e) => setProp((p: ContactFormProps) => { p.inputBg = e.target.value; })}
|
||||
style={{ width: 32, height: 24, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', background: 'none', padding: 0 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Input Border */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Input Border Color</label>
|
||||
<input
|
||||
type="color"
|
||||
value={props.inputBorder || '#d1d5db'}
|
||||
onChange={(e) => setProp((p: ContactFormProps) => { p.inputBorder = e.target.value; })}
|
||||
style={{ width: 32, height: 24, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', background: 'none', padding: 0 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fields Editor */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Fields</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{fields.map((field, i) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<select
|
||||
value={field.type}
|
||||
onChange={(e) => updateField(i, 'type', e.target.value)}
|
||||
style={{ ...inputStyle, width: 70, flex: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
{fieldTypes.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={field.label}
|
||||
onChange={(e) => updateField(i, 'label', e.target.value)}
|
||||
placeholder="Label"
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeField(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={field.name}
|
||||
onChange={(e) => updateField(i, 'name', e.target.value)}
|
||||
placeholder="name attr"
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={field.placeholder}
|
||||
onChange={(e) => updateField(i, 'placeholder', e.target.value)}
|
||||
placeholder="Placeholder"
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.required}
|
||||
onChange={(e) => updateField(i, 'required', e.target.checked)}
|
||||
/>
|
||||
Required
|
||||
</label>
|
||||
</div>
|
||||
{field.type === 'select' && (
|
||||
<div>
|
||||
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'block', marginBottom: 2 }}>Options (one per line)</label>
|
||||
<textarea
|
||||
value={(field.options || []).join('\n')}
|
||||
onChange={(e) => updateField(i, 'options', e.target.value.split('\n').filter((s: string) => s.trim()))}
|
||||
rows={3}
|
||||
placeholder="Option 1 Option 2 Option 3"
|
||||
style={{ ...inputStyle, resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addField}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Field
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
ContactForm.craft = {
|
||||
@@ -358,21 +169,24 @@ ContactForm.craft = {
|
||||
labelColor: '#374151',
|
||||
inputBg: '#ffffff',
|
||||
inputBorder: '#d1d5db',
|
||||
recipientEmail: '',
|
||||
thankYouUrl: '',
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: ContactFormSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(ContactForm as any).toHtml = (props: ContactFormProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
(ContactForm as any).toHtml = (props: ContactFormProps, _childrenHtml: string, nodeId?: string) => {
|
||||
const formStyle = cssPropsToString({
|
||||
padding: '32px',
|
||||
display: 'flex',
|
||||
@@ -380,23 +194,28 @@ ContactForm.craft = {
|
||||
gap: '20px',
|
||||
...props.style,
|
||||
});
|
||||
const labelColor = props.labelColor || '#374151';
|
||||
const inputBg = props.inputBg || '#ffffff';
|
||||
const inputBorder = props.inputBorder || '#d1d5db';
|
||||
// Sanitized -- raw string-interpolation sinks in inputStyleStr/labelHtml
|
||||
// below.
|
||||
const labelColor = cssValue(props.labelColor) || '#374151';
|
||||
const inputBg = cssValue(props.inputBg) || '#ffffff';
|
||||
const inputBorder = cssValue(props.inputBorder) || '#d1d5db';
|
||||
const inputStyleStr = `width:100%;padding:10px 14px;font-size:14px;font-family:Inter,sans-serif;border:1px solid ${inputBorder};border-radius:6px;background-color:${inputBg};color:#1f2937;box-sizing:border-box;outline:none`;
|
||||
|
||||
const fieldsHtml = (props.fields || defaultFields).map((field) => {
|
||||
const fieldsHtml = (props.fields || defaultFields).map((field, i) => {
|
||||
const reqStar = field.required ? '<span style="color:#ef4444;margin-left:2px">*</span>' : '';
|
||||
const labelHtml = `<label style="font-size:14px;font-weight:500;color:${labelColor}">${esc(field.label)}${reqStar}</label>`;
|
||||
// Deterministic id: index + slugified name, so repeated fields with the
|
||||
// same name (or no name) still get unique, stable ids -- no Math.random.
|
||||
const fieldId = `field-${i}-${slugId(field.name)}`;
|
||||
const labelHtml = `<label for="${escapeAttr(fieldId)}" style="font-size:14px;font-weight:500;color:${labelColor}">${escapeHtml(field.label)}${reqStar}</label>`;
|
||||
const reqAttr = field.required ? ' required' : '';
|
||||
let inputHtml = '';
|
||||
if (field.type === 'textarea') {
|
||||
inputHtml = `<textarea name="${esc(field.name)}" placeholder="${esc(field.placeholder)}" rows="4" style="${inputStyleStr};resize:vertical"${reqAttr}></textarea>`;
|
||||
inputHtml = `<textarea id="${escapeAttr(fieldId)}" name="${escapeAttr(field.name)}" placeholder="${escapeAttr(field.placeholder)}" rows="4" style="${inputStyleStr};resize:vertical"${reqAttr}></textarea>`;
|
||||
} else if (field.type === 'select') {
|
||||
const opts = (field.options || []).map((o) => `<option value="${esc(o)}">${esc(o)}</option>`).join('');
|
||||
inputHtml = `<select name="${esc(field.name)}" style="${inputStyleStr};cursor:pointer"${reqAttr}><option value="">${esc(field.placeholder || 'Select...')}</option>${opts}</select>`;
|
||||
const opts = (field.options || []).map((o) => `<option value="${escapeAttr(o)}">${escapeHtml(o)}</option>`).join('');
|
||||
inputHtml = `<select id="${escapeAttr(fieldId)}" name="${escapeAttr(field.name)}" style="${inputStyleStr};cursor:pointer"${reqAttr}><option value="">${escapeHtml(field.placeholder || 'Select...')}</option>${opts}</select>`;
|
||||
} else {
|
||||
inputHtml = `<input type="${field.type}" name="${esc(field.name)}" placeholder="${esc(field.placeholder)}" style="${inputStyleStr}"${reqAttr} />`;
|
||||
inputHtml = `<input id="${escapeAttr(fieldId)}" type="${sanitizeInputType(field.type)}" name="${escapeAttr(field.name)}" placeholder="${escapeAttr(field.placeholder)}" style="${inputStyleStr}"${reqAttr} />`;
|
||||
}
|
||||
return `<div style="display:flex;flex-direction:column;gap:6px">${labelHtml}${inputHtml}</div>`;
|
||||
}).join('\n ');
|
||||
@@ -414,10 +233,18 @@ ContactForm.craft = {
|
||||
alignSelf: 'flex-start',
|
||||
});
|
||||
|
||||
const { marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.formAction, nodeId);
|
||||
|
||||
// The form-sender relay delivers success via a full-page 303 redirect
|
||||
// (to thankYouUrl or a hosted thanks.php page) -- there is no in-page JS
|
||||
// that reveals an inline success element today. Emit successMessage as a
|
||||
// forward-compatible data attribute so a future AJAX/JS submission mode
|
||||
// can read it, without implying a live mechanism that doesn't exist yet.
|
||||
const successAttr = props.successMessage ? ` data-whp-success-message="${escapeAttr(props.successMessage)}"` : '';
|
||||
|
||||
return {
|
||||
html: `<form action="${esc(props.formAction || '#')}" method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
|
||||
${fieldsHtml}
|
||||
<button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${esc(props.submitText || 'Send Message')}</button>
|
||||
html: `${marker}<form action="${actionAttr}" method="POST"${successAttr}${formStyle ? ` style="${formStyle}"` : ''}>
|
||||
${honeypot ? ` ${honeypot}\n` : ''}${fieldsHtml ? ` ${fieldsHtml}\n` : ''} <button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${escapeHtml(props.submitText || 'Send Message')}</button>
|
||||
</form>`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { FormButton } from './FormButton';
|
||||
|
||||
const toHtml = (FormButton as any).toHtml;
|
||||
|
||||
describe('FormButton.toHtml', () => {
|
||||
test('normal text renders as-is', () => {
|
||||
const { html } = toHtml({ text: 'Send it' }, '');
|
||||
expect(html).toContain('>Send it<');
|
||||
expect(html).toContain('type="submit"');
|
||||
});
|
||||
|
||||
test('type="submit" is a hardcoded literal, not prop-driven', () => {
|
||||
const { html } = toHtml({ text: 'Submit' }, '');
|
||||
expect(html).toMatch(/<button type="submit"/);
|
||||
});
|
||||
|
||||
test('text content is escaped for <, >, &, and " (consistent with escapeHtml)', () => {
|
||||
const { html } = toHtml({ text: '<script>alert(1)</script> & "quoted"' }, '');
|
||||
expect(html).not.toContain('<script>');
|
||||
expect(html).toContain('<script>alert(1)</script>');
|
||||
expect(html).toContain('&');
|
||||
expect(html).toContain('"quoted"');
|
||||
});
|
||||
|
||||
test('box-model style (margin/border/box-shadow/opacity) flows through via the style prop', () => {
|
||||
const { html } = toHtml({ text: 'Submit', style: { marginTop: '12px', border: '2px solid #000', boxShadow: '0 2px 4px rgba(0,0,0,.2)', opacity: '0.8' } }, '');
|
||||
expect(html).toContain('margin-top:12px');
|
||||
expect(html).toContain('border:2px solid #000');
|
||||
expect(html).toContain('opacity:0.8');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FormButton.craft.props includes animation/visibility defaults', () => {
|
||||
test('has blank/false defaults', () => {
|
||||
expect(FormButton.craft!.props).toMatchObject({
|
||||
animation: '', animationDelay: '', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,16 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml } from '../../utils/escape';
|
||||
|
||||
interface FormButtonProps {
|
||||
text?: string;
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
export const FormButton: UserComponent<FormButtonProps> = ({
|
||||
@@ -42,101 +48,6 @@ export const FormButton: UserComponent<FormButtonProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const FormButtonSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as FormButtonProps,
|
||||
}));
|
||||
|
||||
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' },
|
||||
];
|
||||
|
||||
const radiusPresets = ['0px', '4px', '6px', '8px', '9999px'];
|
||||
const widthPresets = ['auto', '100%'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.text || ''}
|
||||
onChange={(e) => setProp((p: FormButtonProps) => { p.text = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{colorPresets.map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
onClick={() => setProp((p: FormButtonProps) => {
|
||||
p.style = { ...p.style, backgroundColor: preset.bg, color: preset.color };
|
||||
})}
|
||||
title={preset.label}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: preset.bg, cursor: 'pointer',
|
||||
outline: props.style?.backgroundColor === preset.bg ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Border Radius</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{radiusPresets.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setProp((p: FormButtonProps) => { p.style = { ...p.style, borderRadius: r }; })}
|
||||
style={{
|
||||
padding: '2px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.style?.borderRadius === r ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Width</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{widthPresets.map((w) => (
|
||||
<button
|
||||
key={w}
|
||||
onClick={() => setProp((p: FormButtonProps) => { p.style = { ...p.style, width: w }; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.style?.width === w ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{w === 'auto' ? 'Auto' : 'Full Width'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
FormButton.craft = {
|
||||
@@ -152,15 +63,17 @@ FormButton.craft = {
|
||||
fontSize: '16px',
|
||||
border: 'none',
|
||||
},
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: FormButtonSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
@@ -172,7 +85,7 @@ FormButton.craft = {
|
||||
cursor: 'pointer',
|
||||
...props.style,
|
||||
});
|
||||
const escapedText = (props.text || 'Submit').replace(/</g, '<').replace(/>/g, '>');
|
||||
const escapedText = escapeHtml(props.text || 'Submit');
|
||||
return {
|
||||
html: `<button type="submit"${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</button>`,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { FormContainer } from './FormContainer';
|
||||
|
||||
const toHtml = (FormContainer as any).toHtml;
|
||||
|
||||
describe('FormContainer.toHtml relay wiring', () => {
|
||||
test('with recipientEmail: marker + placeholder action + honeypot, forces POST', () => {
|
||||
const { html } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', method: 'GET' }, '<input name="email">');
|
||||
expect(html).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" recipient="a@b.com" thankyou="\/thx"-->/);
|
||||
expect(html).toMatch(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
|
||||
expect(html).toContain('method="POST"'); // relay forces POST even though method=GET
|
||||
expect(html).toContain('name="_gotcha"');
|
||||
// honeypot precedes the form's children
|
||||
expect(html.indexOf('_gotcha')).toBeLessThan(html.indexOf('name="email"'));
|
||||
// marker id === action id
|
||||
const mid = html.match(/id="(F_[0-9a-z]+)"/)![1];
|
||||
expect(html).toContain(`__WHP_FORM_ACTION__${mid}__`);
|
||||
});
|
||||
|
||||
test('without recipientEmail: legacy action/method, no marker or honeypot', () => {
|
||||
const { html } = toHtml({ action: '/legacy', method: 'POST' }, '<input name="email">');
|
||||
expect(html).not.toContain('WHP-FORM');
|
||||
expect(html).not.toContain('_gotcha');
|
||||
expect(html).toContain('action="/legacy"');
|
||||
expect(html).toContain('<input name="email">');
|
||||
});
|
||||
|
||||
test('same node id -> identical marker+placeholder ids across two calls', () => {
|
||||
const { html: html1 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx' }, '<input name="email">', 'node-fc1');
|
||||
const { html: html2 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx' }, '<input name="email">', 'node-fc1');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
|
||||
test('two different node ids -> different fids', () => {
|
||||
const { html: html1 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx' }, '<input name="email">', 'node-fc1');
|
||||
const { html: html2 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx' }, '<input name="email">', 'node-fc2');
|
||||
const mid1 = html1.match(/<!--WHP-FORM id="([^"]+)"/)![1];
|
||||
const mid2 = html2.match(/<!--WHP-FORM id="([^"]+)"/)![1];
|
||||
expect(mid1).not.toBe(mid2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FormContainer.toHtml method attribute sanitization', () => {
|
||||
test('malicious method value cannot break out of the attribute; falls back to POST', () => {
|
||||
const { html } = toHtml({ action: '/legacy', method: 'POST"><script>alert(1)</script>' }, '');
|
||||
expect(html).not.toContain('<script');
|
||||
expect(html).toContain('method="POST"');
|
||||
});
|
||||
|
||||
test('legitimate GET method still passes through unchanged (non-relay path)', () => {
|
||||
const { html } = toHtml({ action: '/legacy', method: 'GET' }, '');
|
||||
expect(html).toContain('method="GET"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FormContainer.toHtml box-model style passthrough', () => {
|
||||
test('margin/border/box-shadow/opacity flow through via the style prop', () => {
|
||||
const { html } = toHtml({ action: '/legacy', style: { marginTop: '20px', border: '3px dashed #ccc', boxShadow: '0 4px 8px rgba(0,0,0,.2)', opacity: '0.95' } }, '');
|
||||
expect(html).toContain('margin-top:20px');
|
||||
expect(html).toContain('border:3px dashed #ccc');
|
||||
expect(html).toContain('opacity:0.95');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FormContainer.craft.props includes animation/visibility defaults', () => {
|
||||
test('has blank/false defaults', () => {
|
||||
expect(FormContainer.craft!.props).toMatchObject({
|
||||
animation: '', animationDelay: '', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,12 +2,21 @@ import React, { CSSProperties } from 'react';
|
||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||
import { Container } from '../layout/Container';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { relayFormWiring } from '../../utils/form-relay-wiring';
|
||||
import { sanitizeFormMethod } from '../../utils/escape';
|
||||
|
||||
interface FormContainerProps {
|
||||
action?: string;
|
||||
method?: 'GET' | 'POST';
|
||||
recipientEmail?: string;
|
||||
thankYouUrl?: string;
|
||||
style?: CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
export const FormContainer: UserComponent<FormContainerProps> = ({
|
||||
@@ -40,69 +49,6 @@ export const FormContainer: UserComponent<FormContainerProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const FormContainerSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as FormContainerProps,
|
||||
}));
|
||||
|
||||
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Form Action URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.action || ''}
|
||||
onChange={(e) => setProp((p: FormContainerProps) => { p.action = e.target.value; })}
|
||||
placeholder="https://... or /api/submit"
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Method</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['GET', 'POST'] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setProp((p: FormContainerProps) => { p.method = m; })}
|
||||
style={{
|
||||
padding: '4px 12px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.method === m ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: FormContainerProps) => { p.style = { ...p.style, backgroundColor: c }; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
FormContainer.craft = {
|
||||
@@ -110,31 +56,38 @@ FormContainer.craft = {
|
||||
props: {
|
||||
action: '#',
|
||||
method: 'POST',
|
||||
recipientEmail: '',
|
||||
thankYouUrl: '',
|
||||
style: {
|
||||
padding: '24px',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e4e4e7',
|
||||
},
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: FormContainerSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(FormContainer as any).toHtml = (props: FormContainerProps, childrenHtml: string) => {
|
||||
(FormContainer as any).toHtml = (props: FormContainerProps, childrenHtml: string, nodeId?: string) => {
|
||||
const styleStr = cssPropsToString({
|
||||
padding: '24px',
|
||||
...props.style,
|
||||
});
|
||||
const { useRelay, marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.action, nodeId);
|
||||
const method = useRelay ? 'POST' : sanitizeFormMethod(props.method); // relay requires POST
|
||||
const body = honeypot + childrenHtml; // honeypot as first child
|
||||
return {
|
||||
html: `<form action="${props.action || '#'}" method="${props.method || 'POST'}"${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</form>`,
|
||||
html: `${marker}<form action="${actionAttr}" method="${method}"${styleStr ? ` style="${styleStr}"` : ''}>${body}</form>`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { InputField } from './InputField';
|
||||
|
||||
const toHtml = (InputField as any).toHtml;
|
||||
|
||||
describe('InputField.toHtml accessibility (F2.1)', () => {
|
||||
test('label for= matches input id=', () => {
|
||||
const { html } = toHtml({ label: 'Your Name', name: 'name' }, '');
|
||||
const forMatch = html.match(/<label for="([^"]+)"/);
|
||||
const idMatch = html.match(/<input id="([^"]+)"/);
|
||||
expect(forMatch).toBeTruthy();
|
||||
expect(idMatch).toBeTruthy();
|
||||
expect(forMatch![1]).toBe(idMatch![1]);
|
||||
});
|
||||
|
||||
test('id is deterministic (derived from name, not random) -- stable across calls', () => {
|
||||
const { html: html1 } = toHtml({ label: 'Email', name: 'email' }, '');
|
||||
const { html: html2 } = toHtml({ label: 'Email', name: 'email' }, '');
|
||||
const id1 = html1.match(/<input id="([^"]+)"/)![1];
|
||||
const id2 = html2.match(/<input id="([^"]+)"/)![1];
|
||||
expect(id1).toBe(id2);
|
||||
});
|
||||
|
||||
test('no visible label: input gets aria-label from placeholder', () => {
|
||||
const { html } = toHtml({ label: '', name: 'phone', placeholder: 'Phone number' }, '');
|
||||
expect(html).not.toContain('<label');
|
||||
expect(html).toContain('aria-label="Phone number"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('InputField.toHtml deterministic + unique ids (thread node id, resolves id-collision finding)', () => {
|
||||
test('label for= still matches input id= after threading the node id', () => {
|
||||
const { html } = toHtml({ label: 'Your Name', name: 'name' }, '', 'node-in1');
|
||||
const forMatch = html.match(/<label for="([^"]+)"/);
|
||||
const idMatch = html.match(/<input id="([^"]+)"/);
|
||||
expect(forMatch![1]).toBe(idMatch![1]);
|
||||
});
|
||||
|
||||
test('same node id -> identical output across calls (deterministic, no Math.random)', () => {
|
||||
const { html: html1 } = toHtml({ label: 'Name', name: 'name' }, '', 'node-in1');
|
||||
const { html: html2 } = toHtml({ label: 'Name', name: 'name' }, '', 'node-in1');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
|
||||
test('two instances with the SAME default name but different node ids do not collide', () => {
|
||||
const { html: html1 } = toHtml({ label: 'Your Name', name: 'name' }, '', 'node-in1');
|
||||
const { html: html2 } = toHtml({ label: 'Your Name', name: 'name' }, '', 'node-in2');
|
||||
const id1 = html1.match(/<input id="([^"]+)"/)![1];
|
||||
const id2 = html2.match(/<input id="([^"]+)"/)![1];
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
|
||||
test('no nodeId (legacy 2-arg call): id derivation stays deterministic, not random', () => {
|
||||
const { html: html1 } = toHtml({ label: 'Email', name: 'email' }, '');
|
||||
const { html: html2 } = toHtml({ label: 'Email', name: 'email' }, '');
|
||||
const id1 = html1.match(/<input id="([^"]+)"/)![1];
|
||||
const id2 = html2.match(/<input id="([^"]+)"/)![1];
|
||||
expect(id1).toBe(id2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('InputField.toHtml type attribute sanitization', () => {
|
||||
test('malicious type value cannot break out of the attribute; falls back to type="text"', () => {
|
||||
const { html } = toHtml({ label: 'Name', name: 'name', type: 'text" autofocus onfocus="alert(1)' as any }, '');
|
||||
expect(html).not.toContain('onfocus=');
|
||||
expect(html).not.toContain('autofocus');
|
||||
expect(html).toContain('type="text"');
|
||||
});
|
||||
|
||||
test('legitimate number type still passes through unchanged', () => {
|
||||
const { html } = toHtml({ label: 'Age', name: 'age', type: 'number' as const }, '');
|
||||
expect(html).toContain('type="number"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('InputField.toHtml box-model style passthrough', () => {
|
||||
test('margin/border/box-shadow/opacity flow through via the style prop', () => {
|
||||
const { html } = toHtml({ label: 'Name', name: 'name', style: { marginBottom: '8px', border: '1px solid #333', boxShadow: '0 1px 2px rgba(0,0,0,.1)', opacity: '0.9' } }, '');
|
||||
expect(html).toContain('margin-bottom:8px');
|
||||
expect(html).toContain('border:1px solid #333');
|
||||
expect(html).toContain('opacity:0.9');
|
||||
});
|
||||
});
|
||||
|
||||
describe('InputField.craft.props includes animation/visibility defaults', () => {
|
||||
test('has blank/false defaults', () => {
|
||||
expect(InputField.craft!.props).toMatchObject({
|
||||
animation: '', animationDelay: '', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, scopeId, sanitizeInputType } from '../../utils/escape';
|
||||
|
||||
interface InputFieldProps {
|
||||
label?: string;
|
||||
@@ -9,6 +10,11 @@ interface InputFieldProps {
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
export const InputField: UserComponent<InputFieldProps> = ({
|
||||
@@ -65,81 +71,6 @@ export const InputField: UserComponent<InputFieldProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const InputFieldSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as InputFieldProps,
|
||||
}));
|
||||
|
||||
const typeOptions: InputFieldProps['type'][] = ['text', 'email', 'password', 'number', 'tel', 'url'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Label</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.label || ''}
|
||||
onChange={(e) => setProp((p: InputFieldProps) => { p.label = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Type</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{typeOptions.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setProp((p: InputFieldProps) => { p.type = t; })}
|
||||
style={{
|
||||
padding: '3px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.type === t ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.name || ''}
|
||||
onChange={(e) => setProp((p: InputFieldProps) => { p.name = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Placeholder</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.placeholder || ''}
|
||||
onChange={(e) => setProp((p: InputFieldProps) => { p.placeholder = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!props.required}
|
||||
onChange={(e) => setProp((p: InputFieldProps) => { p.required = e.target.checked; })}
|
||||
/>
|
||||
Required
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
InputField.craft = {
|
||||
@@ -151,21 +82,22 @@ InputField.craft = {
|
||||
placeholder: 'Enter your name',
|
||||
required: false,
|
||||
style: {},
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: InputFieldSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(InputField as any).toHtml = (props: InputFieldProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
(InputField as any).toHtml = (props: InputFieldProps, _childrenHtml: string, nodeId?: string) => {
|
||||
const wrapStyle = cssPropsToString({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -173,13 +105,23 @@ InputField.craft = {
|
||||
...props.style,
|
||||
});
|
||||
const reqAttr = props.required ? ' required' : '';
|
||||
// Deterministic AND unique id: scoped on the Craft node id so the
|
||||
// <label for> always matches the <input id> AND two InputField instances
|
||||
// that share the same (often default) `name` -- e.g. two untouched
|
||||
// "Input" blocks both named "name" -- don't collide on `field-name` and
|
||||
// clobber each other's for/id wiring. Falls back to the old name-derived
|
||||
// hash for legacy 2-arg call sites without a node id.
|
||||
const fieldId = scopeId(nodeId, props.name || 'field', 'field');
|
||||
const labelHtml = props.label
|
||||
? `<label style="font-size:14px;font-weight:500;color:#18181b">${esc(props.label)}${props.required ? '<span style="color:#ef4444"> *</span>' : ''}</label>`
|
||||
? `<label for="${escapeAttr(fieldId)}" style="font-size:14px;font-weight:500;color:#18181b">${escapeHtml(props.label)}${props.required ? '<span style="color:#ef4444"> *</span>' : ''}</label>`
|
||||
: '';
|
||||
const ariaLabelAttr = !props.label
|
||||
? ` aria-label="${escapeAttr(props.placeholder || props.name || 'Input field')}"`
|
||||
: '';
|
||||
return {
|
||||
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
|
||||
${labelHtml}
|
||||
<input type="${props.type || 'text'}" name="${esc(props.name || 'field')}" placeholder="${esc(props.placeholder || '')}"${reqAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box" />
|
||||
<input id="${escapeAttr(fieldId)}" type="${sanitizeInputType(props.type)}" name="${escapeAttr(props.name || 'field')}" placeholder="${escapeAttr(props.placeholder || '')}"${reqAttr}${ariaLabelAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box" />
|
||||
</div>`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { SubscribeForm } from './SubscribeForm';
|
||||
|
||||
const toHtml = (SubscribeForm as any).toHtml;
|
||||
|
||||
describe('SubscribeForm.toHtml hardcoded attributes stay hardcoded (no raw prop breakout)', () => {
|
||||
test('form method is always POST regardless of any injected props', () => {
|
||||
const { html } = toHtml({ heading: 'Join us', method: 'GET"><script>alert(1)</script>' } as any, '');
|
||||
expect(html).toMatch(/<form action="[^"]*" method="POST"/);
|
||||
expect(html).not.toContain('<script');
|
||||
});
|
||||
|
||||
test('email input type is always "email" regardless of any injected props', () => {
|
||||
const { html } = toHtml({ type: 'text"><img src=x onerror=alert(1)>' } as any, '');
|
||||
expect(html).toContain('<input type="email"');
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).not.toContain('onerror=');
|
||||
});
|
||||
|
||||
test('layout enum only ever feeds one of two fixed literal style strings, never raw', () => {
|
||||
const { html: inlineHtml } = toHtml({ layout: 'inline' }, '');
|
||||
const { html: stackedHtml } = toHtml({ layout: 'stacked' }, '');
|
||||
expect(inlineHtml).toContain('flex-direction:row');
|
||||
expect(stackedHtml).toContain('flex-direction:column');
|
||||
});
|
||||
|
||||
test('malicious layout value cannot inject raw CSS/attribute breakout (falls through the isInline boolean check to the stacked literal)', () => {
|
||||
const { html } = toHtml({ layout: '"><script>alert(1)</script>' as any }, '');
|
||||
expect(html).not.toContain('<script');
|
||||
expect(html).toContain('flex-direction:column');
|
||||
});
|
||||
|
||||
test('normal render still produces expected structure', () => {
|
||||
const { html } = toHtml({ heading: 'Subscribe', placeholder: 'you@example.com', buttonText: 'Go' }, '');
|
||||
expect(html).toContain('Subscribe');
|
||||
expect(html).toContain('placeholder="you@example.com"');
|
||||
expect(html).toContain('>Go<');
|
||||
});
|
||||
});
|
||||
|
||||
// F1: SubscribeForm previously emitted `<form method="POST">` with no action
|
||||
// at all -- a published subscribe form silently did nothing on submit.
|
||||
// Wired through the same relay contract as ContactForm/FormContainer
|
||||
// (utils/form-relay-wiring.ts) so setting a recipient makes it functional.
|
||||
describe('SubscribeForm.toHtml is functional (not a dead POST)', () => {
|
||||
test('without a recipient: still has a real (non-empty) action -- "#" fallback, not a bare method="POST"', () => {
|
||||
const { html } = toHtml({}, '');
|
||||
expect(html).toMatch(/<form action="#" method="POST"/);
|
||||
});
|
||||
|
||||
test('with recipientEmail: emits the relay marker, placeholder action, and honeypot -- a working submission path', () => {
|
||||
const { html } = toHtml({ recipientEmail: 'news@example.com', thankYouUrl: '/thanks' }, '', 'node-sub1');
|
||||
expect(html).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" recipient="news@example.com" thankyou="\/thanks"-->/);
|
||||
expect(html).toMatch(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
|
||||
expect(html).toContain('name="_gotcha"');
|
||||
const mid = html.match(/id="(F_[0-9a-z]+)"/)![1];
|
||||
expect(html).toContain(`__WHP_FORM_ACTION__${mid}__`);
|
||||
});
|
||||
|
||||
test('the email input keeps its name="email" so the relay receives it', () => {
|
||||
const { html } = toHtml({ recipientEmail: 'news@example.com' }, '', 'node-sub2');
|
||||
expect(html).toContain('name="email"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SubscribeForm.toHtml box-model style passthrough', () => {
|
||||
test('margin/border/box-shadow/opacity flow through via the style prop', () => {
|
||||
const { html } = toHtml({ style: { marginTop: '16px', border: '1px solid #ddd', boxShadow: '0 2px 6px rgba(0,0,0,.15)', opacity: '0.85' } }, '');
|
||||
expect(html).toContain('margin-top:16px');
|
||||
expect(html).toContain('border:1px solid #ddd');
|
||||
expect(html).toContain('opacity:0.85');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SubscribeForm.craft.props includes animation/visibility defaults', () => {
|
||||
test('has blank/false defaults', () => {
|
||||
expect(SubscribeForm.craft!.props).toMatchObject({
|
||||
animation: '', animationDelay: '', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { relayFormWiring } from '../../utils/form-relay-wiring';
|
||||
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
||||
|
||||
interface SubscribeFormProps {
|
||||
heading?: string;
|
||||
@@ -9,6 +11,17 @@ interface SubscribeFormProps {
|
||||
buttonColor?: string;
|
||||
layout?: 'inline' | 'stacked';
|
||||
style?: CSSProperties;
|
||||
/** "Send submissions to" address -- same relay contract as ContactForm/
|
||||
* FormContainer (see utils/form-relay-wiring.ts). Blank = no relay; the
|
||||
* published form then has no working action at all, which is the bug
|
||||
* this prop exists to fix. */
|
||||
recipientEmail?: string;
|
||||
thankYouUrl?: string;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
export const SubscribeForm: UserComponent<SubscribeFormProps> = ({
|
||||
@@ -98,132 +111,6 @@ export const SubscribeForm: UserComponent<SubscribeFormProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const SubscribeFormSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as SubscribeFormProps,
|
||||
}));
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
|
||||
};
|
||||
|
||||
const buttonColorPresets = ['#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#f59e0b', '#18181b', '#0ea5e9', '#ec4899'];
|
||||
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a', '#eff6ff', '#f0fdf4', '#fef3c7'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{/* Heading */}
|
||||
<div>
|
||||
<label style={labelStyle}>Heading</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.heading || ''}
|
||||
onChange={(e) => setProp((p: SubscribeFormProps) => { p.heading = e.target.value; })}
|
||||
placeholder="Subscribe to our newsletter"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Placeholder */}
|
||||
<div>
|
||||
<label style={labelStyle}>Placeholder</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.placeholder || ''}
|
||||
onChange={(e) => setProp((p: SubscribeFormProps) => { p.placeholder = e.target.value; })}
|
||||
placeholder="Enter your email"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Button Text */}
|
||||
<div>
|
||||
<label style={labelStyle}>Button Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.buttonText || ''}
|
||||
onChange={(e) => setProp((p: SubscribeFormProps) => { p.buttonText = e.target.value; })}
|
||||
placeholder="Subscribe"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Layout */}
|
||||
<div>
|
||||
<label style={labelStyle}>Layout</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
onClick={() => setProp((p: SubscribeFormProps) => { p.layout = 'inline'; })}
|
||||
style={{
|
||||
flex: 1, padding: '6px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: (props.layout || 'inline') === 'inline' ? '#3b82f6' : '#27272a',
|
||||
color: (props.layout || 'inline') === 'inline' ? '#fff' : '#a1a1aa',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Inline
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setProp((p: SubscribeFormProps) => { p.layout = 'stacked'; })}
|
||||
style={{
|
||||
flex: 1, padding: '6px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.layout === 'stacked' ? '#3b82f6' : '#27272a',
|
||||
color: props.layout === 'stacked' ? '#fff' : '#a1a1aa',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Stacked
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Button Color */}
|
||||
<div>
|
||||
<label style={labelStyle}>Button Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{buttonColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: SubscribeFormProps) => { p.buttonColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: (props.buttonColor || '#3b82f6') === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background */}
|
||||
<div>
|
||||
<label style={labelStyle}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: SubscribeFormProps) => { p.style = { ...p.style, backgroundColor: c }; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
SubscribeForm.craft = {
|
||||
@@ -235,21 +122,24 @@ SubscribeForm.craft = {
|
||||
buttonColor: '#3b82f6',
|
||||
layout: 'inline',
|
||||
style: { backgroundColor: '#f8fafc' },
|
||||
recipientEmail: '',
|
||||
thankYouUrl: '',
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: SubscribeFormSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(SubscribeForm as any).toHtml = (props: SubscribeFormProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
(SubscribeForm as any).toHtml = (props: SubscribeFormProps, _childrenHtml: string, nodeId?: string) => {
|
||||
const {
|
||||
heading = 'Subscribe to our newsletter',
|
||||
placeholder = 'Enter your email',
|
||||
@@ -268,7 +158,7 @@ SubscribeForm.craft = {
|
||||
});
|
||||
|
||||
const headingHtml = heading
|
||||
? `<h3 style="font-size:22px;font-weight:600;color:#1f2937;margin-bottom:20px;font-family:Inter,sans-serif">${esc(heading)}</h3>`
|
||||
? `<h3 style="font-size:22px;font-weight:600;color:#1f2937;margin-bottom:20px;font-family:Inter,sans-serif">${escapeHtml(heading)}</h3>`
|
||||
: '';
|
||||
|
||||
const formStyle = cssPropsToString({
|
||||
@@ -295,12 +185,22 @@ SubscribeForm.craft = {
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
// Same relay contract as ContactForm/FormContainer: a recipientEmail wires
|
||||
// the form through the WHP form-sender relay (marker + placeholder action
|
||||
// + honeypot, provisioned/rewritten at publish time). Previously this form
|
||||
// always emitted `<form method="POST">` with NO action at all -- a
|
||||
// published subscribe form silently did nothing on submit. Falling back to
|
||||
// `formAction`-less relay wiring (fallbackAction undefined -> '#') keeps
|
||||
// the old no-recipient case visually identical (action="#") while making
|
||||
// the relay path actually functional once an admin sets an email.
|
||||
const { marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, undefined, nodeId);
|
||||
|
||||
return {
|
||||
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>
|
||||
html: `${marker}<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>
|
||||
${headingHtml}
|
||||
<form method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
|
||||
<input type="email" name="email" placeholder="${esc(placeholder)}" required style="${inputStyleStr}" />
|
||||
<button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${esc(buttonText)}</button>
|
||||
<form action="${actionAttr}" method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
|
||||
${honeypot ? ` ${honeypot}\n` : ''} <input type="email" name="email" placeholder="${escapeAttr(placeholder)}" required style="${inputStyleStr}" />
|
||||
<button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${escapeHtml(buttonText)}</button>
|
||||
</form>
|
||||
</div>`,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { TextareaField } from './TextareaField';
|
||||
|
||||
const toHtml = (TextareaField as any).toHtml;
|
||||
|
||||
describe('TextareaField.toHtml accessibility (F2.1)', () => {
|
||||
test('label for= matches textarea id=', () => {
|
||||
const { html } = toHtml({ label: 'Message', name: 'message' }, '');
|
||||
const forMatch = html.match(/<label for="([^"]+)"/);
|
||||
const idMatch = html.match(/<textarea id="([^"]+)"/);
|
||||
expect(forMatch).toBeTruthy();
|
||||
expect(idMatch).toBeTruthy();
|
||||
expect(forMatch![1]).toBe(idMatch![1]);
|
||||
});
|
||||
|
||||
test('no visible label: textarea gets aria-label from placeholder', () => {
|
||||
const { html } = toHtml({ label: '', name: 'notes', placeholder: 'Anything else?' }, '');
|
||||
expect(html).not.toContain('<label');
|
||||
expect(html).toContain('aria-label="Anything else?"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TextareaField.toHtml deterministic + unique ids (thread node id, resolves id-collision finding)', () => {
|
||||
test('label for= still matches textarea id= after threading the node id', () => {
|
||||
const { html } = toHtml({ label: 'Message', name: 'message' }, '', 'node-ta1');
|
||||
const forMatch = html.match(/<label for="([^"]+)"/);
|
||||
const idMatch = html.match(/<textarea id="([^"]+)"/);
|
||||
expect(forMatch![1]).toBe(idMatch![1]);
|
||||
});
|
||||
|
||||
test('same node id -> identical output across calls (deterministic, no Math.random)', () => {
|
||||
const { html: html1 } = toHtml({ label: 'Message', name: 'message' }, '', 'node-ta1');
|
||||
const { html: html2 } = toHtml({ label: 'Message', name: 'message' }, '', 'node-ta1');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
|
||||
test('two instances with the SAME default name but different node ids do not collide', () => {
|
||||
const { html: html1 } = toHtml({ label: 'Message', name: 'message' }, '', 'node-ta1');
|
||||
const { html: html2 } = toHtml({ label: 'Message', name: 'message' }, '', 'node-ta2');
|
||||
const id1 = html1.match(/<textarea id="([^"]+)"/)![1];
|
||||
const id2 = html2.match(/<textarea id="([^"]+)"/)![1];
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
|
||||
test('no nodeId (legacy 2-arg call): id derivation stays deterministic, not random', () => {
|
||||
const { html: html1 } = toHtml({ label: 'Message', name: 'message' }, '');
|
||||
const { html: html2 } = toHtml({ label: 'Message', name: 'message' }, '');
|
||||
const id1 = html1.match(/<textarea id="([^"]+)"/)![1];
|
||||
const id2 = html2.match(/<textarea id="([^"]+)"/)![1];
|
||||
expect(id1).toBe(id2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TextareaField.toHtml rows attribute sanitization', () => {
|
||||
test('malicious rows value cannot break out of the attribute; falls back to a numeric rows', () => {
|
||||
const { html } = toHtml({ label: 'Message', name: 'message', rows: '4"><script>alert(1)</script>' as any }, '');
|
||||
expect(html).not.toContain('<script');
|
||||
expect(html).toMatch(/rows="\d+"/);
|
||||
});
|
||||
|
||||
test('legitimate numeric rows still passes through unchanged', () => {
|
||||
const { html } = toHtml({ label: 'Message', name: 'message', rows: 8 }, '');
|
||||
expect(html).toContain('rows="8"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TextareaField.toHtml box-model style passthrough', () => {
|
||||
test('margin/border/box-shadow/opacity flow through via the style prop', () => {
|
||||
const { html } = toHtml({ label: 'Message', name: 'message', style: { marginTop: '10px', border: '1px solid #555', boxShadow: '0 1px 3px rgba(0,0,0,.15)', opacity: '0.7' } }, '');
|
||||
expect(html).toContain('margin-top:10px');
|
||||
expect(html).toContain('border:1px solid #555');
|
||||
expect(html).toContain('opacity:0.7');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TextareaField.craft.props includes animation/visibility defaults', () => {
|
||||
test('has blank/false defaults', () => {
|
||||
expect(TextareaField.craft!.props).toMatchObject({
|
||||
animation: '', animationDelay: '', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, scopeId } from '../../utils/escape';
|
||||
|
||||
interface TextareaFieldProps {
|
||||
label?: string;
|
||||
@@ -9,6 +10,11 @@ interface TextareaFieldProps {
|
||||
rows?: number;
|
||||
required?: boolean;
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
export const TextareaField: UserComponent<TextareaFieldProps> = ({
|
||||
@@ -67,81 +73,6 @@ export const TextareaField: UserComponent<TextareaFieldProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const TextareaFieldSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as TextareaFieldProps,
|
||||
}));
|
||||
|
||||
const rowsPresets = [2, 3, 4, 6, 8];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Label</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.label || ''}
|
||||
onChange={(e) => setProp((p: TextareaFieldProps) => { p.label = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.name || ''}
|
||||
onChange={(e) => setProp((p: TextareaFieldProps) => { p.name = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Placeholder</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.placeholder || ''}
|
||||
onChange={(e) => setProp((p: TextareaFieldProps) => { p.placeholder = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Rows</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{rowsPresets.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setProp((p: TextareaFieldProps) => { p.rows = r; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.rows === r ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!props.required}
|
||||
onChange={(e) => setProp((p: TextareaFieldProps) => { p.required = e.target.checked; })}
|
||||
/>
|
||||
Required
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
TextareaField.craft = {
|
||||
@@ -153,21 +84,22 @@ TextareaField.craft = {
|
||||
rows: 4,
|
||||
required: false,
|
||||
style: {},
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: TextareaFieldSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(TextareaField as any).toHtml = (props: TextareaFieldProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
(TextareaField as any).toHtml = (props: TextareaFieldProps, _childrenHtml: string, nodeId?: string) => {
|
||||
const wrapStyle = cssPropsToString({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -175,13 +107,28 @@ TextareaField.craft = {
|
||||
...props.style,
|
||||
});
|
||||
const reqAttr = props.required ? ' required' : '';
|
||||
// `rows` is declared as a TS `number` but arrives unchecked (AI update_props
|
||||
// path only validates node_id; deserialized saved-state JSON is untyped at
|
||||
// runtime), so a string like `4"><script>...` must be coerced to a real
|
||||
// number before interpolation, not trusted as already-numeric.
|
||||
const rows = Number(props.rows) || 4;
|
||||
// Deterministic AND unique id: scoped on the Craft node id so the
|
||||
// <label for> always matches the <textarea id> AND two TextareaField
|
||||
// instances that share the same (often default) `name` -- e.g. two
|
||||
// untouched "Textarea" blocks both named "message" -- don't collide on
|
||||
// `field-message`. Falls back to the old name-derived hash for legacy
|
||||
// 2-arg call sites without a node id.
|
||||
const fieldId = scopeId(nodeId, props.name || 'message', 'field');
|
||||
const labelHtml = props.label
|
||||
? `<label style="font-size:14px;font-weight:500;color:#18181b">${esc(props.label)}${props.required ? '<span style="color:#ef4444"> *</span>' : ''}</label>`
|
||||
? `<label for="${escapeAttr(fieldId)}" style="font-size:14px;font-weight:500;color:#18181b">${escapeHtml(props.label)}${props.required ? '<span style="color:#ef4444"> *</span>' : ''}</label>`
|
||||
: '';
|
||||
const ariaLabelAttr = !props.label
|
||||
? ` aria-label="${escapeAttr(props.placeholder || props.name || 'Textarea field')}"`
|
||||
: '';
|
||||
return {
|
||||
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
|
||||
${labelHtml}
|
||||
<textarea name="${esc(props.name || 'message')}" placeholder="${esc(props.placeholder || '')}" rows="${props.rows || 4}"${reqAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box;resize:vertical;font-family:inherit"></textarea>
|
||||
<textarea id="${escapeAttr(fieldId)}" name="${escapeAttr(props.name || 'message')}" placeholder="${escapeAttr(props.placeholder || '')}" rows="${escapeAttr(String(rows))}"${reqAttr}${ariaLabelAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box;resize:vertical;font-family:inherit"></textarea>
|
||||
</div>`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { BackgroundSection } from './BackgroundSection';
|
||||
|
||||
const toHtml = (BackgroundSection as any).toHtml;
|
||||
|
||||
describe('BackgroundSection.toHtml anchorId', () => {
|
||||
test('escapes a malicious anchorId (attribute breakout attempt)', () => {
|
||||
const { html } = toHtml({ anchorId: 'x" onmouseover="alert(1)' }, 'child');
|
||||
expect(html).not.toContain('onmouseover="alert(1)"');
|
||||
});
|
||||
|
||||
test('a normal anchorId still renders correctly', () => {
|
||||
const { html } = toHtml({ anchorId: 'my-bg-section' }, 'child');
|
||||
expect(html).toContain('id="my-bg-section"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BackgroundSection.toHtml style-value XSS hardening', () => {
|
||||
test('a malicious bgImage cannot break out of the outer style attribute via url(...)', () => {
|
||||
const malicious = 'javascript:alert(1)) foo{background:red}</style><script>alert(1)</script';
|
||||
const { html } = toHtml({ bgImage: malicious }, 'child');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
expect(html).not.toContain('javascript:alert(1)');
|
||||
});
|
||||
|
||||
test('a malicious bgColor cannot break out of the outer style attribute', () => {
|
||||
const malicious = 'red" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ bgColor: malicious }, 'child');
|
||||
expect(html).not.toContain('onmouseover="alert(1)"');
|
||||
});
|
||||
|
||||
test('a malicious overlayColor cannot break out of the overlay style attribute', () => {
|
||||
const malicious = 'red" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ overlayColor: malicious }, 'child');
|
||||
expect(html).not.toContain('onmouseover="alert(1)"');
|
||||
});
|
||||
|
||||
test('a wrong-typed overlayOpacity (string, not number) cannot break out of the overlay style attribute', () => {
|
||||
const malicious = '0.4" onmouseover="alert(1)' as any;
|
||||
const { html } = toHtml({ overlayOpacity: malicious }, 'child');
|
||||
expect(html).not.toContain('onmouseover="alert(1)"');
|
||||
});
|
||||
|
||||
test('a malicious innerMaxWidth cannot break out of the inner style attribute', () => {
|
||||
const malicious = '1200px" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ innerMaxWidth: malicious }, 'child');
|
||||
expect(html).not.toContain('onmouseover="alert(1)"');
|
||||
});
|
||||
|
||||
test('normal props still render correctly', () => {
|
||||
const { html } = toHtml({ bgImage: 'https://example.com/bg.jpg', bgColor: '#1e293b', overlayColor: '#000000', overlayOpacity: 0.4, innerMaxWidth: '1200px' }, 'child');
|
||||
expect(html).toContain("url('https://example.com/bg.jpg')");
|
||||
expect(html).toContain('background-color:#1e293b');
|
||||
expect(html).toContain('opacity:0.4');
|
||||
expect(html).toContain('max-width:1200px');
|
||||
expect(html).toContain('child');
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import React, { CSSProperties } from 'react';
|
||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||
import { Container } from './Container';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeAttr } from '../../utils/escape';
|
||||
|
||||
interface BackgroundSectionProps {
|
||||
bgImage?: string;
|
||||
@@ -11,6 +12,7 @@ interface BackgroundSectionProps {
|
||||
innerMaxWidth?: string;
|
||||
style?: CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
anchorId?: string;
|
||||
}
|
||||
|
||||
export const BackgroundSection: UserComponent<BackgroundSectionProps> = ({
|
||||
@@ -20,12 +22,14 @@ export const BackgroundSection: UserComponent<BackgroundSectionProps> = ({
|
||||
overlayOpacity = 0.4,
|
||||
innerMaxWidth = '1200px',
|
||||
style = {},
|
||||
anchorId,
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
@@ -65,92 +69,6 @@ export const BackgroundSection: UserComponent<BackgroundSectionProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const BackgroundSectionSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as BackgroundSectionProps,
|
||||
}));
|
||||
|
||||
const bgColorPresets = ['#1e293b', '#0f172a', '#18181b', '#1e3a5f', '#312e81', '#064e3b', '#7f1d1d', '#ffffff'];
|
||||
const overlayPresets = ['#000000', '#1e293b', '#0f172a', '#312e81', '#064e3b', '#7f1d1d'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Image URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.bgImage || ''}
|
||||
onChange={(e) => setProp((p: BackgroundSectionProps) => { p.bgImage = e.target.value; })}
|
||||
placeholder="https://... or /storage/assets/..."
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: BackgroundSectionProps) => { p.bgColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.bgColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Overlay Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{overlayPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: BackgroundSectionProps) => { p.overlayColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.overlayColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>
|
||||
Overlay Opacity: {Math.round((props.overlayOpacity ?? 0.4) * 100)}%
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={Math.round((props.overlayOpacity ?? 0.4) * 100)}
|
||||
onChange={(e) => setProp((p: BackgroundSectionProps) => { p.overlayOpacity = parseInt(e.target.value, 10) / 100; })}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Inner Max Width</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.innerMaxWidth || '1200px'}
|
||||
onChange={(e) => setProp((p: BackgroundSectionProps) => { p.innerMaxWidth = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
BackgroundSection.craft = {
|
||||
@@ -162,15 +80,13 @@ BackgroundSection.craft = {
|
||||
overlayOpacity: 0.4,
|
||||
innerMaxWidth: '1200px',
|
||||
style: { padding: '0' },
|
||||
anchorId: '',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: BackgroundSectionSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
@@ -200,7 +116,8 @@ BackgroundSection.craft = {
|
||||
margin: '0 auto',
|
||||
padding: '60px 20px',
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
return {
|
||||
html: `<section${outerStyle ? ` style="${outerStyle}"` : ''}><div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></section>`,
|
||||
html: `<section${idAttr}${outerStyle ? ` style="${outerStyle}"` : ''}><div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></section>`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { ColumnLayout } from './ColumnLayout';
|
||||
|
||||
const toHtml = (ColumnLayout as any).toHtml;
|
||||
|
||||
describe('ColumnLayout.toHtml width export from split', () => {
|
||||
test('non-default split (70-30) exports per-column width CSS matching each column', () => {
|
||||
const { html } = toHtml({ columns: 2, split: '70-30', gap: '16px' }, '<div>A</div><div>B</div>');
|
||||
// First column gets 70%, second gets 30% (same mapping as getWidths()).
|
||||
expect(html).toMatch(/nth-child\(1\)[^}]*calc\(70% - 16px\)/);
|
||||
expect(html).toMatch(/nth-child\(2\)[^}]*calc\(30% - 16px\)/);
|
||||
});
|
||||
|
||||
test('default 50-50 split still exports equal widths', () => {
|
||||
const { html } = toHtml({ columns: 2, split: '50-50', gap: '16px' }, '<div>A</div><div>B</div>');
|
||||
expect(html).toMatch(/nth-child\(1\)[^}]*calc\(50% - 16px\)/);
|
||||
expect(html).toMatch(/nth-child\(2\)[^}]*calc\(50% - 16px\)/);
|
||||
});
|
||||
|
||||
test('3-column 33-33-33 split exports three width rules', () => {
|
||||
const { html } = toHtml({ columns: 3, split: '33-33-33', gap: '16px' }, '<div>A</div><div>B</div><div>C</div>');
|
||||
expect(html).toMatch(/nth-child\(1\)[^}]*calc\(33\.333% - 16px\)/);
|
||||
expect(html).toMatch(/nth-child\(2\)[^}]*calc\(33\.333% - 16px\)/);
|
||||
expect(html).toMatch(/nth-child\(3\)[^}]*calc\(33\.333% - 16px\)/);
|
||||
});
|
||||
|
||||
test('childrenHtml is preserved in the output', () => {
|
||||
const { html } = toHtml({ columns: 2, split: '70-30', gap: '16px' }, '<div>A</div><div>B</div>');
|
||||
expect(html).toContain('<div>A</div><div>B</div>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ColumnLayout.toHtml deterministic + unique scope ids (thread node id, no Math.random)', () => {
|
||||
const props = { columns: 2, split: '50-50', gap: '16px' };
|
||||
|
||||
test('same node id -> identical output across calls (deterministic)', () => {
|
||||
const { html: html1 } = toHtml(props, '', 'node-col1');
|
||||
const { html: html2 } = toHtml(props, '', 'node-col1');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
|
||||
test('different node ids -> different, non-colliding scope classes (identical columns/split/gap, no collision)', () => {
|
||||
const { html: html1 } = toHtml(props, '', 'node-col1');
|
||||
const { html: html2 } = toHtml(props, '', 'node-col2');
|
||||
const cls1 = html1.match(/class="([^"]+)"/)![1];
|
||||
const cls2 = html2.match(/class="([^"]+)"/)![1];
|
||||
expect(cls1).not.toBe(cls2);
|
||||
});
|
||||
|
||||
test('the <style> nth-child rule and the div class= use the SAME scope', () => {
|
||||
const { html } = toHtml(props, '', 'node-col1');
|
||||
const cls = html.match(/class="([^"]+)"/)![1];
|
||||
expect(html).toContain(`.${cls} > :nth-child(1)`);
|
||||
});
|
||||
|
||||
test('no nodeId (legacy 2-arg call): still deterministic across repeated calls, not random', () => {
|
||||
const { html: html1 } = toHtml(props, '<div>A</div><div>B</div>');
|
||||
const { html: html2 } = toHtml(props, '<div>A</div><div>B</div>');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ColumnLayout.toHtml XSS hardening (gap into <style>)', () => {
|
||||
test('a gap value containing </style><script> is neutralized in the <style>-context nth-child rule', () => {
|
||||
const malicious = '0px)}</style><script>alert(1)</script><style>{';
|
||||
const { html } = toHtml({ columns: 2, split: '50-50', gap: malicious }, '<div>A</div><div>B</div>', 'node-xss');
|
||||
expect(html).not.toContain('</style><script');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('a gap value containing a quote/semicolon breakout is neutralized in the style attribute', () => {
|
||||
const malicious = '16px" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ columns: 2, split: '50-50', gap: malicious }, '', 'node-xss2');
|
||||
expect(html).not.toMatch(/style="[^"]*"[^>]*onmouseover/);
|
||||
});
|
||||
|
||||
test('a normal gap value still renders correctly', () => {
|
||||
const { html } = toHtml({ columns: 2, split: '50-50', gap: '24px' }, '<div>A</div>', 'node-normal');
|
||||
expect(html).toContain('gap:24px');
|
||||
expect(html).toMatch(/calc\(50% - 24px\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ColumnLayout.toHtml vertical alignment (align-items on the flex row)', () => {
|
||||
test('style.alignItems flows into the emitted style attribute (aligns uneven columns)', () => {
|
||||
const { html } = toHtml({ columns: 2, split: '50-50', gap: '16px', style: { alignItems: 'center' } }, '<div>A</div><div>B</div>');
|
||||
expect(html).toContain('align-items:center');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ColumnLayout.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
|
||||
test('margin/padding/border/box-shadow/opacity all flow into the emitted style attribute', () => {
|
||||
const { html } = toHtml(
|
||||
{
|
||||
columns: 2,
|
||||
split: '50-50',
|
||||
gap: '16px',
|
||||
style: {
|
||||
marginTop: '10px', marginRight: '10px', marginBottom: '10px', marginLeft: '10px',
|
||||
paddingTop: '5px',
|
||||
border: '2px solid #ff0000',
|
||||
boxShadow: '0 4px 8px rgba(0,0,0,0.12)',
|
||||
opacity: '0.8',
|
||||
},
|
||||
},
|
||||
'<div>A</div><div>B</div>',
|
||||
);
|
||||
expect(html).toContain('margin-top:10px');
|
||||
expect(html).toContain('padding-top:5px');
|
||||
expect(html).toContain('border:2px solid #ff0000');
|
||||
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
|
||||
expect(html).toContain('opacity:0.8');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ColumnLayout.craft.props exposes the vertical-alignment/box-model/animation/visibility rollout', () => {
|
||||
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
||||
const props = (ColumnLayout as any).craft.props;
|
||||
expect(props.animation).toBe('');
|
||||
expect(props.animationDelay).toBe('0');
|
||||
expect(props.hideOnDesktop).toBe(false);
|
||||
expect(props.hideOnTablet).toBe(false);
|
||||
expect(props.hideOnMobile).toBe(false);
|
||||
});
|
||||
|
||||
test('style carries blank/default alignItems and box-model keys', () => {
|
||||
const style = (ColumnLayout as any).craft.props.style;
|
||||
expect(style).toHaveProperty('alignItems');
|
||||
expect(style).toHaveProperty('marginTop');
|
||||
expect(style).toHaveProperty('paddingTop');
|
||||
expect(style.border).toBe('none');
|
||||
expect(style.boxShadow).toBe('none');
|
||||
expect(style.opacity).toBe('1');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { CSSProperties, useState } from 'react';
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||
import { Container } from './Container';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeAttr, scopeId, cssValue } from '../../utils/escape';
|
||||
|
||||
type SplitOption =
|
||||
| '100'
|
||||
@@ -18,6 +19,12 @@ interface ColumnLayoutProps {
|
||||
gap?: string;
|
||||
style?: CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
anchorId?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
}
|
||||
|
||||
const splitToWidths: Record<string, string[]> = {
|
||||
@@ -59,6 +66,7 @@ export const ColumnLayout: UserComponent<ColumnLayoutProps> = ({
|
||||
split = '50-50',
|
||||
gap = '16px',
|
||||
style = {},
|
||||
anchorId,
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
const widths = getWidths(split, columns);
|
||||
@@ -66,6 +74,7 @@ export const ColumnLayout: UserComponent<ColumnLayoutProps> = ({
|
||||
return (
|
||||
<div
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
@@ -90,177 +99,6 @@ export const ColumnLayout: UserComponent<ColumnLayoutProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const ColumnLayoutSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as ColumnLayoutProps,
|
||||
}));
|
||||
|
||||
const [showCustom, setShowCustom] = useState(false);
|
||||
|
||||
/* Preset options -- common splits up to 6 columns */
|
||||
const presetOptions: { columns: number; split: SplitOption; label: string }[] = [
|
||||
{ columns: 1, split: '100', label: '1 Col' },
|
||||
{ columns: 2, split: '50-50', label: '2 Equal' },
|
||||
{ columns: 2, split: '30-70', label: '30/70' },
|
||||
{ columns: 2, split: '70-30', label: '70/30' },
|
||||
{ columns: 2, split: '40-60', label: '40/60' },
|
||||
{ columns: 2, split: '60-40', label: '60/40' },
|
||||
{ columns: 3, split: '33-33-33', label: '3 Equal' },
|
||||
{ columns: 3, split: '25-50-25', label: '25/50/25' },
|
||||
{ columns: 4, split: '25-25-25-25', label: '4 Equal' },
|
||||
{ columns: 5, split: '20-20-20-20-20', label: '5 Equal' },
|
||||
{ columns: 6, split: '16-16-16-16-16-16', label: '6 Equal' },
|
||||
];
|
||||
|
||||
const gapPresets = ['0px', '8px', '16px', '24px', '32px'];
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{/* Preset layouts */}
|
||||
<div>
|
||||
<label style={labelStyle}>Column Layout</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{presetOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.label}
|
||||
onClick={() => {
|
||||
setProp((p: ColumnLayoutProps) => { p.columns = opt.columns; p.split = opt.split; });
|
||||
setShowCustom(false);
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.split === opt.split && props.columns === opt.columns ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom column count (7-10) */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowCustom(!showCustom)}
|
||||
style={{
|
||||
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: showCustom ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{showCustom ? 'Hide Custom' : 'Custom (7-10 columns)'}
|
||||
</button>
|
||||
{showCustom && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<label style={labelStyle}>Number of Columns</label>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={10}
|
||||
value={props.columns || 2}
|
||||
onChange={(e) => {
|
||||
const cols = parseInt(e.target.value);
|
||||
setProp((p: ColumnLayoutProps) => { p.columns = cols; p.split = 'equal'; });
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: '#e4e4e7', minWidth: 24, textAlign: 'center' }}>{props.columns || 2}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gap */}
|
||||
<div>
|
||||
<label style={labelStyle}>Gap</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{gapPresets.map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
onClick={() => setProp((p: ColumnLayoutProps) => { p.gap = g; })}
|
||||
style={{
|
||||
padding: '2px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.gap === g ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{g}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Individual Column Widths */}
|
||||
<div>
|
||||
<label style={labelStyle}>Column Widths (%)</label>
|
||||
<p style={{ fontSize: 10, color: '#71717a', marginBottom: 6 }}>
|
||||
Adjust each column's width. Values should roughly total 100%.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{Array.from({ length: props.columns || 2 }).map((_, i) => {
|
||||
const currentWidths = getWidths(props.split || 'equal', props.columns || 2);
|
||||
const currentPct = parseFloat(currentWidths[i]) || (100 / (props.columns || 2));
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ fontSize: 10, color: '#71717a', minWidth: 40 }}>Col {i + 1}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={10}
|
||||
max={90}
|
||||
step={5}
|
||||
value={Math.round(currentPct)}
|
||||
onChange={(e) => {
|
||||
const newPct = parseInt(e.target.value);
|
||||
const cols = props.columns || 2;
|
||||
const widths = getWidths(props.split || 'equal', cols).map(w => parseFloat(w));
|
||||
const oldPct = widths[i];
|
||||
const diff = newPct - oldPct;
|
||||
widths[i] = newPct;
|
||||
// Distribute the difference across other columns proportionally
|
||||
const others = widths.filter((_, j) => j !== i);
|
||||
const otherTotal = others.reduce((a, b) => a + b, 0);
|
||||
if (otherTotal > 0) {
|
||||
for (let j = 0; j < widths.length; j++) {
|
||||
if (j !== i) {
|
||||
widths[j] = widths[j] - (diff * (widths[j] / otherTotal));
|
||||
if (widths[j] < 5) widths[j] = 5;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Normalize to 100%
|
||||
const total = widths.reduce((a, b) => a + b, 0);
|
||||
const normalized = widths.map(w => ((w / total) * 100).toFixed(1) + '%');
|
||||
const customSplit = normalized.map(w => parseFloat(w).toFixed(0)).join('-') as SplitOption;
|
||||
setProp((p: ColumnLayoutProps) => { p.split = customSplit; });
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ fontSize: 11, color: '#e4e4e7', minWidth: 35, textAlign: 'right' }}>
|
||||
{Math.round(currentPct)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
ColumnLayout.craft = {
|
||||
@@ -269,22 +107,40 @@ ColumnLayout.craft = {
|
||||
columns: 2,
|
||||
split: '50-50',
|
||||
gap: '16px',
|
||||
style: {},
|
||||
style: {
|
||||
alignItems: '',
|
||||
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
|
||||
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
opacity: '1',
|
||||
},
|
||||
anchorId: '',
|
||||
animation: '',
|
||||
animationDelay: '0',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: ColumnLayoutSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(ColumnLayout as any).toHtml = (props: ColumnLayoutProps, childrenHtml: string) => {
|
||||
const gap = props.gap || '16px';
|
||||
(ColumnLayout as any).toHtml = (props: ColumnLayoutProps, childrenHtml: string, nodeId?: string) => {
|
||||
const columns = props.columns || 2;
|
||||
const split = props.split || '50-50';
|
||||
// Sanitized once here so BOTH the raw <style> nth-child rule below AND the
|
||||
// cssPropsToString-built outerStyle get a safe value -- gap is a raw
|
||||
// string-interpolation sink into a <style> block (worst case: </style>
|
||||
// breakout -> arbitrary <script>), see task-cssxss-brief.md.
|
||||
const gap = cssValue(props.gap) || '16px';
|
||||
const widths = getWidths(split, columns);
|
||||
|
||||
const outerStyle = cssPropsToString({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
@@ -292,7 +148,23 @@ ColumnLayout.craft = {
|
||||
width: '100%',
|
||||
...props.style,
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
|
||||
// Each column is exported as an independently-serialized child node, so
|
||||
// toHtml has no direct handle on individual children to rewrite their
|
||||
// inline flex-basis. Instead, scope an nth-child CSS rule (with
|
||||
// !important, to win over any stale inline flex baked into a child at
|
||||
// creation time) to a generated class -- same width mapping (getWidths)
|
||||
// the editor render uses. Precedent: Menu/Navbar toHtml already emit
|
||||
// scoped <style> blocks for hover CSS. The class is scoped on the Craft
|
||||
// node id so two ColumnLayout instances with identical columns/split/gap
|
||||
// don't collide on the same class and cross-apply each other's widths.
|
||||
const scope = scopeId(nodeId, `${columns}:${split}:${gap}`, 'cols');
|
||||
const widthCss = widths
|
||||
.map((w, i) => `.${scope} > :nth-child(${i + 1}) { flex: 0 0 calc(${w} - ${gap}) !important; }`)
|
||||
.join('\n ');
|
||||
|
||||
return {
|
||||
html: `<div${outerStyle ? ` style="${outerStyle}"` : ''}>${childrenHtml}</div>`,
|
||||
html: `<style>\n ${widthCss}\n</style>\n<div class="${scope}"${idAttr}${outerStyle ? ` style="${outerStyle}"` : ''}>${childrenHtml}</div>`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Container } from './Container';
|
||||
|
||||
const toHtml = (Container as any).toHtml;
|
||||
|
||||
describe('Container.toHtml cssId/cssClass', () => {
|
||||
test('emits id and class when both set', () => {
|
||||
const { html } = toHtml({ cssId: 'my-id', cssClass: 'my-class' }, 'child');
|
||||
expect(html).toContain('id="my-id"');
|
||||
expect(html).toContain('class="my-class"');
|
||||
});
|
||||
|
||||
test('emits neither id nor class when empty/unset', () => {
|
||||
const { html } = toHtml({}, 'child');
|
||||
expect(html).not.toContain(' id="');
|
||||
expect(html).not.toContain(' class="');
|
||||
});
|
||||
|
||||
test('escapes cssId/cssClass values', () => {
|
||||
const { html } = toHtml({ cssId: 'x" onerror="alert(1)', cssClass: 'y" onerror="alert(1)' }, 'child');
|
||||
expect(html).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('cssId takes precedence over anchorId when both set (no duplicate id attrs)', () => {
|
||||
const { html } = toHtml({ cssId: 'explicit-id', anchorId: 'anchor-id' }, 'child');
|
||||
const idMatches = html.match(/ id="/g) || [];
|
||||
expect(idMatches.length).toBe(1);
|
||||
expect(html).toContain('id="explicit-id"');
|
||||
});
|
||||
|
||||
test('falls back to anchorId when cssId is not set', () => {
|
||||
const { html } = toHtml({ anchorId: 'anchor-id' }, 'child');
|
||||
expect(html).toContain('id="anchor-id"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Container.toHtml tag allowlist (adversarial re-review, same class as C1)', () => {
|
||||
test('a malicious tag value falls back to div -- no injected <img>, no broken-out attrs', () => {
|
||||
const { html } = toHtml({ tag: 'div><img src=x onerror=alert(1)' }, 'child');
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).not.toContain('onerror');
|
||||
expect(html.startsWith('<div')).toBe(true);
|
||||
expect(html.endsWith('</div>')).toBe(true);
|
||||
});
|
||||
|
||||
test('a tag value outside the known-safe set falls back to div', () => {
|
||||
const { html } = toHtml({ tag: 'script' }, 'child');
|
||||
expect(html.startsWith('<div')).toBe(true);
|
||||
expect(html).not.toContain('<script');
|
||||
});
|
||||
|
||||
test('a valid tag (section) still emits <section', () => {
|
||||
const { html } = toHtml({ tag: 'section' }, 'child');
|
||||
expect(html).toContain('<section');
|
||||
expect(html).toContain('</section>');
|
||||
});
|
||||
|
||||
test('all other allowlisted tags still work', () => {
|
||||
for (const tag of ['div', 'article', 'header', 'footer', 'main']) {
|
||||
const { html } = toHtml({ tag }, 'child');
|
||||
expect(html.startsWith(`<${tag}`)).toBe(true);
|
||||
expect(html.endsWith(`</${tag}>`)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Container.toHtml vertical alignment (justify-content + min-height)', () => {
|
||||
// Regression lock: Container/Section must NOT unconditionally become a
|
||||
// flex container. Flex-blockifies in-flow children, forcing components
|
||||
// that deliberately render display:inline-block (ButtonLink, Icon) to
|
||||
// stack vertically instead of sitting side-by-side -- a real visual
|
||||
// regression for existing published pages that never touch vertical
|
||||
// alignment.
|
||||
test('does NOT become a flex container when no vertical alignment is set (plain block flow preserved)', () => {
|
||||
const { html } = toHtml({}, 'child');
|
||||
expect(html).not.toContain('display:flex');
|
||||
expect(html).not.toContain('flex-direction');
|
||||
});
|
||||
|
||||
test('does NOT become a flex container from min-height alone (min-height must not itself trigger flex)', () => {
|
||||
const { html } = toHtml({ style: { minHeight: '400px' } }, 'child');
|
||||
expect(html).not.toContain('display:flex');
|
||||
expect(html).not.toContain('flex-direction');
|
||||
expect(html).toContain('min-height:400px');
|
||||
});
|
||||
|
||||
test('becomes a column flex container when style.justifyContent is set (feature still works)', () => {
|
||||
const { html } = toHtml({ style: { justifyContent: 'center' } }, 'child');
|
||||
expect(html).toContain('display:flex');
|
||||
expect(html).toContain('flex-direction:column');
|
||||
expect(html).toContain('justify-content:center');
|
||||
});
|
||||
|
||||
test('style.minHeight flows into the emitted style attribute', () => {
|
||||
const { html } = toHtml({ style: { minHeight: '400px' } }, 'child');
|
||||
expect(html).toContain('min-height:400px');
|
||||
});
|
||||
|
||||
test('justify-content and min-height still flow through in boxed (contentWidth) mode', () => {
|
||||
const { html } = toHtml({ contentWidth: 'boxed', style: { justifyContent: 'flex-end', minHeight: '500px' } }, 'child');
|
||||
expect(html).toContain('display:flex');
|
||||
expect(html).toContain('flex-direction:column');
|
||||
expect(html).toContain('justify-content:flex-end');
|
||||
expect(html).toContain('min-height:500px');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Container.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
|
||||
test('margin/padding/border/box-shadow/opacity all flow into the emitted style attribute', () => {
|
||||
const { html } = toHtml(
|
||||
{
|
||||
style: {
|
||||
marginTop: '10px', marginRight: '10px', marginBottom: '10px', marginLeft: '10px',
|
||||
paddingTop: '5px',
|
||||
border: '2px solid #ff0000',
|
||||
boxShadow: '0 4px 8px rgba(0,0,0,0.12)',
|
||||
opacity: '0.8',
|
||||
},
|
||||
},
|
||||
'child',
|
||||
);
|
||||
expect(html).toContain('margin-top:10px');
|
||||
expect(html).toContain('padding-top:5px');
|
||||
expect(html).toContain('border:2px solid #ff0000');
|
||||
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
|
||||
expect(html).toContain('opacity:0.8');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Container.craft.props exposes the vertical-alignment/box-model/animation/visibility rollout', () => {
|
||||
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
||||
const props = (Container as any).craft.props;
|
||||
expect(props.animation).toBe('');
|
||||
expect(props.animationDelay).toBe('0');
|
||||
expect(props.hideOnDesktop).toBe(false);
|
||||
expect(props.hideOnTablet).toBe(false);
|
||||
expect(props.hideOnMobile).toBe(false);
|
||||
});
|
||||
|
||||
test('style carries blank/default vertical-alignment and box-model keys', () => {
|
||||
const style = (Container as any).craft.props.style;
|
||||
expect(style).toHaveProperty('justifyContent');
|
||||
expect(style).toHaveProperty('minHeight');
|
||||
expect(style).toHaveProperty('marginTop');
|
||||
expect(style).toHaveProperty('paddingTop');
|
||||
expect(style.border).toBe('none');
|
||||
expect(style.boxShadow).toBe('none');
|
||||
expect(style.opacity).toBe('1');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,20 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { SettingsTabs } from '../../ui/SettingsTabs';
|
||||
import { BorderControl } from '../../ui/BorderControl';
|
||||
import { AdvancedTab } from '../../ui/AdvancedTab';
|
||||
import { escapeAttr } from '../../utils/escape';
|
||||
|
||||
// The only tag names Container actually supports (matches the TS union
|
||||
// below and the `tag` default in `.craft.props`). `tag` is settable via the
|
||||
// AI `update_props` path and from deserialized saved state -- neither is
|
||||
// type-checked at runtime -- so a malicious value like
|
||||
// `div><img src=x onerror=alert(1)` must never reach the `<${tag}` template
|
||||
// position in `toHtml`/the live render. Anything not in this allowlist
|
||||
// falls back to `'div'`.
|
||||
const ALLOWED_CONTAINER_TAGS = ['div', 'section', 'article', 'header', 'footer', 'main'] as const;
|
||||
export type ContainerTag = (typeof ALLOWED_CONTAINER_TAGS)[number];
|
||||
|
||||
export const sanitizeContainerTag = (tag: unknown): ContainerTag =>
|
||||
(ALLOWED_CONTAINER_TAGS as readonly unknown[]).includes(tag) ? (tag as ContainerTag) : 'div';
|
||||
|
||||
interface ContainerProps {
|
||||
style?: CSSProperties;
|
||||
@@ -11,6 +22,7 @@ interface ContainerProps {
|
||||
children?: React.ReactNode;
|
||||
cssId?: string;
|
||||
cssClass?: string;
|
||||
anchorId?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
@@ -31,31 +43,58 @@ const flexAlignFromTextAlign = (textAlign: CSSProperties['textAlign']): CSSPrope
|
||||
return {};
|
||||
};
|
||||
|
||||
// Container only becomes display:flex/flex-direction:column at its root
|
||||
// (both in the editor render below and in toHtml) when the user has
|
||||
// actually set `style.justifyContent` (the Vertical Alignment control,
|
||||
// paired with `style.minHeight`) -- i.e. the flex conversion is gated on
|
||||
// vertical-align actually being in use, not unconditional. In-flow children
|
||||
// of a flex container get CSS-blockified, which would force components that
|
||||
// deliberately render `display:inline-block` (ButtonLink, Icon) to stack
|
||||
// vertically instead of sitting side-by-side -- a real visual regression for
|
||||
// any container/section that never touches vertical alignment, not a no-op.
|
||||
// So plain block flow (no `display`/`flex-direction` at all) is preserved
|
||||
// unless vertical-align is set. `flexAlignFromTextAlign` above still
|
||||
// supplies its own conditional flex conversion (cross-axis alignItems from
|
||||
// `textAlign`) independently -- unrelated to this gate.
|
||||
|
||||
export const Container: UserComponent<ContainerProps> = ({
|
||||
style = {},
|
||||
tag = 'div',
|
||||
children,
|
||||
fullWidth = false,
|
||||
contentWidth = 'full',
|
||||
anchorId,
|
||||
cssId,
|
||||
cssClass,
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
|
||||
const safeTag = sanitizeContainerTag(tag);
|
||||
const needsBoxedWrapper = contentWidth === 'boxed';
|
||||
const flexStyles = flexAlignFromTextAlign(style.textAlign);
|
||||
const hasVerticalAlign = !!style.justifyContent;
|
||||
|
||||
const outerStyle: CSSProperties = {
|
||||
minHeight: '40px',
|
||||
...style,
|
||||
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
|
||||
...(fullWidth ? { width: '100vw', marginLeft: 'calc(-50vw + 50%)' } : {}),
|
||||
...(needsBoxedWrapper ? {} : flexStyles),
|
||||
};
|
||||
|
||||
// cssId is the user-facing "CSS ID" advanced field; it takes precedence
|
||||
// over anchorId (the scroll-jump anchor) when both happen to be set, since
|
||||
// only one `id` attribute can be emitted on the element.
|
||||
const idValue = cssId || anchorId || undefined;
|
||||
|
||||
const el = React.createElement(
|
||||
tag,
|
||||
safeTag,
|
||||
{
|
||||
ref: (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); },
|
||||
style: outerStyle,
|
||||
'data-craft-container': 'true',
|
||||
id: idValue,
|
||||
className: cssClass || undefined,
|
||||
},
|
||||
needsBoxedWrapper
|
||||
? React.createElement('div', { style: { maxWidth: '1200px', margin: '0 auto', ...flexStyles } }, children)
|
||||
@@ -65,265 +104,51 @@ export const Container: UserComponent<ContainerProps> = ({
|
||||
return el;
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const cLabelStyle: React.CSSProperties = {
|
||||
fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6,
|
||||
textTransform: 'uppercase', letterSpacing: '0.3px',
|
||||
};
|
||||
const cInputStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '5px 8px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
const cPresetBtnStyle = (active: boolean): React.CSSProperties => ({
|
||||
padding: '3px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46', background: active ? '#3b82f6' : '#27272a', color: active ? '#fff' : '#e4e4e7',
|
||||
});
|
||||
const cSwatchStyle = (color: string, active: boolean): React.CSSProperties => ({
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46', backgroundColor: color, cursor: 'pointer',
|
||||
outline: active ? '2px solid #3b82f6' : 'none', outlineOffset: 1,
|
||||
});
|
||||
|
||||
const cToggleBtnStyle = (active: boolean): React.CSSProperties => ({
|
||||
flex: 1, padding: '5px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: active ? '#3b82f6' : '#27272a',
|
||||
color: active ? '#fff' : '#e4e4e7',
|
||||
fontWeight: active ? 600 : 400,
|
||||
textAlign: 'center',
|
||||
});
|
||||
|
||||
const ContainerSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as ContainerProps,
|
||||
}));
|
||||
|
||||
const bgColors = ['transparent', '#ffffff', '#f9fafb', '#f1f5f9', '#1f2937', '#111827', '#0f172a', '#3b82f6', '#10b981', '#8b5cf6', '#ec4899', '#f59e0b'];
|
||||
const gradients = [
|
||||
{ label: 'None', value: 'none' },
|
||||
{ label: 'Purple', value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
|
||||
{ label: 'Blue', value: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' },
|
||||
{ label: 'Sunset', value: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)' },
|
||||
{ label: 'Dark', value: 'linear-gradient(135deg, #0f172a 0%, #1e3a5f 100%)' },
|
||||
{ label: 'Green', value: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)' },
|
||||
];
|
||||
const alignPresets = [
|
||||
{ label: 'Left', value: 'left', icon: 'fa-align-left' },
|
||||
{ label: 'Center', value: 'center', icon: 'fa-align-center' },
|
||||
{ label: 'Right', value: 'right', icon: 'fa-align-right' },
|
||||
];
|
||||
|
||||
const currentBg = props.style?.backgroundColor || '';
|
||||
const currentBgImage = props.style?.backgroundImage || '';
|
||||
|
||||
return (
|
||||
<SettingsTabs
|
||||
general={
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{/* Tag */}
|
||||
<div>
|
||||
<label style={cLabelStyle}>HTML Element</label>
|
||||
<select
|
||||
value={props.tag || 'div'}
|
||||
onChange={(e) => setProp((p: ContainerProps) => { p.tag = e.target.value as ContainerProps['tag']; })}
|
||||
style={cInputStyle}
|
||||
>
|
||||
{['div', 'section', 'article', 'header', 'footer', 'main'].map((t) => (
|
||||
<option key={t} value={t}><{t}></option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Full Width */}
|
||||
<div>
|
||||
<label style={{ ...cLabelStyle, display: 'flex', alignItems: 'center', gap: 6, textTransform: 'none', fontWeight: 500, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.fullWidth || false}
|
||||
onChange={(e) => setProp((p: ContainerProps) => { p.fullWidth = e.target.checked; })}
|
||||
/>
|
||||
Full Width
|
||||
</label>
|
||||
<span style={{ fontSize: 10, color: '#71717a', lineHeight: '1.3', display: 'block', marginTop: 2 }}>
|
||||
Breaks out of parent constraints to fill the viewport width
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content Width */}
|
||||
<div>
|
||||
<label style={cLabelStyle}>Content Width</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
onClick={() => setProp((p: ContainerProps) => { p.contentWidth = 'full'; })}
|
||||
style={cToggleBtnStyle((props.contentWidth || 'full') === 'full')}
|
||||
>
|
||||
Full
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setProp((p: ContainerProps) => { p.contentWidth = 'boxed'; })}
|
||||
style={cToggleBtnStyle(props.contentWidth === 'boxed')}
|
||||
>
|
||||
Boxed (1200px)
|
||||
</button>
|
||||
</div>
|
||||
<span style={{ fontSize: 10, color: '#71717a', lineHeight: '1.3', display: 'block', marginTop: 4 }}>
|
||||
{props.contentWidth === 'boxed'
|
||||
? 'Content is centered with a max-width of 1200px'
|
||||
: 'Content fills the full container width'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
style={
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{/* Background Color */}
|
||||
<div>
|
||||
<label style={cLabelStyle}>Background Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgColors.map((c) => (
|
||||
<button key={c} onClick={() => setProp((p: ContainerProps) => { p.style = { ...p.style, backgroundColor: c, backgroundImage: 'none' }; })}
|
||||
style={cSwatchStyle(c === 'transparent' ? '#fff' : c, currentBg === c)} title={c} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background Gradient */}
|
||||
<div>
|
||||
<label style={cLabelStyle}>Gradient</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{gradients.map((g) => (
|
||||
<button key={g.value} onClick={() => setProp((p: ContainerProps) => {
|
||||
p.style = { ...p.style, backgroundImage: g.value === 'none' ? 'none' : g.value, backgroundColor: 'transparent' };
|
||||
})} style={{
|
||||
width: 32, height: 24, borderRadius: 4, cursor: 'pointer',
|
||||
border: currentBgImage === g.value ? '2px solid #3b82f6' : '1px solid #3f3f46',
|
||||
background: g.value === 'none' ? '#27272a' : g.value,
|
||||
}} title={g.label} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background Image */}
|
||||
<div>
|
||||
<label style={cLabelStyle}>Background Image</label>
|
||||
<input type="text" placeholder="Image URL..."
|
||||
value={(props.style?.backgroundImage || '').replace(/^url\(['"]?|['"]?\)$/g, '')}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value.trim();
|
||||
setProp((p: ContainerProps) => {
|
||||
p.style = { ...p.style, backgroundImage: val ? `url('${val}')` : 'none', backgroundSize: 'cover', backgroundPosition: 'center' };
|
||||
});
|
||||
}}
|
||||
style={cInputStyle} />
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
|
||||
{['cover', 'contain', 'auto'].map((s) => (
|
||||
<button key={s} onClick={() => setProp((p: ContainerProps) => { p.style = { ...p.style, backgroundSize: s }; })}
|
||||
style={cPresetBtnStyle(props.style?.backgroundSize === s)}>{s}</button>
|
||||
))}
|
||||
{['center', 'top', 'bottom'].map((pos) => (
|
||||
<button key={pos} onClick={() => setProp((p: ContainerProps) => { p.style = { ...p.style, backgroundPosition: pos }; })}
|
||||
style={cPresetBtnStyle(props.style?.backgroundPosition === pos)}>{pos}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overlay */}
|
||||
<div>
|
||||
<label style={cLabelStyle}>Overlay Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input type="color" value={props.style?.['--overlayColor' as keyof CSSProperties] || '#000000'}
|
||||
onChange={(e) => setProp((p: ContainerProps) => { p.style = { ...p.style, ['--overlayColor' as keyof CSSProperties]: e.target.value }; })}
|
||||
style={{ width: 32, height: 24, border: 'none', background: 'none', cursor: 'pointer' }} />
|
||||
<span style={{ fontSize: 11, color: '#71717a' }}>Overlay (via CSS custom property)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parallax */}
|
||||
<div>
|
||||
<label style={{ ...cLabelStyle, display: 'flex', alignItems: 'center', gap: 6, textTransform: 'none', fontWeight: 500 }}>
|
||||
<input type="checkbox"
|
||||
checked={props.style?.backgroundAttachment === 'fixed'}
|
||||
onChange={(e) => setProp((p: ContainerProps) => { p.style = { ...p.style, backgroundAttachment: e.target.checked ? 'fixed' : 'scroll' }; })} />
|
||||
Parallax Effect
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Text Alignment */}
|
||||
<div>
|
||||
<label style={cLabelStyle}>Content Alignment</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{alignPresets.map((a) => (
|
||||
<button key={a.value} onClick={() => setProp((p: ContainerProps) => { p.style = { ...p.style, textAlign: a.value as any }; })}
|
||||
style={{ ...cPresetBtnStyle(props.style?.textAlign === a.value), flex: 1 }}>
|
||||
<i className={`fa ${a.icon}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Border */}
|
||||
<BorderControl
|
||||
style={props.style || {}}
|
||||
onChange={(updates) => setProp((p: ContainerProps) => { p.style = { ...p.style, ...updates }; })}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
advanced={
|
||||
<AdvancedTab
|
||||
style={props.style || {}}
|
||||
onStyleChange={(updates) => setProp((p: ContainerProps) => { p.style = { ...p.style, ...updates }; })}
|
||||
showTagSelector
|
||||
tag={props.tag || 'div'}
|
||||
onTagChange={(tag) => setProp((p: ContainerProps) => { p.tag = tag as ContainerProps['tag']; })}
|
||||
cssId={props.cssId || ''}
|
||||
onCssIdChange={(id) => setProp((p: ContainerProps) => { p.cssId = id; })}
|
||||
cssClass={props.cssClass || ''}
|
||||
onCssClassChange={(cls) => setProp((p: ContainerProps) => { p.cssClass = cls; })}
|
||||
hideOnDesktop={props.hideOnDesktop}
|
||||
onHideOnDesktopChange={(v) => setProp((p: ContainerProps) => { p.hideOnDesktop = v; })}
|
||||
hideOnTablet={props.hideOnTablet}
|
||||
onHideOnTabletChange={(v) => setProp((p: ContainerProps) => { p.hideOnTablet = v; })}
|
||||
hideOnMobile={props.hideOnMobile}
|
||||
onHideOnMobileChange={(v) => setProp((p: ContainerProps) => { p.hideOnMobile = v; })}
|
||||
animation={props.animation}
|
||||
onAnimationChange={(v) => setProp((p: ContainerProps) => { p.animation = v; })}
|
||||
animationDelay={props.animationDelay}
|
||||
onAnimationDelayChange={(v) => setProp((p: ContainerProps) => { p.animationDelay = v; })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Container.craft = {
|
||||
displayName: 'Container',
|
||||
props: {
|
||||
style: { padding: '20px', minHeight: '100px' },
|
||||
style: {
|
||||
padding: '20px',
|
||||
minHeight: '100px',
|
||||
justifyContent: '',
|
||||
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
|
||||
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
opacity: '1',
|
||||
},
|
||||
tag: 'div',
|
||||
fullWidth: false,
|
||||
contentWidth: 'full',
|
||||
anchorId: '',
|
||||
cssId: '',
|
||||
cssClass: '',
|
||||
animation: '',
|
||||
animationDelay: '0',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => true,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: ContainerSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Container as any).toHtml = (props: ContainerProps, childrenHtml: string) => {
|
||||
const tag = props.tag || 'div';
|
||||
const tag = sanitizeContainerTag(props.tag);
|
||||
const isBoxed = props.contentWidth === 'boxed';
|
||||
const flexStyles = flexAlignFromTextAlign(props.style?.textAlign);
|
||||
const hasVerticalAlign = !!props.style?.justifyContent;
|
||||
|
||||
const outerCss: CSSProperties = {
|
||||
...props.style,
|
||||
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
|
||||
...(isBoxed ? {} : flexStyles),
|
||||
};
|
||||
|
||||
@@ -333,11 +158,15 @@ Container.craft = {
|
||||
}
|
||||
|
||||
const styleStr = cssPropsToString(outerCss);
|
||||
// cssId wins over anchorId when both are set (see the render fn above for why).
|
||||
const idValue = props.cssId || props.anchorId;
|
||||
const idAttr = idValue ? ` id="${escapeAttr(idValue)}"` : '';
|
||||
const classAttr = props.cssClass ? ` class="${escapeAttr(props.cssClass)}"` : '';
|
||||
|
||||
if (isBoxed) {
|
||||
const innerStyle = cssPropsToString({ maxWidth: '1200px', margin: '0 auto', ...flexStyles });
|
||||
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></${tag}>` };
|
||||
return { html: `<${tag}${idAttr}${classAttr}${styleStr ? ` style="${styleStr}"` : ''}><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></${tag}>` };
|
||||
}
|
||||
|
||||
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</${tag}>` };
|
||||
return { html: `<${tag}${idAttr}${classAttr}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</${tag}>` };
|
||||
};
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||
import { Container } from './Container';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface FooterZoneProps {
|
||||
style?: CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FooterZone: UserComponent<FooterZoneProps> = ({ style = {}, children }) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
|
||||
return (
|
||||
<footer
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
data-zone="footer"
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '50px',
|
||||
borderTop: '1px solid rgba(148,163,184,0.15)',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<Element id="footer-content" is={Container} canvas tag="div" style={{ padding: '0' }}>
|
||||
{children}
|
||||
</Element>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
const FooterZoneSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as FooterZoneProps,
|
||||
}));
|
||||
|
||||
const bgPresets = ['#ffffff', '#f9fafb', '#1f2937', '#111827', '#0f172a'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<p style={{ fontSize: 11, color: '#f59e0b', margin: 0 }}>
|
||||
<strong>Footer Zone</strong> -- This section appears on all pages.
|
||||
</p>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 }}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{bgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: FooterZoneProps) => { p.style = { ...p.style, backgroundColor: c }; })}
|
||||
style={{ width: 28, height: 28, borderRadius: 4, border: '1px solid #3f3f46', background: c, cursor: 'pointer' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FooterZone.craft = {
|
||||
displayName: 'Footer Zone',
|
||||
props: {
|
||||
style: { backgroundColor: '#0f172a', color: '#94a3b8', padding: '40px 20px', textAlign: 'center' as const },
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => false,
|
||||
canMoveIn: () => true,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: FooterZoneSettings,
|
||||
},
|
||||
};
|
||||
|
||||
(FooterZone as any).toHtml = (props: FooterZoneProps, childrenHtml: string) => {
|
||||
const styleStr = cssPropsToString({
|
||||
width: '100%',
|
||||
...props.style,
|
||||
});
|
||||
return { html: `<footer${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</footer>` };
|
||||
};
|
||||
@@ -1,81 +0,0 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||
import { Container } from './Container';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface HeaderZoneProps {
|
||||
style?: CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const HeaderZone: UserComponent<HeaderZoneProps> = ({ style = {}, children }) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
|
||||
return (
|
||||
<header
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
data-zone="header"
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '50px',
|
||||
borderBottom: '1px solid rgba(148,163,184,0.15)',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<Element id="header-content" is={Container} canvas tag="div" style={{ padding: '0' }}>
|
||||
{children}
|
||||
</Element>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
const HeaderZoneSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as HeaderZoneProps,
|
||||
}));
|
||||
|
||||
const bgPresets = ['#ffffff', '#f9fafb', '#1f2937', '#111827', '#0f172a'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<p style={{ fontSize: 11, color: '#f59e0b', margin: 0 }}>
|
||||
<strong>Header Zone</strong> -- This section appears on all pages.
|
||||
</p>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 }}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{bgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: HeaderZoneProps) => { p.style = { ...p.style, backgroundColor: c }; })}
|
||||
style={{ width: 28, height: 28, borderRadius: 4, border: '1px solid #3f3f46', background: c, cursor: 'pointer' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
HeaderZone.craft = {
|
||||
displayName: 'Header Zone',
|
||||
props: {
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => false, // Header stays at the top, can't be moved
|
||||
canMoveIn: () => true,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: HeaderZoneSettings,
|
||||
},
|
||||
};
|
||||
|
||||
(HeaderZone as any).toHtml = (props: HeaderZoneProps, childrenHtml: string) => {
|
||||
const styleStr = cssPropsToString({
|
||||
width: '100%',
|
||||
...props.style,
|
||||
});
|
||||
return { html: `<header${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</header>` };
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Section } from './Section';
|
||||
|
||||
const toHtml = (Section as any).toHtml;
|
||||
|
||||
describe('Section.toHtml anchorId', () => {
|
||||
test('escapes a malicious anchorId (attribute breakout attempt)', () => {
|
||||
const { html } = toHtml({ anchorId: 'x" onmouseover="alert(1)' }, 'child');
|
||||
expect(html).not.toContain('onmouseover="alert(1)"');
|
||||
});
|
||||
|
||||
test('a normal anchorId still renders correctly', () => {
|
||||
const { html } = toHtml({ anchorId: 'my-section' }, 'child');
|
||||
expect(html).toContain('id="my-section"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Section.toHtml childrenHtml passthrough', () => {
|
||||
test('children are preserved', () => {
|
||||
const { html } = toHtml({}, '<p>hello</p>');
|
||||
expect(html).toContain('<p>hello</p>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Section.toHtml shape divider color/height XSS hardening', () => {
|
||||
test('a malicious topDividerColor cannot break out of the SVG style attribute', () => {
|
||||
const malicious = 'red" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ topDivider: 'wave', topDividerColor: malicious }, '');
|
||||
expect(html).not.toContain('onmouseover="alert(1)"');
|
||||
});
|
||||
|
||||
test('a malicious topDividerColor cannot inject a </style><script> breakout', () => {
|
||||
const malicious = 'red</style><script>alert(1)</script>';
|
||||
const { html } = toHtml({ topDivider: 'wave', topDividerColor: malicious }, '');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('a malicious bottomDividerHeight cannot break out of the wrapper style attribute', () => {
|
||||
const malicious = '50px" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ bottomDivider: 'angle', bottomDividerHeight: malicious }, '');
|
||||
expect(html).not.toContain('onmouseover="alert(1)"');
|
||||
});
|
||||
|
||||
test('a normal divider color/height still renders correctly', () => {
|
||||
const { html } = toHtml({ topDivider: 'wave', topDividerColor: '#123456', topDividerHeight: '80px' }, '');
|
||||
expect(html).toContain('fill:#123456');
|
||||
expect(html).toContain('height:80px');
|
||||
});
|
||||
|
||||
test('divider shape "none" emits no divider markup', () => {
|
||||
const { html } = toHtml({ topDivider: 'none' }, 'child');
|
||||
expect(html).not.toContain('<svg');
|
||||
});
|
||||
|
||||
test('an unrecognized divider shape value emits no divider markup and no injected content', () => {
|
||||
const malicious = 'wave"><script>alert(1)</script>' as any;
|
||||
const { html } = toHtml({ topDivider: malicious }, 'child');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
expect(html).not.toContain('<svg');
|
||||
});
|
||||
|
||||
test('a prototype-property-name divider shape (__proto__) does not leak [object Object]/function source into the SVG path', () => {
|
||||
const { html } = toHtml({ topDivider: '__proto__' as any }, 'child');
|
||||
expect(html).not.toContain('[object');
|
||||
expect(html).not.toContain('native code');
|
||||
expect(html).not.toContain('<svg');
|
||||
});
|
||||
|
||||
test('a prototype-property-name divider shape (toString) does not leak Object.prototype.toString source into the SVG path', () => {
|
||||
const { html } = toHtml({ topDivider: 'toString' as any }, 'child');
|
||||
expect(html).not.toContain('[object');
|
||||
expect(html).not.toContain('native code');
|
||||
expect(html).not.toContain('<svg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Section.toHtml vertical alignment (justify-content + min-height)', () => {
|
||||
// Regression lock: same rationale as Container -- see Container.toHtml.test.ts.
|
||||
// Section must not unconditionally become a flex container, or it
|
||||
// blockifies inline-block children (ButtonLink, Icon) that are meant to
|
||||
// sit side-by-side in existing published sections.
|
||||
test('does NOT become a flex container when no vertical alignment is set (plain block flow preserved)', () => {
|
||||
const { html } = toHtml({}, 'child');
|
||||
expect(html).not.toContain('display:flex');
|
||||
expect(html).not.toContain('flex-direction');
|
||||
});
|
||||
|
||||
test('does NOT become a flex container from min-height alone (min-height must not itself trigger flex)', () => {
|
||||
const { html } = toHtml({ style: { minHeight: '600px' } }, 'child');
|
||||
expect(html).not.toContain('display:flex');
|
||||
expect(html).not.toContain('flex-direction');
|
||||
expect(html).toContain('min-height:600px');
|
||||
});
|
||||
|
||||
test('becomes a column flex container when style.justifyContent is set (feature still works)', () => {
|
||||
const { html } = toHtml({ style: { justifyContent: 'center' } }, 'child');
|
||||
expect(html).toContain('display:flex');
|
||||
expect(html).toContain('flex-direction:column');
|
||||
expect(html).toContain('justify-content:center');
|
||||
});
|
||||
|
||||
test('style.minHeight flows into the emitted style attribute', () => {
|
||||
const { html } = toHtml({ style: { minHeight: '600px' } }, 'child');
|
||||
expect(html).toContain('min-height:600px');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Section.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
|
||||
test('margin/padding/border/box-shadow/opacity all flow into the emitted style attribute', () => {
|
||||
const { html } = toHtml(
|
||||
{
|
||||
style: {
|
||||
marginTop: '10px', marginRight: '10px', marginBottom: '10px', marginLeft: '10px',
|
||||
paddingTop: '5px',
|
||||
border: '2px solid #ff0000',
|
||||
boxShadow: '0 4px 8px rgba(0,0,0,0.12)',
|
||||
opacity: '0.8',
|
||||
},
|
||||
},
|
||||
'child',
|
||||
);
|
||||
expect(html).toContain('margin-top:10px');
|
||||
expect(html).toContain('padding-top:5px');
|
||||
expect(html).toContain('border:2px solid #ff0000');
|
||||
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
|
||||
expect(html).toContain('opacity:0.8');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Section.craft.props exposes the vertical-alignment/box-model/animation/visibility rollout', () => {
|
||||
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
||||
const props = (Section as any).craft.props;
|
||||
expect(props.animation).toBe('');
|
||||
expect(props.animationDelay).toBe('0');
|
||||
expect(props.hideOnDesktop).toBe(false);
|
||||
expect(props.hideOnTablet).toBe(false);
|
||||
expect(props.hideOnMobile).toBe(false);
|
||||
});
|
||||
|
||||
test('style carries blank/default vertical-alignment and box-model keys', () => {
|
||||
const style = (Section as any).craft.props.style;
|
||||
expect(style).toHaveProperty('justifyContent');
|
||||
expect(style).toHaveProperty('minHeight');
|
||||
expect(style).toHaveProperty('marginTop');
|
||||
expect(style).toHaveProperty('paddingTop');
|
||||
expect(style.border).toBe('none');
|
||||
expect(style.boxShadow).toBe('none');
|
||||
expect(style.opacity).toBe('1');
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import React, { CSSProperties } from 'react';
|
||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { Container } from './Container';
|
||||
import { escapeAttr, cssValue } from '../../utils/escape';
|
||||
|
||||
/* ---------- Shape Divider SVG Paths ---------- */
|
||||
|
||||
@@ -15,8 +16,6 @@ const DIVIDER_PATHS: Record<Exclude<DividerShape, 'none'>, string> = {
|
||||
zigzag: 'M0,120 L100,40 L200,120 L300,40 L400,120 L500,40 L600,120 L700,40 L800,120 L900,40 L1000,120 L1100,40 L1200,120 Z',
|
||||
};
|
||||
|
||||
const DIVIDER_SHAPES: DividerShape[] = ['none', 'wave', 'angle', 'curve', 'triangle', 'zigzag'];
|
||||
|
||||
interface SectionProps {
|
||||
style?: CSSProperties;
|
||||
innerMaxWidth?: string;
|
||||
@@ -27,6 +26,12 @@ interface SectionProps {
|
||||
bottomDivider?: DividerShape;
|
||||
bottomDividerColor?: string;
|
||||
bottomDividerHeight?: string;
|
||||
anchorId?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
}
|
||||
|
||||
/* ---------- Divider renderer ---------- */
|
||||
@@ -38,7 +43,14 @@ const ShapeDivider: React.FC<{
|
||||
position: 'top' | 'bottom';
|
||||
}> = ({ shape, color, height, position }) => {
|
||||
if (!shape || shape === 'none') return null;
|
||||
const path = DIVIDER_PATHS[shape];
|
||||
// `shape` is attacker-controlled (AI update_props / deserialized state) and
|
||||
// not runtime-type-checked. A plain-object index lookup with a string key
|
||||
// like '__proto__', 'toString', or 'constructor' returns an INHERITED
|
||||
// Object.prototype value (not undefined), which would otherwise leak
|
||||
// "[object Object]" / a function's source text into the SVG `d` attribute
|
||||
// below. hasOwnProperty restricts the lookup to the real allowlisted keys.
|
||||
if (!Object.prototype.hasOwnProperty.call(DIVIDER_PATHS, shape)) return null;
|
||||
const path = DIVIDER_PATHS[shape as Exclude<DividerShape, 'none'>];
|
||||
if (!path) return null;
|
||||
|
||||
const isTop = position === 'top';
|
||||
@@ -85,19 +97,29 @@ export const Section: UserComponent<SectionProps> = ({
|
||||
bottomDivider = 'none',
|
||||
bottomDividerColor = '#ffffff',
|
||||
bottomDividerHeight = '50px',
|
||||
anchorId,
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
|
||||
const hasTopDivider = topDivider && topDivider !== 'none';
|
||||
const hasBottomDivider = bottomDivider && bottomDivider !== 'none';
|
||||
// Section's root only becomes a column flex container when the user has
|
||||
// actually set `style.justifyContent` (Vertical Alignment control, paired
|
||||
// with `style.minHeight`) -- see the matching note in Container.tsx for
|
||||
// why an unconditional conversion is a real regression (blockifies
|
||||
// deliberately inline-block children like ButtonLink/Icon) rather than a
|
||||
// no-op, so plain block flow is preserved unless vertical-align is set.
|
||||
const hasVerticalAlign = !!style.justifyContent;
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null) => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
width: '100%',
|
||||
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
|
||||
...style,
|
||||
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
|
||||
}}
|
||||
>
|
||||
{hasTopDivider && (
|
||||
@@ -129,203 +151,22 @@ export const Section: UserComponent<SectionProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const sLabelStyle: React.CSSProperties = {
|
||||
fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6,
|
||||
textTransform: 'uppercase', letterSpacing: '0.3px',
|
||||
};
|
||||
|
||||
const sInputStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
|
||||
const sSelectStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '5px 8px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
|
||||
const DividerSettings: React.FC<{
|
||||
label: string;
|
||||
shape: DividerShape;
|
||||
color: string;
|
||||
height: string;
|
||||
onShapeChange: (s: DividerShape) => void;
|
||||
onColorChange: (c: string) => void;
|
||||
onHeightChange: (h: string) => void;
|
||||
}> = ({ label, shape, color, height, onShapeChange, onColorChange, onHeightChange }) => {
|
||||
const heightNum = parseInt(height, 10) || 50;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<label style={sLabelStyle}>{label}</label>
|
||||
|
||||
{/* Shape selector */}
|
||||
<select
|
||||
value={shape || 'none'}
|
||||
onChange={(e) => onShapeChange(e.target.value as DividerShape)}
|
||||
style={sSelectStyle}
|
||||
>
|
||||
{DIVIDER_SHAPES.map((s) => (
|
||||
<option key={s} value={s}>{s === 'none' ? 'None' : s.charAt(0).toUpperCase() + s.slice(1)}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{shape && shape !== 'none' && (
|
||||
<>
|
||||
{/* Color picker */}
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
type="color"
|
||||
value={color || '#ffffff'}
|
||||
onChange={(e) => onColorChange(e.target.value)}
|
||||
style={{ width: 32, height: 28, border: '1px solid #3f3f46', borderRadius: 4, background: 'none', cursor: 'pointer', padding: 0 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={color || '#ffffff'}
|
||||
onChange={(e) => onColorChange(e.target.value)}
|
||||
style={{ ...sInputStyle, flex: 1 }}
|
||||
placeholder="#ffffff"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Height slider */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 10, color: '#71717a' }}>Height</span>
|
||||
<span style={{ fontSize: 10, color: '#a1a1aa' }}>{heightNum}px</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={10}
|
||||
max={200}
|
||||
value={heightNum}
|
||||
onChange={(e) => onHeightChange(`${e.target.value}px`)}
|
||||
style={{ width: '100%', accentColor: '#3b82f6' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Small SVG preview */}
|
||||
<div style={{ background: '#18181b', borderRadius: 4, padding: 4, border: '1px solid #3f3f46', overflow: 'hidden' }}>
|
||||
<svg viewBox="0 0 1200 120" preserveAspectRatio="none" style={{ width: '100%', height: 30, fill: color || '#ffffff', display: 'block' }}>
|
||||
<path d={DIVIDER_PATHS[shape]} />
|
||||
</svg>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SectionSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as SectionProps,
|
||||
}));
|
||||
|
||||
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#0f172a', '#1e293b', '#18181b', '#f0fdf4', '#eff6ff'];
|
||||
const paddingPresets = ['0px', '20px', '40px', '60px', '80px', '120px'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: SectionProps) => { p.style = { ...p.style, backgroundColor: c }; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Gradient</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. linear-gradient(135deg, #667eea, #764ba2)"
|
||||
value={(props.style?.background as string) || ''}
|
||||
onChange={(e) => setProp((p: SectionProps) => { p.style = { ...p.style, background: e.target.value }; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Padding (top/bottom)</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{paddingPresets.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setProp((pr: SectionProps) => {
|
||||
pr.style = { ...pr.style, paddingTop: p, paddingBottom: p };
|
||||
})}
|
||||
style={{
|
||||
padding: '2px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.style?.paddingTop === p ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Inner Max Width</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.innerMaxWidth || '1200px'}
|
||||
onChange={(e) => setProp((p: SectionProps) => { p.innerMaxWidth = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Divider separator */}
|
||||
<div style={{ borderTop: '1px solid #3f3f46', paddingTop: 10 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#e4e4e7', marginBottom: 10 }}>Shape Dividers</div>
|
||||
|
||||
<DividerSettings
|
||||
label="Top Divider"
|
||||
shape={props.topDivider || 'none'}
|
||||
color={props.topDividerColor || '#ffffff'}
|
||||
height={props.topDividerHeight || '50px'}
|
||||
onShapeChange={(s) => setProp((p: SectionProps) => { p.topDivider = s; })}
|
||||
onColorChange={(c) => setProp((p: SectionProps) => { p.topDividerColor = c; })}
|
||||
onHeightChange={(h) => setProp((p: SectionProps) => { p.topDividerHeight = h; })}
|
||||
/>
|
||||
|
||||
<div style={{ height: 10 }} />
|
||||
|
||||
<DividerSettings
|
||||
label="Bottom Divider"
|
||||
shape={props.bottomDivider || 'none'}
|
||||
color={props.bottomDividerColor || '#ffffff'}
|
||||
height={props.bottomDividerHeight || '50px'}
|
||||
onShapeChange={(s) => setProp((p: SectionProps) => { p.bottomDivider = s; })}
|
||||
onColorChange={(c) => setProp((p: SectionProps) => { p.bottomDividerColor = c; })}
|
||||
onHeightChange={(h) => setProp((p: SectionProps) => { p.bottomDividerHeight = h; })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Section.craft = {
|
||||
displayName: 'Section',
|
||||
props: {
|
||||
style: { padding: '40px 0', backgroundColor: '#ffffff' },
|
||||
style: {
|
||||
padding: '40px 0',
|
||||
backgroundColor: '#ffffff',
|
||||
minHeight: '',
|
||||
justifyContent: '',
|
||||
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
|
||||
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
opacity: '1',
|
||||
},
|
||||
innerMaxWidth: '1200px',
|
||||
topDivider: 'none',
|
||||
topDividerColor: '#ffffff',
|
||||
@@ -333,15 +174,18 @@ Section.craft = {
|
||||
bottomDivider: 'none',
|
||||
bottomDividerColor: '#ffffff',
|
||||
bottomDividerHeight: '50px',
|
||||
anchorId: '',
|
||||
animation: '',
|
||||
animationDelay: '0',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => true,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: SectionSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
@@ -353,12 +197,16 @@ function buildDividerHtml(
|
||||
position: 'top' | 'bottom',
|
||||
): string {
|
||||
if (!shape || shape === 'none') return '';
|
||||
const path = DIVIDER_PATHS[shape];
|
||||
// See the matching hasOwnProperty guard in <ShapeDivider> above -- same
|
||||
// prototype-pollution-shaped lookup, same fix.
|
||||
if (!Object.prototype.hasOwnProperty.call(DIVIDER_PATHS, shape)) return '';
|
||||
const path = DIVIDER_PATHS[shape as Exclude<DividerShape, 'none'>];
|
||||
if (!path) return '';
|
||||
|
||||
const isTop = position === 'top';
|
||||
const h = height || '50px';
|
||||
const c = color || '#ffffff';
|
||||
// Sanitized -- raw string-interpolation sink in the SVG `fill:${c}` below.
|
||||
const c = cssValue(color) || '#ffffff';
|
||||
|
||||
const wrapperStyle = cssPropsToString({
|
||||
position: 'absolute',
|
||||
@@ -379,11 +227,13 @@ function buildDividerHtml(
|
||||
(Section as any).toHtml = (props: SectionProps, childrenHtml: string) => {
|
||||
const hasTopDivider = props.topDivider && props.topDivider !== 'none';
|
||||
const hasBottomDivider = props.bottomDivider && props.bottomDivider !== 'none';
|
||||
const hasVerticalAlign = !!props.style?.justifyContent;
|
||||
|
||||
const outerStyle = cssPropsToString({
|
||||
width: '100%',
|
||||
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
|
||||
...props.style,
|
||||
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
|
||||
});
|
||||
const innerStyle = cssPropsToString({
|
||||
maxWidth: props.innerMaxWidth || '1200px',
|
||||
@@ -394,8 +244,9 @@ function buildDividerHtml(
|
||||
|
||||
const topHtml = buildDividerHtml(props.topDivider, props.topDividerColor, props.topDividerHeight, 'top');
|
||||
const bottomHtml = buildDividerHtml(props.bottomDivider, props.bottomDividerColor, props.bottomDividerHeight, 'bottom');
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
|
||||
return {
|
||||
html: `<section${outerStyle ? ` style="${outerStyle}"` : ''}>${topHtml}<div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div>${bottomHtml}</section>`,
|
||||
html: `<section${idAttr}${outerStyle ? ` style="${outerStyle}"` : ''}>${topHtml}<div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div>${bottomHtml}</section>`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 <img src> without a real <Editor>. */
|
||||
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(<ImageBlock src="" alt="Image" />);
|
||||
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(<ImageBlock alt="Image" />);
|
||||
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(<ImageBlock src="https://example.com/photo.jpg" alt="A photo" />);
|
||||
const img = container.querySelector('img')!;
|
||||
expect(img.getAttribute('src')).toBe('https://example.com/photo.jpg');
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { ImageBlock, pxAttr } from './ImageBlock';
|
||||
|
||||
const toHtml = (ImageBlock as any).toHtml;
|
||||
|
||||
describe('ImageBlock.toHtml src/alt XSS hardening', () => {
|
||||
test('a javascript: src never reaches the output', () => {
|
||||
const { html } = toHtml({ src: 'javascript:alert(1)' }, '');
|
||||
expect(html).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
test('a malicious src cannot break out of the src attribute', () => {
|
||||
const malicious = 'https://example.com/x.jpg" onerror="alert(1)';
|
||||
const { html } = toHtml({ src: malicious }, '');
|
||||
expect(html).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('a malicious alt cannot break out of the alt attribute', () => {
|
||||
const malicious = 'x" onerror="alert(1)';
|
||||
const { html } = toHtml({ src: 'https://example.com/x.jpg', alt: malicious }, '');
|
||||
expect(html).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('a placeholder/empty src emits no output', () => {
|
||||
const { html } = toHtml({ src: '' }, '');
|
||||
expect(html).toBe('');
|
||||
});
|
||||
|
||||
test('a normal image still renders correctly', () => {
|
||||
const { html } = toHtml({ src: 'https://example.com/photo.jpg', alt: 'A photo' }, '');
|
||||
expect(html).toContain('src="https://example.com/photo.jpg"');
|
||||
expect(html).toContain('alt="A photo"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ImageBlock.toHtml perf attributes (always emitted)', () => {
|
||||
test('loading="lazy" and decoding="async" are always present', () => {
|
||||
const { html } = toHtml({ src: 'https://example.com/photo.jpg' }, '');
|
||||
expect(html).toContain('loading="lazy"');
|
||||
expect(html).toContain('decoding="async"');
|
||||
});
|
||||
|
||||
test('width/height attributes are emitted when the style has plain px values', () => {
|
||||
const { html } = toHtml({ src: 'https://example.com/photo.jpg', style: { width: '400px', height: '300px' } }, '');
|
||||
expect(html).toContain('width="400"');
|
||||
expect(html).toContain('height="300"');
|
||||
});
|
||||
|
||||
test('width/height attributes are omitted when the style value is not a plain px length', () => {
|
||||
const { html } = toHtml({ src: 'https://example.com/photo.jpg', style: { width: '50%', height: 'auto' } }, '');
|
||||
expect(html).not.toMatch(/\swidth="/);
|
||||
expect(html).not.toMatch(/\sheight="/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pxAttr', () => {
|
||||
test('extracts the numeric portion of a plain px length', () => {
|
||||
expect(pxAttr('400px')).toBe('400');
|
||||
expect(pxAttr('12.5px')).toBe('12.5');
|
||||
});
|
||||
|
||||
test('returns undefined for non-px units, non-string, or unset values', () => {
|
||||
expect(pxAttr('50%')).toBeUndefined();
|
||||
expect(pxAttr('auto')).toBeUndefined();
|
||||
expect(pxAttr(undefined)).toBeUndefined();
|
||||
expect(pxAttr(400)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ImageBlock.toHtml CSS framing crop (aspect-ratio + object-fit + object-position)', () => {
|
||||
test('style.aspectRatio, objectFit, objectPosition all flow into the emitted style attribute', () => {
|
||||
const { html } = toHtml(
|
||||
{ src: 'https://example.com/photo.jpg', style: { aspectRatio: '16 / 9', objectFit: 'cover', objectPosition: 'center top' } },
|
||||
''
|
||||
);
|
||||
expect(html).toContain('aspect-ratio:16 / 9');
|
||||
expect(html).toContain('object-fit:cover');
|
||||
expect(html).toContain('object-position:center top');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ImageBlock.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
|
||||
test('margin/padding/border/box-shadow/opacity all flow into the emitted style attribute', () => {
|
||||
const { html } = toHtml(
|
||||
{
|
||||
src: 'https://example.com/photo.jpg',
|
||||
style: {
|
||||
marginTop: '10px', marginRight: '10px', marginBottom: '10px', marginLeft: '10px',
|
||||
paddingTop: '5px',
|
||||
border: '2px solid #ff0000',
|
||||
boxShadow: '0 4px 8px rgba(0,0,0,0.12)',
|
||||
opacity: '0.8',
|
||||
},
|
||||
},
|
||||
''
|
||||
);
|
||||
expect(html).toContain('margin-top:10px');
|
||||
expect(html).toContain('padding-top:5px');
|
||||
expect(html).toContain('border:2px solid #ff0000');
|
||||
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
|
||||
expect(html).toContain('opacity:0.8');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ImageBlock.craft.props exposes the box-model/animation/visibility rollout', () => {
|
||||
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
||||
const props = (ImageBlock as any).craft.props;
|
||||
expect(props.animation).toBe('');
|
||||
expect(props.animationDelay).toBe('0');
|
||||
expect(props.hideOnDesktop).toBe(false);
|
||||
expect(props.hideOnTablet).toBe(false);
|
||||
expect(props.hideOnMobile).toBe(false);
|
||||
});
|
||||
|
||||
test('style carries blank/default box-model and crop keys', () => {
|
||||
const style = (ImageBlock as any).craft.props.style;
|
||||
expect(style).toHaveProperty('aspectRatio');
|
||||
expect(style).toHaveProperty('objectFit');
|
||||
expect(style).toHaveProperty('objectPosition');
|
||||
expect(style).toHaveProperty('marginTop');
|
||||
expect(style).toHaveProperty('paddingTop');
|
||||
expect(style.border).toBe('none');
|
||||
expect(style.boxShadow).toBe('none');
|
||||
expect(style.opacity).toBe('1');
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,31 @@
|
||||
import React, { CSSProperties, useCallback, useRef, useState } from 'react';
|
||||
import React, { CSSProperties, useCallback, useRef } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeAttr, safeImageUrl } 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;
|
||||
alt?: string;
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
/** Extracts the numeric portion of a plain "<n>px" CSS length string, for
|
||||
* emitting real `width`/`height` HTML attributes on the exported `<img>`
|
||||
* (helps the browser reserve layout space before the image loads --
|
||||
* avoiding CLS -- something a CSS-only width/height can't do on its own).
|
||||
* Returns undefined for any other unit ('%', 'auto', '', etc.) so the
|
||||
* attribute is simply omitted when the pixel size isn't known. */
|
||||
export function pxAttr(v: unknown): string | undefined {
|
||||
if (typeof v !== 'string') return undefined;
|
||||
const m = v.trim().match(/^(\d+(?:\.\d+)?)px$/);
|
||||
return m ? m[1] : undefined;
|
||||
}
|
||||
|
||||
// Helper: upload a file to the WHP API and return the proxy URL
|
||||
@@ -65,7 +83,7 @@ export const ImageBlock: UserComponent<ImageBlockProps> = ({
|
||||
imgRef.current = ref;
|
||||
if (ref) connect(drag(ref));
|
||||
}}
|
||||
src={src}
|
||||
src={src || PLACEHOLDER_SRC}
|
||||
alt={alt || 'Image'}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
@@ -80,392 +98,30 @@ export const ImageBlock: UserComponent<ImageBlockProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Helpers for parsing CSS unit values ---------- */
|
||||
|
||||
type SizeUnit = 'px' | '%' | 'auto';
|
||||
|
||||
function parseSizeValue(value: string | number | undefined): { num: string; unit: SizeUnit } {
|
||||
if (!value || value === 'auto') return { num: '', unit: 'auto' };
|
||||
const str = String(value);
|
||||
if (str === 'auto') return { num: '', unit: 'auto' };
|
||||
const match = str.match(/^(\d+(?:\.\d+)?)\s*(px|%)$/);
|
||||
if (match) return { num: match[1], unit: match[2] as SizeUnit };
|
||||
// Pure number = px
|
||||
if (/^\d+(?:\.\d+)?$/.test(str)) return { num: str, unit: 'px' };
|
||||
return { num: '', unit: 'px' };
|
||||
}
|
||||
|
||||
function buildSizeString(num: string, unit: SizeUnit): string | undefined {
|
||||
if (unit === 'auto') return 'auto';
|
||||
if (!num) return undefined;
|
||||
return `${num}${unit}`;
|
||||
}
|
||||
|
||||
type Alignment = 'left' | 'center' | 'right';
|
||||
|
||||
function detectAlignment(style: CSSProperties | undefined): Alignment {
|
||||
if (!style) return 'left';
|
||||
const ml = style.marginLeft;
|
||||
const mr = style.marginRight;
|
||||
if (ml === 'auto' && mr === 'auto') return 'center';
|
||||
if (ml === 'auto' && mr !== 'auto') return 'right';
|
||||
return 'left';
|
||||
}
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const ImageBlockSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as ImageBlockProps,
|
||||
}));
|
||||
|
||||
const isPlaceholder = !props.src || props.src === PLACEHOLDER_SRC || props.src?.startsWith('data:image/svg');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showBrowser, setShowBrowser] = useState(false);
|
||||
const [browserAssets, setBrowserAssets] = useState<any[]>([]);
|
||||
const [browserLoading, setBrowserLoading] = useState(false);
|
||||
|
||||
// Sizing unit state
|
||||
const widthParsed = parseSizeValue(props.style?.width);
|
||||
const [widthUnit, setWidthUnit] = useState<SizeUnit>(widthParsed.unit === 'auto' ? 'px' : widthParsed.unit);
|
||||
const heightParsed = parseSizeValue(props.style?.height);
|
||||
const [heightUnit, setHeightUnit] = useState<SizeUnit>(heightParsed.unit === 'auto' ? 'px' : heightParsed.unit);
|
||||
const maxWidthParsed = parseSizeValue(props.style?.maxWidth);
|
||||
const [maxWidthUnit, setMaxWidthUnit] = useState<SizeUnit>(maxWidthParsed.unit === 'auto' ? '%' : maxWidthParsed.unit);
|
||||
|
||||
const alignment = detectAlignment(props.style);
|
||||
|
||||
const handleUpload = useCallback(async (file: File) => {
|
||||
const url = await uploadToWhp(file);
|
||||
if (url) setProp((p: ImageBlockProps) => { p.src = url; });
|
||||
}, [setProp]);
|
||||
|
||||
const handleBrowse = useCallback(async () => {
|
||||
if (showBrowser) { setShowBrowser(false); return; }
|
||||
const cfg = (window as any).WHP_CONFIG;
|
||||
if (!cfg) return;
|
||||
setBrowserLoading(true);
|
||||
try {
|
||||
const resp = await fetch(`${cfg.apiUrl}?action=list_assets&site_id=${cfg.siteId}`);
|
||||
const data = await resp.json();
|
||||
if (data.success && Array.isArray(data.assets)) {
|
||||
const images = data.assets.filter((a: any) => (a.type || '').startsWith('image'));
|
||||
setBrowserAssets(images);
|
||||
setShowBrowser(true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Browse failed:', e);
|
||||
} finally {
|
||||
setBrowserLoading(false);
|
||||
}
|
||||
}, [showBrowser]);
|
||||
|
||||
const radiusPresets = ['0', '8px', '16px', '32px', '50%'];
|
||||
|
||||
const setPropStyle = useCallback((key: string, value: string | undefined) => {
|
||||
setProp((p: ImageBlockProps) => {
|
||||
p.style = { ...p.style, [key]: value };
|
||||
});
|
||||
}, [setProp]);
|
||||
|
||||
const setAlignment = useCallback((align: Alignment) => {
|
||||
setProp((p: ImageBlockProps) => {
|
||||
const s = { ...p.style };
|
||||
if (align === 'center') {
|
||||
s.marginLeft = 'auto';
|
||||
s.marginRight = 'auto';
|
||||
s.display = 'block';
|
||||
} else if (align === 'right') {
|
||||
s.marginLeft = 'auto';
|
||||
s.marginRight = undefined;
|
||||
s.display = 'block';
|
||||
} else {
|
||||
s.marginLeft = undefined;
|
||||
s.marginRight = undefined;
|
||||
s.display = 'block';
|
||||
}
|
||||
p.style = s;
|
||||
});
|
||||
}, [setProp]);
|
||||
|
||||
// Extract friendly filename from URL
|
||||
const getFriendlyName = (src: string) => {
|
||||
const match = src.match(/filename=([^&]+)/);
|
||||
if (match) return decodeURIComponent(match[1]).replace(/^\d+_[a-f0-9]+_/, '');
|
||||
return src.split('/').pop() || 'image';
|
||||
};
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
|
||||
const inputStyle: CSSProperties = { flex: 1, minWidth: 0, padding: '4px 6px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 };
|
||||
const selectStyle: CSSProperties = { padding: '4px 2px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, cursor: 'pointer' };
|
||||
const btnStyle = (active: boolean): CSSProperties => ({
|
||||
flex: 1, padding: '4px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: active ? '#3b82f6' : '#27272a',
|
||||
color: active ? '#fff' : '#a1a1aa',
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{/* Image preview */}
|
||||
<div>
|
||||
<label style={labelStyle}>Image Source</label>
|
||||
|
||||
{!isPlaceholder ? (
|
||||
<>
|
||||
{/* Current image thumbnail + filename + remove */}
|
||||
<div style={{ marginBottom: 8, borderRadius: 6, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
|
||||
<img src={props.src} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 150, objectFit: 'cover' }} />
|
||||
<button onClick={() => setProp((p: ImageBlockProps) => { p.src = PLACEHOLDER_SRC; })}
|
||||
style={{ position: 'absolute', top: 4, right: 4, width: 24, height: 24, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 12, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
title="Remove image">
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#a1a1aa', marginBottom: 8, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<i className="fa fa-check-circle" style={{ color: '#10b981' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{getFriendlyName(props.src || '')}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Drop zone when no image set */
|
||||
<div
|
||||
style={{ padding: '20px 12px', border: '2px dashed #3f3f46', borderRadius: 6, textAlign: 'center', color: '#71717a', fontSize: 12, cursor: 'pointer', marginBottom: 8, transition: 'border-color 0.15s' }}
|
||||
onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = '#3b82f6'; }}
|
||||
onDragLeave={(e) => { e.currentTarget.style.borderColor = '#3f3f46'; }}
|
||||
onDrop={async (e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.borderColor = '#3f3f46';
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file && file.type.startsWith('image/')) await handleUpload(file);
|
||||
}}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<i className="fa fa-cloud-upload" style={{ fontSize: 24, display: 'block', marginBottom: 6, color: '#3b82f6' }} />
|
||||
Drop image here or click to upload
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons: Upload + Browse */}
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{ flex: 1, padding: '8px 10px', fontSize: 12, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#3b82f6', color: '#fff', fontWeight: 500 }}
|
||||
>
|
||||
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBrowse}
|
||||
style={{ flex: 1, padding: '8px 10px', fontSize: 12, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: showBrowser ? '#3b82f6' : '#27272a', color: showBrowser ? '#fff' : '#e4e4e7' }}
|
||||
>
|
||||
<i className={`fa ${browserLoading ? 'fa-spinner fa-spin' : 'fa-folder-open'}`} style={{ marginRight: 4 }} /> Browse
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Inline asset browser grid */}
|
||||
{showBrowser && (
|
||||
<div style={{ maxHeight: 200, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, marginTop: 8, background: '#18181b', borderRadius: 6, padding: 4 }}>
|
||||
{browserAssets.map(asset => (
|
||||
<div
|
||||
key={asset.name}
|
||||
onClick={() => { setProp((p: ImageBlockProps) => { p.src = asset.url; }); setShowBrowser(false); }}
|
||||
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1', transition: 'border-color 0.15s' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#3b82f6'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
|
||||
>
|
||||
<img src={asset.url} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
</div>
|
||||
))}
|
||||
{browserAssets.length === 0 && (
|
||||
<p style={{ gridColumn: '1 / -1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: '12px 0', margin: 0 }}>No images uploaded yet. Use Upload above.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
|
||||
onChange={(e) => { const file = e.target.files?.[0]; if (file) handleUpload(file); e.target.value = ''; }} />
|
||||
|
||||
{/* URL input (collapsed, for advanced users) */}
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<input type="text"
|
||||
value={isPlaceholder ? '' : (props.src || '')}
|
||||
onChange={(e) => setProp((p: ImageBlockProps) => { p.src = e.target.value || PLACEHOLDER_SRC; })}
|
||||
placeholder="Or paste image URL..."
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#1e1e2a', color: '#71717a', border: '1px solid #27272a', borderRadius: 4, fontSize: 10 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alt Text */}
|
||||
<div>
|
||||
<label style={labelStyle}>Alt Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.alt || ''}
|
||||
onChange={(e) => setProp((p: ImageBlockProps) => { p.alt = e.target.value; })}
|
||||
placeholder="Describe the image..."
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Width */}
|
||||
<div>
|
||||
<label style={labelStyle}>Width</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={widthParsed.num}
|
||||
disabled={props.style?.width === 'auto'}
|
||||
onChange={(e) => {
|
||||
const val = buildSizeString(e.target.value, widthUnit);
|
||||
setPropStyle('width', val || 'auto');
|
||||
}}
|
||||
placeholder="auto"
|
||||
style={inputStyle}
|
||||
/>
|
||||
<select
|
||||
value={props.style?.width === 'auto' ? 'auto' : widthUnit}
|
||||
onChange={(e) => {
|
||||
const unit = e.target.value as SizeUnit;
|
||||
if (unit === 'auto') {
|
||||
setPropStyle('width', 'auto');
|
||||
} else {
|
||||
setWidthUnit(unit);
|
||||
const num = widthParsed.num || '100';
|
||||
setPropStyle('width', `${num}${unit}`);
|
||||
}
|
||||
}}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="px">px</option>
|
||||
<option value="%">%</option>
|
||||
<option value="auto">auto</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Max Width */}
|
||||
<div>
|
||||
<label style={labelStyle}>Max Width</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={maxWidthParsed.num}
|
||||
onChange={(e) => {
|
||||
const val = buildSizeString(e.target.value, maxWidthUnit);
|
||||
setPropStyle('maxWidth', val || '100%');
|
||||
}}
|
||||
placeholder="100%"
|
||||
style={inputStyle}
|
||||
/>
|
||||
<select
|
||||
value={maxWidthUnit}
|
||||
onChange={(e) => {
|
||||
const unit = e.target.value as SizeUnit;
|
||||
setMaxWidthUnit(unit);
|
||||
const num = maxWidthParsed.num || '100';
|
||||
setPropStyle('maxWidth', `${num}${unit}`);
|
||||
}}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="px">px</option>
|
||||
<option value="%">%</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Height */}
|
||||
<div>
|
||||
<label style={labelStyle}>Height</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={heightParsed.num}
|
||||
disabled={props.style?.height === 'auto'}
|
||||
onChange={(e) => {
|
||||
const val = buildSizeString(e.target.value, heightUnit);
|
||||
setPropStyle('height', val || 'auto');
|
||||
}}
|
||||
placeholder="auto"
|
||||
style={inputStyle}
|
||||
/>
|
||||
<select
|
||||
value={props.style?.height === 'auto' ? 'auto' : heightUnit}
|
||||
onChange={(e) => {
|
||||
const unit = e.target.value as SizeUnit;
|
||||
if (unit === 'auto') {
|
||||
setPropStyle('height', 'auto');
|
||||
} else {
|
||||
setHeightUnit(unit);
|
||||
const num = heightParsed.num || '300';
|
||||
setPropStyle('height', `${num}${unit}`);
|
||||
}
|
||||
}}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="px">px</option>
|
||||
<option value="auto">auto</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Object Fit (visible when both width and height are explicit values) */}
|
||||
{props.style?.width && props.style.width !== 'auto' && props.style?.height && props.style.height !== 'auto' && (
|
||||
<div>
|
||||
<label style={labelStyle}>Object Fit</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['cover', 'contain', 'fill', 'none'] as const).map((fit) => (
|
||||
<button
|
||||
key={fit}
|
||||
onClick={() => setPropStyle('objectFit', fit)}
|
||||
style={btnStyle(props.style?.objectFit === fit)}
|
||||
>
|
||||
{fit}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alignment */}
|
||||
<div>
|
||||
<label style={labelStyle}>Alignment</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button onClick={() => setAlignment('left')} style={btnStyle(alignment === 'left')}>
|
||||
<i className="fa fa-align-left" style={{ marginRight: 3 }} />Left
|
||||
</button>
|
||||
<button onClick={() => setAlignment('center')} style={btnStyle(alignment === 'center')}>
|
||||
<i className="fa fa-align-center" style={{ marginRight: 3 }} />Center
|
||||
</button>
|
||||
<button onClick={() => setAlignment('right')} style={btnStyle(alignment === 'right')}>
|
||||
<i className="fa fa-align-right" style={{ marginRight: 3 }} />Right
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Border Radius */}
|
||||
<div>
|
||||
<label style={labelStyle}>Border Radius</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{radiusPresets.map((r) => (
|
||||
<button key={r} onClick={() => setPropStyle('borderRadius', r)}
|
||||
style={btnStyle(props.style?.borderRadius === r)}
|
||||
>{r}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ImageBlock.craft = {
|
||||
displayName: 'Image',
|
||||
props: { src: PLACEHOLDER_SRC, alt: '', style: { width: '100%', height: 'auto' } },
|
||||
props: {
|
||||
src: PLACEHOLDER_SRC,
|
||||
alt: '',
|
||||
style: {
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
aspectRatio: '',
|
||||
objectFit: '' as CSSProperties['objectFit'],
|
||||
objectPosition: '',
|
||||
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
|
||||
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
opacity: '1',
|
||||
},
|
||||
animation: '',
|
||||
animationDelay: '0',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true },
|
||||
related: { settings: ImageBlockSettings },
|
||||
};
|
||||
|
||||
(ImageBlock as any).toHtml = (props: ImageBlockProps, _c: string) => {
|
||||
@@ -475,6 +131,9 @@ ImageBlock.craft = {
|
||||
return { html: '' };
|
||||
}
|
||||
const s = cssPropsToString({ display: 'block', maxWidth: '100%', ...props.style });
|
||||
const alt = props.alt ? ` alt="${props.alt.replace(/"/g, '"')}"` : ' alt=""';
|
||||
return { html: `<img src="${src}"${alt}${s ? ` style="${s}"` : ''} />` };
|
||||
const alt = props.alt ? ` alt="${escapeAttr(props.alt)}"` : ' alt=""';
|
||||
const widthAttr = pxAttr((props.style as any)?.width);
|
||||
const heightAttr = pxAttr((props.style as any)?.height);
|
||||
const dims = `${widthAttr ? ` width="${widthAttr}"` : ''}${heightAttr ? ` height="${heightAttr}"` : ''}`;
|
||||
return { html: `<img src="${escapeAttr(safeImageUrl(src))}"${alt}${dims} loading="lazy" decoding="async"${s ? ` style="${s}"` : ''} />` };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { MapEmbed } from './MapEmbed';
|
||||
|
||||
const toHtml = (MapEmbed as any).toHtml;
|
||||
|
||||
describe('MapEmbed.toHtml iframe accessibility (F2.4)', () => {
|
||||
test('iframe has a non-empty title attribute', () => {
|
||||
const { html } = toHtml({ address: 'New York, NY' }, '');
|
||||
expect(html).toMatch(/<iframe[^>]*title="[^"]+"/);
|
||||
});
|
||||
|
||||
test('title reflects the configured address', () => {
|
||||
const { html } = toHtml({ address: 'Golden Gate Bridge' }, '');
|
||||
expect(html).toContain('title="Map of Golden Gate Bridge"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MapEmbed.toHtml iframe src ampersand encoding (F-export review Minor)', () => {
|
||||
test('the iframe src (built by string concatenation with literal &) emits & in the attribute, not a raw &', () => {
|
||||
const { html } = toHtml({ address: 'New York, NY', zoom: 14 }, '');
|
||||
const srcMatch = html.match(/<iframe src="([^"]+)"/);
|
||||
expect(srcMatch).toBeTruthy();
|
||||
// The raw src is `...q=...&z=14&output=embed` -- concatenated with
|
||||
// literal `&`s -- so the emitted attribute must HTML-encode them.
|
||||
expect(srcMatch![1]).toContain('&z=14');
|
||||
expect(srcMatch![1]).toContain('&output=embed');
|
||||
expect(srcMatch![1]).not.toMatch(/&(?!amp;)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MapEmbed.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
|
||||
test('margin/padding/border/box-shadow/opacity all flow into the wrapper style attribute', () => {
|
||||
const { html } = toHtml(
|
||||
{
|
||||
address: 'New York, NY',
|
||||
style: {
|
||||
marginTop: '16px',
|
||||
paddingRight: '4px',
|
||||
border: '1px solid #cccccc',
|
||||
boxShadow: '0 4px 8px rgba(0,0,0,0.12)',
|
||||
opacity: '0.95',
|
||||
},
|
||||
},
|
||||
''
|
||||
);
|
||||
expect(html).toContain('margin-top:16px');
|
||||
expect(html).toContain('padding-right:4px');
|
||||
expect(html).toContain('border:1px solid #cccccc');
|
||||
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
|
||||
expect(html).toContain('opacity:0.95');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MapEmbed.craft.props exposes the animation/visibility rollout', () => {
|
||||
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
||||
const props = (MapEmbed as any).craft.props;
|
||||
expect(props.animation).toBe('');
|
||||
expect(props.animationDelay).toBe('0');
|
||||
expect(props.hideOnDesktop).toBe(false);
|
||||
expect(props.hideOnTablet).toBe(false);
|
||||
expect(props.hideOnMobile).toBe(false);
|
||||
});
|
||||
|
||||
test('style carries blank/default box-model keys', () => {
|
||||
const style = (MapEmbed as any).craft.props.style;
|
||||
expect(style).toHaveProperty('marginTop');
|
||||
expect(style).toHaveProperty('paddingTop');
|
||||
expect(style.border).toBe('none');
|
||||
expect(style.boxShadow).toBe('none');
|
||||
expect(style.opacity).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MapEmbed.toHtml address/zoom/height XSS hardening', () => {
|
||||
test('a malicious address cannot break out of the src or title attribute', () => {
|
||||
const malicious = 'X" onerror="alert(1)';
|
||||
const { html } = toHtml({ address: malicious }, '');
|
||||
expect(html).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('a wrong-typed zoom (string with attribute-breakout chars) cannot break out of the src attribute', () => {
|
||||
const malicious = '14"><script>alert(1)</script>' as any;
|
||||
const { html } = toHtml({ address: 'X', zoom: malicious }, '');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
expect(html).not.toContain('"><script');
|
||||
});
|
||||
|
||||
test('a wrong-typed zoom is coerced to a safe numeric value in the exported URL (defense in depth beyond escaping)', () => {
|
||||
const malicious = '14"><script>alert(1)</script>' as any;
|
||||
const { html } = toHtml({ address: 'X', zoom: malicious }, '');
|
||||
const srcMatch = html.match(/<iframe src="([^"]+)"/);
|
||||
expect(srcMatch).toBeTruthy();
|
||||
// Decode the entity-escaped src back to a plain string and confirm the
|
||||
// `z=` param is a bare, well-formed number -- not the raw attacker string.
|
||||
const decoded = srcMatch![1].replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
expect(decoded).toMatch(/[&?]z=\d+(&|$)/);
|
||||
});
|
||||
|
||||
test('a malicious height cannot break out of the iframe style attribute', () => {
|
||||
const malicious = '400px" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ address: 'X', height: malicious }, '');
|
||||
expect(html).not.toContain('onmouseover="alert(1)"');
|
||||
});
|
||||
|
||||
test('a normal zoom/height still renders correctly', () => {
|
||||
const { html } = toHtml({ address: 'X', zoom: 10, height: '300px' }, '');
|
||||
expect(html).toContain('z=10');
|
||||
expect(html).toContain('height:300px');
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,31 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeAttr, safeUrl } from '../../utils/escape';
|
||||
|
||||
interface MapEmbedProps {
|
||||
address?: string;
|
||||
zoom?: number;
|
||||
height?: string;
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
function buildMapUrl(address: string, zoom: number): string {
|
||||
const encoded = encodeURIComponent(address);
|
||||
return `https://maps.google.com/maps?q=${encoded}&z=${zoom}&output=embed`;
|
||||
// `zoom` is declared as `number` but is not runtime-type-checked (AI
|
||||
// update_props / deserialized state can hand us anything). The final src
|
||||
// string is still run through escapeAttr(safeUrl(...)) at the toHtml call
|
||||
// site, which already blocks attribute-breakout -- but Number-coercing
|
||||
// here too keeps the emitted URL a well-formed `z=<digits>` query param
|
||||
// instead of smuggling arbitrary attacker text into it.
|
||||
const z = Number(zoom);
|
||||
const safeZoom = Number.isFinite(z) ? z : 14;
|
||||
return `https://maps.google.com/maps?q=${encoded}&z=${safeZoom}&output=embed`;
|
||||
}
|
||||
|
||||
export const MapEmbed: UserComponent<MapEmbedProps> = ({
|
||||
@@ -53,79 +67,6 @@ export const MapEmbed: UserComponent<MapEmbedProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const MapEmbedSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as MapEmbedProps,
|
||||
}));
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
|
||||
};
|
||||
|
||||
const heightPresets = ['300px', '400px', '500px', '600px'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{/* Address */}
|
||||
<div>
|
||||
<label style={labelStyle}>Address</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.address || ''}
|
||||
onChange={(e) => setProp((p: MapEmbedProps) => { p.address = e.target.value; })}
|
||||
placeholder="Enter an address or location..."
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Zoom */}
|
||||
<div>
|
||||
<label style={labelStyle}>Zoom: {props.zoom || 14}</label>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={20}
|
||||
value={props.zoom || 14}
|
||||
onChange={(e) => setProp((p: MapEmbedProps) => { p.zoom = parseInt(e.target.value, 10); })}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Height */}
|
||||
<div>
|
||||
<label style={labelStyle}>Height</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 6 }}>
|
||||
{heightPresets.map((h) => (
|
||||
<button
|
||||
key={h}
|
||||
onClick={() => setProp((p: MapEmbedProps) => { p.height = h; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.height === h ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{h}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={props.height || ''}
|
||||
onChange={(e) => setProp((p: MapEmbedProps) => { p.height = e.target.value; })}
|
||||
placeholder="e.g. 400px"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
MapEmbed.craft = {
|
||||
@@ -134,16 +75,24 @@ MapEmbed.craft = {
|
||||
address: 'New York, NY',
|
||||
zoom: 14,
|
||||
height: '400px',
|
||||
style: {},
|
||||
style: {
|
||||
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
|
||||
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
opacity: '1',
|
||||
},
|
||||
animation: '',
|
||||
animationDelay: '0',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: MapEmbedSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
@@ -168,6 +117,6 @@ MapEmbed.craft = {
|
||||
const src = buildMapUrl(address, zoom);
|
||||
|
||||
return {
|
||||
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><iframe src="${src}" loading="lazy" referrerpolicy="no-referrer-when-downgrade" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div>`,
|
||||
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><iframe src="${escapeAttr(safeUrl(src))}" title="${escapeAttr(`Map of ${address}`)}" loading="lazy" referrerpolicy="no-referrer-when-downgrade" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div>`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { VideoBlock } from './VideoBlock';
|
||||
|
||||
const toHtml = (VideoBlock as any).toHtml;
|
||||
|
||||
function embedSrc(videoUrl: string): string {
|
||||
const { html } = toHtml({ videoUrl }, '');
|
||||
const m = html.match(/<iframe src="([^"]+)"/) || html.match(/<video src="([^"]+)"/);
|
||||
return m ? m[1].replace(/&/g, '&') : '';
|
||||
}
|
||||
|
||||
describe('VideoBlock URL parsing (D4)', () => {
|
||||
test('youtube.com/watch?v=ID (existing case) resolves to embed URL', () => {
|
||||
expect(embedSrc('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
|
||||
});
|
||||
|
||||
test('youtu.be/ID resolves to embed URL', () => {
|
||||
expect(embedSrc('https://youtu.be/dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
|
||||
});
|
||||
|
||||
test('youtube.com/embed/ID (existing case) resolves to embed URL', () => {
|
||||
expect(embedSrc('https://www.youtube.com/embed/dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
|
||||
});
|
||||
|
||||
test('youtube.com/shorts/ID resolves to embed URL', () => {
|
||||
expect(embedSrc('https://www.youtube.com/shorts/dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
|
||||
});
|
||||
|
||||
test('youtube.com/live/ID resolves to embed URL', () => {
|
||||
expect(embedSrc('https://www.youtube.com/live/dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
|
||||
});
|
||||
|
||||
test('youtube.com/watch?...&v=ID (v not first param) resolves to embed URL', () => {
|
||||
expect(embedSrc('https://www.youtube.com/watch?list=PLxyz&v=dQw4w9WgXcQ&index=3')).toContain('youtube.com/embed/dQw4w9WgXcQ');
|
||||
});
|
||||
|
||||
test('vimeo.com/ID (existing case) resolves to player URL', () => {
|
||||
expect(embedSrc('https://vimeo.com/123456789')).toContain('https://player.vimeo.com/video/123456789');
|
||||
});
|
||||
|
||||
test('vimeo.com/ID/HASH (private video) resolves to player URL with hash param', () => {
|
||||
const src = embedSrc('https://vimeo.com/123456789/abcdef1234');
|
||||
expect(src).toContain('https://player.vimeo.com/video/123456789');
|
||||
expect(src).toContain('h=abcdef1234');
|
||||
});
|
||||
|
||||
test('direct .mp4 file still works', () => {
|
||||
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4' }, '');
|
||||
expect(html).toContain('<video src="https://example.com/clip.mp4"');
|
||||
});
|
||||
|
||||
test('unrecognized URL yields no output (type "none")', () => {
|
||||
const { html } = toHtml({ videoUrl: 'not-a-real-video-url' }, '');
|
||||
expect(html).toBe('');
|
||||
});
|
||||
|
||||
test('emitted src is safeUrl-wrapped: javascript: scheme never reaches output', () => {
|
||||
const { html } = toHtml({ videoUrl: 'javascript:alert(1)' }, '');
|
||||
expect(html).not.toContain('javascript:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('VideoBlock.toHtml iframe accessibility (F2.4)', () => {
|
||||
test('normal-mode YouTube/Vimeo iframe has a title attribute', () => {
|
||||
const { html } = toHtml({ videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' }, '');
|
||||
expect(html).toMatch(/<iframe[^>]*title="[^"]+"/);
|
||||
});
|
||||
|
||||
test('background-mode YouTube/Vimeo iframe has a title attribute', () => {
|
||||
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789', isBackground: true }, '');
|
||||
expect(html).toMatch(/<iframe[^>]*title="[^"]+"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VideoBlock.toHtml overlay/innerMaxWidth XSS hardening (background mode)', () => {
|
||||
test('a malicious overlayColor cannot break out of the overlay style attribute', () => {
|
||||
const malicious = 'red" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789', isBackground: true, overlayColor: malicious }, '');
|
||||
expect(html).not.toContain('onmouseover="alert(1)"');
|
||||
});
|
||||
|
||||
test('a wrong-typed overlayOpacity (string, not number) cannot break out of the overlay style attribute', () => {
|
||||
const malicious = '50" onmouseover="alert(1)' as any;
|
||||
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789', isBackground: true, overlayOpacity: malicious }, '');
|
||||
expect(html).not.toContain('onmouseover="alert(1)"');
|
||||
});
|
||||
|
||||
test('a malicious innerMaxWidth cannot break out of the inner style attribute', () => {
|
||||
const malicious = '1200px" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789', isBackground: true, innerMaxWidth: malicious }, '');
|
||||
expect(html).not.toContain('onmouseover="alert(1)"');
|
||||
});
|
||||
|
||||
test('a malicious style.borderRadius cannot break out of the style attribute (normal mode, iframe wrapper)', () => {
|
||||
const malicious = { borderRadius: '8px" onmouseover="alert(1)' } as any;
|
||||
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789', style: malicious }, '');
|
||||
expect(html).not.toContain('onmouseover="alert(1)"');
|
||||
});
|
||||
|
||||
test('a malicious style.borderRadius cannot break out of the style attribute (direct file <video>)', () => {
|
||||
const malicious = { borderRadius: '8px" onmouseover="alert(1)' } as any;
|
||||
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4', style: malicious }, '');
|
||||
expect(html).not.toContain('onmouseover="alert(1)"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('VideoBlock.toHtml iframe src ampersand encoding (F-export review Minor)', () => {
|
||||
test('embed params joined with literal & are HTML-entity-encoded in the emitted src attribute', () => {
|
||||
// autoplay+muted+controls=false forces buildEmbedParams to concatenate
|
||||
// multiple query params onto the URL with literal `&`s.
|
||||
const { html } = toHtml(
|
||||
{ videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', autoplay: true, muted: true, controls: false },
|
||||
''
|
||||
);
|
||||
const srcMatch = html.match(/<iframe src="([^"]+)"/);
|
||||
expect(srcMatch).toBeTruthy();
|
||||
expect(srcMatch![1]).toMatch(/&/);
|
||||
expect(srcMatch![1]).not.toMatch(/&(?!amp;)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VideoBlock.toHtml size + aspect ratio (frame honors style props, not a hardcoded 16:9)', () => {
|
||||
test('direct file: style.width flows to the wrapper, style.aspectRatio flows to the <video>', () => {
|
||||
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4', style: { width: '50%', aspectRatio: '4 / 3' } }, '');
|
||||
expect(html).toMatch(/<div style="[^"]*width:50%[^"]*"/);
|
||||
expect(html).toMatch(/<video[^>]*style="[^"]*aspect-ratio:4 \/ 3[^"]*"/);
|
||||
});
|
||||
|
||||
test('direct file: no aspectRatio set -- no aspect-ratio declaration is forced onto the <video>', () => {
|
||||
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4' }, '');
|
||||
const videoTag = html.match(/<video[^>]*>/)![0];
|
||||
expect(videoTag).not.toContain('aspect-ratio');
|
||||
});
|
||||
|
||||
test('YouTube/Vimeo: style.aspectRatio overrides the 16:9 default on the iframe container', () => {
|
||||
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789', style: { aspectRatio: '1 / 1' } }, '');
|
||||
expect(html).toMatch(/<div[^>]*style="[^"]*aspect-ratio:1 \/ 1[^"]*"[^>]*><iframe/);
|
||||
});
|
||||
|
||||
test('YouTube/Vimeo: defaults to 16 / 9 when no aspectRatio style is set', () => {
|
||||
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789' }, '');
|
||||
expect(html).toMatch(/<div[^>]*style="[^"]*aspect-ratio:16 \/ 9[^"]*"[^>]*><iframe/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VideoBlock.toHtml poster + preload (file type)', () => {
|
||||
test('poster attribute is emitted (escaped) and preload="metadata" is always present on a direct file', () => {
|
||||
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4', poster: 'https://example.com/poster.jpg' }, '');
|
||||
expect(html).toContain('poster="https://example.com/poster.jpg"');
|
||||
expect(html).toContain('preload="metadata"');
|
||||
});
|
||||
|
||||
test('no poster prop -- no poster attribute is emitted, but preload="metadata" still is', () => {
|
||||
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4' }, '');
|
||||
expect(html).not.toContain('poster=');
|
||||
expect(html).toContain('preload="metadata"');
|
||||
});
|
||||
|
||||
test('a malicious poster (javascript: scheme) is blocked by safeImageUrl', () => {
|
||||
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4', poster: 'javascript:alert(1)' }, '');
|
||||
expect(html).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
test('a poster value cannot break out of the poster attribute', () => {
|
||||
const malicious = 'https://example.com/x.jpg" onerror="alert(1)';
|
||||
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4', poster: malicious }, '');
|
||||
expect(html).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('VideoBlock.craft.props exposes the box-model/animation/visibility rollout', () => {
|
||||
test('poster, animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
||||
const props = (VideoBlock as any).craft.props;
|
||||
expect(props.poster).toBe('');
|
||||
expect(props.animation).toBe('');
|
||||
expect(props.animationDelay).toBe('0');
|
||||
expect(props.hideOnDesktop).toBe(false);
|
||||
expect(props.hideOnTablet).toBe(false);
|
||||
expect(props.hideOnMobile).toBe(false);
|
||||
});
|
||||
|
||||
test('style carries blank/default box-model + size/aspect keys', () => {
|
||||
const style = (VideoBlock as any).craft.props.style;
|
||||
expect(style).toHaveProperty('width');
|
||||
expect(style).toHaveProperty('aspectRatio');
|
||||
expect(style).toHaveProperty('marginTop');
|
||||
expect(style).toHaveProperty('paddingTop');
|
||||
expect(style.border).toBe('none');
|
||||
expect(style.boxShadow).toBe('none');
|
||||
expect(style.opacity).toBe('1');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { CSSProperties, useCallback, useRef, useState } from 'react';
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||
import { Container } from '../layout/Container';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeAttr, safeUrl, safeImageUrl } from '../../utils/escape';
|
||||
|
||||
/* ---------- Types ---------- */
|
||||
|
||||
@@ -11,6 +12,7 @@ interface VideoBlockProps {
|
||||
videoUrl?: string;
|
||||
videoType?: VideoType;
|
||||
embedUrl?: string;
|
||||
poster?: string;
|
||||
autoplay?: boolean;
|
||||
muted?: boolean;
|
||||
loop?: boolean;
|
||||
@@ -21,22 +23,52 @@ interface VideoBlockProps {
|
||||
innerMaxWidth?: string;
|
||||
style?: CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
/* ---------- URL detection ---------- */
|
||||
|
||||
/**
|
||||
* Extract a YouTube video ID from any of the URL shapes WHP users paste:
|
||||
* youtu.be/ID, youtube.com/embed/ID, youtube.com/shorts/ID,
|
||||
* youtube.com/live/ID (path-based), and youtube.com/watch?...v=ID where `v`
|
||||
* may appear anywhere in the query string (not just as the first param).
|
||||
*/
|
||||
function extractYouTubeId(url: string): string | null {
|
||||
const pathMatch = url.match(
|
||||
/(?:youtube\.com\/(?:embed|shorts|live)\/|youtu\.be\/)([a-zA-Z0-9_-]+)/
|
||||
);
|
||||
if (pathMatch) return pathMatch[1];
|
||||
|
||||
const queryMatch = url.match(/youtube\.com\/watch\?([^\s#]+)/);
|
||||
if (queryMatch) {
|
||||
const v = new URLSearchParams(queryMatch[1]).get('v');
|
||||
if (v) return v;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectVideoType(url: string): { type: VideoType; embedUrl: string } {
|
||||
if (!url) return { type: 'none', embedUrl: '' };
|
||||
|
||||
// YouTube
|
||||
const ytMatch = url.match(
|
||||
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]+)/
|
||||
);
|
||||
if (ytMatch) return { type: 'youtube', embedUrl: `https://www.youtube.com/embed/${ytMatch[1]}?rel=0` };
|
||||
const ytId = extractYouTubeId(url);
|
||||
if (ytId) return { type: 'youtube', embedUrl: `https://www.youtube.com/embed/${ytId}?rel=0` };
|
||||
|
||||
// Vimeo
|
||||
const vmMatch = url.match(/vimeo\.com\/(\d+)/);
|
||||
if (vmMatch) return { type: 'vimeo', embedUrl: `https://player.vimeo.com/video/${vmMatch[1]}` };
|
||||
// Vimeo: vimeo.com/ID, or vimeo.com/ID/HASH for unlisted/private videos
|
||||
// (the hash becomes the player's `h` query param).
|
||||
const vmMatch = url.match(/vimeo\.com\/(\d+)(?:\/([a-zA-Z0-9]+))?/);
|
||||
if (vmMatch) {
|
||||
const embedUrl = vmMatch[2]
|
||||
? `https://player.vimeo.com/video/${vmMatch[1]}?h=${vmMatch[2]}`
|
||||
: `https://player.vimeo.com/video/${vmMatch[1]}`;
|
||||
return { type: 'vimeo', embedUrl };
|
||||
}
|
||||
|
||||
// Direct file
|
||||
if (url.match(/\.(mp4|webm|ogg|mov)(\?|$)/i)) return { type: 'file', embedUrl: url };
|
||||
@@ -60,28 +92,6 @@ function buildEmbedParams(
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/* ---------- Upload helper ---------- */
|
||||
|
||||
async function uploadToWhp(file: File): Promise<string | null> {
|
||||
const cfg = (window as any).WHP_CONFIG;
|
||||
if (!cfg) return URL.createObjectURL(file);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
const resp = await fetch(`${cfg.apiUrl}?action=upload_asset&site_id=${cfg.siteId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': cfg.csrfToken },
|
||||
body: formData,
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success && data.url) return data.url;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Placeholder ---------- */
|
||||
|
||||
const VIDEO_PLACEHOLDER = (
|
||||
@@ -116,6 +126,7 @@ export const VideoBlock: UserComponent<VideoBlockProps> = ({
|
||||
videoUrl = '',
|
||||
videoType: _videoTypeProp,
|
||||
embedUrl: _embedUrlProp,
|
||||
poster = '',
|
||||
autoplay = false,
|
||||
muted = true,
|
||||
loop = false,
|
||||
@@ -255,8 +266,7 @@ export const VideoBlock: UserComponent<VideoBlockProps> = ({
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
paddingBottom: '56.25%',
|
||||
height: 0,
|
||||
aspectRatio: (style as any)?.aspectRatio || '16 / 9',
|
||||
overflow: 'hidden',
|
||||
borderRadius: (style as any)?.borderRadius || undefined,
|
||||
}}
|
||||
@@ -265,8 +275,7 @@ export const VideoBlock: UserComponent<VideoBlockProps> = ({
|
||||
src={buildEmbedParams(embedUrl, { autoplay, muted, loop, controls })}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
@@ -280,14 +289,18 @@ export const VideoBlock: UserComponent<VideoBlockProps> = ({
|
||||
{type === 'file' && (
|
||||
<video
|
||||
src={embedUrl}
|
||||
poster={poster || undefined}
|
||||
autoPlay={autoplay}
|
||||
muted={muted}
|
||||
loop={loop}
|
||||
controls={controls}
|
||||
preload="metadata"
|
||||
playsInline
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
aspectRatio: (style as any)?.aspectRatio || undefined,
|
||||
objectFit: 'cover',
|
||||
borderRadius: (style as any)?.borderRadius || undefined,
|
||||
}}
|
||||
/>
|
||||
@@ -296,342 +309,6 @@ export const VideoBlock: UserComponent<VideoBlockProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ========================================================================
|
||||
Settings Panel
|
||||
======================================================================== */
|
||||
|
||||
const VideoBlockSettings: React.FC = () => {
|
||||
const {
|
||||
actions: { setProp },
|
||||
props,
|
||||
} = useNode((node) => ({
|
||||
props: node.data.props as VideoBlockProps,
|
||||
}));
|
||||
|
||||
const [urlInput, setUrlInput] = useState(props.videoUrl || '');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const detected = props.videoUrl ? detectVideoType(props.videoUrl) : { type: 'none' as VideoType, embedUrl: '' };
|
||||
|
||||
const applyUrl = useCallback(
|
||||
(url: string) => {
|
||||
const info = detectVideoType(url);
|
||||
setProp((p: VideoBlockProps) => {
|
||||
p.videoUrl = url;
|
||||
p.videoType = info.type;
|
||||
p.embedUrl = info.embedUrl;
|
||||
});
|
||||
},
|
||||
[setProp]
|
||||
);
|
||||
|
||||
const handleUpload = useCallback(
|
||||
async (file: File) => {
|
||||
const url = await uploadToWhp(file);
|
||||
if (url) {
|
||||
setUrlInput(url);
|
||||
applyUrl(url);
|
||||
}
|
||||
},
|
||||
[applyUrl]
|
||||
);
|
||||
|
||||
const handleBrowse = useCallback(async () => {
|
||||
const cfg = (window as any).WHP_CONFIG;
|
||||
if (!cfg) return;
|
||||
try {
|
||||
const resp = await fetch(`${cfg.apiUrl}?action=list_assets&site_id=${cfg.siteId}`);
|
||||
const data = await resp.json();
|
||||
if (data.success && Array.isArray(data.assets)) {
|
||||
const videos = data.assets.filter(
|
||||
(a: any) => (a.type || '').startsWith('video') || (a.name || '').match(/\.(mp4|webm|ogg|mov)$/i)
|
||||
);
|
||||
if (videos.length === 0) {
|
||||
alert('No video assets uploaded yet. Use the Upload button to add one.');
|
||||
return;
|
||||
}
|
||||
const names = videos.map((a: any, i: number) => `${i + 1}. ${a.name}`).join('\n');
|
||||
const choice = prompt(`Select a video (enter number):\n\n${names}`);
|
||||
if (choice) {
|
||||
const idx = parseInt(choice, 10) - 1;
|
||||
if (videos[idx]) {
|
||||
setUrlInput(videos[idx].url);
|
||||
applyUrl(videos[idx].url);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Browse failed:', e);
|
||||
}
|
||||
}, [applyUrl]);
|
||||
|
||||
const typeBadge = (label: string, color: string) => (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
background: color,
|
||||
color: '#fff',
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
|
||||
const overlayPresets = ['#000000', '#1e293b', '#0f172a', '#312e81', '#064e3b', '#7f1d1d'];
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '4px 8px',
|
||||
background: '#27272a',
|
||||
color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
};
|
||||
const checkboxRowStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
color: '#e4e4e7',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{/* Video URL */}
|
||||
<div>
|
||||
<label style={labelStyle}>Video URL</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={urlInput}
|
||||
onChange={(e) => setUrlInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') applyUrl(urlInput);
|
||||
}}
|
||||
placeholder="YouTube, Vimeo, or direct video URL..."
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => applyUrl(urlInput)}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
fontSize: 11,
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: '#3b82f6',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Detected type badge */}
|
||||
{detected.type !== 'none' && (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
{detected.type === 'youtube' && typeBadge('YouTube', '#dc2626')}
|
||||
{detected.type === 'vimeo' && typeBadge('Vimeo', '#1ab7ea')}
|
||||
{detected.type === 'file' && typeBadge('Video File', '#16a34a')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload / Browse */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 10px',
|
||||
fontSize: 12,
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: '#3b82f6',
|
||||
color: '#fff',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBrowse}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 10px',
|
||||
fontSize: 12,
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-folder-open" style={{ marginRight: 4 }} /> Browse
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleUpload(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Playback options */}
|
||||
<div>
|
||||
<label style={labelStyle}>Playback</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<label style={checkboxRowStyle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.autoplay ?? false}
|
||||
onChange={(e) => setProp((p: VideoBlockProps) => { p.autoplay = e.target.checked; })}
|
||||
/>
|
||||
Autoplay
|
||||
</label>
|
||||
<label style={checkboxRowStyle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.muted ?? true}
|
||||
onChange={(e) => setProp((p: VideoBlockProps) => { p.muted = e.target.checked; })}
|
||||
/>
|
||||
Muted
|
||||
</label>
|
||||
<label style={checkboxRowStyle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.loop ?? false}
|
||||
onChange={(e) => setProp((p: VideoBlockProps) => { p.loop = e.target.checked; })}
|
||||
/>
|
||||
Loop
|
||||
</label>
|
||||
<label style={checkboxRowStyle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.controls ?? true}
|
||||
onChange={(e) => setProp((p: VideoBlockProps) => { p.controls = e.target.checked; })}
|
||||
/>
|
||||
Show Controls
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode toggle */}
|
||||
<div>
|
||||
<label style={labelStyle}>Display Mode</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
onClick={() => setProp((p: VideoBlockProps) => { p.isBackground = false; })}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 10px',
|
||||
fontSize: 11,
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: !props.isBackground ? '#3b82f6' : '#27272a',
|
||||
color: !props.isBackground ? '#fff' : '#a1a1aa',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Normal
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setProp((p: VideoBlockProps) => { p.isBackground = true; })}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 10px',
|
||||
fontSize: 11,
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.isBackground ? '#3b82f6' : '#27272a',
|
||||
color: props.isBackground ? '#fff' : '#a1a1aa',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Background
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background mode options */}
|
||||
{props.isBackground && (
|
||||
<>
|
||||
<div>
|
||||
<label style={labelStyle}>Overlay Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{overlayPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: VideoBlockProps) => { p.overlayColor = c; })}
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 4,
|
||||
border: '1px solid #3f3f46',
|
||||
backgroundColor: c,
|
||||
cursor: 'pointer',
|
||||
outline: props.overlayColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>
|
||||
Overlay Opacity: {props.overlayOpacity ?? 50}%
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={props.overlayOpacity ?? 50}
|
||||
onChange={(e) =>
|
||||
setProp((p: VideoBlockProps) => {
|
||||
p.overlayOpacity = parseInt(e.target.value, 10);
|
||||
})
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Inner Max Width</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.innerMaxWidth || '1200px'}
|
||||
onChange={(e) => setProp((p: VideoBlockProps) => { p.innerMaxWidth = e.target.value; })}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ========================================================================
|
||||
Craft Config
|
||||
======================================================================== */
|
||||
@@ -642,6 +319,7 @@ VideoBlock.craft = {
|
||||
videoUrl: '',
|
||||
videoType: 'none',
|
||||
embedUrl: '',
|
||||
poster: '',
|
||||
autoplay: false,
|
||||
muted: true,
|
||||
loop: false,
|
||||
@@ -650,16 +328,26 @@ VideoBlock.craft = {
|
||||
overlayColor: '#000000',
|
||||
overlayOpacity: 50,
|
||||
innerMaxWidth: '1200px',
|
||||
style: {},
|
||||
style: {
|
||||
width: '',
|
||||
aspectRatio: '',
|
||||
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
|
||||
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
opacity: '1',
|
||||
},
|
||||
animation: '',
|
||||
animationDelay: '0',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => true,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: VideoBlockSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ========================================================================
|
||||
@@ -669,6 +357,7 @@ VideoBlock.craft = {
|
||||
(VideoBlock as any).toHtml = (props: VideoBlockProps, childrenHtml: string) => {
|
||||
const {
|
||||
videoUrl = '',
|
||||
poster = '',
|
||||
autoplay = false,
|
||||
muted = true,
|
||||
loop: doLoop = false,
|
||||
@@ -722,7 +411,7 @@ VideoBlock.craft = {
|
||||
zIndex: '0',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
videoHtml = `<video src="${embedUrl}" autoplay muted loop playsinline${vidStyle ? ` style="${vidStyle}"` : ''}></video>`;
|
||||
videoHtml = `<video src="${escapeAttr(safeUrl(embedUrl))}" autoplay muted loop playsinline${vidStyle ? ` style="${vidStyle}"` : ''}></video>`;
|
||||
} else if ((type === 'youtube' || type === 'vimeo') && embedUrl) {
|
||||
const iframeSrc = buildEmbedParams(embedUrl, { autoplay: true, muted: true, loop: true, controls: false });
|
||||
const ifrStyle = cssPropsToString({
|
||||
@@ -738,7 +427,7 @@ VideoBlock.craft = {
|
||||
zIndex: '0',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
videoHtml = `<iframe src="${iframeSrc}" allow="autoplay; encrypted-media" allowfullscreen${ifrStyle ? ` style="${ifrStyle}"` : ''}></iframe>`;
|
||||
videoHtml = `<iframe src="${escapeAttr(safeUrl(iframeSrc))}" title="Embedded video" allow="autoplay; encrypted-media" allowfullscreen${ifrStyle ? ` style="${ifrStyle}"` : ''}></iframe>`;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -757,21 +446,19 @@ VideoBlock.craft = {
|
||||
const iframeSrc = buildEmbedParams(embedUrl, { autoplay, muted, loop: doLoop, controls });
|
||||
const containerStyle = cssPropsToString({
|
||||
position: 'relative',
|
||||
paddingBottom: '56.25%',
|
||||
height: '0',
|
||||
aspectRatio: (style as any)?.aspectRatio || '16 / 9',
|
||||
overflow: 'hidden',
|
||||
borderRadius: (style as any)?.borderRadius || undefined,
|
||||
});
|
||||
const iframeStyle = cssPropsToString({
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: '0',
|
||||
inset: '0',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
});
|
||||
return {
|
||||
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><div${containerStyle ? ` style="${containerStyle}"` : ''}><iframe src="${iframeSrc}" allow="autoplay; encrypted-media; picture-in-picture" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div></div>`,
|
||||
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><div${containerStyle ? ` style="${containerStyle}"` : ''}><iframe src="${escapeAttr(safeUrl(iframeSrc))}" title="Embedded video" allow="autoplay; encrypted-media; picture-in-picture" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div></div>`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -782,13 +469,16 @@ VideoBlock.craft = {
|
||||
if (doLoop) vidAttrs.push('loop');
|
||||
if (controls) vidAttrs.push('controls');
|
||||
vidAttrs.push('playsinline');
|
||||
const posterAttr = poster ? ` poster="${escapeAttr(safeImageUrl(poster))}"` : '';
|
||||
const vidStyle = cssPropsToString({
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
aspectRatio: (style as any)?.aspectRatio || undefined,
|
||||
objectFit: 'cover',
|
||||
borderRadius: (style as any)?.borderRadius || undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><video src="${embedUrl}" ${vidAttrs.join(' ')}${vidStyle ? ` style="${vidStyle}"` : ''}></video></div>`,
|
||||
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><video src="${escapeAttr(safeUrl(embedUrl))}"${posterAttr} preload="metadata" ${vidAttrs.join(' ')}${vidStyle ? ` style="${vidStyle}"` : ''}></video></div>`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Accordion } from './Accordion';
|
||||
|
||||
const toHtml = (Accordion as any).toHtml;
|
||||
|
||||
const items = [
|
||||
{ title: 'Q1', content: 'A1', isOpen: true },
|
||||
{ title: 'Q2', content: 'A2', isOpen: false },
|
||||
];
|
||||
|
||||
describe('Accordion.toHtml basic export', () => {
|
||||
test('renders a <details> per item with the open attribute honored', () => {
|
||||
const { html } = toHtml({ items }, '');
|
||||
const detailsBlocks = html.match(/<details[^>]*>/g) || [];
|
||||
expect(detailsBlocks.length).toBe(2);
|
||||
expect(detailsBlocks[0]).toContain(' open');
|
||||
expect(detailsBlocks[1]).not.toContain(' open');
|
||||
});
|
||||
|
||||
test('headerBg/headerColor/contentBg/borderColor emit into the panel styles', () => {
|
||||
const { html } = toHtml({ items, headerBg: '#111111', headerColor: '#222222', contentBg: '#333333', borderColor: '#444444' }, '');
|
||||
expect(html).toContain('#111111');
|
||||
expect(html).toContain('#222222');
|
||||
expect(html).toContain('#333333');
|
||||
expect(html).toContain('#444444');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accordion.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||
const props = (Accordion as any).craft.props;
|
||||
expect(props).toHaveProperty('animation', '');
|
||||
expect(props).toHaveProperty('animationDelay', '');
|
||||
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||
expect(props).toHaveProperty('hideOnTablet', false);
|
||||
expect(props).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { CSSProperties, useState } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, cssValue } from '../../utils/escape';
|
||||
|
||||
interface AccordionItem {
|
||||
title: string;
|
||||
@@ -15,6 +16,12 @@ interface AccordionProps {
|
||||
headerColor?: string;
|
||||
contentBg?: string;
|
||||
borderColor?: string;
|
||||
anchorId?: string;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const defaultItems: AccordionItem[] = [
|
||||
@@ -30,6 +37,7 @@ export const Accordion: UserComponent<AccordionProps> = ({
|
||||
headerColor = '#18181b',
|
||||
contentBg = '#ffffff',
|
||||
borderColor = '#e2e8f0',
|
||||
anchorId,
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
@@ -56,6 +64,7 @@ export const Accordion: UserComponent<AccordionProps> = ({
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
padding: '60px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
@@ -119,155 +128,6 @@ export const Accordion: UserComponent<AccordionProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const AccordionSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as AccordionProps,
|
||||
}));
|
||||
|
||||
const items = props.items || defaultItems;
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||||
|
||||
const updateItem = (index: number, field: keyof AccordionItem, value: string | boolean) => {
|
||||
setProp((p: AccordionProps) => {
|
||||
const updated = [...(p.items || defaultItems)];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
p.items = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addItem = () => {
|
||||
setProp((p: AccordionProps) => {
|
||||
p.items = [...(p.items || defaultItems), { title: 'New Question', content: 'Answer goes here.', isOpen: false }];
|
||||
});
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
setProp((p: AccordionProps) => {
|
||||
const updated = [...(p.items || defaultItems)];
|
||||
updated.splice(index, 1);
|
||||
p.items = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const colorSwatches = ['#f8fafc', '#f1f5f9', '#e2e8f0', '#ffffff', '#18181b', '#1e293b', '#3b82f6', '#8b5cf6'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Header Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{colorSwatches.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: AccordionProps) => { p.headerBg = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.headerBg === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Header Text Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['#18181b', '#1f2937', '#374151', '#ffffff', '#e2e8f0', '#3b82f6'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: AccordionProps) => { p.headerColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.headerColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Content Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#1e293b'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: AccordionProps) => { p.contentBg = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.contentBg === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Border Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['#e2e8f0', '#cbd5e1', '#d1d5db', '#3f3f46', '#52525b'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: AccordionProps) => { p.borderColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.borderColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Items</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{items.map((item, i) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input type="text" value={item.title} onChange={(e) => updateItem(i, 'title', e.target.value)} placeholder="Title" style={{ ...inputStyle, flex: 1 }} />
|
||||
<button
|
||||
onClick={() => removeItem(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={item.content}
|
||||
onChange={(e) => updateItem(i, 'content', e.target.value)}
|
||||
placeholder="Content"
|
||||
rows={2}
|
||||
style={{ ...inputStyle, resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addItem}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Item
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Accordion.craft = {
|
||||
@@ -279,29 +139,34 @@ Accordion.craft = {
|
||||
headerColor: '#18181b',
|
||||
contentBg: '#ffffff',
|
||||
borderColor: '#e2e8f0',
|
||||
anchorId: '',
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: AccordionSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Accordion as any).toHtml = (props: AccordionProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>');
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '60px 20px',
|
||||
...props.style,
|
||||
});
|
||||
const headerBg = props.headerBg || '#f8fafc';
|
||||
const headerColor = props.headerColor || '#18181b';
|
||||
const contentBg = props.contentBg || '#ffffff';
|
||||
const borderColor = props.borderColor || '#e2e8f0';
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
// Sanitized -- raw string-interpolation sinks in the <details>/<summary>
|
||||
// style attributes below.
|
||||
const headerBg = cssValue(props.headerBg) || '#f8fafc';
|
||||
const headerColor = cssValue(props.headerColor) || '#18181b';
|
||||
const contentBg = cssValue(props.contentBg) || '#ffffff';
|
||||
const borderColor = cssValue(props.borderColor) || '#e2e8f0';
|
||||
const items = props.items || defaultItems;
|
||||
|
||||
const panels = items.map((item, i) => {
|
||||
@@ -311,16 +176,16 @@ Accordion.craft = {
|
||||
const borderBottom = i === items.length - 1 ? `border:1px solid ${borderColor};` : `border:1px solid ${borderColor};border-bottom:none;`;
|
||||
return `<details${openAttr} style="${borderBottom}${topRadius}${bottomRadius}">
|
||||
<summary style="padding:16px 20px;background-color:${headerBg};color:${headerColor};cursor:pointer;font-weight:600;font-size:16px;list-style:none;display:flex;justify-content:space-between;align-items:center">
|
||||
${esc(item.title)}
|
||||
${escapeHtml(item.title)}
|
||||
</summary>
|
||||
<div style="padding:16px 20px;background-color:${contentBg};color:#4b5563;font-size:14px;line-height:1.6;border-top:1px solid ${borderColor}">
|
||||
${esc(item.content)}
|
||||
${escapeHtml(item.content)}
|
||||
</div>
|
||||
</details>`;
|
||||
}).join('\n ');
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:800px;margin:0 auto;display:flex;flex-direction:column">
|
||||
${panels}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { CTASection } from './CTASection';
|
||||
|
||||
const toHtml = (CTASection as any).toHtml;
|
||||
|
||||
describe('CTASection.toHtml basic export', () => {
|
||||
test('renders heading, description, and CTA buttons', () => {
|
||||
const { html } = toHtml({ heading: 'Hi', description: 'Sub', ctas: [{ text: 'Go', href: '#', variant: 'primary' }] }, '');
|
||||
expect(html).toContain('Hi');
|
||||
expect(html).toContain('Sub');
|
||||
expect(html).toContain('Go');
|
||||
});
|
||||
|
||||
test('box-model style props (margin/padding/border/boxShadow/opacity) flow through to the section style=""', () => {
|
||||
const { html } = toHtml({
|
||||
heading: 'Hi',
|
||||
description: 'Sub',
|
||||
style: { marginTop: '10px', paddingLeft: '5px', border: '1px solid #000', boxShadow: '0 1px 2px rgba(0,0,0,0.1)', opacity: '0.5' },
|
||||
}, '');
|
||||
expect(html).toContain('margin-top:10px');
|
||||
expect(html).toContain('padding-left:5px');
|
||||
expect(html).toContain('border:1px solid #000');
|
||||
expect(html).toContain('box-shadow:0 1px 2px rgba(0,0,0,0.1)');
|
||||
expect(html).toContain('opacity:0.5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CTASection.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||
const props = (CTASection as any).craft.props;
|
||||
expect(props).toHaveProperty('animation', '');
|
||||
expect(props).toHaveProperty('animationDelay', '');
|
||||
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||
expect(props).toHaveProperty('hideOnTablet', false);
|
||||
expect(props).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,24 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
||||
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
||||
|
||||
interface CTASectionProps {
|
||||
heading?: string;
|
||||
description?: string;
|
||||
ctas?: CtaButton[];
|
||||
/** Legacy props kept for backward compat with saved projects. */
|
||||
buttonText?: string;
|
||||
buttonHref?: string;
|
||||
gradient?: string;
|
||||
anchorId?: string;
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
|
||||
@@ -16,9 +26,11 @@ const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
|
||||
export const CTASection: UserComponent<CTASectionProps> = ({
|
||||
heading = 'Ready to Get Started?',
|
||||
description = 'Join thousands of satisfied users and start building your dream website today.',
|
||||
buttonText = 'Start Free Trial',
|
||||
buttonHref = '#',
|
||||
ctas,
|
||||
buttonText,
|
||||
buttonHref,
|
||||
gradient = defaultGradient,
|
||||
anchorId,
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
@@ -28,9 +40,13 @@ export const CTASection: UserComponent<CTASectionProps> = ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const effectiveCtas = normalizeCtas({ ctas, buttonText, buttonHref });
|
||||
const ctaDefaults = { primaryBg: '#ffffff', primaryText: '#18181b', outlineText: '#ffffff' };
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
background: gradient,
|
||||
padding: '80px 20px',
|
||||
@@ -46,105 +62,16 @@ export const CTASection: UserComponent<CTASectionProps> = ({
|
||||
<p style={{ fontSize: '18px', color: 'rgba(255,255,255,0.85)', marginBottom: '28px', lineHeight: '1.6' }}>
|
||||
{description}
|
||||
</p>
|
||||
<a
|
||||
href={buttonHref}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '14px 36px',
|
||||
backgroundColor: '#ffffff',
|
||||
color: '#18181b',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '8px',
|
||||
fontWeight: '600',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
{buttonText}
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
{effectiveCtas.map((cta, i) => (
|
||||
<a key={i} href={cta.href || '#'} onClick={(e) => e.preventDefault()}
|
||||
style={ctaInlineStyle(cta, ctaDefaults)}>
|
||||
{cta.text}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const CTASectionSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as CTASectionProps,
|
||||
}));
|
||||
|
||||
const gradientPresets = [
|
||||
{ label: 'Blue-Purple', value: 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)' },
|
||||
{ label: 'Purple', value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
|
||||
{ label: 'Teal', value: 'linear-gradient(135deg, #0d9488 0%, #0f766e 100%)' },
|
||||
{ label: 'Sunset', value: 'linear-gradient(135deg, #f97316 0%, #ec4899 100%)' },
|
||||
{ label: 'Dark', value: 'linear-gradient(135deg, #1e293b 0%, #0f172a 100%)' },
|
||||
{ label: 'Ocean', value: 'linear-gradient(135deg, #0ea5e9 0%, #6366f1 100%)' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Heading</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.heading || ''}
|
||||
onChange={(e) => setProp((p: CTASectionProps) => { p.heading = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Description</label>
|
||||
<textarea
|
||||
value={props.description || ''}
|
||||
onChange={(e) => setProp((p: CTASectionProps) => { p.description = e.target.value; })}
|
||||
rows={2}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12, resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.buttonText || ''}
|
||||
onChange={(e) => setProp((p: CTASectionProps) => { p.buttonText = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.buttonHref || ''}
|
||||
onChange={(e) => setProp((p: CTASectionProps) => { p.buttonHref = e.target.value; })}
|
||||
placeholder="https://..."
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Gradient</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{gradientPresets.map((g) => (
|
||||
<button
|
||||
key={g.label}
|
||||
onClick={() => setProp((p: CTASectionProps) => { p.gradient = g.value; })}
|
||||
title={g.label}
|
||||
style={{
|
||||
width: 32, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
background: g.value, cursor: 'pointer',
|
||||
outline: props.gradient === g.value ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -155,37 +82,43 @@ CTASection.craft = {
|
||||
props: {
|
||||
heading: 'Ready to Get Started?',
|
||||
description: 'Join thousands of satisfied users and start building your dream website today.',
|
||||
buttonText: 'Start Free Trial',
|
||||
buttonHref: '#',
|
||||
ctas: [
|
||||
{ text: 'Start Free Trial', href: '#', variant: 'primary' },
|
||||
] as CtaButton[],
|
||||
gradient: defaultGradient,
|
||||
anchorId: '',
|
||||
style: {},
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: CTASectionSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(CTASection as any).toHtml = (props: CTASectionProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>');
|
||||
const sectionStyle = cssPropsToString({
|
||||
background: props.gradient || defaultGradient,
|
||||
padding: '80px 20px',
|
||||
textAlign: 'center',
|
||||
...props.style,
|
||||
});
|
||||
const ctas = normalizeCtas(props);
|
||||
const buttonsHtml = ctasToHtml(ctas, { primaryBg: '#ffffff', primaryText: '#18181b', outlineText: '#ffffff' });
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:700px;margin:0 auto">
|
||||
<h2 style="font-size:36px;font-weight:700;color:#ffffff;margin-bottom:12px">${esc(props.heading || '')}</h2>
|
||||
<p style="font-size:18px;color:rgba(255,255,255,0.85);margin-bottom:28px;line-height:1.6">${esc(props.description || '')}</p>
|
||||
<a href="${props.buttonHref || '#'}" style="display:inline-block;padding:14px 36px;background-color:#ffffff;color:#18181b;text-decoration:none;border-radius:8px;font-weight:600;font-size:16px">${esc(props.buttonText || '')}</a>
|
||||
<h2 style="font-size:36px;font-weight:700;color:#ffffff;margin-bottom:12px">${escapeHtml(props.heading || '')}</h2>
|
||||
<p style="font-size:18px;color:rgba(255,255,255,0.85);margin-bottom:28px;line-height:1.6">${escapeHtml(props.description || '')}</p>
|
||||
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">${buttonsHtml}</div>
|
||||
</div>
|
||||
</section>`,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { CallToAction } from './CallToAction';
|
||||
|
||||
const toHtml = (CallToAction as any).toHtml;
|
||||
|
||||
describe('CallToAction.toHtml basic export', () => {
|
||||
test('renders heading, description, and CTA buttons', () => {
|
||||
const { html } = toHtml({ heading: 'Hi', description: 'Sub', ctas: [{ text: 'Go', href: '#', variant: 'primary' }] }, '');
|
||||
expect(html).toContain('Hi');
|
||||
expect(html).toContain('Sub');
|
||||
expect(html).toContain('Go');
|
||||
});
|
||||
|
||||
test('box-model style props (margin/padding/border/boxShadow/opacity) flow through to the section style=""', () => {
|
||||
const { html } = toHtml({
|
||||
heading: 'Hi',
|
||||
description: 'Sub',
|
||||
style: { marginBottom: '12px', paddingRight: '6px', border: '2px dashed #333', boxShadow: '0 4px 8px rgba(0,0,0,0.12)', opacity: '0.75' },
|
||||
}, '');
|
||||
expect(html).toContain('margin-bottom:12px');
|
||||
expect(html).toContain('padding-right:6px');
|
||||
expect(html).toContain('border:2px dashed #333');
|
||||
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
|
||||
expect(html).toContain('opacity:0.75');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CallToAction.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||
const props = (CallToAction as any).craft.props;
|
||||
expect(props).toHaveProperty('animation', '');
|
||||
expect(props).toHaveProperty('animationDelay', '');
|
||||
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||
expect(props).toHaveProperty('hideOnTablet', false);
|
||||
expect(props).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,14 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
||||
import { escapeHtml, escapeAttr, cssValue } from '../../utils/escape';
|
||||
|
||||
interface CallToActionProps {
|
||||
heading?: string;
|
||||
description?: string;
|
||||
ctas?: CtaButton[];
|
||||
/** Legacy props kept for backward compat with saved projects. */
|
||||
buttonText?: string;
|
||||
buttonHref?: string;
|
||||
secondaryButtonText?: string;
|
||||
@@ -15,7 +19,13 @@ interface CallToActionProps {
|
||||
overlayOpacity?: number;
|
||||
textColor?: string;
|
||||
buttonColor?: string;
|
||||
anchorId?: string;
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
|
||||
@@ -23,16 +33,18 @@ const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
|
||||
export const CallToAction: UserComponent<CallToActionProps> = ({
|
||||
heading = 'Ready to Get Started?',
|
||||
description = 'Join thousands of satisfied users and start building your dream website today.',
|
||||
buttonText = 'Get Started',
|
||||
buttonHref = '#',
|
||||
secondaryButtonText = '',
|
||||
secondaryButtonHref = '#',
|
||||
ctas,
|
||||
buttonText,
|
||||
buttonHref,
|
||||
secondaryButtonText,
|
||||
secondaryButtonHref,
|
||||
bgType = 'gradient',
|
||||
bgValue = defaultGradient,
|
||||
overlayColor = '#000000',
|
||||
overlayOpacity = 0,
|
||||
textColor = '#ffffff',
|
||||
buttonColor = '#ffffff',
|
||||
anchorId,
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
@@ -56,9 +68,13 @@ export const CallToAction: UserComponent<CallToActionProps> = ({
|
||||
const isButtonDark = buttonColor === '#ffffff' || buttonColor === '#f8fafc';
|
||||
const buttonTextColor = isButtonDark ? '#18181b' : '#ffffff';
|
||||
|
||||
const effectiveCtas = normalizeCtas({ ctas, buttonText, buttonHref, secondaryButtonText, secondaryButtonHref });
|
||||
const ctaDefaults = { primaryBg: buttonColor, primaryText: buttonTextColor, outlineText: textColor };
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
position: 'relative',
|
||||
padding: '80px 20px',
|
||||
@@ -89,290 +105,18 @@ export const CallToAction: UserComponent<CallToActionProps> = ({
|
||||
{description}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
<a
|
||||
href={buttonHref}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '14px 36px',
|
||||
backgroundColor: buttonColor,
|
||||
color: buttonTextColor,
|
||||
textDecoration: 'none',
|
||||
borderRadius: '8px',
|
||||
fontWeight: '600',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
{buttonText}
|
||||
{effectiveCtas.map((cta, i) => (
|
||||
<a key={i} href={cta.href || '#'} onClick={(e) => e.preventDefault()}
|
||||
style={ctaInlineStyle(cta, ctaDefaults)}>
|
||||
{cta.text}
|
||||
</a>
|
||||
{secondaryButtonText && (
|
||||
<a
|
||||
href={secondaryButtonHref}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '14px 36px',
|
||||
backgroundColor: 'transparent',
|
||||
color: textColor,
|
||||
textDecoration: 'none',
|
||||
borderRadius: '8px',
|
||||
fontWeight: '600',
|
||||
fontSize: '16px',
|
||||
border: `2px solid ${textColor}`,
|
||||
}}
|
||||
>
|
||||
{secondaryButtonText}
|
||||
</a>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const CallToActionSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as CallToActionProps,
|
||||
}));
|
||||
|
||||
const gradientPresets = [
|
||||
{ label: 'Blue-Purple', value: 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)' },
|
||||
{ label: 'Purple', value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
|
||||
{ label: 'Teal', value: 'linear-gradient(135deg, #0d9488 0%, #0f766e 100%)' },
|
||||
{ label: 'Sunset', value: 'linear-gradient(135deg, #f97316 0%, #ec4899 100%)' },
|
||||
{ label: 'Dark', value: 'linear-gradient(135deg, #1e293b 0%, #0f172a 100%)' },
|
||||
{ label: 'Ocean', value: 'linear-gradient(135deg, #0ea5e9 0%, #6366f1 100%)' },
|
||||
];
|
||||
|
||||
const colorPresets = ['#2563eb', '#7c3aed', '#0d9488', '#18181b', '#0f172a', '#1e293b', '#dc2626', '#f97316'];
|
||||
const buttonColorPresets = ['#ffffff', '#18181b', '#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#f59e0b', '#ec4899'];
|
||||
const textColorPresets = ['#ffffff', '#f8fafc', '#e2e8f0', '#18181b', '#1e293b', '#fef3c7'];
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Heading</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.heading || ''}
|
||||
onChange={(e) => setProp((p: CallToActionProps) => { p.heading = e.target.value; })}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Description</label>
|
||||
<textarea
|
||||
value={props.description || ''}
|
||||
onChange={(e) => setProp((p: CallToActionProps) => { p.description = e.target.value; })}
|
||||
rows={2}
|
||||
style={{ ...inputStyle, resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Primary Button */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Primary Button Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.buttonText || ''}
|
||||
onChange={(e) => setProp((p: CallToActionProps) => { p.buttonText = e.target.value; })}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Primary Button URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.buttonHref || ''}
|
||||
onChange={(e) => setProp((p: CallToActionProps) => { p.buttonHref = e.target.value; })}
|
||||
placeholder="https://..."
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Secondary Button */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Secondary Button Text <span style={{ opacity: 0.5 }}>(leave empty to hide)</span></label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.secondaryButtonText || ''}
|
||||
onChange={(e) => setProp((p: CallToActionProps) => { p.secondaryButtonText = e.target.value; })}
|
||||
placeholder="e.g. Learn More"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{props.secondaryButtonText && (
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Secondary Button URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.secondaryButtonHref || ''}
|
||||
onChange={(e) => setProp((p: CallToActionProps) => { p.secondaryButtonHref = e.target.value; })}
|
||||
placeholder="https://..."
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background Type */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Type</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['color', 'gradient', 'image'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => {
|
||||
setProp((p: CallToActionProps) => {
|
||||
p.bgType = t;
|
||||
if (t === 'color') p.bgValue = '#2563eb';
|
||||
if (t === 'gradient') p.bgValue = defaultGradient;
|
||||
if (t === 'image') p.bgValue = '';
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.bgType === t ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background sub-controls */}
|
||||
{props.bgType === 'color' && (
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{colorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: CallToActionProps) => { p.bgValue = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.bgValue === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.bgType === 'gradient' && (
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Gradient</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{gradientPresets.map((g) => (
|
||||
<button
|
||||
key={g.label}
|
||||
onClick={() => setProp((p: CallToActionProps) => { p.bgValue = g.value; })}
|
||||
title={g.label}
|
||||
style={{
|
||||
width: 32, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
background: g.value, cursor: 'pointer',
|
||||
outline: props.bgValue === g.value ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.bgType === 'image' && (
|
||||
<>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Image URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.bgValue || ''}
|
||||
onChange={(e) => setProp((p: CallToActionProps) => { p.bgValue = e.target.value; })}
|
||||
placeholder="https://..."
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>
|
||||
Overlay Opacity: {props.overlayOpacity ?? 0}%
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={props.overlayOpacity ?? 0}
|
||||
onChange={(e) => setProp((p: CallToActionProps) => { p.overlayOpacity = parseInt(e.target.value); })}
|
||||
style={{ width: '100%', accentColor: '#3b82f6' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Overlay Color</label>
|
||||
<input
|
||||
type="color"
|
||||
value={props.overlayColor || '#000000'}
|
||||
onChange={(e) => setProp((p: CallToActionProps) => { p.overlayColor = e.target.value; })}
|
||||
style={{ width: 32, height: 24, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', background: 'none', padding: 0 }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Text Color */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Text Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{textColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: CallToActionProps) => { p.textColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.textColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Button Color */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{buttonColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: CallToActionProps) => { p.buttonColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.buttonColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
CallToAction.craft = {
|
||||
@@ -380,10 +124,11 @@ CallToAction.craft = {
|
||||
props: {
|
||||
heading: 'Ready to Get Started?',
|
||||
description: 'Join thousands of satisfied users and start building your dream website today.',
|
||||
buttonText: 'Get Started',
|
||||
buttonHref: '#',
|
||||
secondaryButtonText: 'Learn More',
|
||||
secondaryButtonHref: '#',
|
||||
ctas: [
|
||||
{ text: 'Get Started', href: '#', variant: 'primary' },
|
||||
{ text: 'Learn More', href: '#', variant: 'outline' },
|
||||
] as CtaButton[],
|
||||
anchorId: '',
|
||||
bgType: 'gradient',
|
||||
bgValue: defaultGradient,
|
||||
overlayColor: '#000000',
|
||||
@@ -391,25 +136,27 @@ CallToAction.craft = {
|
||||
textColor: '#ffffff',
|
||||
buttonColor: '#ffffff',
|
||||
style: {},
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: CallToActionSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(CallToAction as any).toHtml = (props: CallToActionProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
const bgType = props.bgType || 'gradient';
|
||||
const bgValue = props.bgValue || defaultGradient;
|
||||
const textColor = props.textColor || '#ffffff';
|
||||
// Sanitized -- raw string-interpolation sink in the heading/description
|
||||
// style attributes below.
|
||||
const textColor = cssValue(props.textColor) || '#ffffff';
|
||||
const buttonColor = props.buttonColor || '#ffffff';
|
||||
const isButtonDark = buttonColor === '#ffffff' || buttonColor === '#f8fafc';
|
||||
const buttonTextColor = isButtonDark ? '#18181b' : '#ffffff';
|
||||
@@ -445,41 +192,16 @@ CallToAction.craft = {
|
||||
overlayHtml = `<div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div>`;
|
||||
}
|
||||
|
||||
let secondaryBtnHtml = '';
|
||||
if (props.secondaryButtonText) {
|
||||
const secStyle = cssPropsToString({
|
||||
display: 'inline-block',
|
||||
padding: '14px 36px',
|
||||
backgroundColor: 'transparent',
|
||||
color: textColor,
|
||||
textDecoration: 'none',
|
||||
borderRadius: '8px',
|
||||
fontWeight: '600',
|
||||
fontSize: '16px',
|
||||
border: `2px solid ${textColor}`,
|
||||
});
|
||||
secondaryBtnHtml = `\n <a href="${props.secondaryButtonHref || '#'}"${secStyle ? ` style="${secStyle}"` : ''}>${esc(props.secondaryButtonText)}</a>`;
|
||||
}
|
||||
|
||||
const btnStyle = cssPropsToString({
|
||||
display: 'inline-block',
|
||||
padding: '14px 36px',
|
||||
backgroundColor: buttonColor,
|
||||
color: buttonTextColor,
|
||||
textDecoration: 'none',
|
||||
borderRadius: '8px',
|
||||
fontWeight: '600',
|
||||
fontSize: '16px',
|
||||
});
|
||||
const ctas = normalizeCtas(props);
|
||||
const buttonsHtml = ctasToHtml(ctas, { primaryBg: buttonColor, primaryText: buttonTextColor, outlineText: textColor });
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
${overlayHtml}<div style="max-width:700px;margin:0 auto;position:relative;z-index:1">
|
||||
<h2 style="font-size:36px;font-weight:700;color:${textColor};margin-bottom:12px">${esc(props.heading || '')}</h2>
|
||||
<p style="font-size:18px;color:${textColor};opacity:0.85;margin-bottom:28px;line-height:1.6">${esc(props.description || '')}</p>
|
||||
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">
|
||||
<a href="${props.buttonHref || '#'}"${btnStyle ? ` style="${btnStyle}"` : ''}>${esc(props.buttonText || '')}</a>${secondaryBtnHtml}
|
||||
</div>
|
||||
<h2 style="font-size:36px;font-weight:700;color:${textColor};margin-bottom:12px">${escapeHtml(props.heading || '')}</h2>
|
||||
<p style="font-size:18px;color:${textColor};opacity:0.85;margin-bottom:28px;line-height:1.6">${escapeHtml(props.description || '')}</p>
|
||||
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">${buttonsHtml}</div>
|
||||
</div>
|
||||
</section>`,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { ContentSlider } from './ContentSlider';
|
||||
|
||||
const toHtml = (ContentSlider as any).toHtml;
|
||||
|
||||
const slides = [
|
||||
{ type: 'image' as const, heading: 'One' },
|
||||
{ type: 'image' as const, heading: 'Two' },
|
||||
{ type: 'image' as const, heading: 'Three' },
|
||||
];
|
||||
|
||||
describe('ContentSlider.toHtml accessibility (F1.1)', () => {
|
||||
test('prev/next arrows get aria-labels and are real buttons', () => {
|
||||
const { html } = toHtml({ slides, showArrows: true }, '');
|
||||
expect(html).toMatch(/<button[^>]*aria-label="Previous slide"/);
|
||||
expect(html).toMatch(/<button[^>]*aria-label="Next slide"/);
|
||||
});
|
||||
|
||||
test('dot buttons get "Go to slide N" aria-labels', () => {
|
||||
const { html } = toHtml({ slides, showDots: true }, '');
|
||||
expect(html).toMatch(/<button[^>]*aria-label="Go to slide 1"/);
|
||||
expect(html).toMatch(/<button[^>]*aria-label="Go to slide 2"/);
|
||||
expect(html).toMatch(/<button[^>]*aria-label="Go to slide 3"/);
|
||||
});
|
||||
|
||||
test('slides are wrapped in an aria-live region', () => {
|
||||
const { html } = toHtml({ slides }, '');
|
||||
expect(html).toMatch(/aria-live="(polite|off)"/);
|
||||
});
|
||||
|
||||
test('decorative chevron icons in arrows are aria-hidden', () => {
|
||||
const { html } = toHtml({ slides, showArrows: true }, '');
|
||||
expect(html).toMatch(/<i class="fa fa-chevron-left" aria-hidden="true"><\/i>/);
|
||||
expect(html).toMatch(/<i class="fa fa-chevron-right" aria-hidden="true"><\/i>/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContentSlider.toHtml deterministic + unique scope ids (thread node id)', () => {
|
||||
test('same node id -> identical output across calls (deterministic, no Math.random)', () => {
|
||||
const { html: html1 } = toHtml({ slides }, '', 'node-cs1');
|
||||
const { html: html2 } = toHtml({ slides }, '', 'node-cs1');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
|
||||
test('different node ids -> different, non-colliding scope ids (identical slides, no collision)', () => {
|
||||
const { html: html1 } = toHtml({ slides }, '', 'node-cs1');
|
||||
const { html: html2 } = toHtml({ slides }, '', 'node-cs2');
|
||||
const id1 = html1.match(/<section id="([^"]+)"/)![1];
|
||||
const id2 = html2.match(/<section id="([^"]+)"/)![1];
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
|
||||
test('no nodeId (legacy 2-arg call): still deterministic across repeated calls, not random', () => {
|
||||
const { html: html1 } = toHtml({ slides }, '');
|
||||
const { html: html2 } = toHtml({ slides }, '');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContentSlider.toHtml autoplay silences aria-live and is pausable (F-export review Minor)', () => {
|
||||
test('aria-live is "off" while autoplay is running, to avoid announcing every auto-rotation', () => {
|
||||
const { html } = toHtml({ slides, autoplay: true }, '');
|
||||
expect(html).toContain('aria-live="off"');
|
||||
});
|
||||
|
||||
test('aria-live stays "polite" when autoplay is disabled', () => {
|
||||
const { html } = toHtml({ slides, autoplay: false }, '');
|
||||
expect(html).toContain('aria-live="polite"');
|
||||
});
|
||||
|
||||
test('manual navigation (next/prev/dot) marks the live region "polite"', () => {
|
||||
const { html } = toHtml({ slides, autoplay: true }, '');
|
||||
expect(html).toMatch(/setAttribute\(["']aria-live["'],\s*["']polite["']\)/);
|
||||
});
|
||||
|
||||
test('autoplay pauses on hover and resumes on mouse leave', () => {
|
||||
const { html } = toHtml({ slides, autoplay: true }, '');
|
||||
expect(html).toMatch(/addEventListener\(["']mouseenter["']/);
|
||||
expect(html).toMatch(/addEventListener\(["']mouseleave["']/);
|
||||
});
|
||||
|
||||
test('autoplay pauses when the tab is hidden (visibilitychange) and the interval is clearable', () => {
|
||||
const { html } = toHtml({ slides, autoplay: true }, '');
|
||||
expect(html).toMatch(/visibilitychange/);
|
||||
expect(html).toMatch(/clearInterval\(/);
|
||||
});
|
||||
|
||||
test('no autoplay: no setInterval/hover/visibility wiring at all', () => {
|
||||
const { html } = toHtml({ slides, autoplay: false }, '');
|
||||
expect(html).not.toMatch(/setInterval\(/);
|
||||
expect(html).not.toMatch(/visibilitychange/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContentSlider.toHtml renders slide.imageSrc as a background-image (INT)', () => {
|
||||
test('a slide with imageSrc set exports a background-image referencing it', () => {
|
||||
const slidesWithImage = [
|
||||
{ type: 'image' as const, imageSrc: 'https://example.com/photo.jpg', heading: 'One' },
|
||||
];
|
||||
const { html } = toHtml({ slides: slidesWithImage }, '');
|
||||
expect(html).toContain("background-image:url('https://example.com/photo.jpg')");
|
||||
});
|
||||
|
||||
test('a slide with no imageSrc falls back to bgColor (no broken/empty background-image url)', () => {
|
||||
const slidesNoImage = [
|
||||
{ type: 'image' as const, imageSrc: '', heading: 'One', bgColor: '#123456' },
|
||||
];
|
||||
const { html } = toHtml({ slides: slidesNoImage }, '');
|
||||
expect(html).not.toContain('background-image:url(');
|
||||
expect(html).toContain('background-color:#123456');
|
||||
});
|
||||
|
||||
test('a slide with a data:image/svg+xml imageSrc exports a non-empty background-image url (safeImageUrl, not safeUrl)', () => {
|
||||
const svgDataUri = 'data:image/svg+xml,%3Csvg%2F%3E';
|
||||
const slidesWithSvg = [
|
||||
{ type: 'image' as const, imageSrc: svgDataUri, heading: 'One' },
|
||||
];
|
||||
const { html } = toHtml({ slides: slidesWithSvg }, '');
|
||||
expect(html).toContain(`background-image:url('${svgDataUri}')`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContentSlider.toHtml interval is NOT runtime-type-checked -- must be coerced before it reaches the inline <script> numeric context', () => {
|
||||
test('a malicious interval string cannot inject arbitrary JS into the autoplay setInterval call', () => {
|
||||
const malicious = '5000);alert(document.domain);//';
|
||||
const { html } = toHtml({ slides, autoplay: true, interval: malicious }, '');
|
||||
expect(html).not.toContain('alert(document.domain)');
|
||||
// the setInterval call must still be well-formed with a plain numeral delay
|
||||
expect(html).toMatch(/setInterval\(function\(\)\{show\(current\+1\);\},\d+\);/);
|
||||
});
|
||||
|
||||
test('a non-numeric interval falls back to a safe default delay', () => {
|
||||
const { html } = toHtml({ slides, autoplay: true, interval: 'not-a-number' }, '');
|
||||
expect(html).toMatch(/setInterval\(function\(\)\{show\(current\+1\);\},5000\);/);
|
||||
});
|
||||
|
||||
test('a normal numeric interval still renders as the exact configured delay', () => {
|
||||
const { html } = toHtml({ slides, autoplay: true, interval: 3000 }, '');
|
||||
expect(html).toMatch(/setInterval\(function\(\)\{show\(current\+1\);\},3000\);/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContentSlider.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
|
||||
test('margin/padding/border/box-shadow/opacity all flow into the section style attribute', () => {
|
||||
const { html } = toHtml(
|
||||
{
|
||||
slides,
|
||||
style: {
|
||||
marginBottom: '24px',
|
||||
paddingTop: '8px',
|
||||
border: '3px dashed #00ff00',
|
||||
boxShadow: '0 10px 24px rgba(0,0,0,0.18)',
|
||||
opacity: '0.75',
|
||||
},
|
||||
},
|
||||
''
|
||||
);
|
||||
expect(html).toContain('margin-bottom:24px');
|
||||
expect(html).toContain('padding-top:8px');
|
||||
expect(html).toContain('border:3px dashed #00ff00');
|
||||
expect(html).toContain('box-shadow:0 10px 24px rgba(0,0,0,0.18)');
|
||||
expect(html).toContain('opacity:0.75');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContentSlider.craft.props exposes the animation/visibility rollout', () => {
|
||||
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
||||
const props = (ContentSlider as any).craft.props;
|
||||
expect(props.animation).toBe('');
|
||||
expect(props.animationDelay).toBe('0');
|
||||
expect(props.hideOnDesktop).toBe(false);
|
||||
expect(props.hideOnTablet).toBe(false);
|
||||
expect(props.hideOnMobile).toBe(false);
|
||||
});
|
||||
|
||||
test('style carries blank/default box-model keys', () => {
|
||||
const style = (ContentSlider as any).craft.props.style;
|
||||
expect(style).toHaveProperty('marginTop');
|
||||
expect(style).toHaveProperty('paddingTop');
|
||||
expect(style.border).toBe('none');
|
||||
expect(style.boxShadow).toBe('none');
|
||||
expect(style.opacity).toBe('1');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { CSSProperties, useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, safeUrl, safeImageUrl, scopeId, cssValue } from '../../utils/escape';
|
||||
|
||||
interface Slide {
|
||||
type: 'image' | 'content';
|
||||
@@ -20,6 +21,11 @@ interface ContentSliderProps {
|
||||
showArrows?: boolean;
|
||||
height?: string;
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const defaultSlides: Slide[] = [
|
||||
@@ -215,208 +221,6 @@ export const ContentSlider: UserComponent<ContentSliderProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const ContentSliderSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as ContentSliderProps,
|
||||
}));
|
||||
|
||||
const items = props.slides || defaultSlides;
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
|
||||
const heightPresets = ['300px', '400px', '500px', '600px', '80vh'];
|
||||
const intervalPresets = [3000, 4000, 5000, 7000, 10000];
|
||||
const bgPresets = [
|
||||
'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)',
|
||||
'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
'linear-gradient(135deg, #f59e0b 0%, #ef4444 100%)',
|
||||
'linear-gradient(135deg, #ec4899 0%, #8b5cf6 100%)',
|
||||
'#18181b',
|
||||
'#0f172a',
|
||||
'#1e293b',
|
||||
'#3b82f6',
|
||||
];
|
||||
|
||||
const updateSlide = (index: number, field: keyof Slide, value: string) => {
|
||||
setProp((p: ContentSliderProps) => {
|
||||
const updated = [...(p.slides || defaultSlides)];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
p.slides = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addSlide = () => {
|
||||
setProp((p: ContentSliderProps) => {
|
||||
const current = p.slides || defaultSlides;
|
||||
p.slides = [...current, {
|
||||
type: 'image',
|
||||
imageSrc: '',
|
||||
heading: 'New Slide',
|
||||
text: 'Add your content here',
|
||||
buttonText: '',
|
||||
buttonHref: '#',
|
||||
bgColor: 'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)',
|
||||
}];
|
||||
});
|
||||
};
|
||||
|
||||
const removeSlide = (index: number) => {
|
||||
setProp((p: ContentSliderProps) => {
|
||||
const updated = [...(p.slides || defaultSlides)];
|
||||
updated.splice(index, 1);
|
||||
p.slides = updated;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{/* Height */}
|
||||
<div>
|
||||
<label style={labelStyle}>Height</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{heightPresets.map((h) => (
|
||||
<button
|
||||
key={h}
|
||||
onClick={() => setProp((p: ContentSliderProps) => { p.height = h; })}
|
||||
style={{
|
||||
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.height === h ? '#3b82f6' : '#27272a',
|
||||
color: props.height === h ? '#fff' : '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{h}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Autoplay */}
|
||||
<div>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.autoplay !== false}
|
||||
onChange={(e) => setProp((p: ContentSliderProps) => { p.autoplay = e.target.checked; })}
|
||||
/>
|
||||
Autoplay
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Interval */}
|
||||
{props.autoplay !== false && (
|
||||
<div>
|
||||
<label style={labelStyle}>Interval (ms)</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{intervalPresets.map((ms) => (
|
||||
<button
|
||||
key={ms}
|
||||
onClick={() => setProp((p: ContentSliderProps) => { p.interval = ms; })}
|
||||
style={{
|
||||
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: (props.interval || 5000) === ms ? '#3b82f6' : '#27272a',
|
||||
color: (props.interval || 5000) === ms ? '#fff' : '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{ms / 1000}s
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show Arrows */}
|
||||
<div>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.showArrows !== false}
|
||||
onChange={(e) => setProp((p: ContentSliderProps) => { p.showArrows = e.target.checked; })}
|
||||
/>
|
||||
Show Arrows
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Show Dots */}
|
||||
<div>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.showDots !== false}
|
||||
onChange={(e) => setProp((p: ContentSliderProps) => { p.showDots = e.target.checked; })}
|
||||
/>
|
||||
Show Dots
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Slides */}
|
||||
<div>
|
||||
<label style={labelStyle}>Slides</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{items.map((slide, i) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, color: '#a1a1aa', flex: 'none', width: 18 }}>{i + 1}.</span>
|
||||
<select
|
||||
value={slide.type}
|
||||
onChange={(e) => updateSlide(i, 'type', e.target.value)}
|
||||
style={{ ...inputStyle, width: 70, flex: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
<option value="image">Image</option>
|
||||
<option value="content">Content</option>
|
||||
</select>
|
||||
<input type="text" value={slide.heading || ''} onChange={(e) => updateSlide(i, 'heading', e.target.value)} placeholder="Heading" style={{ ...inputStyle, flex: 1 }} />
|
||||
<button
|
||||
onClick={() => removeSlide(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
<input type="text" value={slide.imageSrc || ''} onChange={(e) => updateSlide(i, 'imageSrc', e.target.value)} placeholder="Image URL (optional)" style={inputStyle} />
|
||||
<input type="text" value={slide.text || ''} onChange={(e) => updateSlide(i, 'text', e.target.value)} placeholder="Text" style={inputStyle} />
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input type="text" value={slide.buttonText || ''} onChange={(e) => updateSlide(i, 'buttonText', e.target.value)} placeholder="Button text" style={{ ...inputStyle, flex: 1 }} />
|
||||
<input type="text" value={slide.buttonHref || ''} onChange={(e) => updateSlide(i, 'buttonHref', e.target.value)} placeholder="Button URL" style={{ ...inputStyle, flex: 1 }} />
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontSize: 10, color: '#a1a1aa', display: 'block', marginBottom: 2 }}>Background</span>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgPresets.map((bg) => (
|
||||
<button
|
||||
key={bg}
|
||||
onClick={() => updateSlide(i, 'bgColor', bg)}
|
||||
style={{
|
||||
width: 22, height: 22, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
background: bg, cursor: 'pointer',
|
||||
outline: slide.bgColor === bg ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addSlide}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Slide
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
ContentSlider.craft = {
|
||||
@@ -428,22 +232,29 @@ ContentSlider.craft = {
|
||||
showDots: true,
|
||||
showArrows: true,
|
||||
height: '400px',
|
||||
style: {},
|
||||
style: {
|
||||
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
|
||||
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
opacity: '1',
|
||||
},
|
||||
animation: '',
|
||||
animationDelay: '0',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: ContentSliderSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(ContentSlider as any).toHtml = (props: ContentSliderProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
(ContentSlider as any).toHtml = (props: ContentSliderProps, _childrenHtml: string, nodeId?: string) => {
|
||||
const {
|
||||
slides = defaultSlides,
|
||||
autoplay = true,
|
||||
@@ -455,7 +266,24 @@ ContentSlider.craft = {
|
||||
} = props;
|
||||
|
||||
const items = slides.length > 0 ? slides : defaultSlides;
|
||||
const uid = 'cs_' + Math.random().toString(36).slice(2, 8);
|
||||
// Number() coercion: `interval` is declared `number` in TS but is NOT
|
||||
// type-checked at runtime -- it arrives raw via the AI `update_props`
|
||||
// path or a deserialized saved-state blob and is interpolated directly
|
||||
// into the inline <script>'s `setInterval(fn, ${interval})` call below as
|
||||
// a bare JS numeral (no quotes around it). A string like
|
||||
// `5000);alert(1);//` would previously close the setInterval() call and
|
||||
// splice arbitrary JS into the page's own <script> tag -- worse than an
|
||||
// HTML attribute breakout, since it runs unconditionally on page load.
|
||||
// Number() of anything non-numeric collapses safely to NaN, so we fall
|
||||
// back to the 5000ms default rather than ever interpolating a
|
||||
// non-numeral.
|
||||
const intervalNum = Number(interval);
|
||||
const safeInterval = Number.isFinite(intervalNum) && intervalNum > 0 ? intervalNum : 5000;
|
||||
// Deterministic AND unique id, scoped on the Craft node id, for this
|
||||
// slider's slide/dot element ids and inline-script globals -- so two
|
||||
// ContentSlider instances (e.g. both left at default slides) don't
|
||||
// collide and end up driving each other's rotation.
|
||||
const uid = scopeId(nodeId, JSON.stringify(items) + safeInterval, 'cs');
|
||||
|
||||
const sectionStyle = cssPropsToString({
|
||||
position: 'relative',
|
||||
@@ -467,21 +295,24 @@ ContentSlider.craft = {
|
||||
|
||||
const slidesHtml = items.map((slide, i) => {
|
||||
const hasBgImage = slide.imageSrc;
|
||||
// Sanitized -- slide.bgColor is a per-slide raw string-interpolation
|
||||
// sink (a malicious value could break out of the style="..." attribute).
|
||||
const safeBgColor = cssValue(slide.bgColor) || '#3b82f6';
|
||||
const bgStyle = hasBgImage
|
||||
? `background-image:url(${esc(slide.imageSrc!)});background-size:cover;background-position:center`
|
||||
? `background-image:url('${escapeAttr(safeImageUrl(slide.imageSrc!))}');background-size:cover;background-position:center`
|
||||
: slide.bgColor?.startsWith('linear-gradient')
|
||||
? `background-image:${slide.bgColor}`
|
||||
: `background-color:${slide.bgColor || '#3b82f6'}`;
|
||||
? `background-image:${safeBgColor}`
|
||||
: `background-color:${safeBgColor}`;
|
||||
|
||||
const contentParts: string[] = [];
|
||||
if (slide.heading) {
|
||||
contentParts.push(`<h2 style="font-size:36px;font-weight:700;color:#ffffff;margin-bottom:12px;font-family:Inter,sans-serif;text-shadow:0 2px 8px rgba(0,0,0,0.3)">${esc(slide.heading)}</h2>`);
|
||||
contentParts.push(`<h2 style="font-size:36px;font-weight:700;color:#ffffff;margin-bottom:12px;font-family:Inter,sans-serif;text-shadow:0 2px 8px rgba(0,0,0,0.3)">${escapeHtml(slide.heading)}</h2>`);
|
||||
}
|
||||
if (slide.text) {
|
||||
contentParts.push(`<p style="font-size:18px;color:rgba(255,255,255,0.9);margin-bottom:20px;font-family:Inter,sans-serif;text-shadow:0 1px 4px rgba(0,0,0,0.3)">${esc(slide.text)}</p>`);
|
||||
contentParts.push(`<p style="font-size:18px;color:rgba(255,255,255,0.9);margin-bottom:20px;font-family:Inter,sans-serif;text-shadow:0 1px 4px rgba(0,0,0,0.3)">${escapeHtml(slide.text)}</p>`);
|
||||
}
|
||||
if (slide.buttonText) {
|
||||
contentParts.push(`<a href="${esc(slide.buttonHref || '#')}" style="display:inline-block;padding:12px 28px;background:#ffffff;color:#18181b;text-decoration:none;border-radius:8px;font-weight:600;font-size:15px;font-family:Inter,sans-serif">${esc(slide.buttonText)}</a>`);
|
||||
contentParts.push(`<a href="${escapeAttr(safeUrl(slide.buttonHref || '#'))}" style="display:inline-block;padding:12px 28px;background:#ffffff;color:#18181b;text-decoration:none;border-radius:8px;font-weight:600;font-size:15px;font-family:Inter,sans-serif">${escapeHtml(slide.buttonText)}</a>`);
|
||||
}
|
||||
|
||||
const innerHtml = contentParts.length > 0
|
||||
@@ -494,24 +325,37 @@ ContentSlider.craft = {
|
||||
}).join('\n ');
|
||||
|
||||
const arrowsHtml = showArrows && items.length > 1
|
||||
? `<button onclick="${uid}_prev()" style="position:absolute;top:50%;left:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:#18181b;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa fa-chevron-left"></i></button>
|
||||
<button onclick="${uid}_next()" style="position:absolute;top:50%;right:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:#18181b;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa fa-chevron-right"></i></button>`
|
||||
? `<button onclick="${uid}_prev()" aria-label="Previous slide" style="position:absolute;top:50%;left:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:#18181b;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa fa-chevron-left" aria-hidden="true"></i></button>
|
||||
<button onclick="${uid}_next()" aria-label="Next slide" style="position:absolute;top:50%;right:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:#18181b;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa fa-chevron-right" aria-hidden="true"></i></button>`
|
||||
: '';
|
||||
|
||||
const dotsHtml = showDots && items.length > 1
|
||||
? `<div style="position:absolute;bottom:16px;left:50%;transform:translateX(-50%);display:flex;gap:8px;z-index:2">
|
||||
${items.map((_, i) => `<button onclick="${uid}_go(${i})" id="${uid}_d${i}" style="width:10px;height:10px;border-radius:50%;border:none;cursor:pointer;background-color:${i === 0 ? '#ffffff' : 'rgba(255,255,255,0.5)'};transition:background-color 0.3s"></button>`).join('\n ')}
|
||||
${items.map((_, i) => `<button onclick="${uid}_go(${i})" id="${uid}_d${i}" aria-label="Go to slide ${i + 1}" style="width:10px;height:10px;border-radius:50%;border:none;cursor:pointer;background-color:${i === 0 ? '#ffffff' : 'rgba(255,255,255,0.5)'};transition:background-color 0.3s"></button>`).join('\n ')}
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
// Autoplay ticks call show() directly (internal), while manual nav goes
|
||||
// through the exposed window[...] functions -- that split lets us mark
|
||||
// the live region "polite" only on manual navigation, and keep it "off"
|
||||
// while autoplay is silently auto-rotating, so screen readers aren't
|
||||
// spammed with an announcement every `interval` ms (F-export review
|
||||
// Minor).
|
||||
const autoplayActive = autoplay && items.length > 1;
|
||||
const liveAttr = autoplayActive ? 'off' : 'polite';
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
html: `<section id="${uid}"${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div id="${uid}_live" aria-live="${liveAttr}">
|
||||
${slidesHtml}
|
||||
</div>
|
||||
${arrowsHtml}
|
||||
${dotsHtml}
|
||||
<script>
|
||||
(function(){
|
||||
var current=0, total=${items.length}, uid="${uid}";
|
||||
var liveRegion=document.getElementById(uid+"_live");
|
||||
function markManualNav(){ if(liveRegion){ liveRegion.setAttribute("aria-live","polite"); } }
|
||||
function show(idx){
|
||||
document.getElementById(uid+"_s"+current).style.opacity="0";
|
||||
${showDots ? `document.getElementById(uid+"_d"+current).style.backgroundColor="rgba(255,255,255,0.5)";` : ''}
|
||||
@@ -519,10 +363,23 @@ ContentSlider.craft = {
|
||||
document.getElementById(uid+"_s"+current).style.opacity="1";
|
||||
${showDots ? `document.getElementById(uid+"_d"+current).style.backgroundColor="#ffffff";` : ''}
|
||||
}
|
||||
window["${uid}_go"]=show;
|
||||
window["${uid}_next"]=function(){show(current+1);};
|
||||
window["${uid}_prev"]=function(){show(current-1);};
|
||||
${autoplay && items.length > 1 ? `setInterval(function(){show(current+1);},${interval});` : ''}
|
||||
window["${uid}_go"]=function(idx){ markManualNav(); show(idx); };
|
||||
window["${uid}_next"]=function(){ markManualNav(); show(current+1); };
|
||||
window["${uid}_prev"]=function(){ markManualNav(); show(current-1); };
|
||||
${autoplayActive ? `
|
||||
var timer=null;
|
||||
function start(){ if(!timer && document.visibilityState!=="hidden"){ timer=setInterval(function(){show(current+1);},${safeInterval}); } }
|
||||
function stop(){ if(timer){ clearInterval(timer); timer=null; } }
|
||||
var root=document.getElementById(uid);
|
||||
if(root){
|
||||
root.addEventListener("mouseenter", stop);
|
||||
root.addEventListener("mouseleave", start);
|
||||
}
|
||||
document.addEventListener("visibilitychange", function(){
|
||||
if(document.visibilityState==="hidden"){ stop(); } else { start(); }
|
||||
});
|
||||
start();
|
||||
` : ''}
|
||||
})();
|
||||
</script>
|
||||
</section>`,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Countdown } from './Countdown';
|
||||
|
||||
const toHtml = (Countdown as any).toHtml;
|
||||
|
||||
describe('Countdown.toHtml validates targetDate before inline-script injection (A4.2)', () => {
|
||||
test('malicious targetDate cannot break out of the new Date(...) call', () => {
|
||||
const { html } = toHtml({ targetDate: '2026-01-01");alert(1)//' }, '');
|
||||
expect(html).not.toContain('alert(');
|
||||
expect(html).not.toContain('");');
|
||||
});
|
||||
|
||||
test('valid date is JSON-encoded into the script', () => {
|
||||
const { html } = toHtml({ targetDate: '2026-01-01' }, '');
|
||||
expect(html).toContain('new Date("2026-01-01")');
|
||||
});
|
||||
|
||||
test('valid date+time is preserved', () => {
|
||||
const { html } = toHtml({ targetDate: '2026-01-01T12:30:00' }, '');
|
||||
expect(html).toContain('new Date("2026-01-01T12:30:00")');
|
||||
});
|
||||
|
||||
test('invalid/empty targetDate falls back safely (no injected literal)', () => {
|
||||
const { html } = toHtml({ targetDate: 'not-a-date' }, '');
|
||||
expect(html).not.toContain('not-a-date');
|
||||
expect(html).toMatch(/new Date\(\)\.getTime\(\)|new Date\(Date\.now\(\)\)\.getTime\(\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Countdown.toHtml deterministic + unique scope ids (thread node id, no Math.random)', () => {
|
||||
const props = { targetDate: '2026-01-01' };
|
||||
|
||||
test('same node id -> identical output across calls (deterministic)', () => {
|
||||
const { html: html1 } = toHtml(props, '', 'node-cd1');
|
||||
const { html: html2 } = toHtml(props, '', 'node-cd1');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
|
||||
test('different node ids -> different, non-colliding element ids (identical props, no collision)', () => {
|
||||
const { html: html1 } = toHtml(props, '', 'node-cd1');
|
||||
const { html: html2 } = toHtml(props, '', 'node-cd2');
|
||||
const id1 = html1.match(/id="([^"]+)_d"/)![1];
|
||||
const id2 = html2.match(/id="([^"]+)_d"/)![1];
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
|
||||
test('no nodeId (legacy 2-arg call): still deterministic across repeated calls, not random', () => {
|
||||
const { html: html1 } = toHtml(props, '');
|
||||
const { html: html2 } = toHtml(props, '');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Countdown.toHtml script nit: ticking interval stops at zero', () => {
|
||||
test('inline script clears its own interval once the countdown reaches zero', () => {
|
||||
const { html } = toHtml({ targetDate: '2026-01-01' }, '');
|
||||
expect(html).toMatch(/clearInterval\(/);
|
||||
});
|
||||
|
||||
test('an already-expired target never schedules a running interval', () => {
|
||||
const { html } = toHtml({ targetDate: '2020-01-01' }, '');
|
||||
expect(html).toMatch(/if\s*\(\s*target\s*-\s*Date\.now\(\)\s*>\s*0\s*\)\s*\{/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Countdown.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||
const props = (Countdown as any).craft.props;
|
||||
expect(props).toHaveProperty('animation', '');
|
||||
expect(props).toHaveProperty('animationDelay', '');
|
||||
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||
expect(props).toHaveProperty('hideOnTablet', false);
|
||||
expect(props).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { CSSProperties, useEffect, useState, useCallback } from 'react';
|
||||
import React, { CSSProperties, useEffect, useState } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, scopeId, cssValue } from '../../utils/escape';
|
||||
|
||||
interface CountdownProps {
|
||||
targetDate?: string;
|
||||
@@ -9,6 +10,12 @@ interface CountdownProps {
|
||||
digitColor?: string;
|
||||
labelColor?: string;
|
||||
bgColor?: string;
|
||||
anchorId?: string;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
interface TimeLeft {
|
||||
@@ -44,6 +51,7 @@ export const Countdown: UserComponent<CountdownProps> = ({
|
||||
digitColor = '#ffffff',
|
||||
labelColor = 'rgba(255,255,255,0.7)',
|
||||
bgColor = '#18181b',
|
||||
anchorId,
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
@@ -98,6 +106,7 @@ export const Countdown: UserComponent<CountdownProps> = ({
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
padding: '60px 20px',
|
||||
textAlign: 'center',
|
||||
@@ -123,107 +132,6 @@ export const Countdown: UserComponent<CountdownProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const CountdownSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as CountdownProps,
|
||||
}));
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
|
||||
};
|
||||
|
||||
const colorPresets = ['#ffffff', '#f8fafc', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'];
|
||||
const bgPresets = ['#18181b', '#0f172a', '#1e293b', '#1e1b4b', '#042f2e', '#27272a', '#ffffff', '#f8fafc'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{/* Target date */}
|
||||
<div>
|
||||
<label style={labelStyle}>Target Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={props.targetDate || DEFAULT_TARGET}
|
||||
onChange={(e) => setProp((p: CountdownProps) => { p.targetDate = e.target.value; })}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Heading */}
|
||||
<div>
|
||||
<label style={labelStyle}>Heading</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.heading || ''}
|
||||
onChange={(e) => setProp((p: CountdownProps) => { p.heading = e.target.value; })}
|
||||
placeholder="Coming Soon"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Digit color */}
|
||||
<div>
|
||||
<label style={labelStyle}>Digit Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{colorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: CountdownProps) => { p.digitColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.digitColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Label color */}
|
||||
<div>
|
||||
<label style={labelStyle}>Label Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{colorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: CountdownProps) => { p.labelColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.labelColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background color */}
|
||||
<div>
|
||||
<label style={labelStyle}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: CountdownProps) => { p.bgColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.bgColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Countdown.craft = {
|
||||
@@ -235,29 +143,33 @@ Countdown.craft = {
|
||||
digitColor: '#ffffff',
|
||||
labelColor: 'rgba(255,255,255,0.7)',
|
||||
bgColor: '#18181b',
|
||||
anchorId: '',
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: CountdownSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Countdown as any).toHtml = (props: CountdownProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
(Countdown as any).toHtml = (props: CountdownProps, _childrenHtml: string, nodeId?: string) => {
|
||||
const {
|
||||
targetDate = DEFAULT_TARGET,
|
||||
heading = 'Coming Soon',
|
||||
style = {},
|
||||
digitColor = '#ffffff',
|
||||
labelColor = 'rgba(255,255,255,0.7)',
|
||||
bgColor = '#18181b',
|
||||
} = props;
|
||||
// Sanitized -- raw string-interpolation sinks in the heading/digit/label
|
||||
// style attributes below.
|
||||
const digitColor = cssValue(props.digitColor) || '#ffffff';
|
||||
const labelColor = cssValue(props.labelColor) || 'rgba(255,255,255,0.7)';
|
||||
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '60px 20px',
|
||||
@@ -265,20 +177,32 @@ Countdown.craft = {
|
||||
backgroundColor: bgColor,
|
||||
...style,
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
|
||||
const headingHtml = heading
|
||||
? `<h2 style="font-size:32px;font-weight:700;color:${digitColor};margin-bottom:32px;font-family:Inter,sans-serif">${esc(heading)}</h2>`
|
||||
? `<h2 style="font-size:32px;font-weight:700;color:${digitColor};margin-bottom:32px;font-family:Inter,sans-serif">${escapeHtml(heading)}</h2>`
|
||||
: '';
|
||||
|
||||
const boxStyle = 'display:flex;flex-direction:column;align-items:center;gap:4px;min-width:80px';
|
||||
const dStyle = `font-size:48px;font-weight:700;color:${digitColor};line-height:1;font-family:Inter,sans-serif`;
|
||||
const lStyle = `font-size:12px;color:${labelColor};text-transform:uppercase;letter-spacing:0.1em;font-family:Inter,sans-serif`;
|
||||
|
||||
// Generate a unique ID for this countdown instance
|
||||
const uid = 'cd_' + Math.random().toString(36).slice(2, 8);
|
||||
// Deterministic AND unique id for this countdown instance's span ids and
|
||||
// getElementById() calls inside its inline script -- scoped on the Craft
|
||||
// node id so two Countdown instances (e.g. both left at default props)
|
||||
// don't collide and end up writing each other's digits.
|
||||
const uid = scopeId(nodeId, targetDate + '::' + heading, 'cd');
|
||||
|
||||
// Only accept a strict date/datetime shape before it's embedded in the
|
||||
// inline <script>; anything else falls back to "now" instead of letting
|
||||
// arbitrary text (e.g. `");alert(1)//`) break out of the new Date(...) call.
|
||||
const VALID_DATE_RE = /^\d{4}-\d{2}-\d{2}([T ][0-9:.\-+Z]*)?$/;
|
||||
const dateExpr = typeof targetDate === 'string' && VALID_DATE_RE.test(targetDate)
|
||||
? `new Date(${JSON.stringify(targetDate)})`
|
||||
: 'new Date()';
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
${headingHtml}
|
||||
<div style="display:flex;justify-content:center;gap:24px;flex-wrap:wrap">
|
||||
<div style="${boxStyle}"><span id="${uid}_d" style="${dStyle}">00</span><span style="${lStyle}">Days</span></div>
|
||||
@@ -288,11 +212,15 @@ Countdown.craft = {
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var target = new Date("${targetDate}").getTime();
|
||||
var target = ${dateExpr}.getTime();
|
||||
var timer = null;
|
||||
function pad(n){ return String(n).padStart(2,'0'); }
|
||||
function update(){
|
||||
var diff = target - Date.now();
|
||||
if(diff<=0){ diff=0; }
|
||||
if(diff<=0){
|
||||
diff=0;
|
||||
if(timer){ clearInterval(timer); timer=null; }
|
||||
}
|
||||
var d = Math.floor(diff/(1000*60*60*24));
|
||||
var h = Math.floor((diff/(1000*60*60))%24);
|
||||
var m = Math.floor((diff/(1000*60))%60);
|
||||
@@ -303,7 +231,9 @@ Countdown.craft = {
|
||||
document.getElementById("${uid}_s").textContent = pad(s);
|
||||
}
|
||||
update();
|
||||
setInterval(update,1000);
|
||||
if(target - Date.now() > 0){
|
||||
timer = setInterval(update,1000);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</section>`,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { FeaturesGrid } from './FeaturesGrid';
|
||||
|
||||
const toHtml = (FeaturesGrid as any).toHtml;
|
||||
|
||||
describe('FeaturesGrid.toHtml image sink uses safeImageUrl (data:image/svg+xml allowed)', () => {
|
||||
test('feat.image as a data:image/svg+xml value emits a non-empty <img src>', () => {
|
||||
const svgDataUri = 'data:image/svg+xml,%3Csvg%2F%3E';
|
||||
const features = [
|
||||
{ title: 'Feature', description: 'Desc', icon: '⚡', image: svgDataUri, imageAlt: 'alt' },
|
||||
];
|
||||
const { html } = toHtml({ features }, '');
|
||||
expect(html).toContain(`<img src="${svgDataUri}"`);
|
||||
});
|
||||
|
||||
test('feat.buttonUrl stays on safeUrl (data:image/svg+xml blocked as a navigation target)', () => {
|
||||
const features = [
|
||||
{ title: 'Feature', description: 'Desc', icon: '⚡', buttonText: 'Go', buttonUrl: 'data:image/svg+xml,<svg onload=alert(1)>' },
|
||||
];
|
||||
const { html } = toHtml({ features }, '');
|
||||
expect(html).toMatch(/<a href=""/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FeaturesGrid.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||
const props = (FeaturesGrid as any).craft.props;
|
||||
expect(props).toHaveProperty('animation', '');
|
||||
expect(props).toHaveProperty('animationDelay', '');
|
||||
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||
expect(props).toHaveProperty('hideOnTablet', false);
|
||||
expect(props).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,42 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, safeUrl, safeImageUrl } from '../../utils/escape';
|
||||
|
||||
interface FeatureItem {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
image?: string;
|
||||
imageAlt?: string;
|
||||
buttonText?: string;
|
||||
buttonUrl?: string;
|
||||
}
|
||||
|
||||
interface FeaturesGridProps {
|
||||
features?: FeatureItem[];
|
||||
style?: CSSProperties;
|
||||
anchorId?: string;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
// Keys image/imageAlt/buttonText/buttonUrl are present (blank) on the defaults so
|
||||
// the guided panel's generic array editor (which derives fields from the first
|
||||
// item's keys) exposes inputs for them. An image renders whenever `image` is set.
|
||||
const defaultFeatures: FeatureItem[] = [
|
||||
{ title: 'Fast & Reliable', description: 'Built for performance with optimized loading and rock-solid uptime.', icon: '⚡' },
|
||||
{ title: 'Easy to Use', description: 'Intuitive drag-and-drop interface that anyone can master in minutes.', icon: '✨' },
|
||||
{ title: 'Fully Responsive', description: 'Looks great on every device, from phones to ultrawide monitors.', icon: '📱' },
|
||||
{ title: 'Fast & Reliable', description: 'Built for performance with optimized loading and rock-solid uptime.', icon: '⚡', image: '', imageAlt: '', buttonText: '', buttonUrl: '' },
|
||||
{ title: 'Easy to Use', description: 'Intuitive drag-and-drop interface that anyone can master in minutes.', icon: '✨', image: '', imageAlt: '', buttonText: '', buttonUrl: '' },
|
||||
{ title: 'Fully Responsive', description: 'Looks great on every device, from phones to ultrawide monitors.', icon: '📱', image: '', imageAlt: '', buttonText: '', buttonUrl: '' },
|
||||
];
|
||||
|
||||
export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
|
||||
features = defaultFeatures,
|
||||
style = {},
|
||||
anchorId,
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
@@ -33,6 +48,7 @@ export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
padding: '80px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
@@ -52,9 +68,26 @@ export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
{feat.image ? (
|
||||
<img
|
||||
src={feat.image}
|
||||
alt={feat.imageAlt || feat.title || ''}
|
||||
style={{ maxWidth: '100%', height: 'auto', marginBottom: '16px', borderRadius: '8px' }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ fontSize: '36px', marginBottom: '16px' }}>{feat.icon}</div>
|
||||
)}
|
||||
<h3 style={{ fontSize: '20px', fontWeight: '600', color: '#18181b', marginBottom: '8px' }}>{feat.title}</h3>
|
||||
<p style={{ fontSize: '14px', color: '#64748b', lineHeight: '1.6' }}>{feat.description}</p>
|
||||
{feat.buttonText ? (
|
||||
<a
|
||||
href={feat.buttonUrl || '#'}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
style={{ display: 'inline-block', marginTop: '16px', padding: '10px 24px', background: '#3b82f6', color: '#fff', borderRadius: '8px', textDecoration: 'none', fontSize: '14px', fontWeight: 600 }}
|
||||
>
|
||||
{feat.buttonText}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -62,100 +95,6 @@ export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const FeaturesGridSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as FeaturesGridProps,
|
||||
}));
|
||||
|
||||
const features = props.features || defaultFeatures;
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
|
||||
const updateFeature = (index: number, field: keyof FeatureItem, value: string) => {
|
||||
setProp((p: FeaturesGridProps) => {
|
||||
const updated = [...(p.features || defaultFeatures)];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
p.features = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addFeature = () => {
|
||||
setProp((p: FeaturesGridProps) => {
|
||||
p.features = [...(p.features || defaultFeatures), { title: 'New Feature', description: 'Describe this feature.', icon: '🔧' }];
|
||||
});
|
||||
};
|
||||
|
||||
const removeFeature = (index: number) => {
|
||||
setProp((p: FeaturesGridProps) => {
|
||||
const updated = [...(p.features || defaultFeatures)];
|
||||
updated.splice(index, 1);
|
||||
p.features = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: FeaturesGridProps) => { p.style = { ...p.style, backgroundColor: c }; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Features</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{(Array.isArray(features) ? features : []).map((feat, i) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input type="text" value={feat.icon} onChange={(e) => updateFeature(i, 'icon', e.target.value)} placeholder="Icon" style={{ ...inputStyle, width: 40, flex: 'none', textAlign: 'center' }} />
|
||||
<input type="text" value={feat.title} onChange={(e) => updateFeature(i, 'title', e.target.value)} placeholder="Title" style={{ ...inputStyle, flex: 1 }} />
|
||||
<button
|
||||
onClick={() => removeFeature(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={feat.description}
|
||||
onChange={(e) => updateFeature(i, 'description', e.target.value)}
|
||||
placeholder="Description"
|
||||
rows={2}
|
||||
style={{ ...inputStyle, resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addFeature}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Feature
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
FeaturesGrid.craft = {
|
||||
@@ -163,35 +102,44 @@ FeaturesGrid.craft = {
|
||||
props: {
|
||||
features: defaultFeatures,
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
anchorId: '',
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: FeaturesGridSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(FeaturesGrid as any).toHtml = (props: FeaturesGridProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>');
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '80px 20px',
|
||||
...props.style,
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
const cards = (props.features || defaultFeatures).map((feat) => {
|
||||
const media = feat.image
|
||||
? `<img src="${escapeAttr(safeImageUrl(feat.image))}" alt="${escapeAttr(feat.imageAlt || feat.title || '')}" style="max-width:100%;height:auto;margin-bottom:16px;border-radius:8px">`
|
||||
: `<div style="font-size:36px;margin-bottom:16px">${escapeHtml(feat.icon)}</div>`;
|
||||
const button = feat.buttonText
|
||||
? `\n <a href="${escapeAttr(safeUrl(feat.buttonUrl || '#'))}" style="display:inline-block;margin-top:16px;padding:10px 24px;background:#3b82f6;color:#fff;border-radius:8px;text-decoration:none;font-size:14px;font-weight:600">${escapeHtml(feat.buttonText)}</a>`
|
||||
: '';
|
||||
return `<div style="text-align:center;padding:32px 24px;border-radius:12px;background-color:#f8fafc;border:1px solid #e2e8f0">
|
||||
<div style="font-size:36px;margin-bottom:16px">${esc(feat.icon)}</div>
|
||||
<h3 style="font-size:20px;font-weight:600;color:#18181b;margin-bottom:8px">${esc(feat.title)}</h3>
|
||||
<p style="font-size:14px;color:#64748b;line-height:1.6">${esc(feat.description)}</p>
|
||||
${media}
|
||||
<h3 style="font-size:20px;font-weight:600;color:#18181b;margin-bottom:8px">${escapeHtml(feat.title)}</h3>
|
||||
<p style="font-size:14px;color:#64748b;line-height:1.6">${escapeHtml(feat.description)}</p>${button}
|
||||
</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(3,1fr);gap:32px">
|
||||
${cards}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Gallery } from './Gallery';
|
||||
|
||||
const toHtml = (Gallery as any).toHtml;
|
||||
|
||||
describe('Gallery.toHtml columns + lightbox controls (previously unexposed props)', () => {
|
||||
test('columns drives the grid-template-columns repeat count', () => {
|
||||
const { html } = toHtml({ images: [{ src: '/a.jpg', alt: 'a' }], columns: 5 }, '');
|
||||
expect(html).toContain('grid-template-columns:repeat(5,1fr)');
|
||||
});
|
||||
|
||||
test('lightbox=true adds the delegated-listener overlay markup (columns unaffected)', () => {
|
||||
const { html } = toHtml({ images: [{ src: '/a.jpg', alt: 'a' }], columns: 4, lightbox: true }, '');
|
||||
expect(html).toContain('grid-template-columns:repeat(4,1fr)');
|
||||
expect(html).toContain('role="dialog"');
|
||||
});
|
||||
|
||||
test('a non-numeric columns value falls back safely (Number() coercion, not NaN in the template)', () => {
|
||||
const { html } = toHtml({ images: [{ src: '/a.jpg' }], columns: 'not-a-number' as any }, '');
|
||||
expect(html).toContain('grid-template-columns:repeat(3,1fr)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gallery.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
|
||||
test('margin/padding/border/box-shadow/opacity all flow into the section style attribute', () => {
|
||||
const { html } = toHtml(
|
||||
{
|
||||
images: [{ src: '/a.jpg', alt: 'a' }],
|
||||
style: {
|
||||
marginTop: '20px',
|
||||
paddingLeft: '12px',
|
||||
border: '1px solid #333333',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.08)',
|
||||
opacity: '0.9',
|
||||
},
|
||||
},
|
||||
''
|
||||
);
|
||||
expect(html).toContain('margin-top:20px');
|
||||
expect(html).toContain('padding-left:12px');
|
||||
expect(html).toContain('border:1px solid #333333');
|
||||
expect(html).toContain('box-shadow:0 1px 2px rgba(0,0,0,0.08)');
|
||||
expect(html).toContain('opacity:0.9');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gallery.craft.props exposes columns/lightbox + the animation/visibility rollout', () => {
|
||||
test('columns and lightbox have their existing defaults', () => {
|
||||
const props = (Gallery as any).craft.props;
|
||||
expect(props.columns).toBe(3);
|
||||
expect(props.lightbox).toBe(false);
|
||||
});
|
||||
|
||||
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
||||
const props = (Gallery as any).craft.props;
|
||||
expect(props.animation).toBe('');
|
||||
expect(props.animationDelay).toBe('0');
|
||||
expect(props.hideOnDesktop).toBe(false);
|
||||
expect(props.hideOnTablet).toBe(false);
|
||||
expect(props.hideOnMobile).toBe(false);
|
||||
});
|
||||
|
||||
test('style carries blank/default box-model keys', () => {
|
||||
const style = (Gallery as any).craft.props.style;
|
||||
expect(style).toHaveProperty('marginTop');
|
||||
expect(style).toHaveProperty('paddingTop');
|
||||
expect(style.border).toBe('none');
|
||||
expect(style.boxShadow).toBe('none');
|
||||
expect(style.opacity).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gallery.toHtml lightbox uses a delegated listener, not per-item onclick (A4.3)', () => {
|
||||
test('no per-item inline onclick with interpolated src', () => {
|
||||
const { html } = toHtml({ images: [{ src: '/a.jpg', alt: 'a' }], lightbox: true }, '');
|
||||
expect(html).not.toMatch(/onclick="[^"]*_open\(/);
|
||||
expect(html).toContain('data-lb-src="/a.jpg"');
|
||||
});
|
||||
|
||||
test('a single-quote in src cannot break the handler (no per-item onclick at all)', () => {
|
||||
const { html } = toHtml({ images: [{ src: "/a'.jpg", alt: 'a' }], lightbox: true }, '');
|
||||
// no per-item onclick handler exists at all (delegated listener only)
|
||||
expect(html).not.toMatch(/onclick="[^"]*_open\(/);
|
||||
// the quote in src is entity-escaped in the data attribute, not raw
|
||||
expect(html).toContain('data-lb-src="/a'.jpg"');
|
||||
expect(html).not.toContain(`data-lb-src="/a'.jpg"`);
|
||||
});
|
||||
|
||||
test('emits exactly one delegated click listener', () => {
|
||||
const { html } = toHtml({ images: [{ src: '/a.jpg' }, { src: '/b.jpg' }], lightbox: true }, '');
|
||||
const matches = html.match(/addEventListener\(['"]click['"]/g) || [];
|
||||
expect(matches.length).toBe(1);
|
||||
});
|
||||
|
||||
test('lightbox=false: no data-lb-src, no script', () => {
|
||||
const { html } = toHtml({ images: [{ src: '/a.jpg' }], lightbox: false }, '');
|
||||
expect(html).not.toContain('data-lb-src');
|
||||
expect(html).not.toContain('<script>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gallery.toHtml lightbox accessibility (F1.3)', () => {
|
||||
test('lightbox overlay has role="dialog", aria-modal, and aria-label', () => {
|
||||
const { html } = toHtml({ images: [{ src: '/a.jpg', alt: 'a' }], lightbox: true }, '');
|
||||
expect(html).toMatch(/role="dialog"/);
|
||||
expect(html).toMatch(/aria-modal="true"/);
|
||||
expect(html).toMatch(/aria-label="[^"]+"/);
|
||||
});
|
||||
|
||||
test('Escape closes the lightbox via the inline script', () => {
|
||||
const { html } = toHtml({ images: [{ src: '/a.jpg', alt: 'a' }], lightbox: true }, '');
|
||||
expect(html).toMatch(/Escape/);
|
||||
});
|
||||
|
||||
test('thumbnails are keyboard-operable when lightbox is enabled (role=button + tabindex=0)', () => {
|
||||
const { html } = toHtml({ images: [{ src: '/a.jpg', alt: 'a' }], lightbox: true }, '');
|
||||
expect(html).toMatch(/data-lb-src="[^"]*"[^>]*role="button"[^>]*tabindex="0"/);
|
||||
});
|
||||
|
||||
test('delegated listener handles Enter/Space for keyboard activation', () => {
|
||||
const { html } = toHtml({ images: [{ src: '/a.jpg' }, { src: '/b.jpg' }], lightbox: true }, '');
|
||||
expect(html).toMatch(/addEventListener\(['"]keydown['"]/);
|
||||
});
|
||||
|
||||
test('lightbox=false: no role="dialog", no role="button" thumbnails', () => {
|
||||
const { html } = toHtml({ images: [{ src: '/a.jpg' }], lightbox: false }, '');
|
||||
expect(html).not.toContain('role="dialog"');
|
||||
expect(html).not.toContain('role="button"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gallery.toHtml deterministic + unique scope ids (thread node id, no Math.random)', () => {
|
||||
const props = { images: [{ src: '/a.jpg', alt: 'a' }], lightbox: true };
|
||||
|
||||
test('same node id -> identical output across calls (deterministic)', () => {
|
||||
const { html: html1 } = toHtml(props, '', 'node-gal1');
|
||||
const { html: html2 } = toHtml(props, '', 'node-gal1');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
|
||||
test('different node ids -> different, non-colliding gallery scope ids (identical images, no collision)', () => {
|
||||
const { html: html1 } = toHtml(props, '', 'node-gal1');
|
||||
const { html: html2 } = toHtml(props, '', 'node-gal2');
|
||||
const id1 = html1.match(/id="([^"]+)_overlay"/)![1];
|
||||
const id2 = html2.match(/id="([^"]+)_overlay"/)![1];
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
|
||||
test('overlay/grid ids and the script function names all use the SAME scope', () => {
|
||||
const { html } = toHtml(props, '', 'node-gal1');
|
||||
const scope = html.match(/id="([^"]+)_overlay"/)![1];
|
||||
expect(html).toContain(`id="${scope}_grid"`);
|
||||
expect(html).toContain(`function ${scope}_close()`);
|
||||
expect(html).toContain(`function ${scope}_open(`);
|
||||
});
|
||||
|
||||
test('no nodeId (legacy 2-arg call): still deterministic across repeated calls, not random', () => {
|
||||
const { html: html1 } = toHtml(props, '');
|
||||
const { html: html2 } = toHtml(props, '');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gallery.toHtml default SVG placeholder images survive export (Bug 2 regression)', () => {
|
||||
test('a default data:image/svg+xml image emits a non-empty img src, not src=""', () => {
|
||||
const { html } = toHtml({}, ''); // no images prop -> component default SVG placeholders
|
||||
expect(html).not.toContain('src=""');
|
||||
expect(html).toMatch(/src="data:image\/svg\+xml[^"]*"/);
|
||||
});
|
||||
|
||||
test('an explicit data:image/svg+xml gallery image src is preserved (not stripped to empty)', () => {
|
||||
const svg = 'data:image/svg+xml,%3Csvg%2F%3E';
|
||||
const { html } = toHtml({ images: [{ src: svg, alt: 'a' }] }, '');
|
||||
expect(html).toContain(`src="${svg}"`);
|
||||
});
|
||||
|
||||
test('lightbox data-lb-src also preserves data:image/svg+xml (still an image context)', () => {
|
||||
const svg = 'data:image/svg+xml,%3Csvg%2F%3E';
|
||||
const { html } = toHtml({ images: [{ src: svg, alt: 'a' }], lightbox: true }, '');
|
||||
expect(html).toContain(`data-lb-src="${svg}"`);
|
||||
});
|
||||
|
||||
test('a javascript: gallery image src still yields an empty src (safeImageUrl still blocks it)', () => {
|
||||
const { html } = toHtml({ images: [{ src: 'javascript:alert(1)', alt: 'a' }] }, '');
|
||||
expect(html).toContain('src=""');
|
||||
expect(html).not.toContain('javascript:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gallery.toHtml lightbox focus management (M-2)', () => {
|
||||
const props = { images: [{ src: '/a.jpg', alt: 'a' }], lightbox: true };
|
||||
|
||||
test('overlay includes a focusable close control with an accessible name and tabindex', () => {
|
||||
const { html } = toHtml(props, '', 'node-gal1');
|
||||
// A close control: a button (or the dialog container) with an accessible
|
||||
// name (aria-label) and an explicit tabindex so it's keyboard-focusable.
|
||||
expect(html).toMatch(/aria-label="[^"]*[Cc]lose[^"]*"[^>]*tabindex="-?\d+"|tabindex="-?\d+"[^>]*aria-label="[^"]*[Cc]lose[^"]*"/);
|
||||
});
|
||||
|
||||
test('script saves document.activeElement on open (for focus restore)', () => {
|
||||
const { html } = toHtml(props, '', 'node-gal1');
|
||||
expect(html).toMatch(/document\.activeElement/);
|
||||
});
|
||||
|
||||
test('script moves focus to the close control / dialog on open', () => {
|
||||
const { html } = toHtml(props, '', 'node-gal1');
|
||||
expect(html).toMatch(/\.focus\(\)/);
|
||||
});
|
||||
|
||||
test('script restores the previously-saved focus on close', () => {
|
||||
const { html } = toHtml(props, '', 'node-gal1');
|
||||
// The close function references a stored "last focused element" variable
|
||||
// and calls .focus() on it, not just moving focus INTO the dialog.
|
||||
const closeFnMatch = html.match(/function\s+\w+_close\s*\(\)\s*\{[^}]*\}/);
|
||||
expect(closeFnMatch).not.toBeNull();
|
||||
expect(closeFnMatch![0]).toMatch(/\.focus\(\)/);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, safeImageUrl, scopeId, cssValue } from '../../utils/escape';
|
||||
|
||||
interface GalleryImage {
|
||||
src: string;
|
||||
@@ -14,6 +15,11 @@ interface GalleryProps {
|
||||
gap?: string;
|
||||
style?: CSSProperties;
|
||||
lightbox?: boolean;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const placeholderSvg = (index: number) => {
|
||||
@@ -103,156 +109,6 @@ export const Gallery: UserComponent<GalleryProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const GallerySettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as GalleryProps,
|
||||
}));
|
||||
|
||||
const images = props.images || defaultImages;
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||||
|
||||
const updateImage = (index: number, field: keyof GalleryImage, value: string) => {
|
||||
setProp((p: GalleryProps) => {
|
||||
const updated = [...(p.images || defaultImages)];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
p.images = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addImage = () => {
|
||||
setProp((p: GalleryProps) => {
|
||||
const current = p.images || defaultImages;
|
||||
p.images = [...current, { src: placeholderSvg(current.length), alt: `Gallery image ${current.length + 1}`, caption: '' }];
|
||||
});
|
||||
};
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
setProp((p: GalleryProps) => {
|
||||
const updated = [...(p.images || defaultImages)];
|
||||
updated.splice(index, 1);
|
||||
p.images = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const columnOptions = [2, 3, 4, 5, 6];
|
||||
const gapOptions = ['8px', '12px', '16px', '24px', '32px'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: GalleryProps) => { p.style = { ...p.style, backgroundColor: c }; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Columns</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{columnOptions.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setProp((p: GalleryProps) => { p.columns = n; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.columns === n ? '#3b82f6' : '#27272a',
|
||||
color: props.columns === n ? '#fff' : '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Gap</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{gapOptions.map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
onClick={() => setProp((p: GalleryProps) => { p.gap = g; })}
|
||||
style={{
|
||||
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.gap === g ? '#3b82f6' : '#27272a',
|
||||
color: props.gap === g ? '#fff' : '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{g}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.lightbox || false}
|
||||
onChange={(e) => setProp((p: GalleryProps) => { p.lightbox = e.target.checked; })}
|
||||
/>
|
||||
Lightbox (click to enlarge in exported HTML)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Images</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{images.map((img, i) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<img
|
||||
src={img.src}
|
||||
alt=""
|
||||
style={{ width: 32, height: 32, objectFit: 'cover', borderRadius: 4, flexShrink: 0, backgroundColor: '#27272a' }}
|
||||
/>
|
||||
<input type="text" value={img.src} onChange={(e) => updateImage(i, 'src', e.target.value)} placeholder="Image URL" style={{ ...inputStyle, flex: 1 }} />
|
||||
<button
|
||||
onClick={() => removeImage(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input type="text" value={img.alt} onChange={(e) => updateImage(i, 'alt', e.target.value)} placeholder="Alt text" style={{ ...inputStyle, flex: 1 }} />
|
||||
<input type="text" value={img.caption || ''} onChange={(e) => updateImage(i, 'caption', e.target.value)} placeholder="Caption" style={{ ...inputStyle, flex: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addImage}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Gallery.craft = {
|
||||
@@ -261,60 +117,132 @@ Gallery.craft = {
|
||||
images: defaultImages,
|
||||
columns: 3,
|
||||
gap: '16px',
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
style: {
|
||||
backgroundColor: '#ffffff',
|
||||
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
|
||||
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
opacity: '1',
|
||||
},
|
||||
lightbox: false,
|
||||
animation: '',
|
||||
animationDelay: '0',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: GallerySettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Gallery as any).toHtml = (props: GalleryProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
(Gallery as any).toHtml = (props: GalleryProps, _childrenHtml: string, nodeId?: string) => {
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '60px 20px',
|
||||
...props.style,
|
||||
});
|
||||
const images = props.images || defaultImages;
|
||||
const columns = props.columns || 3;
|
||||
const gap = props.gap || '16px';
|
||||
// Number() coercion: `columns` is a raw string-interpolation sink into the
|
||||
// grid style attribute below (repeat(${columns},1fr)) -- a non-numeric
|
||||
// (e.g. hand-crafted/AI-generated tree) value would otherwise be able to
|
||||
// break out; Number() of anything non-numeric collapses safely to NaN.
|
||||
const columns = Number(props.columns) || 3;
|
||||
// Sanitized -- gap is a raw string-interpolation sink into the grid style
|
||||
// attribute below.
|
||||
const gap = cssValue(props.gap) || '16px';
|
||||
const lightbox = props.lightbox || false;
|
||||
|
||||
const galleryId = 'gallery_' + Math.random().toString(36).slice(2, 8);
|
||||
// Deterministic AND unique id, scoped on the Craft node id, for this
|
||||
// gallery's overlay/grid element ids and inline-script function names --
|
||||
// so two Gallery instances (e.g. both left at default images) don't
|
||||
// collide and end up sharing/clobbering one lightbox overlay.
|
||||
const galleryId = scopeId(nodeId, JSON.stringify(images) + columns + gap, 'gallery');
|
||||
|
||||
const items = images.map((img) => {
|
||||
const caption = img.caption
|
||||
? `<div style="position:absolute;bottom:0;left:0;right:0;padding:8px 12px;background:linear-gradient(transparent,rgba(0,0,0,0.7));color:#ffffff;font-size:12px;border-bottom-left-radius:8px;border-bottom-right-radius:8px">${esc(img.caption)}</div>`
|
||||
? `<div style="position:absolute;bottom:0;left:0;right:0;padding:8px 12px;background:linear-gradient(transparent,rgba(0,0,0,0.7));color:#ffffff;font-size:12px;border-bottom-left-radius:8px;border-bottom-right-radius:8px">${escapeHtml(img.caption)}</div>`
|
||||
: '';
|
||||
const clickAttr = lightbox ? ` onclick="${galleryId}_open('${esc(img.src)}')" style="cursor:pointer;position:relative;overflow:hidden;border-radius:8px"` : ' style="position:relative;overflow:hidden;border-radius:8px"';
|
||||
return `<div${clickAttr}>
|
||||
<img src="${esc(img.src)}" alt="${esc(img.alt)}" style="width:100%;height:200px;object-fit:cover;display:block;border-radius:8px;background-color:#f1f5f9" />
|
||||
// Lightbox items carry the image URL as a data attribute rather than an
|
||||
// inline onclick with an interpolated src -- a single delegated click
|
||||
// listener below reads it, so a src containing a quote can't break out
|
||||
// of a per-item event-handler string.
|
||||
const lbAttr = lightbox ? ` data-lb-src="${escapeAttr(safeImageUrl(img.src || ''))}" role="button" tabindex="0"` : '';
|
||||
const itemStyle = lightbox ? 'cursor:pointer;position:relative;overflow:hidden;border-radius:8px' : 'position:relative;overflow:hidden;border-radius:8px';
|
||||
return `<div${lbAttr} style="${itemStyle}">
|
||||
<img src="${escapeAttr(safeImageUrl(img.src || ''))}" alt="${escapeAttr(img.alt)}" style="width:100%;height:200px;object-fit:cover;display:block;border-radius:8px;background-color:#f1f5f9" />
|
||||
${caption}
|
||||
</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
let lightboxHtml = '';
|
||||
let gridIdAttr = '';
|
||||
if (lightbox) {
|
||||
gridIdAttr = ` id="${galleryId}_grid"`;
|
||||
// M-2: focus management for the lightbox dialog.
|
||||
// - OPEN: stash `document.activeElement` (the thumbnail that triggered
|
||||
// the open) in a module-scoped var, then move focus onto the close
|
||||
// button -- so a screen-reader/keyboard user lands inside the dialog
|
||||
// instead of focus staying on (or silently falling back to <body>)
|
||||
// behind the now-visible overlay.
|
||||
// - Tab trap: while the overlay is open, every Tab keypress is
|
||||
// intercepted and refocuses the close button (the dialog's only
|
||||
// focusable control besides Escape/click-to-close), so focus can
|
||||
// never wander out into the page content hidden behind the overlay.
|
||||
// - CLOSE (Escape, backdrop click, or the close button): restore focus
|
||||
// to the element stashed on open.
|
||||
lightboxHtml = `
|
||||
<div id="${galleryId}_overlay" onclick="${galleryId}_close()" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:9999;justify-content:center;align-items:center;cursor:pointer">
|
||||
<div id="${galleryId}_overlay" role="dialog" aria-modal="true" aria-label="Image preview" onclick="${galleryId}_close()" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:9999;justify-content:center;align-items:center;cursor:pointer">
|
||||
<button type="button" id="${galleryId}_closebtn" aria-label="Close preview" tabindex="-1" onclick="event.stopPropagation();${galleryId}_close()" style="position:absolute;top:16px;right:16px;width:36px;height:36px;border-radius:50%;border:none;background:rgba(255,255,255,0.15);color:#ffffff;font-size:20px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center">×</button>
|
||||
<img id="${galleryId}_img" src="" alt="" style="max-width:90%;max-height:90%;object-fit:contain;border-radius:8px" />
|
||||
</div>
|
||||
<script>
|
||||
function ${galleryId}_open(src){var o=document.getElementById('${galleryId}_overlay');document.getElementById('${galleryId}_img').src=src;o.style.display='flex';}
|
||||
function ${galleryId}_close(){document.getElementById('${galleryId}_overlay').style.display='none';}
|
||||
var ${galleryId}_lastFocus = null;
|
||||
function ${galleryId}_close(){
|
||||
document.getElementById('${galleryId}_overlay').style.display='none';
|
||||
if(${galleryId}_lastFocus && ${galleryId}_lastFocus.focus) ${galleryId}_lastFocus.focus();
|
||||
${galleryId}_lastFocus = null;
|
||||
}
|
||||
function ${galleryId}_open(src){
|
||||
${galleryId}_lastFocus = document.activeElement;
|
||||
var o = document.getElementById('${galleryId}_overlay');
|
||||
document.getElementById('${galleryId}_img').src = src;
|
||||
o.style.display = 'flex';
|
||||
var c = document.getElementById('${galleryId}_closebtn');
|
||||
if(c) c.focus();
|
||||
}
|
||||
document.getElementById('${galleryId}_grid').addEventListener('click', function(e){
|
||||
var t = e.target.closest('[data-lb-src]');
|
||||
if(!t) return;
|
||||
${galleryId}_open(t.getAttribute('data-lb-src'));
|
||||
});
|
||||
document.getElementById('${galleryId}_grid').addEventListener('keydown', function(e){
|
||||
if(e.key!=='Enter' && e.key!==' ') return;
|
||||
var t = e.target.closest('[data-lb-src]');
|
||||
if(!t) return;
|
||||
e.preventDefault();
|
||||
${galleryId}_open(t.getAttribute('data-lb-src'));
|
||||
});
|
||||
document.addEventListener('keydown', function(e){
|
||||
var o = document.getElementById('${galleryId}_overlay');
|
||||
if(!o || o.style.display==='none') return;
|
||||
if(e.key==='Escape'){ ${galleryId}_close(); return; }
|
||||
if(e.key==='Tab'){
|
||||
e.preventDefault();
|
||||
var c = document.getElementById('${galleryId}_closebtn');
|
||||
if(c) c.focus();
|
||||
}
|
||||
});
|
||||
</script>`;
|
||||
}
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:${gap}">
|
||||
<div${gridIdAttr} style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:${gap}">
|
||||
${items}
|
||||
</div>${lightboxHtml}
|
||||
</section>`,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { HeroSimple } from './HeroSimple';
|
||||
|
||||
const toHtml = (HeroSimple as any).toHtml;
|
||||
|
||||
describe('HeroSimple.toHtml textAlign enum sink (attacker-controlled prop, not enforced at runtime)', () => {
|
||||
test('malicious textAlign value cannot break out of the content div style attribute', () => {
|
||||
const { html } = toHtml({
|
||||
heading: 'Hi',
|
||||
subtitle: 'There',
|
||||
textAlign: 'center;"><script>alert(1)</script>',
|
||||
}, '');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
expect(html).not.toContain('center;">');
|
||||
});
|
||||
|
||||
test('unrecognized textAlign value falls back to a safe default rather than being echoed raw', () => {
|
||||
const { html } = toHtml({ heading: 'Hi', subtitle: 'There', textAlign: 'not-a-real-value' as any }, '');
|
||||
expect(html).not.toContain('text-align:not-a-real-value');
|
||||
});
|
||||
|
||||
test('valid textAlign values are preserved', () => {
|
||||
const { html: left } = toHtml({ heading: 'Hi', subtitle: 'There', textAlign: 'left' }, '');
|
||||
expect(left).toContain('text-align:left');
|
||||
const { html: right } = toHtml({ heading: 'Hi', subtitle: 'There', textAlign: 'right' }, '');
|
||||
expect(right).toContain('text-align:right');
|
||||
});
|
||||
|
||||
test('normal default render is sane', () => {
|
||||
const { html } = toHtml({ heading: 'Welcome', subtitle: 'Sub text' }, '');
|
||||
expect(html).toContain('Welcome');
|
||||
expect(html).toContain('Sub text');
|
||||
expect(html).toContain('text-align:center');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,15 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
||||
import { escapeHtml, escapeAttr, safeUrl, cssValue } from '../../utils/escape';
|
||||
|
||||
interface HeroProps {
|
||||
heading?: string;
|
||||
subtitle?: string;
|
||||
/** New dynamic CTAs. When set (length > 0), legacy primary/secondary fields are ignored. */
|
||||
ctas?: CtaButton[];
|
||||
/** Legacy — kept for backwards compatibility with saved projects. */
|
||||
buttonText?: string;
|
||||
buttonHref?: string;
|
||||
secondaryButtonText?: string;
|
||||
@@ -24,6 +29,7 @@ interface HeroProps {
|
||||
minHeight?: string;
|
||||
verticalAlign?: 'top' | 'center' | 'bottom';
|
||||
textAlign?: 'left' | 'center' | 'right';
|
||||
anchorId?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
@@ -43,10 +49,11 @@ function buildBackground(props: HeroProps): string {
|
||||
export const HeroSimple: UserComponent<HeroProps> = ({
|
||||
heading = 'Build Something Amazing',
|
||||
subtitle = 'Create beautiful websites without writing a single line of code.',
|
||||
buttonText = 'Get Started',
|
||||
buttonHref = '#',
|
||||
secondaryButtonText = '',
|
||||
secondaryButtonHref = '#',
|
||||
ctas,
|
||||
buttonText,
|
||||
buttonHref,
|
||||
secondaryButtonText,
|
||||
secondaryButtonHref,
|
||||
bgType = 'color',
|
||||
bgColor = '#1e293b',
|
||||
bgGradientFrom = '#667eea',
|
||||
@@ -62,6 +69,7 @@ export const HeroSimple: UserComponent<HeroProps> = ({
|
||||
minHeight = '500px',
|
||||
verticalAlign = 'center',
|
||||
textAlign = 'center',
|
||||
anchorId,
|
||||
style = {},
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
@@ -72,9 +80,17 @@ export const HeroSimple: UserComponent<HeroProps> = ({
|
||||
|
||||
const justifyMap = { top: 'flex-start', center: 'center', bottom: 'flex-end' };
|
||||
|
||||
const effectiveCtas = normalizeCtas({ ctas, buttonText, buttonHref, secondaryButtonText, secondaryButtonHref });
|
||||
const ctaDefaults = {
|
||||
primaryBg: buttonBgColor,
|
||||
primaryText: buttonTextColor,
|
||||
outlineText: textColor,
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
...style,
|
||||
background: bgType !== 'image' ? bg : undefined,
|
||||
@@ -134,235 +150,18 @@ export const HeroSimple: UserComponent<HeroProps> = ({
|
||||
{subtitle}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: textAlign === 'center' ? 'center' : textAlign === 'right' ? 'flex-end' : 'flex-start', flexWrap: 'wrap' }}>
|
||||
{buttonText && (
|
||||
<a href={buttonHref} onClick={(e) => e.preventDefault()} style={{
|
||||
display: 'inline-block', padding: '14px 36px', backgroundColor: buttonBgColor,
|
||||
color: buttonTextColor, textDecoration: 'none', borderRadius: '8px',
|
||||
fontWeight: '600', fontSize: '16px',
|
||||
}}>
|
||||
{buttonText}
|
||||
{effectiveCtas.map((cta, i) => (
|
||||
<a key={i} href={cta.href || '#'} onClick={(e) => e.preventDefault()}
|
||||
style={ctaInlineStyle(cta, ctaDefaults)}>
|
||||
{cta.text}
|
||||
</a>
|
||||
)}
|
||||
{secondaryButtonText && (
|
||||
<a href={secondaryButtonHref} onClick={(e) => e.preventDefault()} style={{
|
||||
display: 'inline-block', padding: '14px 36px',
|
||||
backgroundColor: 'transparent', color: textColor,
|
||||
textDecoration: 'none', borderRadius: '8px', fontWeight: '600',
|
||||
fontSize: '16px', border: `2px solid ${textColor}`,
|
||||
}}>
|
||||
{secondaryButtonText}
|
||||
</a>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '6px 8px', background: '#27272a',
|
||||
color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
|
||||
};
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4,
|
||||
};
|
||||
const btnStyle = (active: boolean): React.CSSProperties => ({
|
||||
flex: 1, padding: '6px 4px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: active ? '#3b82f6' : '#27272a',
|
||||
color: active ? '#fff' : '#a1a1aa',
|
||||
fontWeight: active ? 600 : 400,
|
||||
});
|
||||
|
||||
const HeroSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as HeroProps,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{/* Content */}
|
||||
<div>
|
||||
<label style={labelStyle}>Heading</label>
|
||||
<input type="text" value={props.heading || ''} onChange={(e) => setProp((p: HeroProps) => { p.heading = e.target.value; })} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Subtitle</label>
|
||||
<textarea value={props.subtitle || ''} onChange={(e) => setProp((p: HeroProps) => { p.subtitle = e.target.value; })} rows={3} style={{ ...inputStyle, resize: 'vertical' as const }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Button Text</label>
|
||||
<input type="text" value={props.buttonText || ''} onChange={(e) => setProp((p: HeroProps) => { p.buttonText = e.target.value; })} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Button URL</label>
|
||||
<input type="text" value={props.buttonHref || ''} onChange={(e) => setProp((p: HeroProps) => { p.buttonHref = e.target.value; })} placeholder="#" style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Secondary Button Text</label>
|
||||
<input type="text" value={props.secondaryButtonText || ''} onChange={(e) => setProp((p: HeroProps) => { p.secondaryButtonText = e.target.value; })} placeholder="Leave blank to hide" style={inputStyle} />
|
||||
</div>
|
||||
|
||||
{/* Background Type */}
|
||||
<div>
|
||||
<label style={labelStyle}>Background Type</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['color', 'gradient', 'image', 'video'] as const).map((t) => (
|
||||
<button key={t} onClick={() => setProp((p: HeroProps) => { p.bgType = t; })}
|
||||
style={btnStyle(props.bgType === t)}>
|
||||
{t === 'color' ? 'Color' : t === 'gradient' ? 'Gradient' : t === 'image' ? 'Image' : 'Video'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background controls based on type */}
|
||||
{props.bgType === 'color' && (
|
||||
<div>
|
||||
<label style={labelStyle}>Background Color</label>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input type="color" value={props.bgColor || '#1e293b'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.bgColor = e.target.value; })}
|
||||
style={{ width: 36, height: 30, border: 'none', cursor: 'pointer', background: 'none' }} />
|
||||
<input type="text" value={props.bgColor || '#1e293b'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.bgColor = e.target.value; })}
|
||||
style={{ ...inputStyle, flex: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.bgType === 'gradient' && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>From</label>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input type="color" value={props.bgGradientFrom || '#667eea'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientFrom = e.target.value; })}
|
||||
style={{ width: 30, height: 26, border: 'none', cursor: 'pointer', background: 'none' }} />
|
||||
<input type="text" value={props.bgGradientFrom || '#667eea'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientFrom = e.target.value; })}
|
||||
style={{ ...inputStyle, fontSize: 10 }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>To</label>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input type="color" value={props.bgGradientTo || '#764ba2'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientTo = e.target.value; })}
|
||||
style={{ width: 30, height: 26, border: 'none', cursor: 'pointer', background: 'none' }} />
|
||||
<input type="text" value={props.bgGradientTo || '#764ba2'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientTo = e.target.value; })}
|
||||
style={{ ...inputStyle, fontSize: 10 }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Angle: {props.bgGradientAngle || 135}°</label>
|
||||
<input type="range" min={0} max={360} value={props.bgGradientAngle || 135}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientAngle = parseInt(e.target.value); })}
|
||||
style={{ width: '100%' }} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{props.bgType === 'image' && (
|
||||
<div>
|
||||
<label style={labelStyle}>Background Image URL</label>
|
||||
<input type="text" value={props.bgImage || ''} placeholder="https://..."
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.bgImage = e.target.value; })} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.bgType === 'video' && (
|
||||
<div>
|
||||
<label style={labelStyle}>Background Video URL</label>
|
||||
<input type="text" value={props.bgVideo || ''} placeholder="https://...mp4"
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.bgVideo = e.target.value; })} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overlay */}
|
||||
{(props.bgType === 'image' || props.bgType === 'video') && (
|
||||
<div>
|
||||
<label style={labelStyle}>Overlay ({props.overlayOpacity || 0}%)</label>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input type="color" value={props.overlayColor || '#000000'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.overlayColor = e.target.value; })}
|
||||
style={{ width: 30, height: 26, border: 'none', cursor: 'pointer', background: 'none' }} />
|
||||
<input type="range" min={0} max={100} value={props.overlayOpacity || 0}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.overlayOpacity = parseInt(e.target.value); })}
|
||||
style={{ flex: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text & Button Colors */}
|
||||
<div>
|
||||
<label style={labelStyle}>Text Color</label>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input type="color" value={props.textColor || '#ffffff'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.textColor = e.target.value; })}
|
||||
style={{ width: 36, height: 30, border: 'none', cursor: 'pointer', background: 'none' }} />
|
||||
<input type="text" value={props.textColor || '#ffffff'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.textColor = e.target.value; })}
|
||||
style={{ ...inputStyle, flex: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>Button BG</label>
|
||||
<input type="color" value={props.buttonBgColor || '#3b82f6'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.buttonBgColor = e.target.value; })}
|
||||
style={{ width: '100%', height: 30, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>Button Text</label>
|
||||
<input type="color" value={props.buttonTextColor || '#ffffff'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.buttonTextColor = e.target.value; })}
|
||||
style={{ width: '100%', height: 30, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Layout */}
|
||||
<div>
|
||||
<label style={labelStyle}>Min Height</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{['300px', '400px', '500px', '600px', '100vh'].map((h) => (
|
||||
<button key={h} onClick={() => setProp((p: HeroProps) => { p.minHeight = h; })}
|
||||
style={btnStyle(props.minHeight === h)}>{h === '100vh' ? 'Full' : h}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Vertical Align</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['top', 'center', 'bottom'] as const).map((v) => (
|
||||
<button key={v} onClick={() => setProp((p: HeroProps) => { p.verticalAlign = v; })}
|
||||
style={btnStyle(props.verticalAlign === v)}>{v}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Text Align</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['left', 'center', 'right'] as const).map((a) => (
|
||||
<button key={a} onClick={() => setProp((p: HeroProps) => { p.textAlign = a; })}
|
||||
style={btnStyle(props.textAlign === a)}>
|
||||
<i className={`fa fa-align-${a}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
HeroSimple.craft = {
|
||||
@@ -370,10 +169,9 @@ HeroSimple.craft = {
|
||||
props: {
|
||||
heading: 'Build Something Amazing',
|
||||
subtitle: 'Create beautiful websites without writing a single line of code.',
|
||||
buttonText: 'Get Started',
|
||||
buttonHref: '#',
|
||||
secondaryButtonText: '',
|
||||
secondaryButtonHref: '#',
|
||||
ctas: [
|
||||
{ text: 'Get Started', href: '#', variant: 'primary' },
|
||||
] as CtaButton[],
|
||||
bgType: 'color',
|
||||
bgColor: '#1e293b',
|
||||
bgGradientFrom: '#667eea',
|
||||
@@ -389,6 +187,7 @@ HeroSimple.craft = {
|
||||
minHeight: '500px',
|
||||
verticalAlign: 'center',
|
||||
textAlign: 'center',
|
||||
anchorId: '',
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
@@ -396,15 +195,11 @@ HeroSimple.craft = {
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: HeroSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(HeroSimple as any).toHtml = (props: HeroProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>');
|
||||
const bg = buildBackground(props);
|
||||
const justifyMap: Record<string, string> = { top: 'flex-start', center: 'center', bottom: 'flex-end' };
|
||||
|
||||
@@ -425,31 +220,39 @@ HeroSimple.craft = {
|
||||
|
||||
let overlayHtml = '';
|
||||
if ((props.overlayOpacity || 0) > 0) {
|
||||
overlayHtml = `<div style="position:absolute;top:0;left:0;right:0;bottom:0;background-color:${props.overlayColor || '#000'};opacity:${(props.overlayOpacity || 0) / 100};z-index:1"></div>`;
|
||||
const overlayColor = cssValue(props.overlayColor) || '#000';
|
||||
overlayHtml = `<div style="position:absolute;top:0;left:0;right:0;bottom:0;background-color:${overlayColor};opacity:${(props.overlayOpacity || 0) / 100};z-index:1"></div>`;
|
||||
}
|
||||
|
||||
let videoHtml = '';
|
||||
if (props.bgType === 'video' && props.bgVideo) {
|
||||
videoHtml = `<video src="${props.bgVideo}" autoplay muted loop playsinline style="position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;z-index:0"></video>`;
|
||||
videoHtml = `<video src="${escapeAttr(safeUrl(props.bgVideo))}" autoplay muted loop playsinline style="position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;z-index:0"></video>`;
|
||||
}
|
||||
|
||||
const textAlign = props.textAlign || 'center';
|
||||
// Allowlisted -- `textAlign` is declared as a 'left'|'center'|'right' union
|
||||
// but arrives unchecked via AI update_props / deserialized state; it is
|
||||
// interpolated raw into the content div's style attribute below, so any
|
||||
// other value must collapse to a known-safe default rather than being
|
||||
// echoed into the markup.
|
||||
const ALLOWED_TEXT_ALIGN = ['left', 'center', 'right'];
|
||||
const textAlign = ALLOWED_TEXT_ALIGN.includes(props.textAlign as string) ? (props.textAlign as string) : 'center';
|
||||
const justifyBtn = textAlign === 'center' ? 'center' : textAlign === 'right' ? 'flex-end' : 'flex-start';
|
||||
|
||||
let buttonsHtml = '';
|
||||
if (props.buttonText) {
|
||||
buttonsHtml += `<a href="${props.buttonHref || '#'}" style="display:inline-block;padding:14px 36px;background-color:${props.buttonBgColor || '#3b82f6'};color:${props.buttonTextColor || '#fff'};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px">${esc(props.buttonText)}</a>`;
|
||||
}
|
||||
if (props.secondaryButtonText) {
|
||||
buttonsHtml += `<a href="${props.secondaryButtonHref || '#'}" style="display:inline-block;padding:14px 36px;background:transparent;color:${props.textColor || '#fff'};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px;border:2px solid ${props.textColor || '#fff'}">${esc(props.secondaryButtonText)}</a>`;
|
||||
}
|
||||
const ctas = normalizeCtas(props);
|
||||
const buttonsHtml = ctasToHtml(ctas, {
|
||||
primaryBg: props.buttonBgColor || '#3b82f6',
|
||||
primaryText: props.buttonTextColor || '#fff',
|
||||
outlineText: props.textColor || '#fff',
|
||||
});
|
||||
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
const heroTextColor = cssValue(props.textColor) || '#fff';
|
||||
return {
|
||||
html: `<section style="${sectionStyle}">
|
||||
html: `<section${idAttr} style="${sectionStyle}">
|
||||
${videoHtml}${overlayHtml}
|
||||
<div style="max-width:800px;width:100%;position:relative;z-index:2;text-align:${textAlign}">
|
||||
<h1 style="font-size:48px;font-weight:700;color:${props.textColor || '#fff'};margin-bottom:16px;line-height:1.2">${esc(props.heading || '')}</h1>
|
||||
<p style="font-size:20px;color:${props.textColor || '#fff'};opacity:0.85;margin-bottom:32px;line-height:1.6;white-space:pre-line">${esc(props.subtitle || '')}</p>
|
||||
<h1 style="font-size:48px;font-weight:700;color:${heroTextColor};margin-bottom:16px;line-height:1.2">${escapeHtml(props.heading || '')}</h1>
|
||||
<p style="font-size:20px;color:${heroTextColor};opacity:0.85;margin-bottom:32px;line-height:1.6;white-space:pre-line">${escapeHtml(props.subtitle || '')}</p>
|
||||
<div style="display:flex;gap:12px;justify-content:${justifyBtn};flex-wrap:wrap">${buttonsHtml}</div>
|
||||
</div>
|
||||
</section>`,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { NumberCounter } from './NumberCounter';
|
||||
|
||||
const toHtml = (NumberCounter as any).toHtml;
|
||||
|
||||
const counters = [
|
||||
{ number: 150, suffix: '+', label: 'Projects' },
|
||||
{ number: 50, suffix: '+', label: 'Clients' },
|
||||
];
|
||||
|
||||
describe('NumberCounter.toHtml deterministic + unique scope ids (thread node id, no Math.random)', () => {
|
||||
test('same node id -> identical output across calls (deterministic)', () => {
|
||||
const { html: html1 } = toHtml({ counters }, '', 'node-nc1');
|
||||
const { html: html2 } = toHtml({ counters }, '', 'node-nc1');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
|
||||
test('different node ids -> distinct, non-colliding nc_ scopes (identical props, no collision)', () => {
|
||||
const { html: html1 } = toHtml({ counters }, '', 'node-nc1');
|
||||
const { html: html2 } = toHtml({ counters }, '', 'node-nc2');
|
||||
const wrapId1 = html1.match(/<div id="(nc_[^"]+)"/)![1];
|
||||
const wrapId2 = html2.match(/<div id="(nc_[^"]+)"/)![1];
|
||||
expect(wrapId1).not.toBe(wrapId2);
|
||||
});
|
||||
|
||||
test('wrapper id, per-counter ids, and inline script agree on the same uid', () => {
|
||||
const { html } = toHtml({ counters }, '', 'node-nc1');
|
||||
const wrapId = html.match(/<div id="(nc_[^"]+)"/)![1];
|
||||
expect(html).toContain(`id="${wrapId}_n0"`);
|
||||
expect(html).toContain(`id="${wrapId}_n1"`);
|
||||
expect(html).toContain(`var uid="${wrapId}"`);
|
||||
expect(html).toContain('document.getElementById(uid)');
|
||||
expect(html).toContain('document.getElementById(uid+"_n"+i)');
|
||||
});
|
||||
|
||||
test('no nodeId (legacy 2-arg call): still deterministic across repeated calls, not random', () => {
|
||||
const { html: html1 } = toHtml({ counters }, '');
|
||||
const { html: html2 } = toHtml({ counters }, '');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
|
||||
test('two different node ids never collide even with default (no counters override) props', () => {
|
||||
const { html: html1 } = toHtml({}, '', 'node-a');
|
||||
const { html: html2 } = toHtml({}, '', 'node-b');
|
||||
const wrapId1 = html1.match(/<div id="(nc_[^"]+)"/)![1];
|
||||
const wrapId2 = html2.match(/<div id="(nc_[^"]+)"/)![1];
|
||||
expect(wrapId1).not.toBe(wrapId2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NumberCounter.toHtml counter.number is NOT runtime-type-checked -- must be sanitized before it reaches data-target', () => {
|
||||
test('a malicious counter.number cannot break out of the data-target attribute to inject a <script> tag', () => {
|
||||
const malicious = [
|
||||
{ number: '150"><script>alert(1)</script>', suffix: '+', label: 'Evil' },
|
||||
];
|
||||
const { html } = toHtml({ counters: malicious }, '', 'node-nc-evil1');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
expect(html).not.toContain('"><script>');
|
||||
});
|
||||
|
||||
test('a malicious counter.number cannot break out of the data-target attribute to inject an onmouseover handler', () => {
|
||||
const malicious = [
|
||||
{ number: '150" onmouseover="alert(1)', suffix: '+', label: 'Evil' },
|
||||
];
|
||||
const { html } = toHtml({ counters: malicious }, '', 'node-nc-evil2');
|
||||
expect(html).not.toContain('onmouseover=');
|
||||
expect(html).not.toMatch(/data-target="150" onmouseover/);
|
||||
});
|
||||
|
||||
test('normal numeric counter.number values still render as data-target="150"', () => {
|
||||
const normal = [{ number: 150, suffix: '+', label: 'Projects' }];
|
||||
const { html } = toHtml({ counters: normal }, '', 'node-nc-normal');
|
||||
expect(html).toContain('data-target="150"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NumberCounter.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||
const props = (NumberCounter as any).craft.props;
|
||||
expect(props).toHaveProperty('animation', '');
|
||||
expect(props).toHaveProperty('animationDelay', '');
|
||||
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||
expect(props).toHaveProperty('hideOnTablet', false);
|
||||
expect(props).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, scopeId, cssValue } from '../../utils/escape';
|
||||
|
||||
interface Counter {
|
||||
number: number;
|
||||
@@ -15,6 +16,11 @@ interface NumberCounterProps {
|
||||
labelColor?: string;
|
||||
numberSize?: string;
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const defaultCounters: Counter[] = [
|
||||
@@ -87,199 +93,6 @@ export const NumberCounter: UserComponent<NumberCounterProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const NumberCounterSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as NumberCounterProps,
|
||||
}));
|
||||
|
||||
const items = props.counters || defaultCounters;
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
|
||||
const columnOptions = [2, 3, 4, 5, 6];
|
||||
const sizePresets = ['32px', '40px', '48px', '56px', '64px'];
|
||||
const numberColorPresets = ['#3b82f6', '#10b981', '#8b5cf6', '#ef4444', '#f59e0b', '#18181b', '#ec4899', '#0ea5e9'];
|
||||
const labelColorPresets = ['#6b7280', '#374151', '#9ca3af', '#a1a1aa', '#64748b', '#18181b'];
|
||||
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'];
|
||||
|
||||
const updateCounter = (index: number, field: keyof Counter, value: string | number) => {
|
||||
setProp((p: NumberCounterProps) => {
|
||||
const updated = [...(p.counters || defaultCounters)];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
p.counters = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addCounter = () => {
|
||||
setProp((p: NumberCounterProps) => {
|
||||
p.counters = [...(p.counters || defaultCounters), { number: 100, suffix: '+', label: 'New Stat' }];
|
||||
});
|
||||
};
|
||||
|
||||
const removeCounter = (index: number) => {
|
||||
setProp((p: NumberCounterProps) => {
|
||||
const updated = [...(p.counters || defaultCounters)];
|
||||
updated.splice(index, 1);
|
||||
p.counters = updated;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{/* Columns */}
|
||||
<div>
|
||||
<label style={labelStyle}>Columns</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{columnOptions.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setProp((p: NumberCounterProps) => { p.columns = n; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: (props.columns || 4) === n ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Number Size */}
|
||||
<div>
|
||||
<label style={labelStyle}>Number Size</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{sizePresets.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setProp((p: NumberCounterProps) => { p.numberSize = s; })}
|
||||
style={{
|
||||
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: (props.numberSize || '48px') === s ? '#3b82f6' : '#27272a',
|
||||
color: (props.numberSize || '48px') === s ? '#fff' : '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Number Color */}
|
||||
<div>
|
||||
<label style={labelStyle}>Number Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{numberColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: NumberCounterProps) => { p.numberColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: (props.numberColor || '#3b82f6') === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Label Color */}
|
||||
<div>
|
||||
<label style={labelStyle}>Label Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{labelColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: NumberCounterProps) => { p.labelColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: (props.labelColor || '#6b7280') === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background */}
|
||||
<div>
|
||||
<label style={labelStyle}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: NumberCounterProps) => { p.style = { ...p.style, backgroundColor: c }; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Counters */}
|
||||
<div>
|
||||
<label style={labelStyle}>Counters</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{items.map((counter, i) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input
|
||||
type="number"
|
||||
value={counter.number}
|
||||
onChange={(e) => updateCounter(i, 'number', parseInt(e.target.value) || 0)}
|
||||
placeholder="Number"
|
||||
style={{ ...inputStyle, width: 70, flex: 'none' }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={counter.suffix}
|
||||
onChange={(e) => updateCounter(i, 'suffix', e.target.value)}
|
||||
placeholder="Suffix"
|
||||
style={{ ...inputStyle, width: 40, flex: 'none' }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={counter.label}
|
||||
onChange={(e) => updateCounter(i, 'label', e.target.value)}
|
||||
placeholder="Label"
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeCounter(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addCounter}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Counter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
NumberCounter.craft = {
|
||||
@@ -291,32 +104,44 @@ NumberCounter.craft = {
|
||||
labelColor: '#6b7280',
|
||||
numberSize: '48px',
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: NumberCounterSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(NumberCounter as any).toHtml = (props: NumberCounterProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
(NumberCounter as any).toHtml = (props: NumberCounterProps, _childrenHtml: string, nodeId?: string) => {
|
||||
const {
|
||||
counters = defaultCounters,
|
||||
columns = 4,
|
||||
numberColor = '#3b82f6',
|
||||
labelColor = '#6b7280',
|
||||
numberSize = '48px',
|
||||
style = {},
|
||||
} = props;
|
||||
// Number() coercion: `columns` is a raw string-interpolation sink into the
|
||||
// grid style attribute below (repeat(${columns},1fr)); Number() of
|
||||
// anything non-numeric collapses safely to NaN instead of breaking out.
|
||||
const columns = Number(props.columns) || 4;
|
||||
// Sanitized -- raw string-interpolation sinks in the counter/label spans
|
||||
// below.
|
||||
const numberColor = cssValue(props.numberColor) || '#3b82f6';
|
||||
const labelColor = cssValue(props.labelColor) || '#6b7280';
|
||||
const numberSize = cssValue(props.numberSize) || '48px';
|
||||
|
||||
const items = counters.length > 0 ? counters : defaultCounters;
|
||||
const uid = 'nc_' + Math.random().toString(36).slice(2, 8);
|
||||
|
||||
// Deterministic AND unique id for this counter instance's wrapper/span
|
||||
// ids and getElementById() calls inside its inline script -- scoped on
|
||||
// the Craft node id so two NumberCounter instances (e.g. both left at
|
||||
// default props) don't collide and end up animating each other's digits.
|
||||
const seed = items.map((c) => `${c.number}${c.suffix}::${c.label}`).join('|');
|
||||
const uid = scopeId(nodeId, seed, 'nc');
|
||||
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '60px 20px',
|
||||
@@ -325,9 +150,21 @@ NumberCounter.craft = {
|
||||
});
|
||||
|
||||
const countersHtml = items.map((counter, i) => {
|
||||
// Number() coercion + escapeAttr: `counter.number` is declared `number`
|
||||
// per-item inside an array prop, but is NOT type-checked at runtime --
|
||||
// it arrives raw via the AI `update_props` path or a deserialized
|
||||
// saved-state blob and was previously interpolated straight into this
|
||||
// data-target attribute, letting a string like `150"><script>...`
|
||||
// break out of the attribute and inject markup. Number() collapses any
|
||||
// non-numeric value safely to NaN (then 0), and escapeAttr is kept as
|
||||
// defense-in-depth in case Number()'s string coercion output ever
|
||||
// contains a stray character (it can't today, but the sink should never
|
||||
// rely solely on the coercion).
|
||||
const numberVal = Number(counter.number);
|
||||
const safeNumber = Number.isFinite(numberVal) ? numberVal : 0;
|
||||
return `<div style="display:flex;flex-direction:column;align-items:center;gap:8px">
|
||||
<span id="${uid}_n${i}" data-target="${counter.number}" data-suffix="${esc(counter.suffix)}" style="font-size:${numberSize};font-weight:700;color:${numberColor};line-height:1.1;font-family:Inter,sans-serif">0${esc(counter.suffix)}</span>
|
||||
<span style="font-size:15px;color:${labelColor};font-family:Inter,sans-serif;font-weight:500">${esc(counter.label)}</span>
|
||||
<span id="${uid}_n${i}" data-target="${escapeAttr(String(safeNumber))}" data-suffix="${escapeAttr(counter.suffix)}" style="font-size:${numberSize};font-weight:700;color:${numberColor};line-height:1.1;font-family:Inter,sans-serif">0${escapeHtml(counter.suffix)}</span>
|
||||
<span style="font-size:15px;color:${labelColor};font-family:Inter,sans-serif;font-weight:500">${escapeHtml(counter.label)}</span>
|
||||
</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { PricingTable } from './PricingTable';
|
||||
|
||||
const toHtml = (PricingTable as any).toHtml;
|
||||
|
||||
const plans = [
|
||||
{ name: 'Basic', price: '$9', period: '/month', features: ['Feature A'], buttonText: 'Buy', buttonHref: '#', isFeatured: false },
|
||||
{ name: 'Pro', price: '$29', period: '/month', features: ['Feature B'], buttonText: 'Buy', buttonHref: '#', isFeatured: true },
|
||||
];
|
||||
|
||||
describe('PricingTable.toHtml regular-card color overrides (previously hard-coded, now real props)', () => {
|
||||
test('cardBg emits as the non-featured card background', () => {
|
||||
const { html } = toHtml({ plans, cardBg: '#f0f0f0' }, '');
|
||||
expect(html).toContain('background-color:#f0f0f0');
|
||||
});
|
||||
|
||||
test('textColor emits as the non-featured heading/price color', () => {
|
||||
const { html } = toHtml({ plans, textColor: '#123456' }, '');
|
||||
expect(html).toContain('color:#123456');
|
||||
});
|
||||
|
||||
test('subColor emits as the non-featured period text color', () => {
|
||||
const { html } = toHtml({ plans, subColor: '#abcdef' }, '');
|
||||
expect(html).toContain('color:#abcdef');
|
||||
});
|
||||
|
||||
test('featColor emits as the non-featured feature list text color', () => {
|
||||
const { html } = toHtml({ plans, featColor: '#334455' }, '');
|
||||
expect(html).toContain('color:#334455');
|
||||
});
|
||||
|
||||
test('checkColor emits as the non-featured bullet color', () => {
|
||||
const { html } = toHtml({ plans, checkColor: '#00ff00' }, '');
|
||||
expect(html).toContain('color:#00ff00');
|
||||
});
|
||||
|
||||
test('btnBg/btnColor emit as the non-featured button colors', () => {
|
||||
const { html } = toHtml({ plans, btnBg: '#111111', btnColor: '#eeeeee' }, '');
|
||||
expect(html).toContain('background-color:#111111');
|
||||
expect(html).toContain('color:#eeeeee');
|
||||
});
|
||||
|
||||
test('unset overrides fall back to the exact prior literals (backward compatible)', () => {
|
||||
const { html } = toHtml({ plans }, '');
|
||||
// Regular (non-featured) card literals unchanged from before these props existed.
|
||||
expect(html).toContain('background-color:#ffffff');
|
||||
expect(html).toContain('color:#18181b');
|
||||
expect(html).toContain('color:#64748b');
|
||||
expect(html).toContain('color:#4b5563');
|
||||
expect(html).toContain('color:#10b981');
|
||||
});
|
||||
|
||||
test('unset btnBg falls back to featuredBg (original derivation)', () => {
|
||||
const { html } = toHtml({ plans, featuredBg: '#654321' }, '');
|
||||
expect(html).toContain('background-color:#654321');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PricingTable.toHtml XSS hardening (new card color props into style=)', () => {
|
||||
test('a cardBg breakout string cannot escape style=""', () => {
|
||||
const malicious = '#fff" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ plans, cardBg: malicious }, '');
|
||||
expect(html).not.toMatch(/"\s+onmouseover="/);
|
||||
});
|
||||
|
||||
test('a textColor breakout string cannot escape style=""', () => {
|
||||
const malicious = '#000" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ plans, textColor: malicious }, '');
|
||||
expect(html).not.toMatch(/"\s+onmouseover="/);
|
||||
});
|
||||
|
||||
test('a btnBg breakout string cannot escape style=""', () => {
|
||||
const malicious = '#000" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ plans, btnBg: malicious }, '');
|
||||
expect(html).not.toMatch(/"\s+onmouseover="/);
|
||||
});
|
||||
|
||||
test('a btnColor breakout string cannot escape style=""', () => {
|
||||
const malicious = '#000" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ plans, btnColor: malicious }, '');
|
||||
expect(html).not.toMatch(/"\s+onmouseover="/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PricingTable.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||
test('the new regular-card color props are declared blank by default', () => {
|
||||
const props = (PricingTable as any).craft.props;
|
||||
expect(props).toHaveProperty('cardBg', '');
|
||||
expect(props).toHaveProperty('textColor', '');
|
||||
expect(props).toHaveProperty('subColor', '');
|
||||
expect(props).toHaveProperty('featColor', '');
|
||||
expect(props).toHaveProperty('checkColor', '');
|
||||
expect(props).toHaveProperty('btnBg', '');
|
||||
expect(props).toHaveProperty('btnColor', '');
|
||||
});
|
||||
|
||||
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||
const props = (PricingTable as any).craft.props;
|
||||
expect(props).toHaveProperty('animation', '');
|
||||
expect(props).toHaveProperty('animationDelay', '');
|
||||
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||
expect(props).toHaveProperty('hideOnTablet', false);
|
||||
expect(props).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
@@ -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, cssValue } from '../../utils/escape';
|
||||
|
||||
interface PricingPlan {
|
||||
name: string;
|
||||
@@ -17,6 +18,24 @@ interface PricingTableProps {
|
||||
style?: CSSProperties;
|
||||
featuredBg?: string;
|
||||
bulletType?: string;
|
||||
anchorId?: string;
|
||||
/* ---- Regular (non-featured) card colors ----
|
||||
All optional; each falls back to the exact literal the card was
|
||||
previously hard-coded to (or, for the button, to featuredBg -- the
|
||||
button's original derivation) when left unset, so existing saved
|
||||
projects render pixel-identical until a color is explicitly picked. */
|
||||
cardBg?: string;
|
||||
textColor?: string;
|
||||
subColor?: string;
|
||||
featColor?: string;
|
||||
checkColor?: string;
|
||||
btnBg?: string;
|
||||
btnColor?: string;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const bulletChars: Record<string, string> = {
|
||||
@@ -58,6 +77,14 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
|
||||
style = {},
|
||||
featuredBg = '#3b82f6',
|
||||
bulletType = 'check',
|
||||
anchorId,
|
||||
cardBg,
|
||||
textColor,
|
||||
subColor,
|
||||
featColor,
|
||||
checkColor,
|
||||
btnBg,
|
||||
btnColor,
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
@@ -66,9 +93,18 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const regCardBg = cardBg || '#ffffff';
|
||||
const regTextColor = textColor || '#18181b';
|
||||
const regSubColor = subColor || '#64748b';
|
||||
const regFeatColor = featColor || '#4b5563';
|
||||
const regCheckColor = checkColor || '#10b981';
|
||||
const regBtnBg = btnBg || featuredBg;
|
||||
const regBtnColor = btnColor || '#ffffff';
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
padding: '80px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
@@ -91,7 +127,7 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
|
||||
style={{
|
||||
flex: '1 1 280px',
|
||||
maxWidth: '360px',
|
||||
backgroundColor: plan.isFeatured ? featuredBg : '#ffffff',
|
||||
backgroundColor: plan.isFeatured ? featuredBg : regCardBg,
|
||||
border: plan.isFeatured ? 'none' : '1px solid #e2e8f0',
|
||||
borderRadius: '16px',
|
||||
padding: '40px 32px',
|
||||
@@ -123,7 +159,7 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
|
||||
<h3 style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: '600',
|
||||
color: plan.isFeatured ? '#ffffff' : '#18181b',
|
||||
color: plan.isFeatured ? '#ffffff' : regTextColor,
|
||||
marginBottom: '8px',
|
||||
marginTop: plan.isFeatured ? '8px' : '0',
|
||||
}}>
|
||||
@@ -133,14 +169,14 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
|
||||
<span style={{
|
||||
fontSize: '48px',
|
||||
fontWeight: '700',
|
||||
color: plan.isFeatured ? '#ffffff' : '#18181b',
|
||||
color: plan.isFeatured ? '#ffffff' : regTextColor,
|
||||
lineHeight: '1',
|
||||
}}>
|
||||
{plan.price}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '16px',
|
||||
color: plan.isFeatured ? 'rgba(255,255,255,0.8)' : '#64748b',
|
||||
color: plan.isFeatured ? 'rgba(255,255,255,0.8)' : regSubColor,
|
||||
}}>
|
||||
{plan.period}
|
||||
</span>
|
||||
@@ -157,12 +193,12 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
|
||||
{(Array.isArray(plan.features) ? plan.features : []).map((feature, fi) => (
|
||||
<li key={fi} style={{
|
||||
fontSize: '14px',
|
||||
color: plan.isFeatured ? 'rgba(255,255,255,0.9)' : '#4b5563',
|
||||
color: plan.isFeatured ? 'rgba(255,255,255,0.9)' : regFeatColor,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}>
|
||||
<span style={{ color: plan.isFeatured ? '#bbf7d0' : '#10b981', fontWeight: '700' }}>{bulletChars[bulletType] || '✓'}</span>
|
||||
<span style={{ color: plan.isFeatured ? '#bbf7d0' : regCheckColor, fontWeight: '700' }}>{bulletChars[bulletType] || '✓'}</span>
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
@@ -174,8 +210,8 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
|
||||
marginTop: 'auto',
|
||||
display: 'inline-block',
|
||||
padding: '14px 32px',
|
||||
backgroundColor: plan.isFeatured ? '#ffffff' : featuredBg,
|
||||
color: plan.isFeatured ? featuredBg : '#ffffff',
|
||||
backgroundColor: plan.isFeatured ? '#ffffff' : regBtnBg,
|
||||
color: plan.isFeatured ? featuredBg : regBtnColor,
|
||||
textDecoration: 'none',
|
||||
borderRadius: '8px',
|
||||
fontWeight: '600',
|
||||
@@ -193,188 +229,6 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const PricingTableSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as PricingTableProps,
|
||||
}));
|
||||
|
||||
const plans = props.plans || defaultPlans;
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||||
|
||||
const updatePlan = (index: number, field: keyof PricingPlan, value: any) => {
|
||||
setProp((p: PricingTableProps) => {
|
||||
const updated = [...(p.plans || defaultPlans)];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
p.plans = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const updateFeature = (planIndex: number, featureIndex: number, value: string) => {
|
||||
setProp((p: PricingTableProps) => {
|
||||
const updated = [...(p.plans || defaultPlans)];
|
||||
const features = [...(Array.isArray(updated[planIndex].features) ? updated[planIndex].features : [])];
|
||||
features[featureIndex] = value;
|
||||
updated[planIndex] = { ...updated[planIndex], features };
|
||||
p.plans = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addFeature = (planIndex: number) => {
|
||||
setProp((p: PricingTableProps) => {
|
||||
const updated = [...(p.plans || defaultPlans)];
|
||||
updated[planIndex] = { ...updated[planIndex], features: [...updated[planIndex].features, 'New Feature'] };
|
||||
p.plans = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const removeFeature = (planIndex: number, featureIndex: number) => {
|
||||
setProp((p: PricingTableProps) => {
|
||||
const updated = [...(p.plans || defaultPlans)];
|
||||
const features = [...(Array.isArray(updated[planIndex].features) ? updated[planIndex].features : [])];
|
||||
features.splice(featureIndex, 1);
|
||||
updated[planIndex] = { ...updated[planIndex], features };
|
||||
p.plans = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addPlan = () => {
|
||||
setProp((p: PricingTableProps) => {
|
||||
p.plans = [...(p.plans || defaultPlans), {
|
||||
name: 'New Plan',
|
||||
price: '$19',
|
||||
period: '/month',
|
||||
features: ['Feature 1', 'Feature 2'],
|
||||
buttonText: 'Get Started',
|
||||
buttonHref: '#',
|
||||
isFeatured: false,
|
||||
}];
|
||||
});
|
||||
};
|
||||
|
||||
const removePlan = (index: number) => {
|
||||
setProp((p: PricingTableProps) => {
|
||||
const updated = [...(p.plans || defaultPlans)];
|
||||
updated.splice(index, 1);
|
||||
p.plans = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const bgPresets = ['#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444', '#18181b', '#0f172a', '#7c3aed'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: PricingTableProps) => { p.style = { ...p.style, backgroundColor: c }; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Featured Plan Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: PricingTableProps) => { p.featuredBg = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.featuredBg === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Plans</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{plans.map((plan, i) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input type="text" value={plan.name} onChange={(e) => updatePlan(i, 'name', e.target.value)} placeholder="Plan Name" style={{ ...inputStyle, flex: 1 }} />
|
||||
<button
|
||||
onClick={() => removePlan(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input type="text" value={plan.price} onChange={(e) => updatePlan(i, 'price', e.target.value)} placeholder="$29" style={{ ...inputStyle, width: '60px', flex: 'none' }} />
|
||||
<input type="text" value={plan.period} onChange={(e) => updatePlan(i, 'period', e.target.value)} placeholder="/month" style={{ ...inputStyle, width: '70px', flex: 'none' }} />
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#a1a1aa', marginLeft: 'auto' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={plan.isFeatured}
|
||||
onChange={(e) => updatePlan(i, 'isFeatured', e.target.checked)}
|
||||
/>
|
||||
Featured
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input type="text" value={plan.buttonText} onChange={(e) => updatePlan(i, 'buttonText', e.target.value)} placeholder="Button Text" style={{ ...inputStyle, flex: 1 }} />
|
||||
<input type="text" value={plan.buttonHref} onChange={(e) => updatePlan(i, 'buttonHref', e.target.value)} placeholder="URL" style={{ ...inputStyle, flex: 1 }} />
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<span style={{ fontSize: 10, color: '#71717a' }}>Features:</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: 2 }}>
|
||||
{(Array.isArray(plan.features) ? plan.features : []).map((feat, fi) => (
|
||||
<div key={fi} style={{ display: 'flex', gap: 2 }}>
|
||||
<input type="text" value={feat} onChange={(e) => updateFeature(i, fi, e.target.value)} style={{ ...inputStyle, flex: 1 }} />
|
||||
<button
|
||||
onClick={() => removeFeature(i, fi)}
|
||||
style={{ padding: '1px 4px', fontSize: 10, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 3, cursor: 'pointer' }}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => addFeature(i)}
|
||||
style={{ marginTop: 2, width: '100%', padding: '3px', fontSize: 10, background: '#27272a', color: '#a1a1aa', border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer' }}
|
||||
>
|
||||
+ Feature
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addPlan}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Plan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
PricingTable.craft = {
|
||||
@@ -384,43 +238,67 @@ PricingTable.craft = {
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
featuredBg: '#3b82f6',
|
||||
bulletType: 'check',
|
||||
anchorId: '',
|
||||
cardBg: '',
|
||||
textColor: '',
|
||||
subColor: '',
|
||||
featColor: '',
|
||||
checkColor: '',
|
||||
btnBg: '',
|
||||
btnColor: '',
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: PricingTableSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(PricingTable as any).toHtml = (props: PricingTableProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>');
|
||||
const bulletType = props.bulletType || 'check';
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '80px 20px',
|
||||
...props.style,
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
const plans = props.plans || defaultPlans;
|
||||
const featuredBg = props.featuredBg || '#3b82f6';
|
||||
// Sanitized -- featuredBg is a raw string-interpolation sink below (drives
|
||||
// cardBg/btnBg/btnColor, all raw-interpolated into style="...").
|
||||
const featuredBg = cssValue(props.featuredBg) || '#3b82f6';
|
||||
// Sanitized -- regular (non-featured) card color overrides, all raw
|
||||
// string-interpolation sinks into style="..." below. Each falls back to
|
||||
// the exact literal the card was previously hard-coded to (or, for the
|
||||
// button, to featuredBg) when unset, so unmodified pricing tables render
|
||||
// identically to before these props existed.
|
||||
const regCardBg = cssValue(props.cardBg) || '#ffffff';
|
||||
const regTextColor = cssValue(props.textColor) || '#18181b';
|
||||
const regSubColor = cssValue(props.subColor) || '#64748b';
|
||||
const regFeatColor = cssValue(props.featColor) || '#4b5563';
|
||||
const regCheckColor = cssValue(props.checkColor) || '#10b981';
|
||||
const regBtnBg = cssValue(props.btnBg) || featuredBg;
|
||||
const regBtnColor = cssValue(props.btnColor) || '#ffffff';
|
||||
|
||||
const cards = plans.map((plan) => {
|
||||
const cardBg = plan.isFeatured ? featuredBg : '#ffffff';
|
||||
const cardBg = plan.isFeatured ? featuredBg : regCardBg;
|
||||
const cardBorder = plan.isFeatured ? 'border:none;' : 'border:1px solid #e2e8f0;';
|
||||
const textColor = plan.isFeatured ? '#ffffff' : '#18181b';
|
||||
const subColor = plan.isFeatured ? 'rgba(255,255,255,0.8)' : '#64748b';
|
||||
const featColor = plan.isFeatured ? 'rgba(255,255,255,0.9)' : '#4b5563';
|
||||
const checkColor = plan.isFeatured ? '#bbf7d0' : '#10b981';
|
||||
const btnBg = plan.isFeatured ? '#ffffff' : featuredBg;
|
||||
const btnColor = plan.isFeatured ? featuredBg : '#ffffff';
|
||||
const textColor = plan.isFeatured ? '#ffffff' : regTextColor;
|
||||
const subColor = plan.isFeatured ? 'rgba(255,255,255,0.8)' : regSubColor;
|
||||
const featColor = plan.isFeatured ? 'rgba(255,255,255,0.9)' : regFeatColor;
|
||||
const checkColor = plan.isFeatured ? '#bbf7d0' : regCheckColor;
|
||||
const btnBg = plan.isFeatured ? '#ffffff' : regBtnBg;
|
||||
const btnColor = plan.isFeatured ? featuredBg : regBtnColor;
|
||||
const scale = plan.isFeatured ? 'transform:scale(1.05);' : '';
|
||||
const shadow = plan.isFeatured ? 'box-shadow:0 20px 60px rgba(59,130,246,0.3);' : 'box-shadow:0 1px 3px rgba(0,0,0,0.06);';
|
||||
|
||||
const featuresHtml = (Array.isArray(plan.features) ? plan.features : []).map((f) =>
|
||||
`<li style="font-size:14px;color:${featColor};display:flex;align-items:center;gap:8px"><span style="color:${checkColor};font-weight:700">${bulletChars[bulletType] || '✓'}</span>${esc(f)}</li>`
|
||||
`<li style="font-size:14px;color:${featColor};display:flex;align-items:center;gap:8px"><span style="color:${checkColor};font-weight:700">${bulletChars[bulletType] || '✓'}</span>${escapeHtml(f)}</li>`
|
||||
).join('\n ');
|
||||
|
||||
const badge = plan.isFeatured
|
||||
@@ -429,20 +307,20 @@ PricingTable.craft = {
|
||||
|
||||
return `<div style="flex:1 1 280px;max-width:360px;background-color:${cardBg};${cardBorder}border-radius:16px;padding:40px 32px;display:flex;flex-direction:column;align-items:center;text-align:center;position:relative;${scale}${shadow}">
|
||||
${badge}
|
||||
<h3 style="font-size:20px;font-weight:600;color:${textColor};margin-bottom:8px;${plan.isFeatured ? 'margin-top:8px;' : ''}">${esc(plan.name)}</h3>
|
||||
<h3 style="font-size:20px;font-weight:600;color:${textColor};margin-bottom:8px;${plan.isFeatured ? 'margin-top:8px;' : ''}">${escapeHtml(plan.name)}</h3>
|
||||
<div style="margin-bottom:24px">
|
||||
<span style="font-size:48px;font-weight:700;color:${textColor};line-height:1">${esc(plan.price)}</span>
|
||||
<span style="font-size:16px;color:${subColor}">${esc(plan.period)}</span>
|
||||
<span style="font-size:48px;font-weight:700;color:${textColor};line-height:1">${escapeHtml(plan.price)}</span>
|
||||
<span style="font-size:16px;color:${subColor}">${escapeHtml(plan.period)}</span>
|
||||
</div>
|
||||
<ul style="list-style:none;padding:0;margin:0 0 32px 0;width:100%;display:flex;flex-direction:column;gap:12px">
|
||||
${featuresHtml}
|
||||
</ul>
|
||||
<a href="${plan.buttonHref || '#'}" style="margin-top:auto;display:inline-block;padding:14px 32px;background-color:${btnBg};color:${btnColor};text-decoration:none;border-radius:8px;font-weight:600;font-size:14px;width:100%;text-align:center">${esc(plan.buttonText)}</a>
|
||||
<a href="${escapeAttr(safeUrl(plan.buttonHref || '#'))}" style="margin-top:auto;display:inline-block;padding:14px 32px;background-color:${btnBg};color:${btnColor};text-decoration:none;border-radius:8px;font-weight:600;font-size:14px;width:100%;text-align:center">${escapeHtml(plan.buttonText)}</a>
|
||||
</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:1100px;margin:0 auto;display:flex;gap:24px;justify-content:center;align-items:stretch;flex-wrap:wrap">
|
||||
${cards}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Tabs } from './Tabs';
|
||||
|
||||
const toHtml = (Tabs as any).toHtml;
|
||||
|
||||
const tabs = [
|
||||
{ label: 'Overview', content: 'Overview content' },
|
||||
{ label: 'Features', content: 'Features content' },
|
||||
{ label: 'Support', content: 'Support content' },
|
||||
];
|
||||
|
||||
describe('Tabs.toHtml accessibility (F1.2)', () => {
|
||||
test('tab button container has role="tablist"', () => {
|
||||
const { html } = toHtml({ tabs }, '');
|
||||
expect(html).toMatch(/role="tablist"/);
|
||||
});
|
||||
|
||||
test('each tab button has role="tab", aria-selected, aria-controls', () => {
|
||||
const { html } = toHtml({ tabs }, '');
|
||||
const buttonMatches = html.match(/<button[^>]*role="tab"[^>]*>/g) || [];
|
||||
expect(buttonMatches.length).toBe(3);
|
||||
expect(html).toMatch(/aria-selected="true"/);
|
||||
expect(html).toMatch(/aria-selected="false"/);
|
||||
expect(html).toMatch(/role="tab"[^>]*aria-controls="[^"]+"/);
|
||||
});
|
||||
|
||||
test('each panel has role="tabpanel" and aria-labelledby matching a tab id', () => {
|
||||
const { html } = toHtml({ tabs }, '');
|
||||
const panelMatches = html.match(/role="tabpanel"/g) || [];
|
||||
expect(panelMatches.length).toBe(3);
|
||||
|
||||
// aria-controls on the first tab button should point at an id that
|
||||
// actually exists as a panel's id.
|
||||
const controlsMatch = html.match(/role="tab"[^>]*aria-controls="([^"]+)"/);
|
||||
expect(controlsMatch).toBeTruthy();
|
||||
const controlledId = controlsMatch![1];
|
||||
expect(html).toContain(`id="${controlledId}"`);
|
||||
});
|
||||
|
||||
test('ids linking tab<->panel are deterministic (stable across repeated calls, no randomness)', () => {
|
||||
const { html: html1 } = toHtml({ tabs }, '');
|
||||
const { html: html2 } = toHtml({ tabs }, '');
|
||||
const id1 = html1.match(/role="tab"[^>]*aria-controls="([^"]+)"/)![1];
|
||||
const id2 = html2.match(/role="tab"[^>]*aria-controls="([^"]+)"/)![1];
|
||||
expect(id1).toBe(id2);
|
||||
});
|
||||
|
||||
test('arrow-key navigation is wired in the inline script', () => {
|
||||
const { html } = toHtml({ tabs }, '');
|
||||
expect(html).toMatch(/ArrowRight/);
|
||||
expect(html).toMatch(/ArrowLeft/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tabs.toHtml deterministic + unique ids (thread node id, resolves id-collision finding)', () => {
|
||||
test('same node id -> identical output across calls (deterministic, no Math.random)', () => {
|
||||
const { html: html1 } = toHtml({ tabs }, '', 'node-tabs1');
|
||||
const { html: html2 } = toHtml({ tabs }, '', 'node-tabs1');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
|
||||
test('two instances with IDENTICAL default tab content but different node ids do not collide', () => {
|
||||
const { html: html1 } = toHtml({ tabs }, '', 'node-tabs1');
|
||||
const { html: html2 } = toHtml({ tabs }, '', 'node-tabs2');
|
||||
const id1 = html1.match(/role="tab"[^>]*aria-controls="([^"]+)"/)![1];
|
||||
const id2 = html2.match(/role="tab"[^>]*aria-controls="([^"]+)"/)![1];
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
|
||||
test('aria-controls still matches an existing panel id after the node-id change (internal consistency preserved)', () => {
|
||||
const { html } = toHtml({ tabs }, '', 'node-tabs1');
|
||||
const controlsMatch = html.match(/role="tab"[^>]*aria-controls="([^"]+)"/);
|
||||
expect(controlsMatch).toBeTruthy();
|
||||
expect(html).toContain(`id="${controlsMatch![1]}"`);
|
||||
});
|
||||
|
||||
test('no nodeId (legacy 2-arg call): still deterministic across repeated calls, not random', () => {
|
||||
const { html: html1 } = toHtml({ tabs }, '');
|
||||
const { html: html2 } = toHtml({ tabs }, '');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tabs.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||
const props = (Tabs as any).craft.props;
|
||||
expect(props).toHaveProperty('animation', '');
|
||||
expect(props).toHaveProperty('animationDelay', '');
|
||||
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||
expect(props).toHaveProperty('hideOnTablet', false);
|
||||
expect(props).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { CSSProperties, useState } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, scopeId, cssValue } from '../../utils/escape';
|
||||
|
||||
interface TabItem {
|
||||
label: string;
|
||||
@@ -15,6 +16,12 @@ interface TabsProps {
|
||||
inactiveTabBg?: string;
|
||||
inactiveTabColor?: string;
|
||||
contentBg?: string;
|
||||
anchorId?: string;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const defaultTabs: TabItem[] = [
|
||||
@@ -31,6 +38,7 @@ export const Tabs: UserComponent<TabsProps> = ({
|
||||
inactiveTabBg = '#f1f5f9',
|
||||
inactiveTabColor = '#64748b',
|
||||
contentBg = '#ffffff',
|
||||
anchorId,
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
@@ -44,6 +52,7 @@ export const Tabs: UserComponent<TabsProps> = ({
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
padding: '60px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
@@ -97,173 +106,6 @@ export const Tabs: UserComponent<TabsProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const TabsSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as TabsProps,
|
||||
}));
|
||||
|
||||
const tabs = props.tabs || defaultTabs;
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||||
|
||||
const updateTab = (index: number, field: keyof TabItem, value: string) => {
|
||||
setProp((p: TabsProps) => {
|
||||
const updated = [...(p.tabs || defaultTabs)];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
p.tabs = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addTab = () => {
|
||||
setProp((p: TabsProps) => {
|
||||
p.tabs = [...(p.tabs || defaultTabs), { label: 'New Tab', content: 'Tab content goes here.' }];
|
||||
});
|
||||
};
|
||||
|
||||
const removeTab = (index: number) => {
|
||||
setProp((p: TabsProps) => {
|
||||
const updated = [...(p.tabs || defaultTabs)];
|
||||
updated.splice(index, 1);
|
||||
p.tabs = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const colorSwatches = ['#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444', '#18181b', '#ffffff', '#f1f5f9'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Active Tab Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{colorSwatches.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: TabsProps) => { p.activeTabBg = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.activeTabBg === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Active Tab Text Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['#ffffff', '#18181b', '#1f2937', '#e2e8f0'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: TabsProps) => { p.activeTabColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.activeTabColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Inactive Tab Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['#f1f5f9', '#e2e8f0', '#f8fafc', '#ffffff', '#27272a', '#18181b'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: TabsProps) => { p.inactiveTabBg = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.inactiveTabBg === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Inactive Tab Text Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['#64748b', '#94a3b8', '#18181b', '#ffffff'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: TabsProps) => { p.inactiveTabColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.inactiveTabColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Content Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#1e293b'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: TabsProps) => { p.contentBg = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.contentBg === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Tabs</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{tabs.map((tab, i) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input type="text" value={tab.label} onChange={(e) => updateTab(i, 'label', e.target.value)} placeholder="Label" style={{ ...inputStyle, flex: 1 }} />
|
||||
<button
|
||||
onClick={() => removeTab(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={tab.content}
|
||||
onChange={(e) => updateTab(i, 'content', e.target.value)}
|
||||
placeholder="Content"
|
||||
rows={2}
|
||||
style={{ ...inputStyle, resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addTab}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Tab
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Tabs.craft = {
|
||||
@@ -276,41 +118,59 @@ Tabs.craft = {
|
||||
inactiveTabBg: '#f1f5f9',
|
||||
inactiveTabColor: '#64748b',
|
||||
contentBg: '#ffffff',
|
||||
anchorId: '',
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: TabsSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Tabs as any).toHtml = (props: TabsProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>');
|
||||
(Tabs as any).toHtml = (props: TabsProps, _childrenHtml: string, nodeId?: string) => {
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '60px 20px',
|
||||
...props.style,
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
const tabs = props.tabs || defaultTabs;
|
||||
const activeTabBg = props.activeTabBg || '#3b82f6';
|
||||
const activeTabColor = props.activeTabColor || '#ffffff';
|
||||
const inactiveTabBg = props.inactiveTabBg || '#f1f5f9';
|
||||
const inactiveTabColor = props.inactiveTabColor || '#64748b';
|
||||
const contentBg = props.contentBg || '#ffffff';
|
||||
// Sanitized -- raw string-interpolation sinks below, both into style="..."
|
||||
// attributes AND into an inline <script> as single-quoted JS string
|
||||
// literals (a stray `'` there breaks out of the JS string, not just CSS);
|
||||
// cssValue strips quotes too so it neutralizes both contexts at once.
|
||||
const activeTabBg = cssValue(props.activeTabBg) || '#3b82f6';
|
||||
const activeTabColor = cssValue(props.activeTabColor) || '#ffffff';
|
||||
const inactiveTabBg = cssValue(props.inactiveTabBg) || '#f1f5f9';
|
||||
const inactiveTabColor = cssValue(props.inactiveTabColor) || '#64748b';
|
||||
const contentBg = cssValue(props.contentBg) || '#ffffff';
|
||||
|
||||
const tabId = 'tabs_' + Math.random().toString(36).slice(2, 8);
|
||||
// tabId scopes the functional wiring (onclick/getElementById) as well as
|
||||
// the ARIA tab<->panel linking ids. It must be BOTH deterministic (so
|
||||
// aria-controls/aria-labelledby reference the SAME id across repeated
|
||||
// exports of the same page) AND unique (so two Tabs instances with
|
||||
// identical/default content -- e.g. both left at the default tab set --
|
||||
// don't collide and clobber each other's script globals / ARIA links).
|
||||
// Scoping on the Craft node id gives both properties; when it's
|
||||
// unavailable (legacy 2-arg call sites) we fall back to a stable hash of
|
||||
// the tab content, matching the old (collision-prone but never random)
|
||||
// behavior.
|
||||
const scopeSeed = props.anchorId || tabs.map((t) => t.label).join('|') + '::' + tabs.length;
|
||||
const tabId = scopeId(nodeId, scopeSeed, 'tabs');
|
||||
|
||||
const tabButtons = tabs.map((tab, i) => {
|
||||
const isActive = i === 0;
|
||||
return `<button onclick="${tabId}_switch(${i})" id="${tabId}_btn_${i}" style="padding:12px 24px;font-size:14px;font-weight:600;border:none;border-top-left-radius:8px;border-top-right-radius:8px;cursor:pointer;background-color:${isActive ? activeTabBg : inactiveTabBg};color:${isActive ? activeTabColor : inactiveTabColor}">${esc(tab.label)}</button>`;
|
||||
return `<button onclick="${tabId}_switch(${i})" id="${tabId}_btn_${i}" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-controls="${tabId}_panel_${i}" tabindex="${isActive ? '0' : '-1'}" style="padding:12px 24px;font-size:14px;font-weight:600;border:none;border-top-left-radius:8px;border-top-right-radius:8px;cursor:pointer;background-color:${isActive ? activeTabBg : inactiveTabBg};color:${isActive ? activeTabColor : inactiveTabColor}">${escapeHtml(tab.label)}</button>`;
|
||||
}).join('\n ');
|
||||
|
||||
const tabPanels = tabs.map((tab, i) => {
|
||||
return `<div id="${tabId}_panel_${i}" style="padding:24px;background-color:${contentBg};border:1px solid #e2e8f0;border-top:none;border-bottom-left-radius:8px;border-bottom-right-radius:8px;font-size:14px;line-height:1.7;color:#4b5563;min-height:100px;${i !== 0 ? 'display:none' : ''}">${esc(tab.content)}</div>`;
|
||||
return `<div id="${tabId}_panel_${i}" role="tabpanel" aria-labelledby="${tabId}_btn_${i}" tabindex="0" style="padding:24px;background-color:${contentBg};border:1px solid #e2e8f0;border-top:none;border-bottom-left-radius:8px;border-bottom-right-radius:8px;font-size:14px;line-height:1.7;color:#4b5563;min-height:100px;${i !== 0 ? 'display:none' : ''}">${escapeHtml(tab.content)}</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
const switchScript = `<script>
|
||||
@@ -321,14 +181,36 @@ function ${tabId}_switch(idx){
|
||||
var btn=document.getElementById('${tabId}_btn_'+i);
|
||||
btn.style.backgroundColor=i===idx?'${activeTabBg}':'${inactiveTabBg}';
|
||||
btn.style.color=i===idx?'${activeTabColor}':'${inactiveTabColor}';
|
||||
btn.setAttribute('aria-selected', i===idx ? 'true' : 'false');
|
||||
btn.setAttribute('tabindex', i===idx ? '0' : '-1');
|
||||
}
|
||||
}
|
||||
(function(){
|
||||
var total=${tabs.length};
|
||||
for(var i=0;i<total;i++){
|
||||
(function(idx){
|
||||
var btn=document.getElementById('${tabId}_btn_'+idx);
|
||||
btn.addEventListener('keydown', function(e){
|
||||
var next=null;
|
||||
if(e.key==='ArrowRight'){ next=(idx+1)%total; }
|
||||
else if(e.key==='ArrowLeft'){ next=(idx-1+total)%total; }
|
||||
else if(e.key==='Home'){ next=0; }
|
||||
else if(e.key==='End'){ next=total-1; }
|
||||
if(next!==null){
|
||||
e.preventDefault();
|
||||
${tabId}_switch(next);
|
||||
document.getElementById('${tabId}_btn_'+next).focus();
|
||||
}
|
||||
});
|
||||
})(i);
|
||||
}
|
||||
})();
|
||||
</script>`;
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:800px;margin:0 auto">
|
||||
<div style="display:flex;gap:2px;border-bottom:2px solid #e2e8f0">
|
||||
<div role="tablist" style="display:flex;gap:2px;border-bottom:2px solid #e2e8f0">
|
||||
${tabButtons}
|
||||
</div>
|
||||
${tabPanels}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Testimonials } from './Testimonials';
|
||||
|
||||
const toHtml = (Testimonials as any).toHtml;
|
||||
|
||||
const testimonials = [
|
||||
{ quote: 'Quote one', name: 'Name One', title: 'Title One', rating: 5 },
|
||||
{ quote: 'Quote two', name: 'Name Two', title: 'Title Two', rating: 4 },
|
||||
{ quote: 'Quote three', name: 'Name Three', title: 'Title Three', rating: 3 },
|
||||
];
|
||||
|
||||
describe('Testimonials.toHtml single-layout export parity', () => {
|
||||
// The editor's "single" layout shows exactly one testimonial (a single
|
||||
// card, no stacked list). Static-parity fix: toHtml exports exactly one
|
||||
// card too (the first testimonial), matching what the editor displays by
|
||||
// default -- not a stacked list of all testimonials, and not a JS carousel
|
||||
// (this codebase's static export has no published-JS interactivity for
|
||||
// this component).
|
||||
test('single layout: exports exactly one testimonial card, not all of them', () => {
|
||||
const { html } = toHtml({ testimonials, layout: 'single' }, '');
|
||||
expect(html).toContain('Name One');
|
||||
expect(html).not.toContain('Name Two');
|
||||
expect(html).not.toContain('Name Three');
|
||||
expect(html).toContain('Quote one');
|
||||
});
|
||||
|
||||
test('single layout: no carousel controls (prev/next/dots) in static export', () => {
|
||||
const { html } = toHtml({ testimonials, layout: 'single' }, '');
|
||||
expect(html).not.toContain('fa-chevron-left');
|
||||
expect(html).not.toContain('fa-chevron-right');
|
||||
});
|
||||
|
||||
test('grid layout: still exports all testimonials (unchanged behavior)', () => {
|
||||
const { html } = toHtml({ testimonials, layout: 'grid' }, '');
|
||||
expect(html).toContain('Name One');
|
||||
expect(html).toContain('Name Two');
|
||||
expect(html).toContain('Name Three');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Testimonials.toHtml decorative star icons (F2.5)', () => {
|
||||
test('star glyphs are aria-hidden', () => {
|
||||
const { html } = toHtml({ testimonials, layout: 'grid' }, '');
|
||||
const stars = html.match(/<i class="fa fa-star[^"]*"[^>]*>/g) || [];
|
||||
expect(stars.length).toBeGreaterThan(0);
|
||||
stars.forEach((tag: string) => expect(tag).toContain('aria-hidden="true"'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Testimonials.toHtml rating aria-label sink (attacker-controlled `rating`, typed number but unchecked)', () => {
|
||||
test('malicious rating value cannot break out of the star row aria-label attribute', () => {
|
||||
const malicious = [
|
||||
{ quote: 'Q', name: 'N', title: 'T', rating: '5"><script>alert(1)</script>' as any },
|
||||
];
|
||||
const { html } = toHtml({ testimonials: malicious, layout: 'grid' }, '');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
expect(html).not.toContain('5"><script>');
|
||||
});
|
||||
|
||||
test('non-numeric rating falls back to a safe numeric value', () => {
|
||||
const malicious = [
|
||||
{ quote: 'Q', name: 'N', title: 'T', rating: 'not-a-number' as any },
|
||||
];
|
||||
const { html } = toHtml({ testimonials: malicious, layout: 'grid' }, '');
|
||||
expect(html).toMatch(/aria-label="Rating: 0 out of 5"/);
|
||||
});
|
||||
|
||||
test('normal numeric rating still renders correctly', () => {
|
||||
const { html } = toHtml({ testimonials, layout: 'grid' }, '');
|
||||
expect(html).toContain('aria-label="Rating: 5 out of 5"');
|
||||
expect(html).toContain('aria-label="Rating: 4 out of 5"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Testimonials.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||
const props = (Testimonials as any).craft.props;
|
||||
expect(props).toHaveProperty('animation', '');
|
||||
expect(props).toHaveProperty('animationDelay', '');
|
||||
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||
expect(props).toHaveProperty('hideOnTablet', false);
|
||||
expect(props).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { CSSProperties, useState } from 'react';
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, cssValue } from '../../utils/escape';
|
||||
|
||||
interface Testimonial {
|
||||
quote: string;
|
||||
@@ -16,6 +17,12 @@ interface TestimonialsProps {
|
||||
style?: CSSProperties;
|
||||
cardBg?: string;
|
||||
starColor?: string;
|
||||
anchorId?: string;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const defaultTestimonials: Testimonial[] = [
|
||||
@@ -39,10 +46,16 @@ function renderStars(count: number, color: string): React.ReactNode {
|
||||
}
|
||||
|
||||
function starsHtml(count: number, color: string): string {
|
||||
// `count` is `Testimonial.rating`, typed `number` but arrives unchecked via
|
||||
// AI update_props / deserialized state -- coerce to a real number before
|
||||
// it's interpolated into the aria-label attribute below (both the loop
|
||||
// comparison and the escapeAttr(String(...)) call are safe against any
|
||||
// non-numeric/garbage value).
|
||||
const safeCount = Number(count) || 0;
|
||||
const stars = [1, 2, 3, 4, 5].map((i) =>
|
||||
`<i class="fa ${i <= count ? 'fa-star' : 'fa-star-o'}" style="color:${color};font-size:14px"></i>`
|
||||
`<i class="fa ${i <= safeCount ? 'fa-star' : 'fa-star-o'}" style="color:${color};font-size:14px" aria-hidden="true"></i>`
|
||||
).join('');
|
||||
return `<div style="display:flex;gap:2px;justify-content:center;margin-bottom:12px">${stars}</div>`;
|
||||
return `<div style="display:flex;gap:2px;justify-content:center;margin-bottom:12px" role="img" aria-label="${escapeAttr(`Rating: ${safeCount} out of 5`)}">${stars}</div>`;
|
||||
}
|
||||
|
||||
export const Testimonials: UserComponent<TestimonialsProps> = ({
|
||||
@@ -52,6 +65,7 @@ export const Testimonials: UserComponent<TestimonialsProps> = ({
|
||||
style = {},
|
||||
cardBg = '#f8fafc',
|
||||
starColor = '#f59e0b',
|
||||
anchorId,
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
@@ -60,8 +74,6 @@ export const Testimonials: UserComponent<TestimonialsProps> = ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
const cardStyle: CSSProperties = {
|
||||
backgroundColor: cardBg,
|
||||
borderRadius: '12px',
|
||||
@@ -86,6 +98,7 @@ export const Testimonials: UserComponent<TestimonialsProps> = ({
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
padding: '80px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
@@ -99,44 +112,11 @@ export const Testimonials: UserComponent<TestimonialsProps> = ({
|
||||
{items.map((t, i) => renderCard(t, i))}
|
||||
</div>
|
||||
) : (
|
||||
// Static single testimonial (parity with the static toHtml export --
|
||||
// no carousel controls, since the published site has no JS for this
|
||||
// component). Always shows the first testimonial.
|
||||
<div style={{ maxWidth: '600px', margin: '0 auto', position: 'relative' }}>
|
||||
{renderCard(items[currentIndex] || items[0], currentIndex)}
|
||||
{items.length > 1 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '12px', marginTop: '20px' }}>
|
||||
<button
|
||||
onClick={() => setCurrentIndex((prev) => (prev - 1 + items.length) % items.length)}
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: '50%', border: '1px solid #d1d5db',
|
||||
background: '#ffffff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 14, color: '#374151',
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-chevron-left" />
|
||||
</button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
{items.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => setCurrentIndex(i)}
|
||||
style={{
|
||||
width: 8, height: 8, borderRadius: '50%', cursor: 'pointer',
|
||||
backgroundColor: i === currentIndex ? '#3b82f6' : '#d1d5db',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCurrentIndex((prev) => (prev + 1) % items.length)}
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: '50%', border: '1px solid #d1d5db',
|
||||
background: '#ffffff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 14, color: '#374151',
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{renderCard(items[0], 0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -144,208 +124,6 @@ export const Testimonials: UserComponent<TestimonialsProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const TestimonialsSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as TestimonialsProps,
|
||||
}));
|
||||
|
||||
const items = props.testimonials || defaultTestimonials;
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
|
||||
const updateTestimonial = (index: number, field: keyof Testimonial, value: string | number) => {
|
||||
setProp((p: TestimonialsProps) => {
|
||||
const updated = [...(p.testimonials || defaultTestimonials)];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
p.testimonials = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addTestimonial = () => {
|
||||
setProp((p: TestimonialsProps) => {
|
||||
p.testimonials = [...(p.testimonials || defaultTestimonials), { quote: 'Great experience!', name: 'New Person', title: 'Role', rating: 5 }];
|
||||
});
|
||||
};
|
||||
|
||||
const removeTestimonial = (index: number) => {
|
||||
setProp((p: TestimonialsProps) => {
|
||||
const updated = [...(p.testimonials || defaultTestimonials)];
|
||||
updated.splice(index, 1);
|
||||
p.testimonials = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'];
|
||||
const cardBgPresets = ['#f8fafc', '#ffffff', '#f1f5f9', '#e2e8f0', '#27272a', '#1e293b'];
|
||||
const starColorPresets = ['#f59e0b', '#eab308', '#ef4444', '#3b82f6', '#10b981', '#8b5cf6'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{/* Layout */}
|
||||
<div>
|
||||
<label style={labelStyle}>Layout</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
onClick={() => setProp((p: TestimonialsProps) => { p.layout = 'grid'; })}
|
||||
style={{
|
||||
flex: 1, padding: '6px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.layout === 'grid' ? '#3b82f6' : '#27272a',
|
||||
color: props.layout === 'grid' ? '#fff' : '#a1a1aa',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Grid
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setProp((p: TestimonialsProps) => { p.layout = 'single'; })}
|
||||
style={{
|
||||
flex: 1, padding: '6px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.layout === 'single' ? '#3b82f6' : '#27272a',
|
||||
color: props.layout === 'single' ? '#fff' : '#a1a1aa',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Single
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Columns (only for grid) */}
|
||||
{props.layout === 'grid' && (
|
||||
<div>
|
||||
<label style={labelStyle}>Columns</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{[1, 2, 3, 4].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setProp((p: TestimonialsProps) => { p.columns = n; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.columns === n ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section background */}
|
||||
<div>
|
||||
<label style={labelStyle}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: TestimonialsProps) => { p.style = { ...p.style, backgroundColor: c }; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card background */}
|
||||
<div>
|
||||
<label style={labelStyle}>Card Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{cardBgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: TestimonialsProps) => { p.cardBg = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.cardBg === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Star color */}
|
||||
<div>
|
||||
<label style={labelStyle}>Star Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{starColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: TestimonialsProps) => { p.starColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.starColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Testimonials list */}
|
||||
<div>
|
||||
<label style={labelStyle}>Testimonials</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{items.map((t, i) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input type="text" value={t.name} onChange={(e) => updateTestimonial(i, 'name', e.target.value)} placeholder="Name" style={{ ...inputStyle, flex: 1 }} />
|
||||
<button
|
||||
onClick={() => removeTestimonial(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
<input type="text" value={t.title} onChange={(e) => updateTestimonial(i, 'title', e.target.value)} placeholder="Title/Role" style={inputStyle} />
|
||||
<textarea
|
||||
value={t.quote}
|
||||
onChange={(e) => updateTestimonial(i, 'quote', e.target.value)}
|
||||
placeholder="Quote..."
|
||||
rows={2}
|
||||
style={{ ...inputStyle, resize: 'vertical' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ fontSize: 11, color: '#a1a1aa' }}>Rating:</span>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<i
|
||||
key={n}
|
||||
className={`fa ${n <= t.rating ? 'fa-star' : 'fa-star-o'}`}
|
||||
onClick={() => updateTestimonial(i, 'rating', n)}
|
||||
style={{ color: '#f59e0b', cursor: 'pointer', fontSize: 14 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addTestimonial}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Testimonial
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Testimonials.craft = {
|
||||
@@ -357,29 +135,36 @@ Testimonials.craft = {
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
cardBg: '#f8fafc',
|
||||
starColor: '#f59e0b',
|
||||
anchorId: '',
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: TestimonialsSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Testimonials as any).toHtml = (props: TestimonialsProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const {
|
||||
testimonials = defaultTestimonials,
|
||||
layout = 'grid',
|
||||
columns = 3,
|
||||
style = {},
|
||||
cardBg = '#f8fafc',
|
||||
starColor = '#f59e0b',
|
||||
} = props;
|
||||
// Number() coercion: `columns` is a raw string-interpolation sink into the
|
||||
// grid style attribute below (repeat(${columns},1fr)); Number() of
|
||||
// anything non-numeric collapses safely to NaN instead of breaking out.
|
||||
const columns = Number(props.columns) || 3;
|
||||
// Sanitized -- raw string-interpolation sinks below (cardCss / starsHtml
|
||||
// style attributes).
|
||||
const cardBg = cssValue(props.cardBg) || '#f8fafc';
|
||||
const starColor = cssValue(props.starColor) || '#f59e0b';
|
||||
|
||||
const items = testimonials.length > 0 ? testimonials : defaultTestimonials;
|
||||
|
||||
@@ -388,31 +173,34 @@ Testimonials.craft = {
|
||||
backgroundColor: '#ffffff',
|
||||
...style,
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
|
||||
const cardCss = `background-color:${cardBg};border-radius:12px;padding:32px 24px;text-align:center;border:1px solid #e2e8f0`;
|
||||
|
||||
const cards = items.map((t) => {
|
||||
return `<div style="${cardCss}">
|
||||
const cardHtml = (t: Testimonial): string => `<div style="${cardCss}">
|
||||
${starsHtml(t.rating, starColor)}
|
||||
<p style="font-size:15px;color:#374151;line-height:1.7;margin-bottom:16px;font-style:italic;font-family:Inter,sans-serif">“${esc(t.quote)}”</p>
|
||||
<div style="font-weight:600;font-size:14px;color:#18181b;font-family:Inter,sans-serif">${esc(t.name)}</div>
|
||||
<div style="font-size:13px;color:#64748b;font-family:Inter,sans-serif">${esc(t.title)}</div>
|
||||
<p style="font-size:15px;color:#374151;line-height:1.7;margin-bottom:16px;font-style:italic;font-family:Inter,sans-serif">“${escapeHtml(t.quote)}”</p>
|
||||
<div style="font-weight:600;font-size:14px;color:#18181b;font-family:Inter,sans-serif">${escapeHtml(t.name)}</div>
|
||||
<div style="font-size:13px;color:#64748b;font-family:Inter,sans-serif">${escapeHtml(t.title)}</div>
|
||||
</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
if (layout === 'single') {
|
||||
// For single layout, export as grid with 1 column (simpler static export)
|
||||
// Static parity with the editor's single-layout render: exactly ONE
|
||||
// testimonial card (the first), no carousel controls -- the published
|
||||
// export has no JS for this component.
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:600px;margin:0 auto;display:grid;grid-template-columns:1fr;gap:24px">
|
||||
${cards}
|
||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:600px;margin:0 auto">
|
||||
${cardHtml(items[0])}
|
||||
</div>
|
||||
</section>`,
|
||||
};
|
||||
}
|
||||
|
||||
const cards = items.map(cardHtml).join('\n ');
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:24px">
|
||||
${cards}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { escapeHtml, escapeAttr, safeUrl, cssValue } from '../../utils/escape';
|
||||
|
||||
export type CtaVariant = 'primary' | 'outline' | 'ghost';
|
||||
|
||||
export interface CtaButton {
|
||||
text: string;
|
||||
href: string;
|
||||
variant?: CtaVariant;
|
||||
target?: '_blank';
|
||||
}
|
||||
|
||||
export interface CtaStyleDefaults {
|
||||
primaryBg: string;
|
||||
primaryText: string;
|
||||
outlineText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the effective list of CTAs for a section, falling back to legacy
|
||||
* primary/secondary props when ctas[] is absent. New sections write ctas[]
|
||||
* directly; old sections keep rendering until the user touches the settings.
|
||||
*/
|
||||
export function normalizeCtas(props: {
|
||||
ctas?: CtaButton[];
|
||||
buttonText?: string;
|
||||
buttonHref?: string;
|
||||
secondaryButtonText?: string;
|
||||
secondaryButtonHref?: string;
|
||||
}): CtaButton[] {
|
||||
if (Array.isArray(props.ctas) && props.ctas.length > 0) {
|
||||
return props.ctas.filter((c) => c && (c.text || c.href));
|
||||
}
|
||||
const legacy: CtaButton[] = [];
|
||||
if (props.buttonText) legacy.push({ text: props.buttonText, href: props.buttonHref || '#', variant: 'primary' });
|
||||
if (props.secondaryButtonText) legacy.push({ text: props.secondaryButtonText, href: props.secondaryButtonHref || '#', variant: 'outline' });
|
||||
return legacy;
|
||||
}
|
||||
|
||||
export function ctaInlineStyle(cta: CtaButton, defaults: CtaStyleDefaults): CSSProperties {
|
||||
const variant = cta.variant || 'primary';
|
||||
switch (variant) {
|
||||
case 'outline':
|
||||
return {
|
||||
display: 'inline-block', padding: '14px 36px',
|
||||
backgroundColor: 'transparent', color: defaults.outlineText,
|
||||
textDecoration: 'none', borderRadius: '8px',
|
||||
fontWeight: 600, fontSize: '16px',
|
||||
border: `2px solid ${defaults.outlineText}`,
|
||||
};
|
||||
case 'ghost':
|
||||
return {
|
||||
display: 'inline-block', padding: '14px 24px',
|
||||
backgroundColor: 'transparent', color: defaults.outlineText,
|
||||
textDecoration: 'underline', borderRadius: '8px',
|
||||
fontWeight: 600, fontSize: '16px',
|
||||
};
|
||||
case 'primary':
|
||||
default:
|
||||
return {
|
||||
display: 'inline-block', padding: '14px 36px',
|
||||
backgroundColor: defaults.primaryBg, color: defaults.primaryText,
|
||||
textDecoration: 'none', borderRadius: '8px',
|
||||
fontWeight: 600, fontSize: '16px',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function ctaCssString(cta: CtaButton, defaults: CtaStyleDefaults): string {
|
||||
const variant = cta.variant || 'primary';
|
||||
// Sanitized -- these are raw string-interpolation sinks into style="...".
|
||||
// Callers pass user-controlled design-token colors (e.g. HeroSimple's
|
||||
// buttonBgColor/buttonTextColor/textColor) through CtaStyleDefaults, so
|
||||
// sanitize once here rather than at every call site.
|
||||
const outlineText = cssValue(defaults.outlineText) || '#000000';
|
||||
const primaryBg = cssValue(defaults.primaryBg) || '#000000';
|
||||
const primaryText = cssValue(defaults.primaryText) || '#ffffff';
|
||||
switch (variant) {
|
||||
case 'outline':
|
||||
return `display:inline-block;padding:14px 36px;background-color:transparent;color:${outlineText};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px;border:2px solid ${outlineText}`;
|
||||
case 'ghost':
|
||||
return `display:inline-block;padding:14px 24px;background-color:transparent;color:${outlineText};text-decoration:underline;border-radius:8px;font-weight:600;font-size:16px`;
|
||||
case 'primary':
|
||||
default:
|
||||
return `display:inline-block;padding:14px 36px;background-color:${primaryBg};color:${primaryText};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px`;
|
||||
}
|
||||
}
|
||||
|
||||
export function ctasToHtml(ctas: CtaButton[], defaults: CtaStyleDefaults): string {
|
||||
return ctas.map((c) => {
|
||||
const target = c.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||
return `<a href="${escapeAttr(safeUrl(c.href || '#'))}"${target} style="${ctaCssString(c, defaults)}">${escapeHtml(c.text || '')}</a>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { ButtonLink } from './basic/ButtonLink';
|
||||
import { Icon } from './basic/Icon';
|
||||
import { SocialLinks } from './basic/SocialLinks';
|
||||
import { Logo } from './basic/Logo';
|
||||
import { Menu } from './basic/Menu';
|
||||
import { Navbar } from './basic/Navbar';
|
||||
import { ContentSlider } from './sections/ContentSlider';
|
||||
import { FeaturesGrid } from './sections/FeaturesGrid';
|
||||
import { PricingTable } from './sections/PricingTable';
|
||||
import { ImageBlock } from './media/ImageBlock';
|
||||
import { VideoBlock } from './media/VideoBlock';
|
||||
import { Gallery } from './sections/Gallery';
|
||||
import { BackgroundSection } from './layout/BackgroundSection';
|
||||
import { HeroSimple } from './sections/HeroSimple';
|
||||
import { CallToAction } from './sections/CallToAction';
|
||||
import { MapEmbed } from './media/MapEmbed';
|
||||
import { FormContainer } from './forms/FormContainer';
|
||||
|
||||
const XSS = 'javascript:alert(1)';
|
||||
const QUOTE_BREAKOUT = 'x" onerror="alert(1)"';
|
||||
|
||||
function toHtmlOf(Component: any) {
|
||||
return Component.toHtml as (props: any, childrenHtml: string) => { html: string };
|
||||
}
|
||||
|
||||
describe('A3: exported URLs are wrapped in safeUrl + escapeAttr', () => {
|
||||
test('ButtonLink href', () => {
|
||||
const html = toHtmlOf(ButtonLink)({ href: XSS, text: 'Go' }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
const html2 = toHtmlOf(ButtonLink)({ href: QUOTE_BREAKOUT, text: 'Go' }, '').html;
|
||||
expect(html2).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('Icon href (link), class is escaped but NOT safeUrl-filtered', () => {
|
||||
const html = toHtmlOf(Icon)({ link: XSS, icon: 'fa-star' }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
const html2 = toHtmlOf(Icon)({ link: QUOTE_BREAKOUT, icon: 'fa-star' }, '').html;
|
||||
expect(html2).not.toContain('onerror="alert(1)"');
|
||||
// class attribute is a CSS class, not a URL -- still escaped for attr safety
|
||||
const html3 = toHtmlOf(Icon)({ icon: 'fa-star" onerror="alert(1)' }, '').html;
|
||||
expect(html3).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('SocialLinks href', () => {
|
||||
const html = toHtmlOf(SocialLinks)({ links: [{ platform: 'facebook', url: XSS }] }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
const html2 = toHtmlOf(SocialLinks)({ links: [{ platform: 'facebook', url: QUOTE_BREAKOUT }] }, '').html;
|
||||
expect(html2).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('Logo href + image src', () => {
|
||||
const html = toHtmlOf(Logo)({ href: XSS, type: 'text', text: 'Site' }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
const html2 = toHtmlOf(Logo)({ type: 'image', imageSrc: XSS }, '').html;
|
||||
expect(html2).not.toContain('javascript:');
|
||||
const html3 = toHtmlOf(Logo)({ type: 'image', imageSrc: QUOTE_BREAKOUT }, '').html;
|
||||
expect(html3).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('Menu link href', () => {
|
||||
const html = toHtmlOf(Menu)({ links: [{ text: 'x', href: XSS }] }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
const html2 = toHtmlOf(Menu)({ links: [{ text: 'x', href: QUOTE_BREAKOUT }] }, '').html;
|
||||
expect(html2).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('Navbar logo href, logo image src, link hrefs', () => {
|
||||
const html = toHtmlOf(Navbar)({ logoUrl: XSS, logoType: 'text', logoText: 'Site', links: [{ text: 'x', href: XSS }] }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
const html2 = toHtmlOf(Navbar)({ logoType: 'image', logoImage: XSS }, '').html;
|
||||
expect(html2).not.toContain('javascript:');
|
||||
const html3 = toHtmlOf(Navbar)({ logoUrl: QUOTE_BREAKOUT, links: [{ text: 'x', href: QUOTE_BREAKOUT }] }, '').html;
|
||||
expect(html3).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('ContentSlider button href + slide image src (background url)', () => {
|
||||
const html = toHtmlOf(ContentSlider)({ slides: [{ buttonText: 'Go', buttonHref: XSS, imageSrc: '' }] }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
const html2 = toHtmlOf(ContentSlider)({ slides: [{ imageSrc: XSS }] }, '').html;
|
||||
expect(html2).not.toContain('javascript:');
|
||||
const html3 = toHtmlOf(ContentSlider)({ slides: [{ buttonText: 'Go', buttonHref: QUOTE_BREAKOUT }] }, '').html;
|
||||
expect(html3).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('FeaturesGrid image src + button url', () => {
|
||||
const html = toHtmlOf(FeaturesGrid)({ features: [{ title: 't', description: 'd', image: XSS }] }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
const html2 = toHtmlOf(FeaturesGrid)({ features: [{ title: 't', description: 'd', buttonText: 'Go', buttonUrl: XSS }] }, '').html;
|
||||
expect(html2).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
test('PricingTable button href', () => {
|
||||
const html = toHtmlOf(PricingTable)({ plans: [{ name: 'p', price: '$1', period: '/mo', features: [], buttonText: 'Buy', buttonHref: XSS }] }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
const html2 = toHtmlOf(PricingTable)({ plans: [{ name: 'p', price: '$1', period: '/mo', features: [], buttonText: 'Buy', buttonHref: QUOTE_BREAKOUT }] }, '').html;
|
||||
expect(html2).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('ImageBlock img src', () => {
|
||||
const html = toHtmlOf(ImageBlock)({ src: XSS }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
const html2 = toHtmlOf(ImageBlock)({ src: QUOTE_BREAKOUT }, '').html;
|
||||
expect(html2).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('VideoBlock direct-file src is safeUrl-filtered on final emitted src', () => {
|
||||
// matches the .mp4 extension sniff in detectVideoType but carries a javascript: scheme
|
||||
const html = toHtmlOf(VideoBlock)({ videoUrl: 'javascript:alert(1)//x.mp4' }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
const html2 = toHtmlOf(VideoBlock)({ videoUrl: '"><script>alert(1)</script>.mp4' }, '').html;
|
||||
expect(html2).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('VideoBlock youtube embed still works after safeUrl pass', () => {
|
||||
const html = toHtmlOf(VideoBlock)({ videoUrl: 'https://www.youtube.com/watch?v=abc123' }, '').html;
|
||||
expect(html).toContain('https://www.youtube.com/embed/abc123');
|
||||
});
|
||||
|
||||
test('Gallery img src', () => {
|
||||
const html = toHtmlOf(Gallery)({ images: [{ src: XSS, alt: 'a' }] }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
const html2 = toHtmlOf(Gallery)({ images: [{ src: QUOTE_BREAKOUT, alt: 'a' }] }, '').html;
|
||||
expect(html2).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('BackgroundSection bg image url()', () => {
|
||||
const html = toHtmlOf(BackgroundSection)({ bgImage: XSS }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
test('HeroSimple bg image url() and bg video src', () => {
|
||||
const html = toHtmlOf(HeroSimple)({ bgType: 'image', bgImage: XSS }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
const html2 = toHtmlOf(HeroSimple)({ bgType: 'video', bgVideo: XSS }, '').html;
|
||||
expect(html2).not.toContain('javascript:');
|
||||
const html3 = toHtmlOf(HeroSimple)({ bgType: 'video', bgVideo: QUOTE_BREAKOUT }, '').html;
|
||||
expect(html3).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('CallToAction bg image url()', () => {
|
||||
const html = toHtmlOf(CallToAction)({ bgType: 'image', bgValue: XSS }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
test('MapEmbed iframe src (found beyond the brief-listed sites via grep sweep)', () => {
|
||||
const html = toHtmlOf(MapEmbed)({ address: 'New York, NY', zoom: 14 }, '').html;
|
||||
expect(html).toContain('maps.google.com');
|
||||
expect(html).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
test('FormContainer legacy form action is safeUrl-filtered (found via grep sweep)', () => {
|
||||
const html = toHtmlOf(FormContainer)({ action: XSS, method: 'GET' }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
// non-relay legacy path (no recipientEmail) still works normally
|
||||
const html2 = toHtmlOf(FormContainer)({ action: '/legacy', method: 'POST' }, '').html;
|
||||
expect(html2).toContain('action="/legacy"');
|
||||
});
|
||||
});
|
||||
@@ -94,3 +94,68 @@ export const DEVICE_WIDTHS: Record<string, string> = {
|
||||
tablet: '768px',
|
||||
mobile: '375px',
|
||||
};
|
||||
|
||||
/* ---------- ENH-Foundation shared presets ----------
|
||||
Consumed by the reusable StylePanel controls in
|
||||
src/panels/right/styles/shared.tsx (SizeControl, AspectRatioControl,
|
||||
BorderControl, etc). Kept in the `{ label, value }` shape used throughout
|
||||
this file so they drop straight into PresetButtonGrid / ColorSwatchGrid. */
|
||||
|
||||
// Width/height preset row used by SizeControl (image + video sizing).
|
||||
export const SIZE_PRESETS = [
|
||||
{ label: '25%', value: '25%' },
|
||||
{ label: '50%', value: '50%' },
|
||||
{ label: '75%', value: '75%' },
|
||||
{ label: '100%', value: '100%' },
|
||||
{ label: 'Auto', value: 'auto' },
|
||||
{ label: 'Full', value: '100vw' },
|
||||
];
|
||||
|
||||
// CSS `aspect-ratio` values. 'Original' (empty string) clears the property.
|
||||
export const ASPECT_RATIOS = [
|
||||
{ label: 'Original', value: '' },
|
||||
{ label: '1:1', value: '1 / 1' },
|
||||
{ label: '4:3', value: '4 / 3' },
|
||||
{ label: '3:2', value: '3 / 2' },
|
||||
{ label: '16:9', value: '16 / 9' },
|
||||
{ label: '4:5', value: '4 / 5' },
|
||||
{ label: '9:16', value: '9 / 16' },
|
||||
];
|
||||
|
||||
// box-shadow presets; 'Colored' is the color-aware option (accent-tinted).
|
||||
export const SHADOW_PRESETS = [
|
||||
{ label: 'None', value: 'none' },
|
||||
{ label: 'S', value: '0 1px 2px rgba(0,0,0,0.08)' },
|
||||
{ label: 'M', value: '0 4px 8px rgba(0,0,0,0.12)' },
|
||||
{ label: 'L', value: '0 10px 24px rgba(0,0,0,0.18)' },
|
||||
{ label: 'Colored', value: '0 8px 20px rgba(59,130,246,0.35)' },
|
||||
];
|
||||
|
||||
export const LINE_HEIGHTS = [
|
||||
{ label: 'Tight', value: '1.1' },
|
||||
{ label: 'Snug', value: '1.25' },
|
||||
{ label: 'Normal', value: '1.5' },
|
||||
{ label: 'Relaxed', value: '1.75' },
|
||||
{ label: 'Loose', value: '2' },
|
||||
];
|
||||
|
||||
export const LETTER_SPACINGS = [
|
||||
{ label: 'Tight', value: '-0.02em' },
|
||||
{ label: 'Normal', value: 'normal' },
|
||||
{ label: 'Wide', value: '0.05em' },
|
||||
{ label: 'Wider', value: '0.1em' },
|
||||
];
|
||||
|
||||
export const OBJECT_FIT = [
|
||||
{ label: 'Cover', value: 'cover' },
|
||||
{ label: 'Contain', value: 'contain' },
|
||||
{ label: 'Fill', value: 'fill' },
|
||||
{ label: 'None', value: 'none' },
|
||||
];
|
||||
|
||||
export const BORDER_STYLES = [
|
||||
{ label: 'None', value: 'none' },
|
||||
{ label: 'Solid', value: 'solid' },
|
||||
{ label: 'Dashed', value: 'dashed' },
|
||||
{ label: 'Dotted', value: 'dotted' },
|
||||
];
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import React, { useMemo, useRef, useEffect } from 'react';
|
||||
import { Frame, Element } from '@craftjs/core';
|
||||
import { Frame, Element, useEditor } from '@craftjs/core';
|
||||
import { Container } from '../components/layout/Container';
|
||||
import { usePages } from '../state/PageContext';
|
||||
import { DeviceMode } from '../types';
|
||||
import { DEVICE_WIDTHS } from '../constants/presets';
|
||||
import { exportBodyHtml } from '../utils/html-export';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { useMobileChrome } from '../state/MobileChromeContext';
|
||||
|
||||
interface CanvasProps {
|
||||
device: DeviceMode;
|
||||
/** Item 10: when false, applies `.guides-off` to hide the dashed drop-target guides. */
|
||||
showGuides: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,9 +52,10 @@ const ZonePreview: React.FC<{ craftState: string | null; zone: 'header' | 'foote
|
||||
data-zone-preview={zone}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: 40,
|
||||
// Slim hint bar, not a content-height band — an empty zone should not
|
||||
// read as a stray spacer between the page and the header/footer.
|
||||
padding: '5px 12px',
|
||||
backgroundColor: zone === 'header' ? '#ffffff' : '#0f172a',
|
||||
padding: '12px 24px',
|
||||
color: zone === 'header' ? '#9ca3af' : '#64748b',
|
||||
textAlign: 'center',
|
||||
fontSize: 11,
|
||||
@@ -80,24 +85,93 @@ const ZonePreview: React.FC<{ craftState: string | null; zone: 'header' | 'foote
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-zone-preview={zone}
|
||||
className="zone-preview-sep"
|
||||
style={{
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
borderBottom: zone === 'header' ? '1px dashed rgba(245,158,11,0.3)' : 'none',
|
||||
borderTop: zone === 'footer' ? '1px dashed rgba(245,158,11,0.3)' : 'none',
|
||||
borderBottom: zone === 'header' ? '1px dashed rgba(148,163,184,0.25)' : 'none',
|
||||
borderTop: zone === 'footer' ? '1px dashed rgba(148,163,184,0.25)' : 'none',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Canvas: React.FC<CanvasProps> = ({ device }) => {
|
||||
/**
|
||||
* First-run hint shown over the canvas drop area once the current page's
|
||||
* root node exists and has no children yet. Hidden the instant something
|
||||
* is dropped in, and while a drag is in progress (so it never fights the
|
||||
* drop-target UI). `pointer-events: none` (see .empty-canvas-hint in
|
||||
* editor.css) keeps it from intercepting clicks/drops meant for the
|
||||
* underlying empty canvas.
|
||||
*/
|
||||
export const EmptyCanvasHint: React.FC = () => {
|
||||
const { isEmpty, isDragging } = useEditor((state) => {
|
||||
const root = state.nodes['ROOT'];
|
||||
return {
|
||||
isEmpty: !!root && root.data.nodes.length === 0,
|
||||
isDragging: state.events.dragged.size > 0,
|
||||
};
|
||||
});
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (!isEmpty || isDragging) return null;
|
||||
|
||||
return (
|
||||
<div className="empty-canvas-hint">
|
||||
<i className="fa fa-cubes" aria-hidden />
|
||||
<span>
|
||||
{isMobile
|
||||
? <>Tap <strong>Blocks</strong> below to add content, or pick a Template to start.</>
|
||||
: 'Drag blocks from the left panel, or pick a Template to start.'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Canvas: React.FC<CanvasProps> = ({ device, showGuides }) => {
|
||||
const width = DEVICE_WIDTHS[device];
|
||||
const { isEditingHeader, isEditingFooter, headerPage, footerPage } = usePages();
|
||||
|
||||
const isEditingRegularPage = !isEditingHeader && !isEditingFooter;
|
||||
|
||||
// Fast-follow item 4: while `MobileSelectionToolbar` is up (fixed, ~53px
|
||||
// tall, just above the tab bar), it covers whatever content was at the
|
||||
// very bottom of the canvas's own scroll area -- on a short page, the
|
||||
// last section could sit permanently under the toolbar with no way to
|
||||
// scroll past it. Mirrors that toolbar's own visibility condition
|
||||
// (`selectedId && activeSheet === null`) exactly, computed independently
|
||||
// here since Canvas has no other reason to depend on the toolbar
|
||||
// component itself.
|
||||
const isMobile = useIsMobile();
|
||||
const { activeSheet } = useMobileChrome();
|
||||
const { selectedId } = useEditor((state) => {
|
||||
const selected = state.events.selected;
|
||||
const id = selected && selected.size > 0 ? (Array.from(selected)[0] as string) : null;
|
||||
return { selectedId: id && id !== 'ROOT' ? id : null };
|
||||
});
|
||||
const mobileToolbarVisible = isMobile && !!selectedId && activeSheet === null;
|
||||
|
||||
// Fast-follow item 3: `MobileSelectionToolbar`'s "Style" tap scrolls the
|
||||
// selected node up towards the top of the canvas so it stays visible
|
||||
// above the Styles sheet (~65dvh tall) -- but that scroll is still bound
|
||||
// by the canvas's own natural scroll range. For a selected node near the
|
||||
// END of the content (very plausibly the last section on the page --
|
||||
// exactly the kind of node someone just added/duplicated and wants to
|
||||
// style), there may not be enough scrollable distance below it to bring
|
||||
// its top all the way up to the visible band above the sheet; the browser
|
||||
// simply clamps at its existing max scrollTop, leaving the node's top
|
||||
// stuck behind the sheet with nothing anyone can do about it (verified
|
||||
// live: the last section of a page landed under the sheet even after the
|
||||
// scroll-into-view ran). Pad the canvas with a full extra viewport's
|
||||
// worth of scroll room while the sheet is open with a selection, exactly
|
||||
// as `has-mobile-selection-toolbar` (item 4) already pads it for the
|
||||
// fixed toolbar -- generous enough that ANY node, including the very
|
||||
// last one, can always be scrolled with its top reaching the very top of
|
||||
// the canvas (comfortably within the "top ~30%" target).
|
||||
const mobileStylesSheetPad = isMobile && !!selectedId && activeSheet === 'styles';
|
||||
|
||||
const frameStyle = isEditingHeader
|
||||
? { minHeight: '60px', backgroundColor: '#ffffff', padding: '12px 24px', display: 'flex', alignItems: 'center' }
|
||||
: isEditingFooter
|
||||
@@ -107,9 +181,13 @@ export const Canvas: React.FC<CanvasProps> = ({ device }) => {
|
||||
const frameTag = isEditingHeader ? 'header' : isEditingFooter ? 'footer' : 'div';
|
||||
|
||||
return (
|
||||
<div className="editor-canvas">
|
||||
<div
|
||||
className="canvas-device-frame"
|
||||
className={`editor-canvas${mobileToolbarVisible ? ' has-mobile-selection-toolbar' : ''}${
|
||||
mobileStylesSheetPad ? ' has-mobile-styles-sheet' : ''
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`canvas-device-frame${showGuides ? '' : ' guides-off'}`}
|
||||
style={{
|
||||
width,
|
||||
maxWidth: '100%',
|
||||
@@ -139,6 +217,7 @@ export const Canvas: React.FC<CanvasProps> = ({ device }) => {
|
||||
<ZonePreview craftState={headerPage.craftState} zone="header" />
|
||||
)}
|
||||
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Frame>
|
||||
<Element
|
||||
is={Container}
|
||||
@@ -147,6 +226,8 @@ export const Canvas: React.FC<CanvasProps> = ({ device }) => {
|
||||
style={frameStyle}
|
||||
/>
|
||||
</Frame>
|
||||
{isEditingRegularPage && <EmptyCanvasHint />}
|
||||
</div>
|
||||
|
||||
{isEditingRegularPage && (
|
||||
<ZonePreview craftState={footerPage.craftState} zone="footer" />
|
||||
|
||||
@@ -1,16 +1,60 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { TopBar } from '../panels/topbar/TopBar';
|
||||
import { LeftPanel } from '../panels/left/LeftPanel';
|
||||
import { RightPanel } from '../panels/right/RightPanel';
|
||||
import { MobilePanelBar } from '../panels/mobile/MobilePanelBar';
|
||||
import { MobileSelectionToolbar } from '../panels/mobile/MobileSelectionToolbar';
|
||||
import { Canvas } from './Canvas';
|
||||
import { ContextMenu } from '../panels/context-menu/ContextMenu';
|
||||
import { useContextMenu } from '../hooks/useContextMenu';
|
||||
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { MobileChromeProvider } from '../state/MobileChromeContext';
|
||||
import { DeviceMode } from '../types';
|
||||
|
||||
const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides';
|
||||
|
||||
function loadShowGuides(): boolean {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(SHOW_GUIDES_STORAGE_KEY);
|
||||
return stored === null ? true : stored === '1';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export const EditorShell: React.FC = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const [device, setDevice] = useState<DeviceMode>('desktop');
|
||||
// Phase A: default the canvas to the "mobile" preview width the first
|
||||
// time we detect a mobile viewport, so the frame fits without the user
|
||||
// having to reach for the device switcher (now tucked in TopBar's
|
||||
// overflow menu on mobile). Only fires once, and only if the device is
|
||||
// still at its initial default -- it must not fight a device the user
|
||||
// has already picked (e.g. from the overflow menu) on a later re-render.
|
||||
const mobileDeviceAppliedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (isMobile && !mobileDeviceAppliedRef.current) {
|
||||
mobileDeviceAppliedRef.current = true;
|
||||
setDevice((current) => (current === 'desktop' ? 'mobile' : current));
|
||||
}
|
||||
}, [isMobile]);
|
||||
// Item 10: canvas dashed "guide" outlines toggle -- default ON, persisted
|
||||
// so the choice survives a reload. Lifted here (rather than owned by
|
||||
// TopBar or Canvas alone) because the toggle button lives in TopBar but
|
||||
// the `.guides-off` class it drives is applied to Canvas's
|
||||
// `.canvas-device-frame`, mirroring how `device` is already lifted for
|
||||
// the same reason.
|
||||
const [showGuides, setShowGuidesState] = useState<boolean>(loadShowGuides);
|
||||
const setShowGuides = useCallback((next: boolean) => {
|
||||
setShowGuidesState(next);
|
||||
try {
|
||||
window.localStorage.setItem(SHOW_GUIDES_STORAGE_KEY, next ? '1' : '0');
|
||||
} catch {
|
||||
// Storage unavailable (private browsing, etc.) -- in-memory state still works.
|
||||
}
|
||||
}, []);
|
||||
const { menuState, show: showMenu, hide: hideMenu } = useContextMenu();
|
||||
const { query } = useEditor();
|
||||
|
||||
@@ -33,15 +77,28 @@ export const EditorShell: React.FC = () => {
|
||||
}, [query, showMenu]);
|
||||
|
||||
return (
|
||||
// Mobile-A2: shared sheet/modal-open state (see MobileChromeContext) --
|
||||
// provided around the whole shell so TopBar's Templates/Head Code modal
|
||||
// state and MobilePanelBar's sheet state live in one place. Desktop
|
||||
// doesn't render MobilePanelBar and TopBar's desktop branch behaves
|
||||
// identically to before (same booleans, just sourced from context).
|
||||
<MobileChromeProvider>
|
||||
<div className="editor-app">
|
||||
<TopBar device={device} onDeviceChange={setDevice} />
|
||||
<TopBar
|
||||
device={device}
|
||||
onDeviceChange={setDevice}
|
||||
showGuides={showGuides}
|
||||
onToggleGuides={() => setShowGuides(!showGuides)}
|
||||
/>
|
||||
<div className="editor-container">
|
||||
<LeftPanel />
|
||||
{!isMobile && <LeftPanel />}
|
||||
<div onContextMenu={handleContextMenu} style={{ flex: 1, display: 'flex', minWidth: 0 }}>
|
||||
<Canvas device={device} />
|
||||
<Canvas device={device} showGuides={showGuides} />
|
||||
</div>
|
||||
<RightPanel />
|
||||
{!isMobile && <RightPanel />}
|
||||
</div>
|
||||
{isMobile && <MobilePanelBar />}
|
||||
{isMobile && <MobileSelectionToolbar />}
|
||||
<ContextMenu
|
||||
visible={menuState.visible}
|
||||
x={menuState.x}
|
||||
@@ -50,5 +107,6 @@ export const EditorShell: React.FC = () => {
|
||||
onClose={hideMenu}
|
||||
/>
|
||||
</div>
|
||||
</MobileChromeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
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';
|
||||
|
||||
/* Same DOM-harness pattern as Footer.editguard.test.tsx: mock @craftjs/core's
|
||||
useEditor so we can drive editor state without a real <Editor> tree. */
|
||||
let mockNodes: Record<string, { data: { nodes: string[] } }> = {};
|
||||
let mockDraggedSize = 0;
|
||||
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useEditor: (collect: (state: any) => any) =>
|
||||
collect({
|
||||
nodes: mockNodes,
|
||||
events: { dragged: { size: mockDraggedSize } },
|
||||
}),
|
||||
}));
|
||||
|
||||
import { EmptyCanvasHint } from './Canvas';
|
||||
|
||||
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(() => {
|
||||
mockNodes = {};
|
||||
mockDraggedSize = 0;
|
||||
});
|
||||
|
||||
describe('EmptyCanvasHint', () => {
|
||||
test('renders nothing before ROOT has mounted (no root node yet)', () => {
|
||||
render(<EmptyCanvasHint />);
|
||||
expect(container.querySelector('.empty-canvas-hint')).toBeNull();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test('renders the hint once ROOT exists with zero children', () => {
|
||||
mockNodes = { ROOT: { data: { nodes: [] } } };
|
||||
render(<EmptyCanvasHint />);
|
||||
expect(container.querySelector('.empty-canvas-hint')).not.toBeNull();
|
||||
expect(container.textContent).toContain('Drag blocks from the left panel');
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test('hides once the page has content', () => {
|
||||
mockNodes = { ROOT: { data: { nodes: ['node-1'] } } };
|
||||
render(<EmptyCanvasHint />);
|
||||
expect(container.querySelector('.empty-canvas-hint')).toBeNull();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test('hides while a drag is in progress, even on an empty root', () => {
|
||||
mockNodes = { ROOT: { data: { nodes: [] } } };
|
||||
mockDraggedSize = 1;
|
||||
render(<EmptyCanvasHint />);
|
||||
expect(container.querySelector('.empty-canvas-hint')).toBeNull();
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
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';
|
||||
|
||||
/* Same DOM-harness pattern as Footer.editguard.test.tsx: mock @craftjs/core
|
||||
so RenderNode (the <Editor onRender> override) can be driven without a
|
||||
real Editor tree. document.body doubles as the portal target, same as
|
||||
the component itself uses. */
|
||||
let mockNode: {
|
||||
id: string;
|
||||
selected: boolean;
|
||||
hovered: boolean;
|
||||
dom: HTMLElement | null;
|
||||
displayName: string;
|
||||
parent: string | null;
|
||||
isCanvas: boolean;
|
||||
};
|
||||
const selectNodeSpy = vi.fn();
|
||||
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useEditor: () => ({ actions: { selectNode: selectNodeSpy } }),
|
||||
useNode: (collect?: (node: any) => any) => {
|
||||
const node = {
|
||||
events: { selected: mockNode.selected, hovered: mockNode.hovered },
|
||||
dom: mockNode.dom,
|
||||
data: {
|
||||
custom: {},
|
||||
displayName: mockNode.displayName,
|
||||
parent: mockNode.parent,
|
||||
isCanvas: mockNode.isCanvas,
|
||||
},
|
||||
};
|
||||
return { id: mockNode.id, ...(collect ? collect(node) : {}) };
|
||||
},
|
||||
}));
|
||||
|
||||
import { RenderNode } from './RenderNode';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let nodeDom: HTMLElement;
|
||||
|
||||
function render(ui: React.ReactElement) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(ui);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
nodeDom = document.createElement('div');
|
||||
document.body.appendChild(nodeDom);
|
||||
mockNode = {
|
||||
id: 'node-1',
|
||||
selected: false,
|
||||
hovered: false,
|
||||
dom: nodeDom,
|
||||
displayName: 'Heading',
|
||||
parent: 'ROOT',
|
||||
isCanvas: false,
|
||||
};
|
||||
selectNodeSpy.mockClear();
|
||||
});
|
||||
|
||||
const rendered = <span data-testid="inner">hello</span>;
|
||||
|
||||
describe('RenderNode (Editor onRender override)', () => {
|
||||
test('passes render through untouched when not selected', () => {
|
||||
render(<RenderNode render={rendered} />);
|
||||
expect(container.querySelector('[data-testid="inner"]')).not.toBeNull();
|
||||
expect(document.querySelector('.component-indicator')).toBeNull();
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
});
|
||||
|
||||
test('shows the badge with the displayName when selected', () => {
|
||||
mockNode.selected = true;
|
||||
render(<RenderNode render={rendered} />);
|
||||
const badge = document.querySelector('.component-indicator');
|
||||
expect(badge).not.toBeNull();
|
||||
expect(badge?.textContent).toContain('Heading');
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
document.querySelector('.component-indicator')?.remove();
|
||||
});
|
||||
|
||||
test('never shows a badge for ROOT even if "selected"', () => {
|
||||
mockNode.selected = true;
|
||||
mockNode.id = 'ROOT';
|
||||
render(<RenderNode render={rendered} />);
|
||||
expect(document.querySelector('.component-indicator')).toBeNull();
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
});
|
||||
|
||||
test('chevron click selects the parent node', () => {
|
||||
mockNode.selected = true;
|
||||
mockNode.parent = 'parent-42';
|
||||
render(<RenderNode render={rendered} />);
|
||||
const chevron = document.querySelector('.component-indicator-parent-btn') as HTMLElement;
|
||||
expect(chevron).not.toBeNull();
|
||||
act(() => {
|
||||
chevron.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
});
|
||||
expect(selectNodeSpy).toHaveBeenCalledWith('parent-42');
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
document.querySelector('.component-indicator')?.remove();
|
||||
});
|
||||
|
||||
test('no chevron when there is no parent', () => {
|
||||
mockNode.selected = true;
|
||||
mockNode.parent = null;
|
||||
render(<RenderNode render={rendered} />);
|
||||
expect(document.querySelector('.component-indicator-parent-btn')).toBeNull();
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
document.querySelector('.component-indicator')?.remove();
|
||||
});
|
||||
|
||||
test('tags a droppable (isCanvas) node dom with data-craft-node', () => {
|
||||
mockNode.isCanvas = true;
|
||||
render(<RenderNode render={rendered} />);
|
||||
expect(nodeDom.hasAttribute('data-craft-node')).toBe(true);
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
});
|
||||
|
||||
test('does not tag a non-canvas (leaf) node dom with data-craft-node', () => {
|
||||
mockNode.isCanvas = false;
|
||||
render(<RenderNode render={rendered} />);
|
||||
expect(nodeDom.hasAttribute('data-craft-node')).toBe(false);
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
});
|
||||
|
||||
test('never tags ROOT with data-craft-node even though ROOT is a canvas', () => {
|
||||
mockNode.id = 'ROOT';
|
||||
mockNode.isCanvas = true;
|
||||
render(<RenderNode render={rendered} />);
|
||||
expect(nodeDom.hasAttribute('data-craft-node')).toBe(false);
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
});
|
||||
|
||||
test('tags the dom with data-craft-hovered when the Craft hovered event is set (Layers panel hover sync)', () => {
|
||||
mockNode.hovered = true;
|
||||
render(<RenderNode render={rendered} />);
|
||||
expect(nodeDom.hasAttribute('data-craft-hovered')).toBe(true);
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
});
|
||||
|
||||
test('removes data-craft-hovered once the hovered event clears', () => {
|
||||
mockNode.hovered = true;
|
||||
render(<RenderNode render={rendered} />);
|
||||
expect(nodeDom.hasAttribute('data-craft-hovered')).toBe(true);
|
||||
mockNode.hovered = false;
|
||||
act(() => {
|
||||
root.render(<RenderNode render={rendered} />);
|
||||
});
|
||||
expect(nodeDom.hasAttribute('data-craft-hovered')).toBe(false);
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useEditor, useNode } from '@craftjs/core';
|
||||
|
||||
interface RenderNodeProps {
|
||||
render: React.ReactElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Craft.js `<Editor onRender>` override -- wraps every node's render output.
|
||||
* For the currently-selected node it portals a floating badge (component
|
||||
* displayName + a "select parent" chevron) positioned over the node's real
|
||||
* DOM element. Non-selected nodes (the overwhelming majority) and ROOT pass
|
||||
* straight through as a Fragment, so this never touches layout, never
|
||||
* appears in `toHtml` export (that walks the Craft node tree, not this
|
||||
* portal), and doesn't wrap every node in extra DOM.
|
||||
*
|
||||
* It also imperatively tags each node's real DOM element with two
|
||||
* editor-only data attributes (never part of `toHtml` export, which walks
|
||||
* the Craft node tree, not the live DOM):
|
||||
* - `data-craft-node`: set on actual Craft.js droppable containers
|
||||
* (`node.data.isCanvas`, excluding ROOT). `editor.css`'s dashed "guide"
|
||||
* outlines target this attribute instead of blanket tag selectors
|
||||
* (div/section/header/...), so a component's own internal wrapper markup
|
||||
* no longer picks up a guide outline it isn't a real drop target for.
|
||||
* - `data-craft-hovered`: mirrors `node.events.hovered` (Craft's hover
|
||||
* event set). Craft's own `connectors.connect()` (called by every
|
||||
* component) wires a native mouseover/mouseleave listener to this event
|
||||
* internally, so a plain mouse hover over any connected node sets it --
|
||||
* this attribute is a real-mouse-hover canvas highlight. (The Layers
|
||||
* panel's row-hover -> canvas-highlight sync, item 12, uses a sibling
|
||||
* `data-layer-hovered` attribute written directly by LayersPanel.tsx
|
||||
* instead of this event, since the action that would drive it here
|
||||
* -- `actions.setNodeEvent` -- is stripped from the public `useEditor()`
|
||||
* API at runtime.) editor.css matches both attributes for the same
|
||||
* outline and suppresses both under `.guides-off`.
|
||||
*/
|
||||
export const RenderNode: React.FC<RenderNodeProps> = ({ render }) => {
|
||||
const { actions } = useEditor();
|
||||
const { id, isSelected, dom, name, parent, isCanvas, isHovered } = useNode((node) => ({
|
||||
isSelected: node.events.selected,
|
||||
isHovered: node.events.hovered,
|
||||
dom: node.dom,
|
||||
name: (node.data.props?.aiName as string) || node.data.displayName,
|
||||
parent: node.data.parent,
|
||||
isCanvas: node.data.isCanvas,
|
||||
}));
|
||||
|
||||
const badgeRef = useRef<HTMLDivElement>(null);
|
||||
const active = isSelected && id !== 'ROOT' && !!dom;
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!dom || !badgeRef.current) return;
|
||||
const rect = dom.getBoundingClientRect();
|
||||
const badgeHeight = 22;
|
||||
badgeRef.current.style.left = `${Math.max(rect.left, 0)}px`;
|
||||
badgeRef.current.style.top = `${Math.max(rect.top - badgeHeight, 0)}px`;
|
||||
}, [dom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
updatePosition();
|
||||
window.addEventListener('resize', updatePosition);
|
||||
document.addEventListener('scroll', updatePosition, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
document.removeEventListener('scroll', updatePosition, true);
|
||||
};
|
||||
}, [active, updatePosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dom) return;
|
||||
if (isCanvas && id !== 'ROOT') {
|
||||
dom.setAttribute('data-craft-node', '');
|
||||
} else {
|
||||
dom.removeAttribute('data-craft-node');
|
||||
}
|
||||
}, [dom, isCanvas, id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dom) return;
|
||||
if (isHovered) {
|
||||
dom.setAttribute('data-craft-hovered', '');
|
||||
} else {
|
||||
dom.removeAttribute('data-craft-hovered');
|
||||
}
|
||||
}, [dom, isHovered]);
|
||||
|
||||
if (!active) return <>{render}</>;
|
||||
|
||||
return (
|
||||
<>
|
||||
{render}
|
||||
{createPortal(
|
||||
<div ref={badgeRef} className="component-indicator" style={{ position: 'fixed' }}>
|
||||
<span>{name}</span>
|
||||
{parent && (
|
||||
<button
|
||||
type="button"
|
||||
className="component-indicator-parent-btn"
|
||||
title="Select parent"
|
||||
aria-label={`Select parent of ${name}`}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
actions.selectNode(parent);
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-chevron-up" aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, test, expect, afterEach } from 'vitest';
|
||||
import type { NodeTree } from '@craftjs/core';
|
||||
import { getClipboardTree, setClipboardTree } from './clipboard';
|
||||
|
||||
function makeTree(rootId: string, props: Record<string, unknown> = {}): NodeTree {
|
||||
return {
|
||||
rootNodeId: rootId,
|
||||
nodes: {
|
||||
[rootId]: {
|
||||
id: rootId,
|
||||
data: {
|
||||
type: { resolvedName: 'Container' },
|
||||
name: 'Container',
|
||||
displayName: 'Container',
|
||||
props,
|
||||
custom: {},
|
||||
isCanvas: false,
|
||||
parent: 'wherever-it-originally-lived',
|
||||
nodes: [],
|
||||
linkedNodes: {},
|
||||
hidden: false,
|
||||
},
|
||||
info: {},
|
||||
events: { selected: false, dragged: false, hovered: false },
|
||||
dom: null,
|
||||
related: {},
|
||||
rules: {},
|
||||
_hydrationTimestamp: 0,
|
||||
} as unknown as NodeTree['nodes'][string],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('clipboard', () => {
|
||||
afterEach(() => {
|
||||
setClipboardTree(null);
|
||||
});
|
||||
|
||||
test('starts empty', () => {
|
||||
expect(getClipboardTree()).toBeNull();
|
||||
});
|
||||
|
||||
test('set then get returns a tree with the same root id and shape', () => {
|
||||
const tree = makeTree('node-123', { text: 'hello' });
|
||||
setClipboardTree(tree);
|
||||
const got = getClipboardTree();
|
||||
expect(got).not.toBeNull();
|
||||
expect(got!.rootNodeId).toBe('node-123');
|
||||
expect(got!.nodes['node-123'].data.props).toEqual({ text: 'hello' });
|
||||
});
|
||||
|
||||
test('is a shared module-level store -- overwriting replaces the previous value', () => {
|
||||
setClipboardTree(makeTree('first'));
|
||||
setClipboardTree(makeTree('second'));
|
||||
expect(getClipboardTree()!.rootNodeId).toBe('second');
|
||||
});
|
||||
|
||||
test('can be cleared back to null', () => {
|
||||
setClipboardTree(makeTree('node-123'));
|
||||
setClipboardTree(null);
|
||||
expect(getClipboardTree()).toBeNull();
|
||||
});
|
||||
|
||||
test('deep-clones on set: mutating the original tree after set does not affect the stored snapshot', () => {
|
||||
const original = makeTree('node-123', { text: 'original' });
|
||||
setClipboardTree(original);
|
||||
|
||||
// Mutate the original tree's props object directly (as if the source
|
||||
// node were edited, or the same live node got copied again).
|
||||
(original.nodes['node-123'].data.props as Record<string, unknown>).text = 'mutated';
|
||||
|
||||
expect(getClipboardTree()!.nodes['node-123'].data.props).toEqual({ text: 'original' });
|
||||
});
|
||||
|
||||
test('deep-clones nested props (arrays/objects), not just the top-level props object', () => {
|
||||
const original = makeTree('node-123', { links: [{ url: 'https://example.com' }] });
|
||||
setClipboardTree(original);
|
||||
|
||||
(original.nodes['node-123'].data.props as any).links[0].url = 'https://mutated.example.com';
|
||||
|
||||
expect((getClipboardTree()!.nodes['node-123'].data.props as any).links[0].url).toBe(
|
||||
'https://example.com',
|
||||
);
|
||||
});
|
||||
|
||||
test('survives the original tree object being discarded entirely (detached copy, not a live reference)', () => {
|
||||
let tree: NodeTree | null = makeTree('node-abc', { text: 'snapshot' });
|
||||
setClipboardTree(tree);
|
||||
tree = null; // simulate the original page's node/tree going away entirely
|
||||
|
||||
const got = getClipboardTree();
|
||||
expect(got).not.toBeNull();
|
||||
expect(got!.nodes['node-abc'].data.props).toEqual({ text: 'snapshot' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Node, NodeId, NodeTree } from '@craftjs/core';
|
||||
|
||||
/**
|
||||
* Tiny shared clipboard for canvas node copy/paste.
|
||||
*
|
||||
* Both the context menu (right-click Copy/Paste) and the keyboard shortcuts
|
||||
* hook (Ctrl/Cmd+C / Ctrl/Cmd+V) read and write this single module-level
|
||||
* store, so copying a node via one entry point and pasting via the other
|
||||
* behaves consistently instead of each maintaining its own clipboard.
|
||||
*
|
||||
* Deliberately not React state -- nothing in the UI needs to re-render
|
||||
* reactively when the clipboard changes; consumers just read the current
|
||||
* value at the moment they need it (on paste, or when a menu opens).
|
||||
*
|
||||
* Historical bug (cross-page copy/paste): this used to store only the copied
|
||||
* node's bare id (`clipboardNodeId`) and re-resolve it via `query.node(id)`
|
||||
* at paste time. That works fine same-page, but the moment the user switches
|
||||
* pages the canvas is re-deserialized to the target page's Craft.js state --
|
||||
* the copied id no longer exists in `query` at all -- so a cross-page paste
|
||||
* silently no-op'd (or threw, caught, and swallowed). Storing a detached
|
||||
* TREE SNAPSHOT at copy time instead means paste never needs to look the
|
||||
* source id up again: it just hands the snapshot to `regenerateTreeIds` +
|
||||
* `actions.addNodeTree`, which works identically regardless of which page's
|
||||
* state is currently loaded on the canvas.
|
||||
*/
|
||||
let clipboardTree: NodeTree | null = null;
|
||||
|
||||
/**
|
||||
* Deep, detached clone of a live Craft.js `NodeTree` (as returned by
|
||||
* `query.node(id).toNodeTree()`).
|
||||
*
|
||||
* Not a plain `structuredClone(tree)`: for a REAL (live) Craft.js node,
|
||||
* `data.type` is the actual component function/class reference (not a
|
||||
* serializable `{resolvedName}` wrapper) -- `structuredClone` cannot clone a
|
||||
* function and throws `DataCloneError` (see the identical note on
|
||||
* `regenerateTreeIds` in `utils/craft-tree.ts`, which hit this exact bug
|
||||
* historically). `type` is a stable reference shared by every node of that
|
||||
* component across the whole app (it doesn't change per page), so it's safe
|
||||
* to keep by reference -- only the mutable per-node data (`props`, `custom`,
|
||||
* `nodes`, `linkedNodes`) needs an actual deep copy so a later mutation (a
|
||||
* subsequent paste's `setProp`, or a fresh copy of the same live node)
|
||||
* can never reach back into this stored snapshot.
|
||||
*/
|
||||
function cloneNodeTree(tree: NodeTree): NodeTree {
|
||||
const nodes: Record<NodeId, Node> = {};
|
||||
for (const [id, node] of Object.entries(tree.nodes)) {
|
||||
nodes[id] = {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
props: structuredClone(node.data.props),
|
||||
custom: structuredClone(node.data.custom),
|
||||
nodes: [...(node.data.nodes || [])],
|
||||
linkedNodes: { ...(node.data.linkedNodes || {}) },
|
||||
},
|
||||
};
|
||||
}
|
||||
return { rootNodeId: tree.rootNodeId, nodes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the tree snapshot currently on the clipboard, or null if empty.
|
||||
* The returned tree is safe to hand straight to `regenerateTreeIds` --
|
||||
* `regenerateTreeIds` never mutates its input, so repeated pastes of the
|
||||
* same clipboard contents (including across a page switch) all work off the
|
||||
* same untouched snapshot.
|
||||
*/
|
||||
export function getClipboardTree(): NodeTree | null {
|
||||
return clipboardTree;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets (or clears, with `null`) the tree snapshot on the clipboard. The tree
|
||||
* is deep-cloned before being stored (see `cloneNodeTree`) so it is fully
|
||||
* detached from the live Craft.js node it was captured from -- it survives
|
||||
* that node being deleted, mutated, or (the whole point) the canvas being
|
||||
* re-deserialized to a different page entirely.
|
||||
*/
|
||||
export function setClipboardTree(tree: NodeTree | null): void {
|
||||
clipboardTree = tree ? cloneNodeTree(tree) : null;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { useIsMobile } from './useIsMobile';
|
||||
|
||||
/**
|
||||
* Mobile-A2 (review item 5): useIsMobile is the single source of truth every
|
||||
* mobile-only branch (bottom tab bar, collapsed topbar, bottom sheets) reads
|
||||
* from, so it needs direct coverage of: (a) it reflects matchMedia's
|
||||
* `matches`, (b) the `change` listener is registered AND cleaned up on
|
||||
* unmount (a leaked listener would keep re-rendering an unmounted tree /
|
||||
* leak the component instance), and (c) the legacy Safari<14
|
||||
* addListener/removeListener fallback is used when addEventListener isn't
|
||||
* available.
|
||||
*
|
||||
* DOM-harness pattern mirrors PageContext.pure-updaters.test.tsx /
|
||||
* useKeyboardShortcuts.test.tsx: bare createRoot + act, no @testing-library
|
||||
* (not a project dependency).
|
||||
*/
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let originalMatchMedia: typeof window.matchMedia;
|
||||
|
||||
function render(ui: React.ReactElement) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(ui);
|
||||
});
|
||||
}
|
||||
|
||||
function unmount() {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
}
|
||||
|
||||
/** A minimal fake MediaQueryList. `mode` picks which listener API it
|
||||
* exposes, so tests can force the legacy fallback path. */
|
||||
function makeMql(matches: boolean, mode: 'modern' | 'legacy' = 'modern') {
|
||||
const listeners: Array<() => void> = [];
|
||||
const mql: any = {
|
||||
matches,
|
||||
media: '(max-width: 768px)',
|
||||
};
|
||||
if (mode === 'modern') {
|
||||
mql.addEventListener = vi.fn((_type: string, fn: () => void) => listeners.push(fn));
|
||||
mql.removeEventListener = vi.fn((_type: string, fn: () => void) => {
|
||||
const i = listeners.indexOf(fn);
|
||||
if (i !== -1) listeners.splice(i, 1);
|
||||
});
|
||||
} else {
|
||||
mql.addListener = vi.fn((fn: () => void) => listeners.push(fn));
|
||||
mql.removeListener = vi.fn((fn: () => void) => {
|
||||
const i = listeners.indexOf(fn);
|
||||
if (i !== -1) listeners.splice(i, 1);
|
||||
});
|
||||
}
|
||||
return { mql, fire: () => listeners.forEach((fn) => fn()), listenerCount: () => listeners.length };
|
||||
}
|
||||
|
||||
let probedValue: boolean | null = null;
|
||||
const Probe: React.FC = () => {
|
||||
probedValue = useIsMobile();
|
||||
return <span data-value={String(probedValue)} />;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
originalMatchMedia = window.matchMedia;
|
||||
probedValue = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = originalMatchMedia;
|
||||
});
|
||||
|
||||
describe('useIsMobile', () => {
|
||||
test('returns true when the mobile media query matches', () => {
|
||||
const { mql } = makeMql(true);
|
||||
window.matchMedia = vi.fn(() => mql) as any;
|
||||
|
||||
render(<Probe />);
|
||||
expect(probedValue).toBe(true);
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('returns false when the mobile media query does not match', () => {
|
||||
const { mql } = makeMql(false);
|
||||
window.matchMedia = vi.fn(() => mql) as any;
|
||||
|
||||
render(<Probe />);
|
||||
expect(probedValue).toBe(false);
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('registers the change listener via addEventListener and updates on change', () => {
|
||||
const { mql, fire, listenerCount } = makeMql(false);
|
||||
window.matchMedia = vi.fn(() => mql) as any;
|
||||
|
||||
render(<Probe />);
|
||||
expect(probedValue).toBe(false);
|
||||
expect(mql.addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
|
||||
expect(listenerCount()).toBe(1);
|
||||
|
||||
// Flip the query result and fire the registered 'change' listener --
|
||||
// the hook must re-read mql.matches, not just toggle blindly.
|
||||
mql.matches = true;
|
||||
act(() => {
|
||||
fire();
|
||||
});
|
||||
expect(probedValue).toBe(true);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('cleans up the addEventListener listener on unmount', () => {
|
||||
const { mql, listenerCount } = makeMql(true);
|
||||
window.matchMedia = vi.fn(() => mql) as any;
|
||||
|
||||
render(<Probe />);
|
||||
expect(listenerCount()).toBe(1);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mql.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function));
|
||||
expect(listenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
test('falls back to legacy addListener/removeListener when addEventListener is unavailable', () => {
|
||||
const { mql, fire, listenerCount } = makeMql(false, 'legacy');
|
||||
window.matchMedia = vi.fn(() => mql) as any;
|
||||
|
||||
render(<Probe />);
|
||||
expect(probedValue).toBe(false);
|
||||
expect(mql.addListener).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(listenerCount()).toBe(1);
|
||||
|
||||
mql.matches = true;
|
||||
act(() => {
|
||||
fire();
|
||||
});
|
||||
expect(probedValue).toBe(true);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mql.removeListener).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(listenerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/** Mobile breakpoint for the editor chrome (Phase A). Keep in sync with the
|
||||
* `@media (max-width: 768px)` block in `src/styles/editor.css` -- both the
|
||||
* CSS and this hook must agree on where mobile chrome kicks in. */
|
||||
export const MOBILE_BREAKPOINT_PX = 768;
|
||||
const MOBILE_QUERY = `(max-width: ${MOBILE_BREAKPOINT_PX}px)`;
|
||||
|
||||
function computeIsMobile(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
if (typeof window.matchMedia === 'function') {
|
||||
try {
|
||||
return window.matchMedia(MOBILE_QUERY).matches;
|
||||
} catch {
|
||||
// Fall through to the width check below (e.g. some older/embedded
|
||||
// WebViews expose a matchMedia that throws instead of omitting it).
|
||||
}
|
||||
}
|
||||
return window.innerWidth <= MOBILE_BREAKPOINT_PX;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether the viewport is at or below the mobile editor breakpoint.
|
||||
* Drives every mobile-only branch introduced in Phase A (bottom tab bar,
|
||||
* collapsed topbar, bottom sheets) -- desktop rendering must stay identical
|
||||
* above the breakpoint, so this is the single source of truth both React
|
||||
* and (via the matching CSS media query) plain CSS use to agree on "mobile".
|
||||
*
|
||||
* Falls back to a `resize` listener + `window.innerWidth` when
|
||||
* `matchMedia` isn't available (e.g. some test/JSDOM environments), so the
|
||||
* hook degrades gracefully rather than throwing.
|
||||
*/
|
||||
export function useIsMobile(): boolean {
|
||||
const [isMobile, setIsMobile] = useState<boolean>(computeIsMobile);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
if (typeof window.matchMedia === 'function') {
|
||||
let mql: MediaQueryList | null = null;
|
||||
try {
|
||||
mql = window.matchMedia(MOBILE_QUERY);
|
||||
} catch {
|
||||
mql = null;
|
||||
}
|
||||
|
||||
if (mql) {
|
||||
const handleChange = () => setIsMobile(mql!.matches);
|
||||
handleChange();
|
||||
|
||||
if (typeof mql.addEventListener === 'function') {
|
||||
mql.addEventListener('change', handleChange);
|
||||
return () => mql!.removeEventListener('change', handleChange);
|
||||
}
|
||||
|
||||
// Safari < 14 only supports the deprecated addListener/removeListener pair.
|
||||
const legacyMql = mql as MediaQueryList & {
|
||||
addListener?: (listener: () => void) => void;
|
||||
removeListener?: (listener: () => void) => void;
|
||||
};
|
||||
legacyMql.addListener?.(handleChange);
|
||||
return () => legacyMql.removeListener?.(handleChange);
|
||||
}
|
||||
}
|
||||
|
||||
const handleResize = () => setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT_PX);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user